feat(mobile): SEP-30 recovery screen binding a fresh signer - #592
Open
Adeolu01 wants to merge 1 commit into
Open
feat(mobile): SEP-30 recovery screen binding a fresh signer#592Adeolu01 wants to merge 1 commit into
Adeolu01 wants to merge 1 commit into
Conversation
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 is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'srequire_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, andrequireAll: falseallows 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.tssdk/src/recovery/sep30.tsand kept in the app, so mobile has no dependency on the browser SDK build (the same reasoning aslib/webauthn.ts).fetchis 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.EXPO_PUBLIC_RECOVERY_SERVERSand remembered inAsyncStorageso the URLs can be rediscovered after device loss. Onlyhttpsis accepted: a recovery server is asked to sign transactions against the user's wallet, so a plaintext endpoint is not one we talk to.resolveRecoveryServersreports 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.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.tsxFour steps: confirm the wallet address, contact the servers, create the passkey, finalize. The address is checked by reading
get_signerson-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.ts—setPasskeyCredentialwrites 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 inlib/__tests__/recovery.test.tsover an injectedfetch: 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 typecheckpasses.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.rstests (request, finalize, the timelock boundary, and cancellation).Notes for review
/recoverrecovers 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.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.