Skip to content

feat(wallets): signer changes approved by a recovery quorum (EVM quorum E3) - #2261

Draft
daniil-dovgal wants to merge 7 commits into
daniil/ts-quorum-e2-act-as-quorumfrom
daniil/ts-quorum-e3-signer-changes
Draft

daniil-dovgal wants to merge 7 commits into
daniil/ts-quorum-e2-act-as-quorumfrom
daniil/ts-quorum-e3-signer-changes

Conversation

@daniil-dovgal

Copy link
Copy Markdown
Contributor

Description

High level implementation overview

Third EVM milestone of the TS SDK quorum stack, stacked on #2256. The foundation (#2214, #2215, #2233, #2225) adds the quorum types, rules, signer seams and approval polling. E1 (#2255) lets a client approve as a member, and E2 (#2256) lets a member-only key act as its quorum on creating calls. Until m0f settles the public result type, "other members must still sign" arrives as E1's awaiting errors.

Before this PR, every signer change sent one recovery method as approver (withRecoverySigner), so a recovery quorum could not approve, and useRecoveryMethod refused member-only keys. This PR implements EDD 007 §7 "Role" for signer changes (use cases 6 and 7): the four calls take a signer option, sent as approver; useRecoveryMethod(member) picks the member's recovery quorum; E1's approval loop signs the held member.

await wallet.useRecoveryMethod(alice); // Alice is only a member of the recovery quorum
await wallet.addSigner(newDevice); // approver: the recovery quorum; Alice's approval is signed

await wallet.useRecoveryMethod(bob); // on Bob's device
await wallet.approve({ transactionId }); // signs Bob's member entry
flowchart LR
    A["addSigner / removeSigner /<br/>add / removeRecoveryMethod"] --> B["withApprover"]
    B --> C{"signer option?"}
    C -- "a recovery method" --> D["that method signs (as today)"]
    C -- "other (quorum)" --> E["approver as given;<br/>held keys sign (E1)"]
    C -- no --> F{"member selected?"}
    F -- yes --> G["its recovery quorum<br/>(or ambiguous error)"]
    F -- no --> H["withRecoverySigner (today)"]
Loading

Details

  • wallets/services/signer-manager.ts withApprover({ explicit, ignoreMember }, operation) replaces withRecoverySigner in the four calls:
    • An explicit recovery method signs, as useRecoveryMethod would (a server one given by its secret too; the secret moves into the resolver).
    • Any other explicit locator, such as a quorum, is sent as given, and the held keys sign. With no active key, the selected recovery method, else the member, stands in; with no key held, RecoveryMethodRequiredError before any request.
    • A selected member → feat(wallets): quorum rules and quorum API errors (quorum foundation 2/3) #2215's resolveActingRole (signer-change scope): its recovery quorum, QuorumSelectionAmbiguousError, or itself once it is a recovery method.
    • Otherwise, today's withRecoverySigner.
  • useRecoveryMethod (wallet.ts): a member of a recovery quorum that is not a recovery method itself is stored as a member selection (a server member keeps only its address). approve signs its entries, and with no active key the selected key stands in, so use case 6 works on Bob's device as written. The chain wallets' from() carries the selection over, as it carries E2's marker.
  • Device recovery passes ignoreMember, so it keeps the recovery method as approver until E5. Otherwise the install would wait on the quorum, and recovery would delete the new device key. Other calls keep the selection, also while recovery runs.
  • Chains: Solana and Stellar pick the approver the same way, but keep m0d's supportsQuorumApprovals guard. A pending quorum entry there throws QuorumSignerNotSupportedError; an api-key signer leaves it to the API as m0d does, so it gets the awaiting error. Signing held members is EVM only.
  • Quorum-only wallets with nothing selected throw RecoveryMethodRequiredError, which says how to choose. wallets/types.ts: the four option types gain an optional signer; prepareOnly stays required, as on send.

Accepted:

  • A delegated quorum passed as signer is sent as given. The API rejects it, because only recovery entries can approve.
  • A passkey member resolves as the wallet's passkey recovery method when that one is stored without its id, in useRecoveryMethod too (as E1 does in useSigner). Workaround: signer: <quorum>. A server member on its legacy derivation isn't matched either.
  • from() already dropped a selected recovery method before this PR; that gets its own ticket. A server member it forwards can't sign on the chain wallet, whose server-key resolver starts empty (as for server recovery methods today).
  • "A member leaves every recovery quorum" fails before any request. It isn't reachable until E4 adds removing a quorum.

Next Steps

E4: create and manage quorums, on createWallet and as the thing the four calls add or remove. EVM addRecoveryMethod / removeRecoveryMethod work once #2219 lands on main and is merged into the stack.

Test plan

  • Tests first: the first commit adds the spec. Red baseline: 85 failed, 16 passed. Explicit signer was ignored, useRecoveryMethod refused members, and quorum-only wallets threw QuorumSignerNotSupportedError.
  • Suites:
    • wallets/wallet.quorum-signer-changes.test.ts (new, 153 tests): approver choice on every chain; on EVM, which keys sign, landing or awaiting, Bob approving with and without an active key, resume, prepareOnly; on Stellar, the guard and the api-key path; from() on each chain.
    • wallets/wallet.quorum-member-signer.test.ts: every member type, an explicit server secret, a server member signing.
    • wallets/wallet.test.ts: recover() with a selected member (device install and stale-device removal approved by the recovery method, device key kept, selection kept).
    • wallets/services/signer-manager.test.ts: quorum-only rows expect RecoveryMethodRequiredError.
  • Mutation checks on each decided rule above (incl. clearing the selection during or after recovery): each fails its rows.
  • Run: cd packages/wallets && pnpm exec vitest run src/wallets src/quorum src/utils src/chains src/signers → 1252 passed. tsc on src/ is clean, the changed tests type-check, and biome reports no errors.
  • Fresh-context review, three rounds, every finding fixed or decided. The main ones: device recovery deleting the new device key, the active key dropped from the held keys, approve without an active key, and from() losing the selection.

Package updates

  • @crossmint/wallets-sdk: minor (.changeset/quorum-signer-changes.md).

daniil-dovgal and others added 7 commits October 29, 2026 01:48
…(red)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on the four calls; useRecoveryMethod selects a member

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay held; a member that became a recovery method approves as itself; explicit server secret resolved; no key held fails first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecovery method stands in for a missing active key

- withApprover takes one request ({ explicit, ignoreMember }); device recovery passes ignoreMember instead of
  clearing the shared member selection, so a concurrent signer change keeps its quorum approver.
- With no active key, the selected recovery method, else the selected member, signs as the active one.
- Spec reworked for the per-chain guard from the E2 merge: signing rows run on EVM; Stellar asserts the quorum
  approver and QuorumSignerNotSupportedError. Adds recover() selection rows and the server-member derivation row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ain wallets keep the member selection

- approve(): with no active signer, the selected recovery method, else the selected member, signs (EDD 007
  section 7 use case 6). preAuthIfNeeded splits into recover and authenticate so the stand-in starts after recovery.
- EVMWallet / SolanaWallet / StellarWallet.from forward the member selection, as they forward E2's member marker.
- The no-key error names how to hold a key for any explicit approver, not only a quorum.
- JSDoc for the four signer-change calls and useRecoveryMethod describes quorum approvers.
- Tests pin the api-key path on Stellar (the quorum entry is left to the API: awaiting error) and the stale
  device removal during recover() with a member selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd96962

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@crossmint/wallets-sdk Minor
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-react-ui Patch
@crossmint/integration-tests Patch
@crossmint/wallets-playground-expo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch has not been deployed

No deployments
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.

1 participant