Skip to content

fix: add_contact + listContacts work without paired Ledger (#428, partial) - #471

Merged
szhygulin merged 1 commit into
mainfrom
fix/428-contacts-no-ledger
Apr 28, 2026
Merged

fix: add_contact + listContacts work without paired Ledger (#428, partial)#471
szhygulin merged 1 commit into
mainfrom
fix/428-contacts-no-ledger

Conversation

@szhygulin

Copy link
Copy Markdown
Collaborator

Summary

  • Closes #428 partially (user-visible gap). The full state machine (persistent unsigned entries + sign-on-pair upgrade + Solana/TRON support) is intentionally deferred — see "Deferred" below.
  • add_contact no longer hard-fails with CONTACTS_LEDGER_NOT_PAIRED in non-demo mode. When no Ledger is paired, the call falls through to the same in-memory store demo mode uses, returns unsigned: true + anchorAddress: "UNSIGNED_NO_LEDGER". Persistence is process-local (lost on restart) until the deferred state machine ships.
  • list_contacts merges the unsigned overlay on top of the verified signed view; signed entries always win per (label, chain). Each row carries unsigned: true when at least one chain entry is unsigned.
  • remove_contact works on unsigned entries with no Ledger interaction. Mixed labels (signed on one chain + unsigned on another) only require pairing for the signed-entry chain.
  • verify_contacts reports unsignedEntryCount per chain. A chain with only unsigned entries returns ok: false, reason: "no signed entries on this chain (unsigned-only)", unsignedEntryCount: N so the agent surfaces the unsigned overlay rather than silently dropping it.
  • Resolver decorates literal addresses + forward-resolves unsigned labels with a (unsigned) warning. Invariant Fix Compound dust rendering and add tx simulation #7 keeps working in degraded form — the user is told the label isn't anchored cryptographically, so the on-device clear-sign of the literal address remains the trust root.

Trust model

The unsigned overlay does not carry the same safety guarantees as the signed blob. A compromised MCP can inject entries into the in-memory store and the resolver will surface them with a warning. The Ledger device's clear-sign of the literal address remains the safety net — the warning tag exists so users with read-only / accountant-share use cases can opt into the weaker guarantee explicitly.

Test plan

  • vitest run test/contacts-no-ledger-unsigned.test.ts — 8 new cases pass.
  • vitest run test/contacts-demo-mode.test.ts test/contacts.test.ts — 35 existing pass; updated the "demo store isolation" test to reflect the new shared-unsigned-overlay contract.
  • Full suite — 2117/2117 pass.
  • tsc --noEmit clean.

Deferred (not in this PR)

Documented in claude-work/plan-contacts-unsigned-state-machine.md and surfaced in the README roadmap:

  • Persistent on-disk unsigned entries (today's fix is process-local)
  • promote_unsigned_contacts sign-on-pair upgrade flow
  • Tamper-aware merge with signed/unsigned label disagreement
  • Solana / TRON unsigned support

When this PR lands, close #428 as partially fixed, partially deferred with a link to the plan.

🤖 Generated with Claude Code

Issue #428: `add_contact` returned CONTACTS_LEDGER_NOT_PAIRED whenever
no Ledger was paired, blocking the natural first-run / accountant-share
flow where a user wants to label addresses BEFORE setting up signing.
The existing demo-mode fall-through to the in-memory store solved this
only for VAULTPILOT_DEMO=true users (which intercepts broadcasts and
isn't the right answer for read-only / accountant-share use cases).

Smallest fix that closes the user-visible gap:

- contacts/index.ts: new `tryPickAnchorForChain` that returns null on
  CONTACTS_LEDGER_NOT_PAIRED instead of throwing. addContact uses it
  in non-demo mode and falls through to the same in-memory demo store
  with `unsigned: true` + `anchorAddress: "UNSIGNED_NO_LEDGER"` when
  no Ledger is paired. removeContact also clears matching unsigned
  in-memory entries before the signed-disk flow, so unsigned-only
  removals don't need a Ledger. listContacts merges unsigned overlay
  on top of the verified signed view (signed wins per (label, chain)).
  verifyContacts surfaces unsignedEntryCount per chain.
- contacts/resolver.ts: when reverseLookup or forwardLookup miss in
  the signed blob, fall back to the in-memory store and append a
  warning that the resolution is unsigned — Invariant #7 keeps
  working in degraded form (the user is told the label isn't anchored
  cryptographically, so they confirm the literal address on-device).
- contacts/schemas.ts: ListedContact + VerifyResult gain optional
  `unsigned` / `unsignedEntryCount` fields.
- index.ts: refreshed all four contact tool descriptions to document
  the no-Ledger fall-through and the unsigned semantics.
- test/contacts-no-ledger-unsigned.test.ts: 8 new cases (addContact
  unsigned response shape, listContacts surfaces unsigned: true,
  removeContact works without Ledger, address-format guard,
  CONTACTS_LABEL_NOT_FOUND when nothing exists, verifyContacts
  unsigned-only mode, resolver reverse-decoration with warning,
  resolver forward-resolution with warning).
- test/contacts-demo-mode.test.ts: updated the "demo store isolation"
  test — pre-#428 it asserted demo entries were invisible from
  production listContacts; post-#428 the in-memory store is the
  shared unsigned overlay so demo entries DO surface in production
  with `unsigned: true`. Added a separate disk-only assertion to
  preserve the "demo never touches disk" invariant.

DEFERRED in this PR (documented in claude-work/plan-contacts-unsigned-
state-machine.md and surfaced in the README roadmap):
  - persistent on-disk unsigned entries (today's fix is process-local)
  - `promote_unsigned_contacts` sign-on-pair upgrade flow
  - tamper-aware merge with signed/unsigned label disagreement
  - Solana / TRON unsigned support

8 new + 35 updated contacts tests pass; 2117/2117 full suite pass;
tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@szhygulin
szhygulin merged commit 8d4334b into main Apr 28, 2026
4 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 28, 2026
@szhygulin
szhygulin deleted the fix/428-contacts-no-ledger branch May 19, 2026 04:27
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contacts module gated on Ledger pairing — blocks first-run UX

1 participant