Skip to content

feat(mobile): SEP-30 recovery screen binding a fresh signer - #592

Open
Adeolu01 wants to merge 1 commit into
Miracle656:mainfrom
Adeolu01:feat/mobile-recover-sep30
Open

feat(mobile): SEP-30 recovery screen binding a fresh signer#592
Adeolu01 wants to merge 1 commit into
Miracle656:mainfrom
Adeolu01:feat/mobile-recover-sep30

Conversation

@Adeolu01

Copy link
Copy Markdown

Summary

The way back in after losing the device that held the wallet's passkey. A passkey-only wallet has no seed phrase, so nothing on a new device can authorize a signer change on its own — the SEP-30 recovery servers registered while the wallet was healthy do it instead.

closes #485

How it works

On-chain this is the wallet contract's two-step rotation. request_recovery(new_signer) is authorized by the wallet's recovery-key address and starts the 7-day timelock; finalize_recovery() is permissionless once the timelock expires and installs the new signer. Both transactions are sourced from the recovery-key address, so the servers' envelope signatures are what satisfy the contract's require_auth — this device never holds a key that could authorize the rotation alone.

With a single recovery server that address is the signer the server contributes, discovered from the server itself. With several it is the multisig account they are all signers on, named by EXPO_PUBLIC_RECOVERY_KEY_ADDRESS; the same collect-and-attach path covers both, and requireAll: false allows an M-of-N threshold.

The timelock is the safety property, not an inconvenience: it is the window in which an owner who still holds their key can cancel a recovery they did not start. So the flow is explicitly two visits, and the app is built to survive the week between them.

What changed

lib/recovery.ts

  • SEP-30 transport — ported from sdk/src/recovery/sep30.ts and kept in the app, so mobile has no dependency on the browser SDK build (the same reasoning as lib/webauthn.ts). fetch is injectable, which is what makes the client unit-testable. Server errors carry the server's own message and HTTP status. SEP-10 authentication is out of scope here as it is in the SDK: the caller supplies the JWT.
  • Server list — parsed from EXPO_PUBLIC_RECOVERY_SERVERS and remembered in AsyncStorage so the URLs can be rediscovered after device loss. Only https is accepted: a recovery server is asked to sign transactions against the user's wallet, so a plaintext endpoint is not one we talk to. resolveRecoveryServers reports per-server failures instead of throwing, because recovery can still proceed on the servers that did answer and the user needs to see which ones did not.
  • The new signer — a fresh device passkey, with the 65-byte uncompressed P-256 point extracted from the DER SPKI the platform returns. The algorithm OIDs are checked rather than trusted: a key on another curve would otherwise be silently truncated to 65 bytes and registered as a signer that can never sign. Dismissing the sheet resolves as a cancellation, not a failure.
  • Transactions — built, simulated, and assembled before signatures are collected, because a signature covers the transaction exactly as submitted and a footprint attached afterwards would invalidate it. A signature issued for a different network is refused rather than attached — it would be rejected on submission anyway, and the mismatch means a misconfigured server, which is worth saying out loud. Contract error codes 13, 14, 15 and 22 are translated into what the user should do about them.
  • Pending recovery — persisted to AsyncStorage, including the new credential id and public key, because a week is a long time to hold state in memory. It is deliberately not the keychain: the device must remember which passkey it created, but it must not present that passkey as the wallet's credential before the contract says it is one. The timelock check mirrors the contract's strict > comparison, and the unlock time is derived from the ledger close time rather than the device clock, so a wrong clock cannot show a finalize date the network will not honour.

app/recover.tsx

Four steps: confirm the wallet address, contact the servers, create the passkey, finalize. The address is checked by reading get_signers on-chain, which is the cheapest possible signal that it is wrong, and the current signer count is shown so the user can see they are looking at the right wallet.

A recovery started on a previous launch is picked back up on mount rather than making the user start over, and while one is in flight the flow cannot start a second — the contract would reject it and the passkey created for it would be orphaned. Finalizing asks the contract whether the new signer is actually listed rather than inferring success from a submitted transaction, and the keychain is written only after that confirms.

lib/walletStore.tssetPasskeyCredential writes the credential id and public key together; one without the other produces assertions the wallet contract cannot verify.

README.md — the recovery section: environment variables, the on-chain mechanism, and why the pending state lives where it does.

Testing

npm test — 24 new unit tests in lib/__tests__/recovery.test.ts over an injected fetch: the SEP-30 client's auth header, URL normalisation, error surfacing and signing path; signature collection including the all-or-nothing and M-of-N cases; server-list parsing (separators, duplicates, non-https rejection) and the remembered-list round trip including recovery from stored junk; wallet and recovery address validation; SPKI extraction with the wrong-curve, compressed-point and truncated cases; signature attachment against a real signed envelope, plus the wrong-network and no-signatures refusals; the timelock boundary; and the contract-error translations. 136 tests pass on this branch.

npm run typecheck passes.

The parts that need a device or live servers — the passkey creation sheet and an end-to-end rotation against a deployed wallet — are not automated. The screen is a thin layer over the tested library for that reason, and the contract side of the mechanism is already covered by contracts/invisible_wallet/src/recovery.rs tests (request, finalize, the timelock boundary, and cancellation).

Notes for review

  • No new dependencies.
  • The web wallet's /recover recovers a known passkey by verifying it against the wallet's on-chain signers, which needs no servers but cannot help a user whose passkey went with the device. That path is unported here on purpose: local P-256 verification needs a curve implementation the app does not carry, and the mnemonic path it offers alongside is already covered on mobile by the encrypted backup screen.
  • The screen assumes the wallet's recovery key was set (set_recovery_key) while it was healthy. A wallet without one gets a clear message rather than a failed transaction; the settings surface for registering identities with recovery servers is a separate backlog item.

Adds the way back into a wallet after the device holding its passkey is gone.
There is no seed phrase to fall back on, so the SEP-30 recovery servers
registered while the wallet was healthy co-sign the contract's
request_recovery, a new passkey is created on this device to be the signer, and
finalize_recovery installs it once the 7-day timelock expires.

Both transactions are sourced from the wallet's recovery-key address so the
servers' envelope signatures satisfy require_auth; the SEP-30 transport is
ported from sdk/src/recovery/sep30.ts and kept in the app. The new credential
is held in the pending-recovery record rather than the keychain until the
contract confirms the rotation, so the device never presents a signer the
wallet does not yet accept.
@Adeolu01
Adeolu01 requested a review from Miracle656 as a code owner July 29, 2026 21:26
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

@Adeolu01 is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@Adeolu01 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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.

57. Recover screen (SEP-30)

1 participant