Repository navigation
fix(#1182): guardian and recovery-key paths share one recovery slot - #1225
Conversation
…ery slot The wallet keeps both recovery systems: the guardian (3-day timelock) and the recovery key (7-day, used by mobile's SEP-30 flow). They now share one pending-recovery slot instead of running blind to each other: - initiate_recovery / request_recovery refuse while either system has a recovery pending, with GuardianRecoveryPending (28) or RecoveryKeyRequestPending (29) naming the system that holds the slot. - complete_recovery / finalize_recovery called for the wrong system report which system holds the slot rather than RecoveryNotPending. - cancel_recovery and cancel_recovery_request both clear whatever is pending, so after either succeeds nothing is counting down. - get_pending_recovery() reports the pending system, key and unlock time. Recovery still replaces every signer, now deliberately and documented in recovery::install_recovered_signer: recovery is only needed when no registered signer can authorise, so every existing signer is lost or compromised, and merging would let a stolen device survive recovery. Wallets already deployed keep the old behaviour; docs tell their owners to call both cancel functions.
|
@Hussman256 is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Merged as This is the best PR I have reviewed in this Wave, and I want to be specific about why. You made the decision the issue asked for, and made the right one. Keeping both paths and giving them one slot beats the obvious answer of deleting one. They do answer different questions — a nominated third party versus a cold key you hold — and each already had a client built on it. The table in your description settled that in four lines.
The errors are now distinguishable —
And the test names are hazards rather than function names: Someone reading that list in a year learns what the module defends. One note, no action needed: that both finalisers still call
|
Taken from the reproducible-build log on main (run 39766885568), which is the
documented source for this value rather than a local build:
DRIFT invisible_wallet.wasm
expected 1368cd637799a9a822c1ff53e24b5315d5671bb3d2e12960c25dc64d7b3fd024
got 336cf8b9c2289728b8bca96d3d4ad339345d65830ad3599b239ebeb7a1c296db
The contract genuinely changed: #1233 added the NextSignerIndex storage key
and changed add_signer's signature, #1225 restructured recovery behind a
shared slot. One drift, and factory, mock_wallet, multisig_wallet and vault
all verified OK in the same run -- which also confirms that reverting #1233's
hand-edit of the multisig_wallet entry was right. That entry still matches at
8d8a46da7219530a977c56a5fe1301461428d4c317fd9ccc6b2fd8dff2e144b9, the 64-char
value; the PR had written a 63-char one.
Every artifact hash in the file is asserted to be 64 characters before commit.
None of this affects deployed wallets. Neither contract is upgradeable, so the
mainnet factory keeps deploying the old code until a new factory ships; this
hash describes what the current source builds, not what is live.
Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Nothing proved an operator could not move a user's money. Now seven tests do. cargo test 149 passing (21 + 122 + 1 + 5), invisible_wallet up from 115 fee_payer_cannot_spend_or_take_over factory_admin_cannot_spend_or_take_over guardian_cannot_spend_or_take_over_directly fee_payer_cannot_run_a_recovery guardian_cannot_finish_its_own_recovery_early fee_payer_plus_guardian_together_are_still_not_the_user the_guardians_one_power_stays_cancelable_by_the_owner The sixth is the one #1175 asked for that a happy-path author would never write: roles in combination, because "neither of these alone can do it" is a weaker claim than "nor can they together". The seventh is its mirror -- it pins the one power a guardian does have, and that the owner can always cancel it. assert_role_cannot_control_wallet as a shared helper is the right shape: each test names a role, and the invariant it must not break is written once. Resolved two conflicts on merge, both from #1225 and the hash update landing first: lib.rs -- both branches added a test module and edited the same `use`. Kept both modules and main's import line, since #1225 moved PendingRecovery out of lib.rs into recovery.rs. expected-hashes.json -- kept main's corrected values. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
A cap of 100 meant a hundred XLM, or a hundred USDC, or a hundred of anything else the wallet held. The limit now takes the token: set_key_spend_limit(key_id, token, cap) remove_key_spend_limit(key_id, token) get_key_spend_limit(key_id, token) get_key_spend_limit_tokens(key_id) -- new, so a client can enumerate cargo test 171 passing (21 + 144 + 1 + 5), invisible_wallet up from 122 #1169 asked for a deliberate decision on what an unset token means, and this one fails closed: SpendLimitTokenNotAllowed, with the reasoning in the error's own doc comment -- "a scoped key may only move the tokens it has been given a budget for". For a key that exists to be restricted, deny-by-default is the right way round, and SpendLimitUnsupportedCall covers a call whose token movement cannot be determined rather than guessing at it. Four conflicts resolved on merge, all additive, all caused by #1225, #1190 and the hash fix landing first: lib.rs both branches added test modules and edited the same `use`; kept all modules and main's import line. lib.rs (errors) DISCRIMINANT COLLISION -- the PR claimed 28 and 29, which #1225 had already taken for GuardianRecoveryPending and RecoveryKeyRequestPending. Renumbered the incoming SpendLimitTokenNotAllowed and SpendLimitUnsupportedCall to 30 and 31. This repo has hit that collision before; the rule is to renumber the arriving variant, never the shipped one. batch_tests.rs the PR moved the WebAuthn fixture to a shared webauthn_fixture.rs while #1230 added a suite-layout comment; kept both. spend_limit.rs both added test scaffolding; kept both. expected-hashes kept main's corrected values. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Only the hash file lands -- the #[cfg(test)] half of this PR is already on main as 028df05, which I pushed an hour earlier after breaking the build the same way. The diff for lib.rs against main is empty. factory.wasm 4da78fd8b4d6c7b4a5c8202754c6ddf6c7ebf3b875f86c6ca0b87c175cc17b2f invisible_wallet.wasm 98ca123cc2ce64ad89e1fa7b7f0259bc13388be63d3624b80cd86e115c128bf8 Both verified against the reproducible-build log (run 39777073456) rather than taken on trust: each value appears there twice, once in the computed artifact block and once in the DRIFT line. I checked because a PR earlier today hand-edited this file and wrote a 63-character SHA-256 for a contract it did not touch. Every one of the five artifact hashes is asserted to be 64 characters before commit. Both contracts genuinely changed today -- #1189 added set_wasm_hash to the factory, and #1199, #1225 and #1233 changed the wallet -- so this is drift with a cause, not a mystery. cargo build --all clean This clears the reproducible-build failure that has been red on main since those merges, which was mine to fix and which this contributor did first. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Closes #1182.
Decision: keep two paths, give them one slot
The wallet keeps both recovery systems. They answer different "who vouches for me" questions, and each has a client built on it:
set_guardian→initiate_recovery→complete_recoveryset_recovery_key→request_recovery→finalize_recoveryapp/recover.tsxRemoving either one would break whichever client is built on it. Nobody chose for them to run blind to each other, though, so they now share one recovery slot. The reasoning is written in the module doc at the top of
contracts/invisible_wallet/src/recovery.rs. The two delays are unchanged, because changing them is a separate decision.What changed (contract)
No race. The only code that writes
RecoveryPendingorRecoveryKeyPendingisrecovery::claim_slot, and it refuses while either key is set. So at most one recovery can be pending.Distinguishable errors. There are two new codes, and each names the system holding the slot:
GuardianRecoveryPending = 28RecoveryKeyRequestPending = 29Starting a recovery in either path returns whichever one applies.
complete_recovery/finalize_recoverycalled while the other system holds the slot also return it, instead ofRecoveryNotPending.RecoveryNotPending(14) now means nothing is pending in either system.RecoveryAlreadyPending(13) is no longer returned, but it stays in the enum so clients can still decode errors from older wallets.Unambiguous cancel.
cancel_recoveryandcancel_recovery_requestboth clear whatever is pending in either system. Both storage keys are removed, so after either succeeds nothing is counting down. The cancel event now carries theRecoverySourceit cancelled.Visibility. The new view
get_pending_recovery() -> Option<PendingRecoveryStatus>returns the source, the new key and the unlock time.Signers are still replaced, deliberately. Both paths go through
recovery::install_recovered_signer, which keeps the reset and explains why. Recovery is only needed when the owner can't authorise with any registered signer; an owner with one working signer would cancel orrotate_signerinstead. That means every existing signer is lost or compromised. Merging would let a stolen device keep a seat on the recovered wallet, and would make recovery unable to evict exactly the key it most needs to evict.What recovery does not touch (documented, not changed): the guardian, the recovery key, allowances, spend limits and session keys. Session keys sit in temporary storage keyed by id and can't be enumerated, so they survive recovery until their own expiry or until the recovered signer revokes them. (The issue text says session keys are wiped; on the current code they are not.) Making recovery invalidate them would need an epoch counter on the session-key ACL, which touches the signing path. I left that for a separate issue.
Tests
contracts/invisible_wallet/src/recovery_slot_test.rsadds 12 tests on a wallet with three signers, a guardian and a recovery key:guardian_first_blocks_recovery_key_and_wins_aloneandrecovery_key_first_blocks_guardian_and_wins_alone: the second start is refused with the other system's code, finalising through the wrong entrypoint names the holder, and only the first system's key is ever installed, even after both delays have passed.no_sequence_of_operations_leaves_two_recoveries_pendingruns every sequence of three operations (512 sequences) drawn from {initiate, request, complete, finalize, cancel, cancel_request, wait 3d, wait 7d}. After every step it reads raw storage and asserts that the two pending keys are never both set, thatget_pending_recoveryagrees with storage, and that the signer set is either untouched or exactly one of the two proposed keys.None of the existing tests were changed or weakened.
cargo testfor the wallet: 102 passed locally before I rebased onto currentmain. After the rebase the only conflict was twomodlines inlib.rs, but I haven't re-run the suite locally on the rebased commit, so CI is the check for that. Mobilejest lib/__tests__/recovery.test.ts: 25 passed, including a new case for codes 28/29.Clients and docs
frontend/mobile/lib/recovery.tstranslates 28 and 29 into user-facing messages. 13, 14 and 15 are kept for older wallets.frontend/docs/pages/sdk-errors.mdxgets corrected rows for 13/14 and new rows for 28/29.frontend/docs/pages/security/key-rotation.mdxgets a "One recovery at a time" section explaining the signer reset, plus guidance for owners of existing wallets.Wallets that already hold money
Deployed wallets keep the old behaviour. There is no upgrade path; only wallets created by a factory initialised with the new WASM hash get this. Owners of existing wallets should be told:
cancel_recoveryandcancel_recovery_request. ARecoveryNotPendingfrom one of them is harmless; it only means that path was idle.RecoveryAlreadyPending/RecoveryNotPendingdon't tell you which path they refer to.Funds are not at risk from upgrading, because nothing migrates. An owner who wants the new behaviour has to create a new wallet from the new factory and move funds across.
contracts/expected-hashes.jsonNot updated yet. I don't have Docker locally, so I can't produce the canonical hash. Once CI's reproducible-build job prints the new
invisible_wallet.wasmhash, I'll push a follow-up commit to this PR updating that one line. The other artifacts should be unchanged, because the factory takes the wallet hash as aninitargument rather than embedding it. Please don't merge before that commit lands.