diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ea0cd4..1b85ba21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `confirm_upgrade` — multi-sig confirmation step for contract upgrades. A second authorized address must call `confirm_upgrade` after `schedule_upgrade` and before `execute_upgrade`. This ensures that a single compromised admin key cannot unilaterally deploy arbitrary WASM — both the admin key and the upgrader key must cooperate to execute an upgrade. `execute_upgrade` returns `Error::UpgradeNotConfirmed` (#58) if the confirmation is absent. The confirmation is automatically cleared by both `cancel_upgrade` and `execute_upgrade`. +- `Error::UpgradeNotConfirmed` (#58) — new error code returned by `execute_upgrade` when the multi-sig confirmation step has not been completed. + +### Changed +- `execute_upgrade` now requires a prior `confirm_upgrade` call. Existing upgrade flows must add a `confirm_upgrade(upgrader)` step between `schedule_upgrade` and `execute_upgrade`. See [docs/upgrade-safety.md](docs/upgrade-safety.md) for the updated procedure. +- `cancel_upgrade` now also clears any recorded `confirm_upgrade` confirmation so it cannot carry over to a subsequent upgrade cycle. +- `docs/upgrade-safety.md` — updated upgrade lifecycle diagram and procedure to include the `confirm_upgrade` step. +- `docs/security.md` — added "Contract Upgrade Multi-Sig" section documenting the threat model and recommendations. +- `docs/error-codes.md` — added entry for error code #58 `UpgradeNotConfirmed`. + ### Added - `admin_resolve_stalled_match` — admin-gated recovery function for Active matches stuck after the 24-hour player rollback window expires and the oracle has failed to submit a result for more than 7 days. Provides a bounded recovery path so funds are never permanently locked in the event of prolonged oracle outage or lost signing keys. - `ADMIN_STALL_WINDOW_SECONDS` constant (7 days) — configurable threshold after which admin can invoke stall resolution, long enough not to compete with the player-initiated 24-hour rollback window. diff --git a/README.md b/README.md index 39e8766c..717c49d7 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ contract source. For a machine-readable listing, see the | `match` / `claim` | `claim_vested_payout` | `(match_id, player, amount_claimed, payout_token)` | | `upgrade` / `sched` | `schedule_upgrade` | `(new_wasm_hash, scheduled_at)` | | `upgrade` / `cancel` | `cancel_upgrade` | `()` | +| `upgrade` / `confirm` | `confirm_upgrade` | `upgrader_address` | | `upgrade` / `exec` | `execute_upgrade` | `new_wasm_hash` | | `upgrade` / `migrated` | `migrate_state` | `(current, target_version)` | diff --git a/contracts/escrow/contract-spec.json b/contracts/escrow/contract-spec.json index 50203ead..5c6a964d 100644 --- a/contracts/escrow/contract-spec.json +++ b/contracts/escrow/contract-spec.json @@ -1,6 +1,6 @@ { "_comment": "Baseline ABI snapshot for the escrow contract. Generated by the abi-snapshot CI job via `stellar contract inspect`. Do NOT edit this file by hand — update it using the procedure in CONTRIBUTING.md#updating-the-abi-snapshot.", - "_generated_at": "2026-08-27", + "_generated_at": "2026-10-20", "_contract": "contracts/escrow", "_generator": "stellar contract inspect --wasm target/wasm32-unknown-unknown/release/escrow.wasm --output json", "functions": [ @@ -53,6 +53,7 @@ { "name": "claim_vested_payout" }, { "name": "register_oracle_with_stake" }, { "name": "schedule_upgrade" }, + { "name": "confirm_upgrade" }, { "name": "execute_upgrade" }, { "name": "get_platform_stats" }, { "name": "get_player_stats" } diff --git a/contracts/escrow/src/errors.rs b/contracts/escrow/src/errors.rs index bc6aa88c..d33f731e 100644 --- a/contracts/escrow/src/errors.rs +++ b/contracts/escrow/src/errors.rs @@ -86,4 +86,8 @@ pub enum Error { /// `set_dispute_period`, `set_minimum_hold_duration`, and `set_fee_tiers` /// when the supplied value is above the configured maximum. ConfigValueTooHigh = 57, + /// `execute_upgrade` requires a second authorized address to have called + /// `confirm_upgrade` after `schedule_upgrade`. This error is returned when + /// no confirmation has been recorded for the pending upgrade hash. + UpgradeNotConfirmed = 58, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 6dbce6d5..4aa7dde6 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -48,7 +48,7 @@ use types::{ DisputeState, FeeTier, Match, MatchState, OracleRotationState, PendingAdminProposal, PendingOracleRotation, Platform, PlatformStats, PlayerBalanceSnapshot, PlayerFreezeKey, PlayerRating, PlayerRatingKey, PlayerStats, PlayerTier, ProtocolConfig, SnapshotReason, - TempOracleRotation, TokenSymbolCacheKey, Winner, + TempOracleRotation, TokenSymbolCacheKey, UpgradeKey, Winner, }; /// ~30 days at 5s/ledger. Used as the default TTL and expiration threshold. @@ -6124,6 +6124,9 @@ impl EscrowContract { env.storage() .instance() .remove(&DataKey::PendingUpgradeHash); + // Clear any confirmation recorded by confirm_upgrade so it cannot + // carry over to a subsequent upgrade cycle. + env.storage().instance().remove(&UpgradeKey::ConfirmedBy); env.events() .publish((Symbol::new(&env, "upgrade"), symbol_short!("cancel")), ()); @@ -6131,6 +6134,44 @@ impl EscrowContract { Ok(()) } + /// Confirm a pending upgrade as a second authorized signer (multi-sig step). + /// + /// A separate `upgrader` address (distinct from the admin that called + /// `schedule_upgrade`) must call this function to attest that the pending + /// WASM hash has been independently reviewed and approved. `execute_upgrade` + /// will be rejected with `UpgradeNotConfirmed` until this confirmation is on + /// record. + /// + /// The upgrader is any address passed in and authorized via `require_auth`. + /// In production this should be a hardware-wallet address or a second + /// signing key that is kept separate from the admin key so that a single + /// compromised key cannot alone deploy arbitrary code. + /// + /// # Arguments + /// * `upgrader` — the address providing the second-factor confirmation. + /// + /// # Errors + /// * `UpgradeNotScheduled` — no upgrade is pending (nothing to confirm). + pub fn confirm_upgrade(env: Env, upgrader: Address) -> Result<(), Error> { + extend_instance_ttl(&env); + upgrader.require_auth(); + + if !env.storage().instance().has(&DataKey::UpgradeScheduledAt) { + return Err(Error::UpgradeNotScheduled); + } + + env.storage() + .instance() + .set(&UpgradeKey::ConfirmedBy, &upgrader); + + env.events().publish( + (Symbol::new(&env, "upgrade"), symbol_short!("confirm")), + upgrader, + ); + + Ok(()) + } + /// Execute the scheduled WASM upgrade after the 7-day review period. /// /// Admin-gated. Calls the host `upgrade` function with the stored WASM @@ -6142,9 +6183,14 @@ impl EscrowContract { /// state-mutating transactions run against the old schema while the /// replacement WASM is being applied. /// + /// A second authorized address must have called `confirm_upgrade` after + /// `schedule_upgrade` was issued. This two-step requirement means that a + /// single compromised admin key cannot unilaterally deploy arbitrary code. + /// /// # Errors /// * `UpgradeNotScheduled` — no upgrade is pending. /// * `UpgradeReviewPeriodNotElapsed` — 7-day review period has not passed. + /// * `UpgradeNotConfirmed` — `confirm_upgrade` has not been called. /// * `InvalidPauseState` — contract is not paused. /// * `Unauthorized` — caller is not the admin. pub fn execute_upgrade(env: Env) -> Result<(), Error> { @@ -6177,6 +6223,12 @@ impl EscrowContract { return Err(Error::UpgradeReviewPeriodNotElapsed); } + // Enforce multi-sig confirmation: a second authorized address must have + // called confirm_upgrade before execution is permitted. + if !env.storage().instance().has(&UpgradeKey::ConfirmedBy) { + return Err(Error::UpgradeNotConfirmed); + } + let new_wasm_hash: BytesN<32> = env .storage() .instance() @@ -6191,6 +6243,8 @@ impl EscrowContract { env.storage() .instance() .remove(&DataKey::PendingUpgradeHash); + // Clear the confirmation so it cannot carry over to a future upgrade. + env.storage().instance().remove(&UpgradeKey::ConfirmedBy); env.deployer() .update_current_contract_wasm(new_wasm_hash.clone()); diff --git a/contracts/escrow/src/tests/upgrade.rs b/contracts/escrow/src/tests/upgrade.rs index 2b6e230a..bd97bf14 100644 --- a/contracts/escrow/src/tests/upgrade.rs +++ b/contracts/escrow/src/tests/upgrade.rs @@ -4,7 +4,8 @@ /// - `get_version` — returns the correct initial version. /// - `schedule_upgrade` — admin-gated, emits event, stores keys. /// - `cancel_upgrade` — removes pending-upgrade keys. -/// - `execute_upgrade` — enforces paused + review-period constraints. +/// - `confirm_upgrade` — records a second-signer confirmation. +/// - `execute_upgrade` — enforces paused + review-period + confirmation constraints. /// - `migrate_state` — version guard, incremental migrations, idempotency. /// - `validate_state` — detects a healthy vs. corrupt instance store. use super::*; @@ -128,6 +129,104 @@ fn test_cancel_upgrade_when_none_pending_is_rejected() { ); } +// ── confirm_upgrade ─────────────────────────────────────────────────────────── + +#[test] +fn test_confirm_upgrade_succeeds_with_any_authorized_address() { + let (env, contract_id, _oracle, _p1, _p2, _token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + client.schedule_upgrade(&dummy_wasm_hash(&env)); + + let upgrader = Address::generate(&env); + // No panic → confirmation accepted. + client.confirm_upgrade(&upgrader); +} + +#[test] +fn test_confirm_upgrade_without_scheduled_upgrade_is_rejected() { + let (env, contract_id, _oracle, _p1, _p2, _token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let upgrader = Address::generate(&env); + let result = client.try_confirm_upgrade(&upgrader); + assert!( + matches!(result, Err(Ok(Error::UpgradeNotScheduled))), + "confirm_upgrade with no pending upgrade must return UpgradeNotScheduled" + ); +} + +#[test] +fn test_confirm_upgrade_emits_event() { + let (env, contract_id, _oracle, _p1, _p2, _token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + client.schedule_upgrade(&dummy_wasm_hash(&env)); + + let upgrader = Address::generate(&env); + client.confirm_upgrade(&upgrader); + + let events = env.events().all(); + let found = events.iter().any(|e| { + let topics: soroban_sdk::Vec = e.1; + if topics.len() < 2 { + return false; + } + let t0 = topics.get(0).unwrap(); + let t1 = topics.get(1).unwrap(); + let s0 = Symbol::try_from_val(&env, &t0).ok(); + let s1 = Symbol::try_from_val(&env, &t1).ok(); + s0 == Some(Symbol::new(&env, "upgrade")) && s1 == Some(symbol_short!("confirm")) + }); + assert!(found, "confirm_upgrade must emit (upgrade, confirm) event"); +} + +#[test] +fn test_execute_upgrade_rejected_without_confirmation() { + let (env, contract_id, _oracle, _p1, _p2, _token, admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + client.schedule_upgrade(&dummy_wasm_hash(&env)); + client.pause(&admin); + + // Advance past the review period but do NOT call confirm_upgrade. + env.ledger().with_mut(|l| { + l.sequence_number += crate::UPGRADE_REVIEW_PERIOD_LEDGERS + 1; + }); + + let result = client.try_execute_upgrade(); + assert!( + matches!(result, Err(Ok(Error::UpgradeNotConfirmed))), + "execute_upgrade without prior confirm_upgrade must return UpgradeNotConfirmed" + ); +} + +#[test] +fn test_cancel_upgrade_clears_confirmation() { + let (env, contract_id, _oracle, _p1, _p2, _token, admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + // Schedule, confirm, then cancel. + client.schedule_upgrade(&dummy_wasm_hash(&env)); + let upgrader = Address::generate(&env); + client.confirm_upgrade(&upgrader); + client.cancel_upgrade(); + + // Re-schedule a new upgrade — the old confirmation must not carry over. + client.schedule_upgrade(&dummy_wasm_hash(&env)); + client.pause(&admin); + + env.ledger().with_mut(|l| { + l.sequence_number += crate::UPGRADE_REVIEW_PERIOD_LEDGERS + 1; + }); + + let result = client.try_execute_upgrade(); + assert!( + matches!(result, Err(Ok(Error::UpgradeNotConfirmed))), + "confirmation from a cancelled upgrade must not carry over to the next upgrade" + ); +} + // ── execute_upgrade ─────────────────────────────────────────────────────────── #[test] @@ -174,6 +273,10 @@ fn test_execute_upgrade_succeeds_after_review_period() { client.schedule_upgrade(&dummy_wasm_hash(&env)); client.pause(&admin); + // Provide the required multi-sig confirmation before execution. + let upgrader = Address::generate(&env); + client.confirm_upgrade(&upgrader); + // Advance past the review period. env.ledger().with_mut(|l| { l.sequence_number += crate::UPGRADE_REVIEW_PERIOD_LEDGERS + 1; @@ -188,6 +291,10 @@ fn test_execute_upgrade_succeeds_after_review_period() { !matches!(result, Err(Ok(Error::UpgradeReviewPeriodNotElapsed))), "execute_upgrade after the review period must not return UpgradeReviewPeriodNotElapsed" ); + assert!( + !matches!(result, Err(Ok(Error::UpgradeNotConfirmed))), + "execute_upgrade after confirm_upgrade must not return UpgradeNotConfirmed" + ); } #[test] diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index a7281bbf..9f9956f2 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -462,6 +462,24 @@ pub enum TokenSymbolCacheKey { TokenSymbol(Address), } +/// Storage keys for the multi-sig upgrade confirmation step. +/// +/// Kept as a separate `#[contracttype]` enum rather than added to `DataKey` +/// for the same reason as other overflow keys: `DataKey` is at its 50-variant +/// XDR cap. +/// +/// `confirm_upgrade` writes `ConfirmedBy(upgrader_address)` once the second +/// authorized signer approves the pending WASM hash. `execute_upgrade` reads +/// this key to enforce the two-step requirement, then removes it. Both +/// `cancel_upgrade` and `execute_upgrade` clear the key so the guard cannot +/// carry over to a subsequent upgrade cycle. +#[contracttype] +pub enum UpgradeKey { + /// The address that confirmed the pending upgrade via `confirm_upgrade`. + /// Absence of this key means no confirmation has been recorded. + ConfirmedBy, +} + /// An oracle-verified ELO / platform rating for a player. /// /// Registered on-chain by the oracle via `register_player_rating` so that diff --git a/docs/error-codes.md b/docs/error-codes.md index d3bc14ee..95e310cb 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -5,7 +5,7 @@ This document is the exhaustive, user-facing reference for every error a caller can receive from the two on-chain Soroban contracts in this repo: -- [`EscrowContract`](../contracts/escrow/src/lib.rs) — [`contracts/escrow/src/errors.rs`](../contracts/escrow/src/errors.rs) (52 variants) +- [`EscrowContract`](../contracts/escrow/src/lib.rs) — [`contracts/escrow/src/errors.rs`](../contracts/escrow/src/errors.rs) (53 variants) - [`OracleContract`](../contracts/oracle/src/lib.rs) — [`contracts/oracle/src/errors.rs`](../contracts/oracle/src/errors.rs) (27 variants) Every variant defined in those two files is documented below. If you add, @@ -149,6 +149,7 @@ These error codes support advanced features including dispute resolution, stakin | 54 | `NotAnOracle` | v0.1.0 | [`submit_result_with_oracle_record`](../contracts/escrow/src/lib.rs), oracle-gated functions | The caller is not a registered oracle address. Distinct from `Unauthorized` (#4) — `#54` is returned specifically when the caller has valid auth credentials but is not on the oracle registry. | Verify the signing key is a registered oracle address via `get_oracle_address`. If the oracle address was rotated, admin must call `update_oracle` with the current key. | A new oracle instance with a fresh key pair submits a result without first having the admin register it via `update_oracle` → `#54`. | | 55 | `DepositInProgress` | v0.1.0 | [`deposit`](../contracts/escrow/src/lib.rs) | A deposit for this match is already in progress — the reentrancy guard tripped. A concurrent or re-entrant deposit call attempted to deposit while another deposit for the same match was still executing. | Do not submit multiple concurrent deposit calls for the same match ID. Retry after the first call completes. | Two parallel oracle service instances both call `deposit` for match 42 at the same time → one succeeds, the other gets `#55`. | | 56 | `DisputeFallbackNotElapsed` | v0.1.0 | Dispute resolution functions | `resolve_dispute_no_quorum` (or equivalent fallback resolution) was called before the dispute's voting deadline plus the configured grace period has elapsed. The no-quorum fallback path requires both the voting window and the grace period to have passed before it can fire. | Check the dispute's deadline and the configured grace period via `get_dispute`. Wait for both to elapse before retrying the fallback resolution call. | Calling the no-quorum fallback resolver one ledger before the grace period ends → `#56`. | +| 58 | `UpgradeNotConfirmed` | v0.2.0 | [`execute_upgrade`](../contracts/escrow/src/lib.rs) | `execute_upgrade` was called without a prior `confirm_upgrade` call from a second authorized address. The multi-sig confirmation step is mandatory — a single admin key cannot unilaterally deploy new WASM. | Call `confirm_upgrade(upgrader)` with a separate authorized key after `schedule_upgrade` and before `execute_upgrade`. | Admin schedules an upgrade and immediately tries to execute it without having the upgrader key sign off → `#58`. | --- @@ -252,6 +253,10 @@ All error codes documented in this file were introduced in v0.1.0. The `Since` c ### v0.2.0 (unreleased) +**Escrow contract** + +- Added `#58 UpgradeNotConfirmed` (since v0.2.0) — returned by `execute_upgrade` when `confirm_upgrade` has not been called by a second authorized address after `schedule_upgrade`. Enforces a two-step multi-sig requirement for contract upgrades so that a single compromised admin key cannot unilaterally deploy arbitrary WASM. + **Oracle contract** - Added `#13 InvalidPauseState` (since v0.2.0) — returned by `pause` when the contract is already paused, or by `unpause` when the contract is not currently paused. Fills the previously unassigned gap at discriminant 13. Prevents duplicate state transitions and spurious events, bringing the oracle in line with the escrow contract's `InvalidPauseState` behaviour. diff --git a/docs/security.md b/docs/security.md index 3672d383..e640d6a7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,6 +7,7 @@ This document outlines the security considerations, trust assumptions, and risk - [Oracle Trust Assumptions](#oracle-trust-assumptions) - [Oracle Compromise](#oracle-compromise) - [Admin Key Risks](#admin-key-risks) +- [Contract Upgrade Multi-Sig](#contract-upgrade-multi-sig) - [Re-initialization Protection](#re-initialization-protection) - [Pause Mechanism](#pause-mechanism) - [Cross-Contract Reentrancy Protection](#cross-contract-reentrancy-protection) @@ -87,6 +88,7 @@ Both contracts have admin addresses with elevated privileges. Compromise of admi 3. **Cold Storage**: Store admin keys offline when not in use 4. **Monitoring**: Monitor admin operations via contract events 5. **Timelock**: Consider implementing timelocks for critical admin operations +6. **Upgrade Multi-sig**: Contract upgrades require a second authorized address to call `confirm_upgrade` before `execute_upgrade` can proceed. A single compromised admin key cannot unilaterally deploy arbitrary code — see [Contract Upgrade Multi-Sig](#contract-upgrade-multi-sig) below. ### Compromise Scenarios @@ -96,6 +98,41 @@ Both contracts have admin addresses with elevated privileges. Compromise of admi | Oracle admin key stolen | Incorrect results can be submitted | Pause oracle, rotate admin, dispute results | | Both keys compromised | Complete contract takeover | Emergency pause, contract migration | +## Contract Upgrade Multi-Sig + +`execute_upgrade` enforces a two-step confirmation requirement. After +`schedule_upgrade` stores a pending WASM hash, a **second authorized address** +must call `confirm_upgrade` before `execute_upgrade` can proceed. If no +confirmation is on record, `execute_upgrade` returns +`Error::UpgradeNotConfirmed` (#58). + +### Why this matters + +Without the confirmation requirement, a single compromised admin key could: + +1. Upload a malicious WASM blob. +2. Schedule an upgrade. +3. Wait for the 7-day review period. +4. Execute the upgrade, replacing the contract with arbitrary code. + +With the confirmation requirement, the attacker also needs to compromise the +separate `upgrader` key before execution is possible. + +### Threat model + +| Scenario | Impact without multi-sig | Impact with multi-sig | +|----------|-------------------------|-----------------------| +| Admin key compromised | Attacker can deploy arbitrary code after review period | Attacker cannot execute — upgrader confirmation missing | +| Upgrader key compromised | N/A | Attacker can confirm but not schedule — both keys needed | +| Both keys compromised | Complete contract takeover | Same — use hardware wallets for both keys | + +### Recommendations + +- Store the `upgrader` key on a separate hardware wallet or HSM from the admin key. +- The `upgrader` address does not require admin privileges — it only needs to sign `confirm_upgrade`. +- Publicly announce and log the upgrader confirmation so the community can verify the multi-sig step was completed by the expected party. +- See [docs/upgrade-safety.md](upgrade-safety.md) for the full upgrade procedure. + ## Re-initialization Protection Both contracts implement strict re-initialization protection to prevent takeover after deployment. diff --git a/docs/upgrade-safety.md b/docs/upgrade-safety.md index 04cbee8a..3a61e480 100644 --- a/docs/upgrade-safety.md +++ b/docs/upgrade-safety.md @@ -52,6 +52,9 @@ schedule_upgrade(wasm_hash) │ │ 7-day review period (UPGRADE_REVIEW_PERIOD_LEDGERS = 120,960) ▼ +confirm_upgrade(upgrader) ← second authorized address must confirm + │ + ▼ pause() the contract ← admin must pause before executing │ ▼ @@ -129,6 +132,9 @@ the following categories of tests: | `test_execute_upgrade_rejected_when_not_paused` | `InvalidPauseState` if not paused | | `test_execute_upgrade_rejected_before_review_period` | `UpgradeReviewPeriodNotElapsed` if too early | | `test_execute_upgrade_rejected_with_no_scheduled_upgrade` | `UpgradeNotScheduled` if none pending | +| `test_execute_upgrade_rejected_without_confirmation` | `UpgradeNotConfirmed` if `confirm_upgrade` was not called | +| `test_confirm_upgrade_without_scheduled_upgrade_is_rejected` | `UpgradeNotScheduled` when confirming with nothing pending | +| `test_cancel_upgrade_clears_confirmation` | Cancelling an upgrade removes any recorded confirmation | ### Rollback Safety @@ -242,35 +248,44 @@ stellar contract invoke \ # 4. Wait for the review period to elapse (7 days = 120,960 ledgers) -# 5. Pause the contract +# 5. Confirm the upgrade with a second authorized key (multi-sig step). +# $UPGRADER_KEYPAIR must be a separate key from $DEPLOYER_KEYPAIR. +# This prevents a single compromised key from deploying arbitrary code. +stellar contract invoke \ + --id $CONTRACT_ESCROW \ + --source $UPGRADER_KEYPAIR \ + --network mainnet \ + -- confirm_upgrade --upgrader "$UPGRADER_ADDRESS" + +# 6. Pause the contract stellar contract invoke \ --id $CONTRACT_ESCROW \ --source $DEPLOYER_KEYPAIR \ --network mainnet \ -- pause -# 6. Execute the upgrade +# 7. Execute the upgrade stellar contract invoke \ --id $CONTRACT_ESCROW \ --source $DEPLOYER_KEYPAIR \ --network mainnet \ -- execute_upgrade -# 7. Run state migration +# 8. Run state migration stellar contract invoke \ --id $CONTRACT_ESCROW \ --source $DEPLOYER_KEYPAIR \ --network mainnet \ -- migrate_state --target_version -# 8. Validate state +# 9. Validate state stellar contract invoke \ --id $CONTRACT_ESCROW \ --source $DEPLOYER_KEYPAIR \ --network mainnet \ -- validate_state -# 9. Unpause the contract +# 10. Unpause the contract stellar contract invoke \ --id $CONTRACT_ESCROW \ --source $DEPLOYER_KEYPAIR \ @@ -280,6 +295,33 @@ stellar contract invoke \ echo "✅ Upgrade complete." ``` +## Multi-Sig Confirmation Requirement + +`execute_upgrade` requires that a second authorized address call +`confirm_upgrade` after `schedule_upgrade`. This two-step requirement means +that a single compromised admin key cannot unilaterally deploy arbitrary code. + +**How it works:** + +1. The admin calls `schedule_upgrade(wasm_hash)` — starts the 7-day review clock. +2. A second authorized `upgrader` address calls `confirm_upgrade(upgrader)` — signs off on the pending hash. +3. After the review period elapses, the admin calls `execute_upgrade` — both conditions (review period elapsed *and* confirmation present) must be satisfied. + +If `confirm_upgrade` was never called, `execute_upgrade` returns +`Error::UpgradeNotConfirmed` (#58). + +The confirmation is automatically cleared by both `cancel_upgrade` and +`execute_upgrade`, so a confirmation from one upgrade cycle can never carry +over to a subsequent cycle. + +**Key management guidance:** + +- Keep the `upgrader` key on a separate hardware wallet or HSM from the admin key. +- The `upgrader` address does not need admin privileges — it only needs to be + able to invoke `confirm_upgrade` and pay the transaction fee. +- For additional security, the `upgrader` address can itself be a multi-sig + wallet requiring M-of-N signatures. + --- ## Rollback Procedure