Skip to content

feat(wallets): quorum-aware recovery identity — useSigner selects the held member (M4-4 part 4/4) - #1996

Open
panosinthezone wants to merge 1 commit into
panos/wal-11292-m4-4c-factory-quorum-runtime-configfrom
panos/wal-11292-m4-4-sdk-quorum-aware-recovery-identity-usesigner
Open

feat(wallets): quorum-aware recovery identity — useSigner selects the held member (M4-4 part 4/4)#1996
panosinthezone wants to merge 1 commit into
panos/wal-11292-m4-4c-factory-quorum-runtime-configfrom
panos/wal-11292-m4-4-sdk-quorum-aware-recovery-identity-usesigner

Conversation

@panosinthezone

@panosinthezone panosinthezone commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Linear: WAL-11292 · EDD §6.5 · Part 4/4 of the M4-4 stack — split for reviewability into #1997 (shared member matcher), #1998 (SignerManager/resolver plumbing), #1999 (factory runtime-config preservation), and this PR (the user-facing flows). The stack's union is byte-identical to this PR's original single-commit form.

Summary

Per EDD §6.5, the caller never selects "the quorum" — it selects, via useSigner, the member it holds. This PR makes that real on top of the stack's plumbing: the remaining QuorumSignerNotSupportedError guards are replaced with working flows or actionable errors. Launch scope is 1-of-n, but nothing here counts thresholds — the same code path serves m-of-n when the API gate lifts.

Review guide

  1. src/wallets/wallet.tsuseSigner / resolveNonDeviceSigner: the merged-config idea and the precedence rules; resolveForcedQuorumMember for the quorumLocator path.
  2. src/wallets/services/device-recovery-service.ts#resolveResumeRecoveryConfig (adopted-member-or-error).
  3. src/wallets/wallet.quorum-recovery.test.ts — the flipped "quorum member selection" suite is the behavioral spec; the useSigner → approve end-to-end test proves the chain joins up.

Compatibility

Old-vs-new dist .d.ts surface diff: one added export (UseSignerOptions), one signature change (useSigner gains an optional trailing param), nothing removed. QuorumSignerNotSupportedError stays exported (M4-6 may reuse it) but is no longer thrown. All new branches are gated on recovery.type === "quorum" — single-admin flows are byte-for-byte unchanged (125 pre-existing wallet.test.ts tests untouched). Changeset (minor) covers the whole stack and rides here.

Testing

  • Full packages/wallets unit suite: 735 passed (29 files) at the top of the stack.
  • New/extended here: flipped wallet.quorum-recovery.test.ts (21 tests incl. e2e approve under member locator, quorumLocator cases, multi-passkey disambiguation, server member secret-strip), device-recovery-service.test.ts (resume with/without adopted member).
  • tsc --noEmit clean; biome clean; dist rebuilt and react-base typechecks against it.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 186a28a

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

This PR includes changesets to release 9 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/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

