diff --git a/app/backend/src/common/soroban-errors/soroban-error.codes.js b/app/backend/src/common/soroban-errors/soroban-error.codes.js index be9303042..f42d00f27 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.codes.js +++ b/app/backend/src/common/soroban-errors/soroban-error.codes.js @@ -43,6 +43,12 @@ var SorobanErrorCode; SorobanErrorCode["VERSION_MISMATCH"] = "CONTRACT_VERSION_MISMATCH"; /** WASM hash provided for upgrade is invalid. */ SorobanErrorCode["INVALID_WASM_HASH"] = "CONTRACT_INVALID_WASM_HASH"; + /** Upgrade window is not currently active; start_upgrade is blocked. */ + SorobanErrorCode["UPGRADE_WINDOW_NOT_ACTIVE"] = "CONTRACT_UPGRADE_WINDOW_NOT_ACTIVE"; + /** An upgrade is already in progress; start_upgrade cannot be called again. */ + SorobanErrorCode["UPGRADE_ALREADY_IN_PROGRESS"] = "CONTRACT_UPGRADE_ALREADY_IN_PROGRESS"; + /** No upgrade is currently in progress; upgrade or complete_upgrade cannot proceed. */ + SorobanErrorCode["UPGRADE_NOT_IN_PROGRESS"] = "CONTRACT_UPGRADE_NOT_IN_PROGRESS"; // ── Admin ───────────────────────────────────────────────────────────────── /** Caller is not the contract admin. */ SorobanErrorCode["NOT_ADMIN"] = "CONTRACT_NOT_ADMIN"; diff --git a/app/backend/src/common/soroban-errors/soroban-error.codes.ts b/app/backend/src/common/soroban-errors/soroban-error.codes.ts index 2cdd89856..d6f9bfae5 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.codes.ts +++ b/app/backend/src/common/soroban-errors/soroban-error.codes.ts @@ -37,11 +37,17 @@ /** Amount provided is zero or negative. */ INVALID_AMOUNT = 'CONTRACT_INVALID_AMOUNT', - // ── Version / upgrade ───────────────────────────────────────────────────── - /** Contract schema version is not supported by this client. */ - VERSION_MISMATCH = 'CONTRACT_VERSION_MISMATCH', - /** WASM hash provided for upgrade is invalid. */ - INVALID_WASM_HASH = 'CONTRACT_INVALID_WASM_HASH', + // ── Version / upgrade ───────────────────────────────────────────────────── + /** Contract schema version is not supported by this client. */ + VERSION_MISMATCH = 'CONTRACT_VERSION_MISMATCH', + /** WASM hash provided for upgrade is invalid. */ + INVALID_WASM_HASH = 'CONTRACT_INVALID_WASM_HASH', + /** Upgrade window is not currently active; start_upgrade is blocked. */ + UPGRADE_WINDOW_NOT_ACTIVE = 'CONTRACT_UPGRADE_WINDOW_NOT_ACTIVE', + /** An upgrade is already in progress; start_upgrade cannot be called again. */ + UPGRADE_ALREADY_IN_PROGRESS = 'CONTRACT_UPGRADE_ALREADY_IN_PROGRESS', + /** No upgrade is currently in progress; upgrade or complete_upgrade cannot proceed. */ + UPGRADE_NOT_IN_PROGRESS = 'CONTRACT_UPGRADE_NOT_IN_PROGRESS', // ── Admin ───────────────────────────────────────────────────────────────── /** Caller is not the contract admin. */ diff --git a/app/backend/src/common/soroban-errors/soroban-error.mapper.js b/app/backend/src/common/soroban-errors/soroban-error.mapper.js index 1314347b0..ec1d5a4e1 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.mapper.js +++ b/app/backend/src/common/soroban-errors/soroban-error.mapper.js @@ -119,6 +119,21 @@ var ERROR_MAPPINGS = [ code: soroban_error_codes_1.SorobanErrorCode.INVALID_WASM_HASH, message: 'The WASM hash provided for the contract upgrade is invalid.', }, + { + pattern: /upgrade.*window.*not.*active|upgrade.*window.*inactive/i, + code: soroban_error_codes_1.SorobanErrorCode.UPGRADE_WINDOW_NOT_ACTIVE, + message: 'The upgrade window is not currently active. Please try again during the scheduled window.', + }, + { + pattern: /upgrade.*already.*in.*progress/i, + code: soroban_error_codes_1.SorobanErrorCode.UPGRADE_ALREADY_IN_PROGRESS, + message: 'An upgrade is already in progress. Please wait for it to complete before starting a new one.', + }, + { + pattern: /upgrade.*not.*in.*progress|no.*upgrade.*in.*progress/i, + code: soroban_error_codes_1.SorobanErrorCode.UPGRADE_NOT_IN_PROGRESS, + message: 'No upgrade is currently in progress. Start an upgrade first before performing this operation.', + }, // ── Input / params ──────────────────────────────────────────────────────── { pattern: /account.*does not exist|account.*not.*found/i, diff --git a/app/backend/src/common/soroban-errors/soroban-error.mapper.ts b/app/backend/src/common/soroban-errors/soroban-error.mapper.ts index 6adf23abd..8cd13296f 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.mapper.ts +++ b/app/backend/src/common/soroban-errors/soroban-error.mapper.ts @@ -151,6 +151,21 @@ const ERROR_MAPPINGS: Array<{ code: SorobanErrorCode.INVALID_WASM_HASH, message: 'The WASM hash provided for the contract upgrade is invalid.', }, + { + pattern: /upgrade.*window.*not.*active|upgrade.*window.*inactive/i, + code: SorobanErrorCode.UPGRADE_WINDOW_NOT_ACTIVE, + message: 'The upgrade window is not currently active. Please try again during the scheduled window.', + }, + { + pattern: /upgrade.*already.*in.*progress/i, + code: SorobanErrorCode.UPGRADE_ALREADY_IN_PROGRESS, + message: 'An upgrade is already in progress. Please wait for it to complete before starting a new one.', + }, + { + pattern: /upgrade.*not.*in.*progress|no.*upgrade.*in.*progress/i, + code: SorobanErrorCode.UPGRADE_NOT_IN_PROGRESS, + message: 'No upgrade is currently in progress. Start an upgrade first before performing this operation.', + }, // ── Input / params ──────────────────────────────────────────────────────── { diff --git a/app/backend/src/common/soroban-errors/soroban-error.mapper.unit.spec.ts b/app/backend/src/common/soroban-errors/soroban-error.mapper.unit.spec.ts index 4e03b1bee..f525de09a 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.mapper.unit.spec.ts +++ b/app/backend/src/common/soroban-errors/soroban-error.mapper.unit.spec.ts @@ -112,6 +112,21 @@ describe('mapSorobanError', () => { expect(result.code).toBe(SorobanErrorCode.INVALID_WASM_HASH); }); + it('maps "upgrade window not active" to UPGRADE_WINDOW_NOT_ACTIVE', () => { + const result = mapSorobanError('upgrade window not active'); + expect(result.code).toBe(SorobanErrorCode.UPGRADE_WINDOW_NOT_ACTIVE); + }); + + it('maps "upgrade already in progress" to UPGRADE_ALREADY_IN_PROGRESS', () => { + const result = mapSorobanError('upgrade already in progress'); + expect(result.code).toBe(SorobanErrorCode.UPGRADE_ALREADY_IN_PROGRESS); + }); + + it('maps "no upgrade in progress" to UPGRADE_NOT_IN_PROGRESS', () => { + const result = mapSorobanError('no upgrade in progress'); + expect(result.code).toBe(SorobanErrorCode.UPGRADE_NOT_IN_PROGRESS); + }); + // ── Admin ─────────────────────────────────────────────────────────────── it('maps "not admin" to NOT_ADMIN', () => { diff --git a/app/contract/.gitignore b/app/contract/.gitignore index d74cc01f6..1c81c0d8d 100644 --- a/app/contract/.gitignore +++ b/app/contract/.gitignore @@ -4,3 +4,6 @@ target # Local settings .soroban .stellar + +# Test snapshots (generated by property-based tests) +contracts/Folder/test_snapshots/ diff --git a/app/contract/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md b/app/contract/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md index 141b14bc3..e24a47553 100644 --- a/app/contract/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md +++ b/app/contract/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md @@ -25,7 +25,7 @@ Added contract-level safeguards and comprehensive invariant enforcement for safe - Function: `storage::is_upgrade_window_active(env)` checks ledger timestamp against `[start, end)` - Gating: `upgrade()` now requires `UpgradeInProgress` and active window. - Verification: `upgrade()` verifies the WASM hash matches the one stored in `start_upgrade()`. -- Error: Returns `InvalidAmount` (repurposed as "upgrade window not active") or `InternalError` (not in progress). +- Error: Returns `UpgradeWindowNotActive` when window is not active or `UpgradeAlreadyInProgress` when already started. **Test**: `upgrade_safety_gate_blocks_upgrade_outside_window`, `upgrade_safety_gate_blocks_direct_upgrade_without_start` @@ -71,7 +71,7 @@ admin::upgrade(hash) ```rust pub fn complete_upgrade(env, caller, new_version) { - if !storage::is_upgrade_in_progress(env) { return Err(InternalError); } + if !storage::is_upgrade_in_progress(env) { return Err(UpgradeNotInProgress); } if new_version != storage::get_pending_upgrade_version(env) { return Err(InvalidContractVersion); } if storage::get_wasm_hash(env) != storage::get_pending_upgrade_wasm_hash(env) { return Err(InternalError); } // ... run migrate() ... @@ -177,7 +177,7 @@ Step 1: Admin calls set_upgrade_window(start, end) Step 2: Admin calls start_upgrade(new_version) → Check: is_upgrade_window_active() → If yes: set UpgradeInProgress = true, emit UpgradeStarted - → If no: return Err(InvalidAmount) + → If no: return Err(UpgradeWindowNotActive) Step 3a: (Deploy) update_current_contract_wasm(new_wasm_hash) → Caller publishes new WASM; contract code swaps @@ -191,9 +191,9 @@ Step 3b: Admin calls complete_upgrade(new_version) **Error Handling**: -- `InvalidAmount`: Used to signal "upgrade window not active" -- `ContractPaused`: Used to signal "upgrade already in progress" (repurposed) -- `InternalError`: Used when post-upgrade invariants fail (repurposed) +- `UpgradeWindowNotActive`: Used to signal "upgrade window not active" +- `UpgradeAlreadyInProgress`: Used to signal "upgrade already in progress" +- `InternalError`: Used when post-upgrade invariants fail --- diff --git a/app/contract/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md b/app/contract/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md index 79a8939f9..df441ebb7 100644 --- a/app/contract/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md +++ b/app/contract/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md @@ -31,10 +31,10 @@ start_upgrade(env, caller, new_version) -> Result<(), RustAcademyError> - **Must be called during active window** (AC1) - Sets `UpgradeInProgress = true` - Emits: `UpgradeStarted { admin, old_version, new_version, ... }` -- **Errors**: - - `InvalidAmount`: Window not active - - `ContractPaused`: Already in-progress - - `InsufficientRole`: Not admin + - **Errors**: + - `UpgradeWindowNotActive`: Window not active + - `UpgradeAlreadyInProgress`: Already in-progress + - `InsufficientRole`: Not admin ### 4. Update WASM @@ -42,9 +42,12 @@ start_upgrade(env, caller, new_version) -> Result<(), RustAcademyError> upgrade(env, caller, new_wasm_hash) -> Result<(), RustAcademyError> ``` -- Swaps contract code (no storage changes) -- Emits: `ContractUpgraded { admin, new_wasm_hash, ... }` -- **Error**: `InsufficientRole` if not admin + - Swaps contract code (no storage changes) + - Emits: `ContractUpgraded { admin, new_wasm_hash, ... }` + - **Errors**: + - `UpgradeNotInProgress`: No upgrade in progress + - `UpgradeWindowNotActive`: Window not active + - `InsufficientRole`: Not admin ### 5. Complete Upgrade (Admin) @@ -57,9 +60,10 @@ complete_upgrade(env, caller, new_version) -> Result - Sets `UpgradeInProgress = false` - Emits: `UpgradeCompleted { admin, old_version, new_version, ... }` - **Returns**: New contract version -- **Errors**: - - `InternalError`: Not in-progress, or invariants failed - - `InvalidContractVersion`: Version mismatch + - **Errors**: + - `UpgradeNotInProgress`: No upgrade in progress + - `InternalError`: Invariants failed post-upgrade + - `InvalidContractVersion`: Version mismatch --- @@ -79,13 +83,14 @@ Day 2 @ 14:15: admin.complete_upgrade(2u32) --- -## Error Codes (Repurposed) +## Error Codes (Semantic) -| Error | Code | Meaning | -| ---------------- | ---- | ------------------------------ | -| `InvalidAmount` | 100 | Upgrade window not active | -| `ContractPaused` | 300 | Upgrade already in-progress | -| `InternalError` | 900 | Invariants failed post-upgrade | +| Error | Code | Meaning | +| ------------------------- | ---- | ------------------------------ | +| `UpgradeWindowNotActive` | 502 | Upgrade window not active | +| `UpgradeAlreadyInProgress`| 503 | Upgrade already in-progress | +| `UpgradeNotInProgress` | 504 | No upgrade in progress | +| `InternalError` | 900 | Invariants failed post-upgrade | --- @@ -168,7 +173,7 @@ Post-upgrade checks that **must** hold: A: No. The original `migrate()` still works standalone. These are optional extra guards. **Q: Can I call `complete_upgrade()` without `start_upgrade()`?** -A: No. `complete_upgrade()` checks `UpgradeInProgress` flag first → returns `InternalError`. +A: No. `complete_upgrade()` checks `UpgradeInProgress` flag first → returns `UpgradeNotInProgress`. **Q: What if invariants fail during `complete_upgrade()`?** A: Contract panics → all storage changes rolled back → upgrade aborted. Retry after fixing. diff --git a/app/contract/UPGRADE_SAFETY_GATE_TEST_GUIDE.md b/app/contract/UPGRADE_SAFETY_GATE_TEST_GUIDE.md index c3d492ca0..767845c32 100644 --- a/app/contract/UPGRADE_SAFETY_GATE_TEST_GUIDE.md +++ b/app/contract/UPGRADE_SAFETY_GATE_TEST_GUIDE.md @@ -33,7 +33,7 @@ assert!(result.is_err(), "start_upgrade must fail when no window is set"); ``` - - Expected: `InvalidAmount` ("upgrade window not active") + - Expected: `UpgradeWindowNotActive` ("upgrade window not active") 2. **Before Window** @@ -45,9 +45,9 @@ assert!(result.is_err(), "start_upgrade must fail before window start"); ``` - - Expected: `InvalidAmount` + - Expected: `UpgradeWindowNotActive` -3. **Within Window** + 3. **Within Window** ```rust gs.env.ledger().with_mut(|li| { @@ -71,7 +71,7 @@ assert!(result.is_err(), "start_upgrade must fail after window end"); ``` - - Expected: `InvalidAmount` + - Expected: `UpgradeWindowNotActive` **Acceptance Criterion**: ✅ AC1 – Upgrades are blocked outside the allowed window. @@ -301,7 +301,7 @@ LIMIT 1; ```rust let result = client.try_start_upgrade(&gs.admin, &3u32); assert!(result.is_err(), "start_upgrade must fail when already in progress"); - // → Expected: ContractPaused ("upgrade already in progress") + // → Expected: `UpgradeAlreadyInProgress` ("upgrade already in progress") ``` 3. **Complete & Retry** diff --git a/app/contract/contracts/Folder/src/admin.rs b/app/contract/contracts/Folder/src/admin.rs index f5f121145..3ada4d8af 100644 --- a/app/contract/contracts/Folder/src/admin.rs +++ b/app/contract/contracts/Folder/src/admin.rs @@ -354,11 +354,11 @@ pub fn start_upgrade( // Check upgrade window is active (Issue #432 AC1) if !storage::is_upgrade_window_active(env) { - return Err(RustAcademyError::InvalidAmount); // Repurpose for "upgrade window not active" + return Err(RustAcademyError::UpgradeWindowNotActive); } if storage::is_upgrade_in_progress(env) { - return Err(RustAcademyError::ContractPaused); // Reuse for "upgrade in progress" + return Err(RustAcademyError::UpgradeAlreadyInProgress); } let old_version = get_version(env); @@ -398,11 +398,11 @@ pub fn upgrade( require_admin(env, caller)?; if !storage::is_upgrade_in_progress(env) { - return Err(RustAcademyError::InternalError); + return Err(RustAcademyError::UpgradeNotInProgress); } if !storage::is_upgrade_window_active(env) { - return Err(RustAcademyError::InvalidAmount); + return Err(RustAcademyError::UpgradeWindowNotActive); } let pending_hash = @@ -448,7 +448,7 @@ pub fn complete_upgrade( new_version: u32, ) -> Result { if !storage::is_upgrade_in_progress(env) { - return Err(RustAcademyError::InternalError); // Not in upgrade state + return Err(RustAcademyError::UpgradeNotInProgress); } // Verify version and hash (Issue #432 AC2) diff --git a/app/contract/contracts/Folder/src/errors.rs b/app/contract/contracts/Folder/src/errors.rs index 401a0b9c0..9203bf031 100644 --- a/app/contract/contracts/Folder/src/errors.rs +++ b/app/contract/contracts/Folder/src/errors.rs @@ -91,4 +91,11 @@ pub enum RustAcademyError { NonceAlreadyUsed = 500, /// The signature's valid_until timestamp has passed; signature expired. SignatureExpired = 501, + // Upgrade gating (502-504) + /// The upgrade window is not currently active; start_upgrade is blocked. + UpgradeWindowNotActive = 502, + /// An upgrade is already in progress; start_upgrade cannot be called again. + UpgradeAlreadyInProgress = 503, + /// No upgrade is currently in progress; upgrade or complete_upgrade cannot proceed. + UpgradeNotInProgress = 504, } diff --git a/app/contract/contracts/Folder/src/lib.rs b/app/contract/contracts/Folder/src/lib.rs index fef4afd25..02293f952 100644 --- a/app/contract/contracts/Folder/src/lib.rs +++ b/app/contract/contracts/Folder/src/lib.rs @@ -1211,6 +1211,8 @@ impl RustAcademyContract { /// /// # Errors /// * `Unauthorized` - Caller is not the admin, or admin not set + /// * `UpgradeNotInProgress` - no upgrade is currently in progress + /// * `UpgradeWindowNotActive` - upgrade window is not currently active /// /// # Security /// Updates the contract's executable code. Call [`migrate`]( RustAcademyContract::migrate) @@ -1267,8 +1269,8 @@ impl RustAcademyContract { /// * `new_wasm_hash` - The target WASM hash /// /// # Errors - /// * `InvalidAmount` - (repurposed) upgrade window not active - /// * `ContractPaused` - (repurposed) upgrade already in progress + /// * `UpgradeWindowNotActive` - upgrade window is not currently active + /// * `UpgradeAlreadyInProgress` - an upgrade is already in progress pub fn start_upgrade( env: Env, caller: Address, @@ -1297,7 +1299,8 @@ impl RustAcademyContract { /// The actual new contract version /// /// # Errors - /// * `InternalError` - no upgrade in progress, or post-upgrade invariants violated + /// * `UpgradeNotInProgress` - no upgrade is currently in progress + /// * `InternalError` - post-upgrade invariants violated pub fn complete_upgrade( env: Env, caller: Address, diff --git a/app/contract/docs/UPGRADE_SAFETY_GATE.md b/app/contract/docs/UPGRADE_SAFETY_GATE.md index 7ea1d8e7b..67b4ffe0a 100644 --- a/app/contract/docs/UPGRADE_SAFETY_GATE.md +++ b/app/contract/docs/UPGRADE_SAFETY_GATE.md @@ -133,7 +133,7 @@ admin_account.complete_upgrade(contract, 2u32)?; - ✅ `start_upgrade()` fails after window end time - ✅ `start_upgrade()` succeeds within window `[start, end)` -**Error Code**: `InvalidAmount` (repurposed as "upgrade window not active") +**Error Code**: `UpgradeWindowNotActive` ### AC2: Post-Upgrade Invariants Enforced Deterministically @@ -235,13 +235,15 @@ pub struct UpgradeCompletedEvent { ## Error Codes -New or repurposed errors: +New upgrade-specific errors: -| Error | Code | Trigger | Context | -| ---------------- | ---- | ------------------------------ | ------------------------------------------- | -| `InvalidAmount` | 100 | Upgrade window not active | `start_upgrade()` outside `[start, end)` | -| `ContractPaused` | 300 | Upgrade already in progress | `start_upgrade()` called twice | -| `InternalError` | 900 | Post-upgrade invariants failed | `complete_upgrade()` after failed migration | +| Error | Code | Trigger | Context | +| ------------------------- | ---- | ------------------------------ | ------------------------------------------- | +| `UpgradeWindowNotActive` | 502 | Upgrade window not active | `start_upgrade()` outside `[start, end)` | +| `UpgradeAlreadyInProgress`| 503 | Upgrade already in progress | `start_upgrade()` called twice | +| `UpgradeNotInProgress` | 504 | No upgrade in progress | `upgrade()` / `complete_upgrade()` without `start_upgrade()` | + +Legacy repurposed codes removed; backend now maps these to stable API codes. --- @@ -291,7 +293,7 @@ let new_version = admin.complete_upgrade(contract, 2u32)?; ```rust // Same setup as above; but now it's Day 1, 13:00 UTC (before window) admin.start_upgrade(contract, 2u32)?; -// → Err: InvalidAmount ("upgrade window not active") +// → Err: UpgradeWindowNotActive ("upgrade window not active") ``` ---