Skip to content

fix(#1182): guardian and recovery-key paths share one recovery slot - #1225

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
Hussman256:fix/issue-1182-one-recovery-at-a-time
Oct 22, 2026
Merged

Miracle656 merged 1 commit into
Miracle656:mainfrom
Hussman256:fix/issue-1182-one-recovery-at-a-time

Conversation

@Hussman256

Copy link
Copy Markdown
Contributor

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:

Guardian Recovery key
Entrypoints set_guardian → initiate_recovery → complete_recovery set_recovery_key → request_recovery → finalize_recovery
Timelock 3 days 7 days
Who vouches A party the owner trusts (another account, a friend, a custodian) SEP-30 recovery servers that re-verify the owner's identities
Used by Web docs' key-rotation flow Mobile app/recover.tsx

Removing 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 RecoveryPending or RecoveryKeyPending is recovery::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 = 28
    • RecoveryKeyRequestPending = 29

    Starting a recovery in either path returns whichever one applies. complete_recovery / finalize_recovery called while the other system holds the slot also return it, instead of RecoveryNotPending. 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_recovery and cancel_recovery_request both 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 the RecoverySource it 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 or rotate_signer instead. 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.rs adds 12 tests on a wallet with three signers, a guardian and a recovery key:

  • The race, both orders. Initiate both, then finalise each. guardian_first_blocks_recovery_key_and_wins_alone and recovery_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.
  • Cross-cancel. Each cancel function cancels the other system's recovery, and the signers are untouched.
  • Signers replaced on both paths, including recovery to a key that was already a signer.
  • Bounded exhaustive check. no_sequence_of_operations_leaves_two_recoveries_pending runs 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, that get_pending_recovery agrees 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 test for the wallet: 102 passed locally before I rebased onto current main. After the rebase the only conflict was two mod lines in lib.rs, but I haven't re-run the suite locally on the rebased commit, so CI is the check for that. Mobile jest lib/__tests__/recovery.test.ts: 25 passed, including a new case for codes 28/29.

Clients and docs

  • frontend/mobile/lib/recovery.ts translates 28 and 29 into user-facing messages. 13, 14 and 15 are kept for older wallets.
  • frontend/docs/pages/sdk-errors.mdx gets corrected rows for 13/14 and new rows for 28/29.
  • frontend/docs/pages/security/key-rotation.mdx gets 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:

  1. Both recovery paths can be pending on your wallet at the same time.
  2. If you're alerted to a recovery you didn't start, call both cancel_recovery and cancel_recovery_request. A RecoveryNotPending from one of them is harmless; it only means that path was idle.
  3. On your wallet, RecoveryAlreadyPending / RecoveryNotPending don't tell you which path they refer to.
  4. Either recovery, if it completes, replaces all of your signers with the single recovered key. Additional devices have to be re-added afterwards.

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.json

Not 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.wasm hash, 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 an init argument rather than embedding it. Please don't merge before that commit lands.

…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.
@vercel

vercel Bot commented Oct 21, 2026

Copy link
Copy Markdown

@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.

@Miracle656
Miracle656 merged commit fb96c7f into Miracle656:main Oct 22, 2026
15 of 19 checks passed
@Miracle656

Copy link
Copy Markdown
Owner

Merged as fb96c7fe. Closes #1182. 138 contract tests passing, up from 127, and it merged on top of #1233 with no conflict despite both touching lib.rs.

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.

claim_slot as a single chokepoint is the correct shape. Every write to either RecoveryPending or RecoveryKeyPending goes through one function, so the invariant holds by construction rather than by every caller remembering to check. That is the difference between a fix and a patch.

The errors are now distinguishable — GuardianRecoveryPending vs RecoveryKeyRequestPending. That was the part of the issue most likely to be skipped, because nothing visibly breaks without it. A caller can now learn which door is occupied, not merely that one is.

cancel_any clears both keys and reports which system held the slot. An owner who cancels a recovery they were warned about has provably cancelled everything. The real danger in the original was a second timelock still counting down behind the one you just stopped.

And the test names are hazards rather than function names:

guardian_first_blocks_recovery_key_and_wins_alone
cancel_recovery_also_cancels_a_recovery_key_request
recovery_to_an_existing_signer_still_evicts_the_others
slot_is_free_again_after_a_recovery_completes

Someone reading that list in a year learns what the module defends. recovery_to_an_existing_signer_still_evicts_the_others is a case I had not thought of when I wrote the issue.

One note, no action needed: that both finalisers still call init_signers and therefore evict every other signer is now a deliberate, tested decision rather than an accident. That was the part of #1182 I was least confident would be handled well, and pinning it with a test is defensible — recovery is for when you have lost control, and a clean slate is arguably what you want there.

expected-hashes.json correctly untouched. The invisible_wallet.wasm hash does need regenerating now, for this and #1233 together, and that is mine to do from the reproducible-build CI log.

Miracle656 added a commit that referenced this pull request Oct 22, 2026
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
Miracle656 added a commit that referenced this pull request Oct 22, 2026
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
Miracle656 added a commit that referenced this pull request Oct 22, 2026
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
Miracle656 added a commit that referenced this pull request Oct 22, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A wallet has two recovery systems and they cannot see each other

2 participants