Comment on lines +1029 to +1033
if (recovery.locator != null && recovery.locator !== quorumLocator) {
throw new Error(
`Quorum locator "${quorumLocator}" does not match this wallet's quorum admin signer ("${recovery.locator}").`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 quorumLocator guard silently skipped when quorum has no locator

The validation fires only when recovery.locator != null, so if the API returns a quorum config without a locator field (the field is typed as locator?: string), any caller-supplied quorumLocator passes unchecked. A caller who deliberately passes quorumLocator: "quorum:deadbeef" expecting a safety guard gets no error — the code proceeds as if the locator matched. Since quorumLocator is explicitly described as a disambiguation tool, silently accepting a wrong value undermines its purpose.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 1029-1033

Comment:
**`quorumLocator` guard silently skipped when quorum has no locator**

The validation fires only when `recovery.locator != null`, so if the API returns a quorum config without a `locator` field (the field is typed as `locator?: string`), any caller-supplied `quorumLocator` passes unchecked. A caller who deliberately passes `quorumLocator: "quorum:deadbeef"` expecting a safety guard gets no error — the code proceeds as if the locator matched. Since `quorumLocator` is explicitly described as a disambiguation tool, silently accepting a wrong value undermines its purpose.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1004 to +1010
if (matches.length > 1) {
// Only passkeys can match several members: id-less, name-less configs match permissively.
throw new Error(
"Multiple passkey members are in this wallet's quorum admin signer. " +
'Specify the credential id or name: wallet.useSigner({ type: "passkey", id: "<credential-id>" })'
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multi-match error message hardcodes "passkey" regardless of type

matches.length > 1 is only expected for passkeys in practice (because { type: "passkey" } without id/name permissively matches all passkey members), but other types could theoretically trigger this too — an { type: "email" } candidate with no email field matches all email members via the candidate.email == null guard in matchesQuorumMember. The current error message and suggestion are misleading for any non-passkey case.

Suggested change
if (matches.length > 1) {
// Only passkeys can match several members: id-less, name-less configs match permissively.
throw new Error(
"Multiple passkey members are in this wallet's quorum admin signer. " +
'Specify the credential id or name: wallet.useSigner({ type: "passkey", id: "<credential-id>" })'
);
}
if (matches.length > 1) {
// Only passkeys can match several members: id-less, name-less configs match permissively.
// Other types could also multi-match if the candidate omits identity fields (e.g. email == null),
// so build the message from the actual type rather than hard-coding "passkey".
const typeHint =
signer.type === "passkey"
? 'Specify the credential id or name: wallet.useSigner({ type: "passkey", id: "<credential-id>" })'
: `Specify the identifying field for type "${signer.type}" to disambiguate.`;
throw new Error(
`Multiple ${signer.type} members are in this wallet's quorum admin signer. ${typeHint}`
);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 1004-1010

Comment:
**Multi-match error message hardcodes "passkey" regardless of type**

`matches.length > 1` is only expected for passkeys in practice (because `{ type: "passkey" }` without `id`/`name` permissively matches all passkey members), but other types could theoretically trigger this too — an `{ type: "email" }` candidate with no `email` field matches all email members via the `candidate.email == null` guard in `matchesQuorumMember`. The current error message and suggestion are misleading for any non-passkey case.

```suggestion
        if (matches.length > 1) {
            // Only passkeys can match several members: id-less, name-less configs match permissively.
            // Other types could also multi-match if the candidate omits identity fields (e.g. email == null),
            // so build the message from the actual type rather than hard-coding "passkey".
            const typeHint =
                signer.type === "passkey"
                    ? 'Specify the credential id or name: wallet.useSigner({ type: "passkey", id: "<credential-id>" })'
                    : `Specify the identifying field for type "${signer.type}" to disambiguate.`;
            throw new Error(
                `Multiple ${signer.type} members are in this wallet's quorum admin signer. ${typeHint}`
            );
        }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
packages/wallets/src/wallets/wallet.ts:1029-1033
**`quorumLocator` guard silently skipped when quorum has no locator**

The validation fires only when `recovery.locator != null`, so if the API returns a quorum config without a `locator` field (the field is typed as `locator?: string`), any caller-supplied `quorumLocator` passes unchecked. A caller who deliberately passes `quorumLocator: "quorum:deadbeef"` expecting a safety guard gets no error — the code proceeds as if the locator matched. Since `quorumLocator` is explicitly described as a disambiguation tool, silently accepting a wrong value undermines its purpose.

### Issue 2
packages/wallets/src/wallets/wallet.ts:1004-1010
**Multi-match error message hardcodes "passkey" regardless of type**

`matches.length > 1` is only expected for passkeys in practice (because `{ type: "passkey" }` without `id`/`name` permissively matches all passkey members), but other types could theoretically trigger this too — an `{ type: "email" }` candidate with no `email` field matches all email members via the `candidate.email == null` guard in `matchesQuorumMember`. The current error message and suggestion are misleading for any non-passkey case.

```suggestion
        if (matches.length > 1) {
            // Only passkeys can match several members: id-less, name-less configs match permissively.
            // Other types could also multi-match if the candidate omits identity fields (e.g. email == null),
            // so build the message from the actual type rather than hard-coding "passkey".
            const typeHint =
                signer.type === "passkey"
                    ? 'Specify the credential id or name: wallet.useSigner({ type: "passkey", id: "<credential-id>" })'
                    : `Specify the identifying field for type "${signer.type}" to disambiguate.`;
            throw new Error(
                `Multiple ${signer.type} members are in this wallet's quorum admin signer. ${typeHint}`
            );
        }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "useSigner with quorums" | Re-trigger Greptile

@panosinthezone
panosinthezone force-pushed the panos/wal-11292-m4-4-sdk-quorum-aware-recovery-identity-usesigner branch from da22107 to 87e7fca Compare July 29, 2026 14:57
@panosinthezone panosinthezone changed the title feat(wallets): quorum-aware recovery identity — useSigner selects the held member feat(wallets): quorum-aware recovery identity — useSigner selects the held member (M4-4 part 4/4) Jul 29, 2026
@panosinthezone
panosinthezone changed the base branch from panos/wal-11291-m4-3-sdk-teach-the-approval-loop-the-nested-quorumapprovals to panos/wal-11292-m4-4c-factory-quorum-runtime-config July 29, 2026 15:00
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "feat(wallets): quorum-aware recovery ide..." | Re-trigger Greptile

… held member

useSigner now resolves a config against the wallet's quorum members and
assembles the matched member as an admin signer under its API locator, with
an optional { quorumLocator } to force the member interpretation. Device-signer
recovery reuses a member previously selected this session, replacing the last
QuorumSignerNotSupportedError guards with working flows or actionable errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@panosinthezone
panosinthezone force-pushed the panos/wal-11292-m4-4c-factory-quorum-runtime-config branch from 2cada2f to b46b40a Compare July 30, 2026 20:43
@panosinthezone
panosinthezone force-pushed the panos/wal-11292-m4-4-sdk-quorum-aware-recovery-identity-usesigner branch from 87e7fca to 186a28a Compare July 30, 2026 20:43
@github-actions

Copy link
Copy Markdown
Contributor

🔥 Smoke Test Results

Status: Failed

Statistics

  • Total Tests: 5
  • Passed: 0 ✅
  • Failed: 1 ❌
  • Skipped: 4 ⚠️
  • Duration: 1.36 min

Test Details


This is a non-blocking smoke test. Full regression tests run separately.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Reviews (3): Last reviewed commit: "feat(wallets): quorum-aware recovery ide..." | Re-trigger Greptile

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