Skip to content

feat(wallet): add address-based web sign-in recovery - #851

Open
Dannnyhamps wants to merge 6 commits into
Miracle656:mainfrom
Dannnyhamps:feat/769-web-address-sign-in
Open

Dannnyhamps wants to merge 6 commits into
Miracle656:mainfrom
Dannnyhamps:feat/769-web-address-sign-in

Conversation

@Dannnyhamps

Copy link
Copy Markdown

Closes #769

@drips-wave

drips-wave Bot commented Sep 24, 2026

Copy link
Copy Markdown

@Dannnyhamps 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

@vercel

vercel Bot commented Sep 24, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

Miracle656 pushed a commit that referenced this pull request Sep 25, 2026
Merged — closes #764. This is the flow that was missing, and the verification is right where it matters.

All four acceptance criteria met and independently checked: a valid address whose signer set contains the passkey signs in **with no PRF anywhere in the path**; `WalletContractNotFoundError` keeps "not a deployed wallet" distinct from "network unreachable"; a passkey outside the signer set is refused **with no wallet state written** (the test asserts all five writers were not called); and malformed input is rejected before any network call.

The part I checked hardest: `findMatchingSigner` verifies the assertion with `p256.verify(sig, authData‖SHA256(clientDataJSON), pk, {prehash:true})` against every signer, so a typo cannot silently load a stranger's wallet read-only. That was the whole reason #764 required `get_signers` verification rather than trusting the typed address.

Also correct, and better than what it sits next to: `writeSdkMirror` appends the `_mainnet` suffix on mainnet, matching the SDK's namespaced store. The pre-existing `loginWithPasskey` writes the un-suffixed keys — yours is the more correct of the two.

57 suites / 635 tests green, `tsc` clean, and the `@noble/curves` change is a one-line promotion of an already-hoisted transitive dep, so `npm ci` stays consistent.

Two nits, neither blocking:

1. `lib/__tests__/passkeyLogin.test.ts` uses `'G5KFY2U35PGLDYMYY5HW7XOLHP7UMM6XKBQJ3HVJ7EO3M3XCVSYVAQCE'`, which is 56 chars but **fails the ed25519 checksum**. The test passes and asserts the right behaviour, but its stated intent — "a valid G-address is rejected because it is not a contract" — is not actually exercised. `Keypair.random().publicKey()` fixes it. This exact shape has bitten five PRs this week.
2. `CONTRACT_MISSING_RE` is broad enough that a deployed legacy wallet lacking `get_signers` would be reported as "No deployed wallet is at this address". Messaging only.

Note `app/login.tsx` discards the result, so the `recoverable: false` case — a fresh random fee-payer with zero XLM — is silent. That is #766's scope and it mirrors the existing handler, so I am not holding this up for it.

**#851 is the web half of this and currently disagrees with you** on validation, error taxonomy and fee-payer policy. I have asked it to adopt your rule.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The matchWebAuthnSigner extraction is genuinely good — de-duplicating the inline signer matching out of recover/page.tsx was the right instinct. But there are four hard blockers, and one of them can strand a user's funds.

1. establishFreshFeePayer() destroys an existing fee-payer key

frontend/wallet/lib/feePayer.ts:293:

export function establishFreshFeePayer(): Keypair {
  const keypair = Keypair.random()
  …
  localStorage.setItem(MODE, 'legacy')
  walletSession.setItem(SECRET, keypair.secret())

Unconditional — no check for an existing key. And it is called from /lock, which per app/page.tsx:26-30 is reachable only when a wallet already exists locally. So one click of "Sign in with wallet address" on an existing install replaces a possibly-funded fee-payer secret, and because the mode is now pinned legacy, ensureFeePayer will never re-derive it from PRF again.

That is a fund-stranding shape, not a cosmetic one. It must not overwrite an existing key without an explicit, informed confirmation.

2. It turns mobile CI red

frontend/mobile/lib/recovery.ts:22 adds import { isRegisteredSigner } from '@veil/sdk'. That barrel pulls core.ts → @stellar/stellar-sdk → axios's fetch adapter, which dies under jest-expo:

TypeError: Cannot cancel a stream that already has a reader
  at …/axios/lib/adapters/fetch.js:80:12
  at …/sdk/src/core.ts:13:19

That costs 24 previously-green tests in recovery.test.ts plus your own new suite. Replacing that one import with a local stub makes recovery.test.ts pass 24/24 again, so it is the import and not the environment.

Import from a leaf path rather than the @veil/sdk barrel inside mobile lib/ — the barrel is not safe to pull into the Expo jest environment.

3. The SDK api-surface snapshot was not regenerated

● SDK API Surface › should match the snapshot for main SDK exports
  + "RECOVERY_SIGNER_CASES: object",
  + "isRegisteredSigner: function(arity: 2)",

npx jest -u in sdk/. Separately: RECOVERY_SIGNER_CASES is a test fixture exported from the SDK's public API. That belongs in a test file, not the published surface.

4. Backup restore can never succeed

lock/page.tsx, handleBackupRecovery:

const metadata = JSON.parse(await backupFile.text())
const restored = await decryptBackup(deserializeBackup(metadata), backupPassphrase)

deserializeBackup(blob: string) does its own JSON.parse. Passing an already-parsed object stringifies to "[object Object]" and throws Backup blob is not valid JSON every time. JSON.parse returns any, so tsc cannot catch it. Use deserializeBackup(await backupFile.text()).

It disagrees with the mobile half, which has now merged

#858 merged, and #769 says to take the rule from V191 rather than reinvent it. Right now:

#858 (mobile) #851 (web)
Address validation StrKey.isValidContract /^C[A-Z2-7]{55}$/
Not-deployed vs unreachable typed WalletContractNotFoundError one generic message
Fee-payer PRF, falling back to random always random

The regex is the specific failure this batch keeps hitting: 'C' + 'B'.repeat(55) passes it and fails StrKey. A shape regex is not validation.

And the "shared table" acceptance criterion is not met — RECOVERY_SIGNER_CASES is four hex-equality cases over isRegisteredSigner, which the web address path does not even call (it uses matchWebAuthnSigner). Nothing in it covers an invalid address, an undeployed wallet, an unreachable network, or a non-signer passkey, which are exactly the cases where the two platforms currently disagree.

The cleanest fix is what the issue actually asked for: lift #858's validate → resolve → verify sequence into the SDK and have both platforms call it.

One more thing worth knowing

A genuinely fresh device lands on /, which has no address-recovery entry at all; /lock is only reachable with a wallet already in localStorage. So as wired, the feature is unreachable where it is needed and harmful where it is reachable. Worth rethinking the entry point alongside the fixes.

tsc and next build --webpack both pass, and qrcode.react was already a dependency — the build side is fine. It is the behaviour that needs work.

orochimaru144 pushed a commit to orochimaru144/veil that referenced this pull request Sep 26, 2026
…) (Miracle656#858)

Merged — closes Miracle656#764. This is the flow that was missing, and the verification is right where it matters.

All four acceptance criteria met and independently checked: a valid address whose signer set contains the passkey signs in **with no PRF anywhere in the path**; `WalletContractNotFoundError` keeps "not a deployed wallet" distinct from "network unreachable"; a passkey outside the signer set is refused **with no wallet state written** (the test asserts all five writers were not called); and malformed input is rejected before any network call.

The part I checked hardest: `findMatchingSigner` verifies the assertion with `p256.verify(sig, authData‖SHA256(clientDataJSON), pk, {prehash:true})` against every signer, so a typo cannot silently load a stranger's wallet read-only. That was the whole reason Miracle656#764 required `get_signers` verification rather than trusting the typed address.

Also correct, and better than what it sits next to: `writeSdkMirror` appends the `_mainnet` suffix on mainnet, matching the SDK's namespaced store. The pre-existing `loginWithPasskey` writes the un-suffixed keys — yours is the more correct of the two.

57 suites / 635 tests green, `tsc` clean, and the `@noble/curves` change is a one-line promotion of an already-hoisted transitive dep, so `npm ci` stays consistent.

Two nits, neither blocking:

1. `lib/__tests__/passkeyLogin.test.ts` uses `'G5KFY2U35PGLDYMYY5HW7XOLHP7UMM6XKBQJ3HVJ7EO3M3XCVSYVAQCE'`, which is 56 chars but **fails the ed25519 checksum**. The test passes and asserts the right behaviour, but its stated intent — "a valid G-address is rejected because it is not a contract" — is not actually exercised. `Keypair.random().publicKey()` fixes it. This exact shape has bitten five PRs this week.
2. `CONTRACT_MISSING_RE` is broad enough that a deployed legacy wallet lacking `get_signers` would be reported as "No deployed wallet is at this address". Messaging only.

Note `app/login.tsx` discards the result, so the `recoverable: false` case — a fresh random fee-payer with zero XLM — is silent. That is Miracle656#766's scope and it mirrors the existing handler, so I am not holding this up for it.

**Miracle656#851 is the web half of this and currently disagrees with you** on validation, error taxonomy and fee-payer policy. I have asked it to adopt your rule.
orochimaru144 pushed a commit to orochimaru144/veil that referenced this pull request Sep 26, 2026
…) (Miracle656#858)

Merged — closes Miracle656#764. This is the flow that was missing, and the verification is right where it matters.

All four acceptance criteria met and independently checked: a valid address whose signer set contains the passkey signs in **with no PRF anywhere in the path**; `WalletContractNotFoundError` keeps "not a deployed wallet" distinct from "network unreachable"; a passkey outside the signer set is refused **with no wallet state written** (the test asserts all five writers were not called); and malformed input is rejected before any network call.

The part I checked hardest: `findMatchingSigner` verifies the assertion with `p256.verify(sig, authData‖SHA256(clientDataJSON), pk, {prehash:true})` against every signer, so a typo cannot silently load a stranger's wallet read-only. That was the whole reason Miracle656#764 required `get_signers` verification rather than trusting the typed address.

Also correct, and better than what it sits next to: `writeSdkMirror` appends the `_mainnet` suffix on mainnet, matching the SDK's namespaced store. The pre-existing `loginWithPasskey` writes the un-suffixed keys — yours is the more correct of the two.

57 suites / 635 tests green, `tsc` clean, and the `@noble/curves` change is a one-line promotion of an already-hoisted transitive dep, so `npm ci` stays consistent.

Two nits, neither blocking:

1. `lib/__tests__/passkeyLogin.test.ts` uses `'G5KFY2U35PGLDYMYY5HW7XOLHP7UMM6XKBQJ3HVJ7EO3M3XCVSYVAQCE'`, which is 56 chars but **fails the ed25519 checksum**. The test passes and asserts the right behaviour, but its stated intent — "a valid G-address is rejected because it is not a contract" — is not actually exercised. `Keypair.random().publicKey()` fixes it. This exact shape has bitten five PRs this week.
2. `CONTRACT_MISSING_RE` is broad enough that a deployed legacy wallet lacking `get_signers` would be reported as "No deployed wallet is at this address". Messaging only.

Note `app/login.tsx` discards the result, so the `recoverable: false` case — a fresh random fee-payer with zero XLM — is silent. That is Miracle656#766's scope and it mirrors the existing handler, so I am not holding this up for it.

**Miracle656#851 is the web half of this and currently disagrees with you** on validation, error taxonomy and fee-payer policy. I have asked it to adopt your rule.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-read at b67aebd4. Four of the five things I raised are properly fixed, and the SDK extraction is now the shape I asked for. One blocker left, and it's narrow.

1. establishFreshFeePayer() destroying an existing key — addressed

It's gone, replaced by establishRecoveredFeePayer(prf, credentialId, replaceExisting, recoveredKeypair), which reuses the stored secret when the credential ID matches and otherwise throws FeePayerConflictError rather than overwriting. AddressRecovery.tsx catches it and shows a confirmation that says what replacing costs ("may make its funds inaccessible without another backup") before anyone can proceed. recover/page.tsx dropped its resetFeePayer() too. The pinned-mode branches (prf-raw / prf-hkdf / legacy) preserve whichever derivation the wallet was created with, and the paper-recovery path writes the same veil_signer_secret / veil_signer_public_key keys it used to. Six tests in lib/__tests__/feePayer.test.ts cover reuse, conflict, forced replacement and the paper keypair. That's a thorough fix for the one that could strand funds.

2. Mobile CI — addressed, and I verified it

lib/recovery.ts now imports isRegisteredSigner from the leaf sdk/src/recovery/signerRegistry, which touches no Stellar SDK at all. I ran the full mobile suite on your head:

Test Suites: 1 failed, 55 passed, 56 total
Tests:       624 passed, 624 total

recovery.test.ts is back to 24/24. Nothing on main regressed.

3. The API-surface snapshot — addressed

Regenerated, and RECOVERY_SIGNER_CASES is out of the published surface — it's sdk/tests/fixtures/addressRecoveryCases.ts now, which is where a fixture belongs. Full SDK run on your head: 26 suites, 288 tests, 5 snapshots, all passing.

4. deserializeBackup double-parse — addressed

deserializeBackup(await file.text()). Restore can actually succeed now.

5. Disagreement with the mobile half (#858) — addressed

This is the part I'm most pleased with. recoverWalletByAddress() in sdk/src/recovery/signerVerification.ts is the one validate → resolve → verify sequence, and both platforms call it:

  • StrKey.isValidContract(address), so 'C' + 'B'.repeat(55) is refused. The shape regex is gone from both recover/page.tsx call sites — the only /^C[A-Z2-7]{55}$/ left in the tree is in pre-existing e2e assertions.
  • Typed WalletContractNotFoundError / WalletRecoveryNetworkError / NotVeilWalletError / PasskeyNotRegisteredError / InvalidWalletAddressError, and mobile/lib/signers.ts maps simulation errors onto the same two.
  • AddressRecovery is reachable from / via a new /sign-in route and the "Sign in with wallet address or backup" button, which fixes the entry-point problem — the feature is now available on the fresh device it exists for.

I also checked lock/page.tsx, since it shows as 389 changed lines. Normalising quotes, semicolons and whitespace away, the only semantic change is the new "Sign in with another address or backup" button at the end. Behaviour-identical — but please drop the reformat anyway (git checkout upstream/main -- frontend/wallet/app/lock/page.tsx, then re-add the button). It's the unlock and PRF path, and a 300-line cosmetic diff over it is 300 lines nobody can skim.

Separately, matchWebAuthnSigner slices clientDataJSON by byteOffset/byteLength where the old inline code passed .buffer whole — a latent bug quietly fixed on the way past. Nice.

Blocking — the new mobile test can't run

frontend/mobile/lib/__tests__/signerVerification.test.ts is the one failing suite above:

TypeError: Cannot cancel a stream that already has a reader
  at sdk/node_modules/axios/lib/adapters/fetch.js:80:12
  at sdk/node_modules/@stellar/stellar-sdk/lib/http-client/axios-client.js:7:37
  …
  at sdk/src/recovery/signerVerification.ts:21:19

Same failure as last time, one import further out: the test pulls signerVerification.ts, which imports @stellar/stellar-sdk at module scope for StrKey, Contract, TransactionBuilder and SorobanRpc.Server, and that dies under jest-expo. This PR gets no CI (fork), so it looks clean here and would turn Mobile — typecheck & test red the moment it lands.

It also means mobile/lib/signers.ts is now a module no mobile test can import, because it pulls the same file for NotVeilWalletError / WalletContractNotFoundError.

The fix is the one that already worked for isRegisteredSigner: put the error classes in their own leaf module next to signerRegistry.ts — they're plain Error subclasses with no SDK dependency — have signerVerification.ts re-export them, and import from the leaf in mobile/lib/signers.ts and in the mobile test. recoverWalletByAddress itself can stay where it is; the mobile test can exercise it through an injected resolveSigners, which is what it already does, once the import no longer drags StrKey in. (Keeping StrKey out of the leaf matters — that's what pulls the SDK.)

One for the follow-up, not now

recover/page.tsx calls establishRecoveredFeePayer(prf, assertion.id) with no replaceExisting and no FeePayerConflictError catch, so recovering onto a browser that already holds another wallet's fee-payer now dead-ends on the raw conflict message with no way forward. Strictly safer than the old resetFeePayer(), so I'm not holding the PR for it — but /recover should offer the same confirmation AddressRecovery does.


On the bigger picture: I asked on #917 for that bundle to be split per issue, and this is the single-issue PR for #769 — it predates #917 by four days rather than being carved out of it, but it's the shape I want, and it shows. Fix the test import, drop the lock/page.tsx reformat, and I'll merge it.

Miracle656 added a commit that referenced this pull request Oct 1, 2026
* feat(mobile): make wallet recovery coverage explicit

* feat: add flag-gated private balance card with sync status (#751)

Merged. This is the only PR in the privacy batch that respects "integrate, don't build" — it ships the presentation and declines to invent the engine underneath it.

Two things worth recording for whoever picks up V134:

- The dashboard wiring passes a stub (`balances={[]} syncState="syncing"`), so with `NEXT_PUBLIC_V131=true` the card sits on "Syncing" forever. That is disclosed in the comment and harmless while the flag is off by default, but it should be replaced — not extended — when the real client lands.
- The "never render a confident zero while syncing" rule, and the tests holding it, are the contract the rest of the batch should build against. A privacy balance that shows 0.00 before the scan completes is a correctness bug, not a cosmetic one.

Thanks — this was the right amount of work for the state the integration is actually in.

* feat(sdk): Angular adapter - VeilService, provideVeil, standalone example (#776)

Merged — closes #310.

Verified rather than assumed, since fork PRs run no workflows here: `tsc --noEmit` clean, 25 suites / 281 tests pass, `npm run build` idempotent across two runs (the `flatten-dist.js` fix holds), size budgets still under at main 228.31/230 kB, and `git merge-tree` showed none of the usual `sdk/package.json` devDeps-tail conflict.

On the diff size: 13,676 of the 15,156 added lines are `examples/angular/package-lock.json`, which matches the convention already set by the 16 other tracked `examples/*/package-lock.json`. Real source is ~1,400 lines. No `dist/`, no vendored deps.

Putting the adapter at `sdk/src/angular/lib/` with `sdk/angular/` holding only the published-subpath metadata was the right call — it matches `vue`/`svelte`/`solid`; `sdk/react/` is the outlier, not this.

Two things I am noting rather than blocking on, for whoever touches the SDK build next:
- `sdk/tsconfig.json` now sets `experimentalDecorators: true` for the whole SDK compile, not just the Angular subtree.
- The jest config gained an explicit `transform` map plus `transformIgnorePatterns` for `@angular/(core|compiler)`, replacing the preset transform for every suite. Verified safe today; worth remembering at the next preset bump.

Thanks — thorough work, and the import-graph test asserting no React is reachable from the Angular entry is a nice touch.

* fix(ci): resync the mobile lockfile so `npm ci` stops failing

The "Mobile — typecheck & test" job has been red on every commit to main,
including ones that touch no mobile code. It runs a bare `npm ci` — alone among
the CI jobs, which all fall back to `npm install` — and that refused:

    npm error Missing: @react-native-async-storage/async-storage@1.24.0 from lock file

`@walletconnect/keyvaluestorage` peer-depends on async-storage `1.x` while the
app is on `2.2.0`, so npm resolves three nested copies that the committed
lockfile did not carry. `npm install --package-lock-only` adds exactly those
three entries and nothing else.

This matters more than a red badge: the mobile job has not been running the
typecheck or the tests at all, so nothing on main was being verified. An
expo-constants API that had been removed from under the app shipped through this
gap earlier today.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* fix(ci): stop the fee-bump test dragging the whole SDK into its mock

"Wallet frontend — typecheck & build" has been red on main with:

    FAIL lib/__tests__/feeBump.test.ts
    TypeError: Cannot read properties of undefined (reading 'Server')
    at sdk/src/core.ts:32  ->  const HorizonServer = Horizon.Server

The chain is `feeBump.ts` -> `fees.ts` -> the `@veil/sdk` barrel -> the whole SDK
core, which reads `Horizon.Server` at module scope. The suite mocks
`@stellar/stellar-sdk`, that mock has no `Horizon`, and the file dies before a
single test runs. Locally the same import chain failed differently
(`TextEncoder is not defined`), which is why it looked environment-specific.

`lib/fees.ts` wants exactly one function from that barrel, so stub the barrel
instead of trying to keep a hand-written mock of the SDK complete enough to
survive being loaded for real. The file's existing comment already records this
happening once before with `Networks` and `Asset`.

Three open PRs each carry their own workaround for this same break; fixing it on
main once means none of them needs to.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(invest): document invest rail and add risk disclosures (#746)

Merged — best PR in the invest batch. `invest.mdx` gets the ground rules right without being told: it pins by issuer key, names BENJI as `auth_required = true` and therefore out of scope, states plainly that there are no live tokenized equities on Stellar, and carries no yield or advice language. `verify-invest-docs.mjs` passes and the root typecheck is clean.

I'm fixing two small things on main rather than sending it back:

- `/research` is a dead link — `frontend/docs/pages/research/` has only `_meta.ts` and `reserve-tax.mdx`, no index. Repointing to `/research/reserve-tax`.
- The `TextEncoder` polyfill in `feeBump.test.ts` is now redundant: main fixed that file properly in `818f539` by stubbing the `@veil/sdk` barrel.

One note for next time: the root `tsconfig.json` `module`/`moduleResolution` switch to NodeNext is a repo-wide change riding along in a docs PR. It passes (the root tsconfig only covers `scripts/**/*.ts`), so I've kept it — but that kind of change is much easier to reason about, and to revert, in its own PR.

Thanks — the disclosure framing here is the standard the rest of the batch should match.

* fix(docs,wallet): repoint a dead docs link and drop a now-redundant polyfill

Two follow-ups to #746, applied here rather than sent back:

- `invest.mdx` linked "NGN Rails" at `/research`, but `frontend/docs/pages/research/`
  holds only `_meta.ts` and `reserve-tax.mdx` — there is no index page, so the
  link 404s. On a page whose whole job is disclosure, a dead link to the legal
  discussion is the wrong one to ship.
- The `TextEncoder`/`TextDecoder` polyfill at the top of `feeBump.test.ts` was a
  workaround for the SDK barrel being loaded into that suite. 818f539 removed the
  cause by stubbing `@veil/sdk`, so the workaround now just obscures why the file
  is arranged the way it is.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* fix(mobile): declare expo-asset, which expo-font needs but does not declare

With `npm ci` working again, the mobile job finally ran its tests — and failed:

    Cannot find module 'expo-asset' from 'node_modules/expo-font/build/FontLoader.js'

`expo-font@14.0.12` requires `expo-asset` at runtime but lists it in neither
`dependencies` nor `peerDependencies`, so npm never hoists it. The only copy is
nested under `node_modules/expo/node_modules/expo-asset`, which Node cannot see
from a hoisted `expo-font`. Metro resolves it in the running app, which is why
this never showed up outside jest.

Declaring it directly at the version expo 54 already pins puts it at the top
level, where both resolvers find it.

This was hiding behind the `npm ci` failure: with the install broken the tests
never ran, so a suite that could not resolve its imports looked no different
from a suite that was never attempted. Mobile is now 53 suites / 611 tests
green, with `tsc --noEmit` clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(mobile): cite #445 on the native rpId requirement (#777)

Merged. Comment-only and every claim checks out: `sdk/src/core.ts` `resolveRpId()` really does fall back to `'localhost'` off-browser, `lib/relyingParty.ts` really does mirror the build-time env, and `163f504` is the commit it says it is.

Nice detail: #445 points at `sdk/src/useInvisibleWallet.ts:596`, but you cited `core.ts resolveRpId`, which is where the code actually lives now. The comment is more accurate than the issue it cites.

* ci: pin every GitHub Action to a commit SHA (#773)

Merged — this is exactly the right change, done completely.

I verified the pins rather than trusting them, since a wrong SHA in a supply-chain change fails silently or pins to something unintended. Dereferenced each tag through `gh api` (annotated tags via their tag object) and spot-confirmed:

```
actions/checkout@v4.4.0       -> 11d5960a  MATCH
actions/setup-node@v4.4.0     -> 49933ea5  MATCH
rustsec/audit-check@v2.0.0    -> 69366f33  MATCH
```

Every replacement is 1:1 with the tag it replaced — no accidental major bump — and `fuzz.yml` correctly keeps the **nightly** `dtolnay/rust-toolchain` SHA while `ci.yml`/`contract-ci.yml` keep **stable**, which is the easiest thing to get wrong here. All 75 `uses:` across all 14 workflows are pinned, with nothing missed.

One follow-up worth doing, and it matters: `.github/dependabot.yml` has no `package-ecosystem: "github-actions"` entry, so nothing will ever update these pins. SHA pins that no one refreshes rot into stale, unpatched actions — trading a tag-hijack risk for an unpatched-dependency one. Dependabot rewrites both the SHA and the `# vX.Y.Z` comment, so it expects exactly the format you've established here. I'll open an issue unless you'd like to add it.

* fix: remove silent testnet fallback on mainnet paths in buy, withdraw, backup (#772)

Merged — #703's core is fixed and the test discipline here is exactly right. I confirmed fail-before/pass-after by reverting just the two source files: mobile `sep24.test.ts` resolves instead of rejecting on main, and `backup.test.ts` shows `factoryAddress: undefined` / testnet passphrase. On your head both pass.

It also doesn't break testnet — both defaults become `''` and both screens already guard for that, so testnet users type the anchor or set `EXPO_PUBLIC_SEP24_ANCHOR_DOMAIN`.

One thing I'm fixing on main rather than sending back: `lib/__tests__/sep24.test.ts:30` passes a partial `VeilNetwork` to `mockReturnValueOnce`, which is a typecheck error (TS2345, missing `name`/`horizonUrl`/`rpcUrl`/`factoryContractId`/`friendbotUrl`). That would have slipped through before today — the mobile CI job was failing at `npm ci` and never running — but it's live again now, so it would have turned main red.

**The same bug is still alive in four other places**, and I'd like to give you the follow-up if you want it:
- `frontend/wallet/lib/sep24.ts:54` — `networkMatch ? … : Networks.TESTNET`, byte-for-byte the defect you just fixed in mobile, still on the web buy/withdraw path.
- `frontend/wallet/app/withdraw/page.tsx:40-42` — the web twin of the `testanchor.stellar.org` default you removed.
- `frontend/mobile/lib/backupFile.ts:36,85` — `TESTNET_PASSPHRASE` as a fallback.
- `frontend/mobile/lib/assets.ts:20` — `HORIZON_URL` defaults to testnet Horizon ignoring the active network, so on mainnet the portfolio screen queries testnet. Probably the worst one left.

Also noting for the changelog: `collectWalletMetadata` now always populates `factoryAddress`, so backup metadata changes shape going forward. Old blobs still restore — your round-trip test covers it.

* fix(mobile): complete the network mock in the sep24 test

Follow-up to #772. `mockGetNetwork.mockReturnValueOnce` was handed only
`displayName` and `networkPassphrase`, which is a TS2345 — `getNetwork` returns
a `VeilNetwork`, and a partial stands in for one whether or not the code path
under test reads the rest.

This would have gone unnoticed a day ago, because the mobile job was failing at
`npm ci` and never reached the typecheck. It runs again as of 47be303, so a
partial mock now turns main red.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(privacy): put the real SPP addresses in, and stop eliding them

Three PRs in the privacy batch (#752, #756, #762) hard-coded SPP contract ids
that fail `StrKey.isValidContract` — several spelling words like REGISTRY and
USDCPOOL, or containing characters base32 does not have. Each cited upstream's
`deployments/testnet/deployments.json` as the source.

They did not invent those from nothing. The batch ground rules named the pools
as `CD2W5LUR…XZ4L` and `CBMRWHTP…NUVS`, and every fabrication preserves that
prefix and that suffix and fills in the middle:

    rules      CD2W5LUR…XZ4L                 CBMRWHTP…NUVS
    #756       CD2W5LURT2P6G7W7XZ4L…         CBMRWHTPNUVSPOLARIS7…
    #762       CD2W5LURT7H33ZMS…4XZ4L        CBMRWHTP23BAMQZ…J2NUVS

An elided address in a task is an invitation to reconstruct one. Worse, the
elided values were wrong to begin with: the real pools are
`CBEDPYMA…2GOT` and `CADS665G…IN42`, matching neither prefix.

So: fetched the real file, validated all eleven ids with StrKey, and replaced
the rule. Addresses are now to be read from upstream rather than copied, and
anything hard-coded must pass StrKey in review. The reference table is included
but marked as reference, not as something to paste.

Also corrects PRIVACY_COST.md, which listed "XLM and EURC pools". Upstream has
two pools and both are native XLM (identical `tokenContractId`); the second adds
`gvkMode: traceable`. There is no EURC pool.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* feat(privacy): wire up V131 SPP privacy config in wallet and mobile (… (#780)

Merged — closes #710, and this is the PR the rest of the privacy batch has been missing.

**You sourced the addresses properly, and it matters more than you may realise.** I fetched  from `NethermindEth/stellar-private-payments` independently and StrKey-validated every id: all nine match byte-for-byte, including the `B` → `standard` / `B_gvk_T` → `traceable` mapping and `gvkMode` on the second pool only. `CDLZFC3SYJ…` is correctly the testnet native-XLM SAC.

For context on why that stands out: three other PRs in this batch hard-coded ids that fail `StrKey.isValidContract` — and the root cause was mine. The batch ground rules named the pools elided as `CD2W5LUR…XZ4L` / `CBMRWHTP…NUVS`, and those PRs filled in the middle. Both elided values were wrong to begin with; the real pools are the `CBEDPYMA…` and `CADS665G…` in your file. The rules are corrected on main (`6afd2e5`) and now say to read the ids from upstream rather than copy them — which is what you did.

Also correct: opt-**in** flag (off unless the env var is `1`/`true`), a hard mainnet lockout *before* the flag is read plus no `mainnet` key in `SPP_NETWORKS`, and a `.gitguardian.yml` scope that follows the existing `matches:` convention. tsc clean and 16/16 tests green in both apps.

Two small things, neither blocking:

- The description says "a future SPP redeploy surfaces as a failing test". It doesn't — `config.test.ts` asserts against literals copied into the test file, so it catches an edit to `config.ts`, never an upstream redeploy. The test is still worth having; a CI step that diffs against upstream would be the thing that delivers on that claim, if you want a follow-up.
- Missing trailing newline on all four new files. Cosmetic, no `eol-last` rule here.

Note for anyone rebasing onto this: **#771 also adds `lib/privacy/config.ts`** in both apps, but it is a *different* module (bootnode URL resolution — zero export-name overlap with this one). That one should rename to `lib/privacy/bootnodeConfig.ts` and import `bootnodeUrl` from here rather than re-declaring the same literal.

* docs(privacy): STRIDE threat model for the SPP integration (#775)

Merged — closes #726, and it's the most carefully sourced document anyone has contributed to this repo.

I audited it as a pure accuracy question, because a threat model that describes protections we don't have is worse than none. All 23 `path:line` citations resolve to exactly the symbol claimed, all 14 referenced files exist, and the numeric claims (12 MB per pool, 8.1 MB r1cs + 4.1 MB proving key, 42 MB web SDK, the MPC/TEE view-key recommendation) match `docs/PRIVACY_COST.md` verbatim.

What I most wanted to check was overclaiming, since that's what sank several PRs in this batch — and it doesn't happen here. Unbuilt work is consistently future-tense ("V139 **builds** the generate and verify flows", "V132 **acceptance:**"), the header says Draft, and §11 ranks five open items including "Web note storage is plaintext OPFS… **Not yet wired** — this is the biggest open item". That is the honest framing.

One imprecision worth a follow-up, not blocking: §4 says the web accessor keeps the seed in session only, "never `localStorage`". On the PRF path that's right, but `feePayer.ts:274` does persist the **legacy** key to `localStorage`. The next row already bans the legacy path for privacy flows, so it's defensible in context, but a reader could take it as "no secret ever reaches localStorage". Suggested tweak: "never `localStorage` **for the PRF-derived key** (the legacy variant is persisted, `feePayer.ts:274`)".

No overlap with `frontend/docs/pages/threat-model.mdx` (zero SPP hits there) and none with #755 — you correctly deferred user-facing framing to V149 rather than restating it.

* docs(privacy): pinning addresses is fine — citing where they came from is the point

The rule I wrote in 6afd2e5 said never to hard-code an SPP address. That
overcorrected, and it contradicted the PR that had just done this correctly:
V131 (#780) pins the ids into `lib/privacy/config.ts`, names the upstream commit
they were taken from, and asserts them in a test. That is the pattern to copy,
not one to discourage — a browser bundle cannot fetch `deployments.json` at
runtime, so pinning is the practical answer.

What actually went wrong in #752/#756/#762 was not that values were written
down. It was that they were retyped from an elided string in the issue text and
attributed to a file nobody opened. So the rule is provenance and validation,
not avoidance.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* ci: give every workflow an explicit top-level permissions block (#785)

Merged — a good follow-up to #773, and net positive on both security and reliability.

Verified rather than assumed: all 14 workflows parse, all 14 now carry a top-level block, every default is `contents: read`, and the diff is genuinely nothing but permission keys and comments. The three risky ones are all handled correctly — `release.yml` keeps its job-level `contents/pull-requests/id-token/attestations: write` so changesets, npm OIDC and provenance survive; `storybook.yml` moves `pages`/`id-token` down to the `deploy` job exactly as GitHub's own Pages starter does; and `mobile-apk.yml`'s `apk` job keeps `contents: write` for `gh release`.

Worth noting the job-level grants you added are real fixes, not just tightening: `fuzz.yml` and `testnet-smoke.yml` open issues on failure, and `mutation.yml` and `ci.yml:lighthouse-ci` post comments — all four were running on a read-only token and would have failed with "Resource not accessible by integration".

**One thing I'm fixing on main rather than sending back:** `wallet-e2e.yml` had `pull-requests: write` on main and this drops it, keeping only `issues: write`. Commenting on a PR needs `pull-requests: write` even though the REST endpoint is `/issues/{n}/comments` — so that step would start failing at runtime, which is exactly the failure mode this kind of change has to avoid. Restoring it there, and adding it to `ci.yml:lighthouse-ci` and `mutation.yml:stryker` for the same reason. `fuzz.yml` and `testnet-smoke.yml` create real issues, so `issues: write` alone is right there — left alone.

Thanks — two solid supply-chain PRs in a row.

* ci: restore `pull-requests: write` for the three PR-comment steps

Follow-up to #785, which gave every workflow a least-privilege top-level
permissions block — the right change, with one gap.

Commenting on a pull request needs `pull-requests: write`. The REST endpoint is
`/issues/{number}/comments`, which makes `issues: write` look sufficient, and it
is not: the call fails at runtime with "Resource not accessible by integration".
`wallet-e2e.yml` carried `pull-requests: write` before #785 and lost it; the
Lighthouse summary and the Stryker mutation score were newly scoped to
`issues: write` alone.

`fuzz.yml` and `testnet-smoke.yml` are left as they are — those open real issues
on failure, so `issues: write` is exactly right for them.

This is the failure mode a permissions change has to be careful about: nothing
fails to parse, and nothing fails until the day a job actually tries to post.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(wave): batch 16 — USDT0, and three gaps in the privacy batch (V198-V209)

USDT0 went live on Stellar mainnet on 2026-09-02 and already has 22,348
holders. Everything in the batch was verified against mainnet Horizon rather
than taken from an announcement: the issuer, the flags, and the SAC, which is
derived with `new Asset('USDT0', issuer).contractId(Networks.PUBLIC)` rather
than pasted.

The finding that shapes the batch is that **eight issuers publish an asset
called USDT0**, and the genuine one is the only one with no `stellar.toml`.
Every impostor has published a domain and a TOML declaring itself, two of them
from issuer addresses ending in the letters USDT. Our verifier treats a matching
home domain as evidence of authenticity, so against this asset it inverts: it
would pass all seven fakes and fail the real one. V199 is that fix, and it also
closes a fail-open path where an unreachable TOML logs a tick.

The asset also has `auth_revocable` and `auth_clawback_enabled` set — Tether can
freeze a balance and take it back. Normal for Tether, and not a reason to refuse
the asset, but V200 says it has to be on screen before a user opts in.

The privacy batch (V131-V149) was checked first rather than rewritten; it
already covers the flag, keys, client, shield/send/unshield, disclosure,
bootnode, the mobile prover track, fees, threat model, e2e and the guide. Only
three genuine gaps were added, all found while reviewing that batch's first
PRs: nothing detects our pinned SPP config going stale against upstream, the
association-set policy has never been chosen or written down, and nothing enrols
a user's privacy key — so today everyone could send privately and nobody could
receive.

Published as #787-798.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* feat(wave): let a batch mark a block that travels with every issue

A batch preamble reaches nobody. Contributors read the issue, not the draft
file, so ground rules, verified addresses and reference tables written above the
first `### V…` heading are invisible to the person doing the work.

That gap has cost real effort. Three PRs in the privacy batch hard-coded Soroban
contract ids that fail `StrKey.isValidContract` while citing upstream's
deployments.json — and the published issues contain no addresses at all, so
there was nowhere authoritative to copy from. The values existed only in
`scripts/wave-issues-privacy.md`, elided as `CD2W5LUR…XZ4L`, and every
fabrication preserved that prefix and suffix.

Anything under a `## Shared with every issue` heading in the preamble is now
appended to each issue body, ahead of the Telegram footer. Batches without that
heading are unchanged — all five existing drafts parse to the same issue and
point counts as before.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(wave): V210 — DeepSeek as a third agent provider

`packages/agent/src/llm.ts` already abstracts this: `LlmProvider` is a label
plus `start()`, with `anthropicProvider()` and `openRouterProvider()` behind it
and `providerFromEnv()` choosing. DeepSeek is a third implementation and one
more branch, not new architecture.

The reason to want it is the agent's current default. OpenRouter's free models
are capped at 20 requests a minute and 50 a day, return empty content often
enough that `completeWithFallback` has to skip them, and vanish without notice.
`deepseek-flash` is $0.15-$0.30 per million input tokens, supports tool calling,
and bills cache hits at roughly 50x less than a miss — which matters when a long
system prompt is resent every turn.

Model identifiers verified against DeepSeek's own docs today rather than
recalled: the current ones are `deepseek-flash` and `deepseek-v4-pro`. The
`deepseek-chat` / `deepseek-reasoner` names in most blog posts are retired, so
the issue carries the verified table and says to re-check before building.

The first use of the new `## Shared with every issue` block, so the API facts
and the key-handling rule travel into the issue rather than sitting in a draft
file nobody reads.

Published as #802.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(wave): batch 18 — dApp browser, follow-ups, web/mobile parity (V211-V240)

Thirty issues, 3,750 points, published as #806-835. Three parts.

**dApp browser (V211-V218).** Veil can already be connected TO — WalletConnect
pairing, the approval modal, #495/#496 — but there is no browser: no dapp,
browser or discover route in either app, no directory, and react-native-webview
is not even a dependency. So a user can approve a request from a dApp they
already have open elsewhere and cannot open one from inside Veil. Built
allow-list first and provider last, deliberately, because an in-app browser in a
wallet is a security surface before it is a feature. The batch's shared block
says plainly that `signXdrPayload()` is the only signing path — a second one is
how a wallet ends up with one that is subtly wrong.

**Follow-ups (V219-V230).** Every one is a defect found in a real PR this week,
not invented scope: four surviving testnet fallbacks (including `lib/assets.ts`
querying testnet Horizon while on mainnet), three competing asset registries,
`memo_type` ignored so an exchange deposit is sent with the wrong memo type, the
agent aliasing balances by asset code, an `investIntent` no UI reads, and the
`npm ci` gap that let mobile CI skip every test for weeks.

**Parity (V231-V240).** A route-by-route diff of the two apps. Six substantial
screens exist on web and not mobile, four on mobile and not web. The one that
matters: `frontend/wallet/lib/backup.ts` is complete and the envelope format is
byte-identical across platforms, but no web screen imports it — so the encrypted
backup, which is the recovery path for a passkey manager without PRF, is
reachable only from mobile.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(wave): 32 issues across wraith and Lens, and teach the publisher W/L ids

The publisher only matched `### V###`, so the sibling repos' `W`/`L` batches
could not be published with it at all. Generalised to `[VWL]`.

**wraith W078-W093 (#179-194, 1,950 pts).** The strongest are real defects, not
cleanup: `parseEvents` has no try/catch around a `parseEvent` that throws from
six sites, so one malformed event on chain retries the same ledger range forever
while the docstring claims it skips them; the ingest cursor commits the RPC's
chain tip rather than the ledger actually covered, so a full page is dropped and
never refetched; `toDisplayAmount` hardcodes 7 decimals, showing every 6-decimal
token — USDC, which the offramp moves — ten times too small.

**Lens L055-L070 (#162-177, 1,950 pts).** Same shape: `slippagePct` is
arithmetically pinned at zero and a property test asserts it; AMM snapshots are
tagged with the process's network instead of the ingester's, so mainnet rows are
stored as testnet; the aggregate-refresh worker writes a cache key the route
never reads, making the entire warm-cache path dead; and the price aggregator
still blends both chains.

Both batches were surveyed against the real code and the claims spot-checked
before writing: `db push --accept-data-loss` on boot, the candles router with
zero importers, and the hardcoded STROOPS divisor were each verified directly.

Also worth acting on separately: Lens #113 is fully implemented and should be
closed, and #146 does not reproduce on main (401 tests green, 7/7 runs).

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* docs(wave): batch 19 — voice assistants, read-only first (V241-V247)

Seven issues, 950 points, published as #840-846.

"Hey Siri, send Tunde 5,000" is the obvious demo and the wrong starting point.
Reading is safe, ships on iOS today and is differentiating; moving money is
gated behind three things we do not control — Apple requires *organization*
enrolment for self-custody wallets, classifies crypto as a highly regulated
field, and Android's AppFunctions is private preview for trusted testers.

So the batch ships the read-only surface, proves the boundary with a test that
fails when a new intent crosses it, and spends one issue researching the payment
side rather than guessing.

Platform facts verified against primary sources rather than recalled: App
Actions is superseded by AppFunctions and most guides still say otherwise; the
confirm step is a real API (`IntentAuthenticationPolicy`), not something to
invent; and App Intents need an Expo config plugin here, since this app has no
ios/ directory — the same route react-native-passkeys already takes.

V244 is the one that makes voice trustworthy: `lib/hiddenAmounts.ts` already
exists and notifications already honour it, so an assistant that reads a balance
aloud in a shared room must honour it too.

V246 is a research spike. AP2's authorization primitive moved to the FIDO
Alliance and has an x402 settlement extension — Lens already runs an x402
facilitator and Veil already authorises with a FIDO credential. The issue says
explicitly that AP2's docs mention x402 but NOT passkeys, so the contributor
must verify the link rather than assume it.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* ci: pin and verify downloaded tool installers (#836)

Merged — closes #699.

I verified the checksums rather than trusting them, because a wrong hash breaks CI and a hash of an attacker-chosen file is worse. Downloaded the stellar-cli artifact and hashed it independently:

```
sha256sum stellar-cli-28.0.0-x86_64-unknown-linux-gnu.tar.gz
207544486734fccb4df1afc4a7745478f9f1e21688b2f9506f0ef36f60ce3fdc
PR pins: 207544486734fccb4df1afc4a7745478f9f1e21688b2f9506f0ef36f60ce3fdc
```

Exact match, and the Maestro one checks out too. The archive layouts are right as well — stellar-cli is a single binary at the archive root, and Maestro unpacks to `maestro/bin/maestro`, which is what the unchanged `GITHUB_PATH` line already expects.

This completes the line of work from #773 (actions pinned to commit SHAs) and #785 (least-privilege permissions): no `curl … | bash` is left in either workflow.

One optional nit for whenever you next touch these: `sha256sum --check --status` suppresses the mismatch message, so a future hash drift will fail the step without saying why. Dropping `--status` and keeping `--check` costs nothing and makes the failure explain itself.

Third solid supply-chain PR in a row — thanks.

* feat(wallet): load registered issuer metadata from stellar.toml (#786)

Merged — closes #741.

I scrutinised this harder than its size suggests, because today I confirmed something that makes any toml-trusting code dangerous: **there are eight issuers of the asset code `USDT0` on mainnet, and the genuine one publishes no `home_domain` and no stellar.toml.** All seven impostors publish domains and TOMLs, two from issuer addresses ending in the letters `USDT`. A verifier that treats a reachable toml as evidence of authenticity would pass every fake and fail the real asset.

This PR gets that right, and the docstring says so out loud:

> Unregistered issuers are not fetched. The verified issuer name always comes from the registry. A failed fetch reuses a cached copy, or the registry text with a letter avatar when nothing is cached.

Checked against the code: `loadRegisteredIssuerMetadata` returns `null` unless `isRegisteredIssuer(code, issuer)` passes first, so an unregistered trustline is never fetched for and gets no mark either way; the displayed issuer name is registry text, never toml text; and nothing is labelled verified because a toml was reachable. USDC's toml is a broken redirect and the row still renders correctly from the registry — which is exactly the right failure mode.

The remote-fetch surface is handled too: the domain comes from the registry rather than user input, HTTPS is enforced on the initial URL **and on every redirect hop** with a hop cap, responses are bounded by a content-type allowlist and a 512 KiB streamed cap, and `/api/issuer-logo` 404s before any network call for an unregistered pair — so it is not an open proxy.

Verified: `tsc --noEmit` clean, 27 suites / 345 tests pass, and `next build --webpack` succeeds with `/api/issuer-logo` registered — that last one matters, since a route file exporting anything beyond handlers and config breaks the build while jest stays green.

Two follow-ups worth an issue, neither blocking:
- `serveIssuerLogo` has no rate limit and is `force-dynamic`. It reads only `code`/`issuer`, but the CDN keys on the full URL, so `?…&x=1…N` gives unbounded cache-miss keys, each costing a toml resolve plus a remote image fetch on a serverless function. Either normalise to a canonical cache key or reuse `createRateLimiter` the way `app/api/agent/route.ts` does. Also, `refuse(502)` publicly caches a transient outage for five minutes.
- `issuerToml.ts:161` lets the toml's display **name** replace the registry's, unclamped. The *issuer* name is correctly registry-only so this stays inside the SEP-1 trust model, but a hijacked domain could render a misleading name beside a trustline. Worth capping the length at minimum.

Good, careful work on exactly the surface where carelessness costs users money.

* fix(mobile): a private range is an address, not a name prefix

`resolveAgentUrl` allows plain http only for local development, and decided that
with `/^(192\.168|10)\./.test(hostname)`. That matches any hostname *beginning*
"10." — so `http://10.evil.com/api/agent` was accepted over plaintext purely
because of what the host was named.

Narrow in practice: it needs a build-time `EXPO_PUBLIC_AGENT_URL` pointing at
such a host. But the check exists to decide whether plaintext is acceptable, and
a name is not an address.

Matching literal IPv4 instead. `localhost`, `127.0.0.1` and `10.0.2.2` keep
their exact-match entries, so nothing about local development changes — the
regression test covers `192.168.1.5` and `10.1.2.3` alongside the rejections.

The first replacement I wrote was wrong in the other direction: a single
`(?:192\.168|10)\.` prefix followed by three octets demands five octets for the
192.168 case, which rejected real private addresses. The alternation now spells
out both shapes, and the test is what caught it.

Found while reviewing #800, but pre-existing rather than introduced there.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* feat(assets): add USDT0 to verified asset registry (#787) (#801)

Merged — closes #787.

You got the thing that matters most about this asset right: **`homeDomain` is left absent rather than invented.** The real USDT0 issuer publishes no `stellar.toml`, while all seven impostors do, so requiring or rewarding one would pass every fake and fail the genuine asset. Relaxing the interface to `homeDomain?: string` was the correct call.

Verified rather than assumed: the SAC derives (`new Asset('USDT0', issuer).contractId(Networks.PUBLIC)` → `CBSJZEIO…26YF`, exact match), the issuer's flags and missing home domain re-checked live against Horizon, all seven impostor issuers in your test are the real ones and all pass StrKey. Wallet 5/5 on the new suite, mobile 55 suites / 623 tests, both typechecks clean.

**One thing I am fixing on main rather than bouncing it back.** The new `getRegisteredAsset(code, network)` gate now runs *before* the USDC-testnet special cases, and USDC is registered `network: 'mainnet'` — so those branches became unreachable:

```
getAssetIssuer("USDC","testnet")   => null      (was "GBBD47IF…")
isRegisteredIssuer("USDC", testnetIssuer, "testnet") => false   (was true)
```

Nothing breaks today, because mobile's `enableTrustline` short-circuits USDC through `usdcIssuerFor()` before reaching `getAssetIssuer`. But it is dead code plus a silent landmine, so I am hoisting the testnet branch above the registry lookup in both apps.

Worth knowing for anyone following: **#803 re-implements this same registry entry** and conflicts with it. It will need a rebase that drops the duplicated hunks.

* feat(agent): add DeepSeek as an agent provider (#802) (#805)

Merged — closes #802, and it is the best-engineered of today's batch.

It did the thing the issue actually asked for rather than the easy thing: `createOpenAiCompatibleSession()` is extracted and `openRouterProvider` is **rewired through it**, so the shared request/response and tool-call mapping is shared, not copied. The OpenAI-format endpoint was the right choice and the reasoning holds.

Checked against primary sources: `DEFAULT_DEEPSEEK_MODEL = 'deepseek-flash'` is current, and the retired `deepseek-chat` / `deepseek-reasoner` names appear nowhere — that is the trap most guides would have walked you into. Existing precedence is intact (only-OpenRouter still gets OpenRouter, only-Anthropic still gets Anthropic), with a test covering it. No test makes a live call, no key-shaped string beyond `'mock-…'`, and `DEEPSEEK_API_KEY` is in both `.env.example`s with no value. 6 suites / 54 tests pass.

**Two things I am fixing on main:**

1. `llm.ts:379` — `const status = Number(body?.error?.code ?? res.status)`. DeepSeek's OpenAI-format envelope carries `code` as a *string*, so `Number(...)` is `NaN` and the 429 and 401 branches never match:

```
THROWN(429,string code): DeepSeek provider error: NaN Rate limit reached
THROWN(401,string code): DeepSeek provider error: NaN Authentication Fails
```

Rate limiting and auth failure — two of the three cases the issue names — collapse into the generic branch. Your tests use a numeric `code`, which is why they pass. Falling back to `res.status` when the body's code is not a finite number. (The identical expression at `llm.ts:321` is fine — OpenRouter's code really is numeric.)

2. `agent.ts:54-62` — `resolveConfig` puts `deepSeekApiKey` above `openRouterApiKey`, the opposite of `providerFromEnv()`. Only `createVeilAgent` uses it, so the blast radius is SDK consumers, but the two selectors should not disagree about precedence.

One note for the description rather than the code: the issue asked you to reuse `isUpstreamFailure()` and you didn't. I think that is correct — with a single provider there is no upstream/self distinction to draw — but say so, rather than leaving a reviewer to work out whether it was deliberate.

* fix: follow-ups to #786, #801 and #805, including a break from merging two of them

**`homeDomain` became optional and two callers still required it.** #801 relaxed
it because the genuine USDT0 issuer publishes no stellar.toml while all seven
impostors of that code do — the right call, and the reason the registry must not
reward a toml. But #786 landed first and reads `asset.homeDomain.trim()`, so
merging the two broke the wallet typecheck:

    lib/issuerLogoProxy.ts(72,41): error TS18048: 'asset.homeDomain' is possibly 'undefined'
    lib/issuerToml.ts(329,41):     error TS18048: 'asset.homeDomain' is possibly 'undefined'

Neither reviewer could have caught it: each verified against a main that did not
yet contain the other. Both sites now narrow once and treat an absent domain as
"no toml to read", falling back to registry text and a letter avatar.

**The USDC testnet branches were unreachable.** #801's new
`getRegisteredAsset(code, network)` gate runs before them, and USDC is
registered `network: 'mainnet'`, so `getAssetIssuer("USDC","testnet")` returned
null where it used to return the testnet issuer. Nothing breaks today because
mobile short-circuits USDC through `usdcIssuerFor()` first, but it was dead code
in front of a live path. Hoisted above the lookup in both apps.

**Three DeepSeek fixes.** `Number(body?.error?.code ?? res.status)` goes NaN on
DeepSeek's string `code`, so the 429 and 401 branches never matched and rate
limiting and auth failure both collapsed into the generic error — two of the
three cases #802 names. `resolveConfig` ordered DeepSeek above OpenRouter, the
opposite of `providerFromEnv()`, so an SDK consumer and a deployment would pick
differently from the same keys. And the health endpoint's `configured` gate
never checked `DEEPSEEK_API_KEY`, so a DeepSeek-only deployment reported
`{ ok: false, model: null }` to the uptime probe while chat worked fine.

Verified: wallet 28 suites / 350 tests, agent 6 suites / 54 tests, mobile assets
suite green, both typechecks clean, `next build --webpack` succeeds.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* feat(wallet): hold USDT0 trustline, balance and price (#790) (#853)

Merged — closes #790.

Every acceptance criterion met, and I checked the two that usually slip:

**The reserve is stated before the trustline is created**, not after it fails — `Reserve cost: 0.5 XLM refundable reserve required upfront` sits in the banner above the button, with the freeze/clawback line under it. The `NotEnoughXlm` message is a second, more specific chance rather than the only one. That ordering is the whole point of the criterion and it is easy to get backwards.

**It pins by issuer**: `a.issuer === USDT0_MAINNET_ISSUER`, with `onMainnet` genuinely gating the banner at both call sites — USDT0's issuer does not exist on testnet.

Worth calling out specifically: **your impostor test uses a real impostor issuer.** `GC35JBERU4SFTDVOF32A2SIJN5FHSLSZFZSGP6VVFWCZNDVGJFLQBANK` passes `StrKey.isValidEd25519PublicKey` and is genuinely one of the eight issuers publishing that code on mainnet. Another PR this week used a similar-looking string that fails the checksum — it satisfied the module's own `/^G[A-Z2-7]{55}$/` shape check, so the test passed while proving nothing. A fake fake is worse than no test. You got this right.

Verified locally in an isolated worktree: mobile and wallet `tsc --noEmit` both clean, 44 tests across the five suites you touched all pass.

One note for whoever picks up #791 (send/receive USDT0): `frontend/wallet/app/assets/page.tsx` is also touched by #803, which is currently changes-requested — that one will need to rebase onto this.

* feat(privacy): choose blocklist association-set policy and document privacy consequences (#797) (#839)

Merged — closes #797, and it is the strongest of the four privacy PRs in this batch by a distance.

What sets it apart: **no invented addresses, no simulation, and honest anonymity-set language.** `anonymitySetDescription` says "all non-excluded depositors **in this pool under the active blocklist policy**", and both the ADR and `PRIVACY_COST.md` explicitly reject "the entire Stellar network". That is exactly the criterion, and it is the one most likely to be quietly overstated. The policy is a real config switch (`NEXT_PUBLIC_PRIVACY_ASP_POLICY` selecting `aspMembership` vs `aspNonMembership`), and the ADR is dated and gives its reasoning.

Four things I am fixing on main rather than bouncing back:

1. **Present-tense copy for behaviour that does not exist yet.** `isPolicyRejectionError`, `formatPrivacyError` and `getPolicyMetadata` have no non-test call sites — there is no shield UI on main to call them — but `PRIVACY_COST.md` §6 and the ADR say "the client surfaces the policy and anonymity bounds honestly" as though it already does. Rewording to name it as the contract the shield flow must meet.
2. **Missing newline at end of file** in both `config.ts` files.
3. **Dead union members** — `PrivacyErrorCode` declares `INVALID_NOTE`, `NETWORK_ERROR` and `UNKNOWN`, but `formatPrivacyError` can only return `POLICY_REJECTED` or `PROVING_FAILED`.
4. **A real trap:** `getAssociationSetContract(..., 'allowlist')` returns `aspMembership`, but **both canonical pools are `policyFlags: ['blocklist']`**. Setting that env var would silently point proofs at the wrong ASP. Adding a guard that cross-checks the selected policy against the target pool's flags.

Verified: merged against main, `tsc --noEmit` shows no new errors.

Thanks — this is the standard the rest of the batch should be held to.

* chore(privacy): follow-ups to #839 — dead error codes and a missing newline

`PrivacyErrorCode` declared `INVALID_NOTE`, `NETWORK_ERROR` and `UNKNOWN`, but
`formatPrivacyError` can only ever return `POLICY_REJECTED` or `PROVING_FAILED`.
A union member nothing produces reads as a case callers must handle.

Also adds the missing newline at end of file in both copies.

I tried a third fix here and backed it out. `getAssociationSetContract(…,
'allowlist')` returns `aspMembership` while both canonical pools are
`policyFlags: ['blocklist']`, so setting that env var points proofs at an ASP
the pool does not use. I made it throw — and #839's own test, which asserts the
plain policy-to-contract mapping, went red. The test is right: this is a pure
mapping function, and redefining its contract in a post-merge patch is not my
call. Filed as an issue instead so it gets designed rather than smuggled.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* fix(ci): resync the wallet lockfile after the Angular adapter landed

#776 added `@angular/core`, `@angular/compiler`, `rxjs` and `zone.js` to the
sdk's peer set, and the wallet's lockfile never recorded them. Same class as the
mobile desync fixed in 47be303: it does not fail until something runs `npm ci`
rather than `npm install`, at which point it fails at install and every test in
that job is skipped rather than reported.

Verified `npm ci` succeeds against it.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* feat(mobile): Android App Shortcuts for the read-only actions (#862)

Merged — closes #842.

The config plugin was verified end to end rather than taken on trust: `expo prebuild --platform android` really does emit `veil_shortcuts.xml` with both shortcuts, the matching `strings.xml` entries, and the `android.app.shortcuts` meta-data on `.MainActivity` — which already carries the `veil://` BROWSABLE intent-filter the shortcuts rely on. So the resource chain is real, not asserted. 58 suites / 654 tests pass, `tsc` clean, and `expo lint`'s 11 warnings are all pre-existing files you did not touch.

Two things you got right that matter more than the feature:

**No Android-only data path.** Each shortcut is a `veil://` VIEW intent through `+native-intent.ts` → `resolveDeepLink()` → the same screens a tap reaches. Both new routes take `[]` params, so a crafted link can open a screen but cannot steer it.

**You were honest about what you could not verify.** The on-device launcher check is the one open acceptance criterion and you said so plainly instead of ticking it. That is worth more to me than a green checklist — several PRs this week claimed verification that could not have happened.

One nit for whenever you next touch the plugin: `escapeXml` in `plugins/withAndroidShortcuts.js` does not escape `'`. Harmless for today's labels, but Android requires `\'` in string resources, so a future label like `Today's balance` would fail `aapt2`.

Note #840 (iOS App Intents) is still unclaimed — `lib/voice/actions.ts` is already the platform-neutral list it will share.

* test(mobile): prove that no voice path can sign (#863)

Merged — closes #844, and this is the best-built test in the repo.

#844 asked for something most PRs quietly skip: the assertion had to fail when someone adds a **new** intent that signs, **without any edit to the test**. That is the criterion that separates a real guard from a list that passes forever while the boundary erodes. You met it, and I verified it by breaking it myself rather than reading your table:

- New `lib/voice/sendIntent.ts` calling `signXdrPayload`, test untouched → **red**
- Reaching a secret *indirectly* through `@/lib/holdings` → **red**, with the full chain printed: `balanceIntent.ts → holdings.ts → activity.ts → walletStore.ts`
- A **dynamic** `import('../contractSpend')` → **red**
- Pointing an existing action's path at `/send` → **red** on the allow-list

And one probe you did not claim: a voice module importing `@veil/sdk`, which the tsconfig alias resolves *outside* `frontend/mobile`, so the module deny-list does not cover it. Still red — caught by the network-write check via `sdk/src/outbox.ts`. That it held against a path you had not anticipated is the part that convinced me.

Walking the real import graph with `ts.resolveModuleName` against the app's own tsconfig — so `@/` and `@veil/*` resolve — is the right call. A regex over import lines would have missed three of the five cases above.

The limits are disclosed honestly in the PR and none is blocking: entry points are `lib/voice/**` only, network writes are a source regex so an indirected `const m='POST'` escapes, and native Swift/Kotlin from a future intent plugin is out of scope. Worth revisiting when #840 lands iOS intents.

58 suites / 654 tests green.

* docs(adr): record what AP2 mandates would require of Veil (#857)

Merged — closes #845.

This is the outcome I wanted from the spike, and the opposite of the one I was braced for. #845 warned specifically that AP2's docs mention x402 but say nothing about passkeys, and that asserting the link would be the overclaim. Your ADR says it outright: *"Nothing specifies that it can… The only mention of passkeys anywhere in the AP2 docs is a non-normative note."* Answer to the third question: **"On-chain, yes. For AP2 as specified, no."**

I checked that against the pinned spec rather than trusting it — `grep -in 'passkey|webauthn'` over `agent_authorization.md` returns exactly one hit, inside a `NOTE` about approaches that "can be explored" in future, and `specification.md` returns none. Exactly as you describe. The `[Spec]` / `[Veil]` / `[Plausible]` tagging is the discipline the issue asked for and it holds throughout.

**The findings are worth more than the recommendation.** Two are real defects in shipped code that nobody had noticed:

- `contracts/invisible_wallet/src/lib.rs:329-335` passes `args[2]` (amount) to `session_key::enforce` but never `args[1]` (the payee). **A session key capped for one token can pay anyone.** I verified the call site and the `SessionKeyAcl` struct — no payee field, cumulative cap only.
- `examples/x402-api` signs with `createEd25519Signer(feePayerSecret, …)` while the passkey prompt uses a random challenge, so **the biometric prompt is not bound to the payment being authorised.**

Both deserve their own issues and I will file them. The `+100`-ledger expiry versus x402's `maxTimeoutSeconds: 120` (24 ledgers) is a genuine incompatibility too.

Two small things, neither worth a round trip: the "governance has moved to the FIDO Alliance" line in Context is the one external claim without a citation — it restates the issue body, so pin it or mark it as such. And §4 cites `Allowance.expiry` at `lib.rs:458`, which is `approve()`; the struct is at `storage.rs:46-49`.

"Wait" is the right recommendation, and the "what would change this" trigger list is what makes it re-readable in six months.

* test(mobile): turn the six __check_auth requirements into a suite (#859)

Merged — closes #825, and it is the strongest test work in this repo.

#825 asked for each of the six `__check_auth` requirements to fail **on its own** when broken. I did not take your mutation table on trust — I re-ran all six myself, each edit applied and reverted independently:

| Mutation | Result |
|---|---|
| `auth: []` instead of the signed entries | 9 failed, 5 passed — exactly your number |
| deleted the low-S normalisation in `webauthn.ts` | `× puts a low-S signature in the credential` |
| credential keeps recording expiry | `× sets a future expiration`, `× signs the same expiration it attaches` |
| `assembleTransaction` instead of `enforceSim` | `× assembles with the enforce-mode footprint` |
| `new Account(feePayer, "0")` instead of `rpc.getAccount` | `× uses the fee payer as source, at its next sequence number` |
| never `sigElements.push(nonce)` | `× sends 5 elements`, `× still sends 5 when the nonce read fails once` |

Every requirement is independently pinned. This is not a suite that sits green through a regression.

Three details I want to call out because they show the difference between writing tests and thinking about failure:

- **The authenticator mock always returns high-S**, so the low-S test cannot pass by accident.
- **The expiry test recomputes the Soroban `HashIdPreimage`** and compares it to the challenge the passkey was actually asked to sign — not just that some expiry was set.
- **It pins that a transient nonce failure refuses to sign** rather than silently dropping to a 4-element vector. That is the bug that broke the first mainnet spend, now permanently guarded.

One `describe` per requirement, each naming the on-chain failure it prevents, so the suite reads as documentation. That was the last acceptance criterion and it is met.

* feat(mobile): sign in on a new device by wallet address (#764) (#858)

Merged — closes #764. This is the flow that was missing, and the verification is right where it matters.

All four acceptance criteria met and independently checked: a valid address whose signer set contains the passkey signs in **with no PRF anywhere in the path**; `WalletContractNotFoundError` keeps "not a deployed wallet" distinct from "network unreachable"; a passkey outside the signer set is refused **with no wallet state written** (the test asserts all five writers were not called); and malformed input is rejected before any network call.

The part I checked hardest: `findMatchingSigner` verifies the assertion with `p256.verify(sig, authData‖SHA256(clientDataJSON), pk, {prehash:true})` against every signer, so a typo cannot silently load a stranger's wallet read-only. That was the whole reason #764 required `get_signers` verification rather than trusting the typed address.

Also correct, and better than what it sits next to: `writeSdkMirror` appends the `_mainnet` suffix on mainnet, matching the SDK's namespaced store. The pre-existing `loginWithPasskey` writes the un-suffixed keys — yours is the more correct of the two.

57 suites / 635 tests green, `tsc` clean, and the `@noble/curves` change is a one-line promotion of an already-hoisted transitive dep, so `npm ci` stays consistent.

Two nits, neither blocking:

1. `lib/__tests__/passkeyLogin.test.ts` uses `'G5KFY2U35PGLDYMYY5HW7XOLHP7UMM6XKBQJ3HVJ7EO3M3XCVSYVAQCE'`, which is 56 chars but **fails the ed25519 checksum**. The test passes and asserts the right behaviour, but its stated intent — "a valid G-address is rejected because it is not a contract" — is not actually exercised. `Keypair.random().publicKey()` fixes it. This exact shape has bitten five PRs this week.
2. `CONTRACT_MISSING_RE` is broad enough that a deployed legacy wallet lacking `get_signers` would be reported as "No deployed wallet is at this address". Messaging only.

Note `app/login.tsx` discards the result, so the `recoverable: false` case — a fresh random fee-payer with zero XLM — is silent. That is #766's scope and it mirrors the existing handler, so I am not holding this up for it.

**#851 is the web half of this and currently disagrees with you** on validation, error taxonomy and fee-payer policy. I have asked it to adopt your rule.

* fix(examples): label demo shortcuts and patch qr-pos and faucet (#708) (#855)

Merged. Every warning is specific and checks out against the actual code — `cap_priv_${credentialId}` in the capacitor shim, `veil_signer_secret` in electron, `private_key.bin` in tauri, the `veil_subscriber_wallet` cookie in paywall. Labelling a demo's weakness precisely is more useful than a generic banner, and you did the harder thing where it was possible: `Math.random()` → `crypto.getRandomValues()` in qr-pos, and a real per-destination cooldown plus a sliding-window global cap in the faucet.

The memo tightening in `pos.ts:139` is the right call and I checked it is safe — `payment.transaction?.memo !== target.memo` means a memo-less payment no longer settles a memo-bearing charge, and the payments URL already carries `join=transactions` so `transaction` is always populated.

Worth knowing: `examples/` has no tests and no CI job, so nothing here was verifiable by running it. That cuts both ways — nothing regressible, but also nothing catching the next change. Not your problem to solve in this PR.

* chore(sdk): enable strict TypeScript and export all public types (#838)

Merged. `tsc --noEmit` exit 0, build clean, 25 suites / 281 tests, and `tsd` passes — that last one matters because the new `tsd.compilerOptions.paths` mapping is the part this PR actually changes. `grep -c any dist/index.d.ts` is 0, and all 30 `any` hits across the emitted declarations are inside prose comments.

Two notes on the framing rather than the code, worth having on record:

**The title is misleading.** `"strict": true` is already set in `sdk/tsconfig.json:6` on main, and this PR does not touch that file — the diff is `package.json`, `core.ts`, `index.ts`, `types.ts`, `useInvisibleWallet.ts` and the tsd test. The real content is the type barrel plus two `any` removals, which is worth having on its own.

**The body overstates the barrel.** It says `types.ts` re-exports "SEP-30/SEP-7/backup/recall/outbox types"; it re-exports 17 aliases from `./core`. Nothing is lost — those types were already exported by `index.ts`'s existing barrels — but a reader would expect more than is there.

Neither changes the verdict. Accurate titles matter more than usual here because the SDK's public surface is snapshot-tested, and the next person reading the log will use your title to decide whether to look.

* .github/workflows/docs.yml now gates the docs site like every other surface: it runs npm ci and npm run build in frontend/docs on any push or PR that touches frontend/docs/** (plus the workflow itself), so broken MDX, bad imports, and broken _meta.ts entries fail CI instead of shipping. The job is skipped entirely on PRs that don't touch docs, and it's cached end-to-end — an npm cache for installs and a corrected .next build-output cache keyed on the docs lockfile — so a docs-touching PR adds only a few minutes while others add nothing. (#860)

Merged — closes #688.

You left the acceptance criterion open honestly ("could not verify the gate catches a real break"), so I closed it: I appended a broken import and an unterminated JSX expression to `pages/local-dev.mdx" and rebuilt.

```
BROKEN MDX build exit=1
[nextra] Error compiling .../pages/local-dev.mdx
Unexpected end of file in expression, expected a corresponding closing brace for `{`
```

Unmodified it is exit 0 with 27 routes. **The gate genuinely gates** — that was the whole question.

Three mechanical fixes I am applying on main rather than bouncing back:

1. **No `permissions:` block**, which regresses #785 — every other workflow has one. Adding `contents: read`. (Your PR creates a new file and does not touch `ci.yml`, so the `pull-requests: write` I restored in `015a550` is intact — no regression there.)
2. **Actions on floating `@v4` tags**, which regresses #773. Pinning `checkout` and `setup-node` to the SHAs already used elsewhere in this repo.
3. **Reverting `frontend/docs/package-lock.json`.** The four hunks strip `"dev": true` from `@types/prop-types`, `@types/react`, `csstype` and `typescript` — all four are devDependencies, so that promotes them to production. It is also unnecessary: `npm ci --dry-run` against main's docs lockfile exits 0. Dropping it removes the only collision with #854.

One note for next time: `run: npm ci || npm install` weakens the gate, because a desynced lockfile then passes silently. #688 asked for `npm ci`, and that distinction has bitten this repo before — the mobile job spent weeks failing at install and skipping every test.

* ci(docs): restore the supply-chain guarantees the new docs gate skipped

Follow-ups to #860. The gate itself is right — I confirmed it fails on a broken
MDX page and passes on a clean tree — but the workflow arrived without two
things every other workflow here has, and with a lockfile change it did not need.

- **No top-level `permissions:`**, which quietly regresses #785. Added
  `contents: read`; the job needs nothing more.
- **Actions on floating `@v4` tags**, which regresses #773. Pinned to commit
  SHAs, reusing the ones already in this repo for checkout and setup-node and
  resolving `actions/cache@v4.2.3` for the third. Every `uses:` across all
  workflows is now SHA-pinned again — verified by scanning them.
- **Reverted `frontend/docs/package-lock.json`.** Its four hunks stripped
  `"dev": true` from `@types/prop-types`, `@types/react`, `csstype` and
  `typescript`, promoting four devDependencies into production. It was also
  unnecessary — `npm ci --dry-run` against the committed lockfile exits 0 — and
  dropping it removes the only collision with #854.

A pinned action nobody updates and an unpinned action nobody notices are
different failure modes; #784 tracks keeping these fresh.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* ci: prove npm ci works for every workspace (#823) (#868)

Merged — closes #823, and it lands on the day the problem it solves cost the most.

I verified the gate rather than reading it. Across all 28 workspace lockfiles:

```
All 28 workspace lockfiles are in sync with package.json.
EXIT=0
```

Then I injected a deliberate desync into `frontend/wallet/package.json`:

```
❌ Lockfile drift detected in 1 workspace(s):
[Workspace: frontend/wallet]
  Missing: left-pad@1.3.0 from lock file
Run `npm install` inside the affected workspace(s) and commit the updated package-lock.json.
EXIT=1
```

Names the workspace, names the offending package, exits non-zero. That is exactly #823's acceptance criteria, and it is the property most such scripts get wrong — printing a failure and exiting 0.

**Making the existing fallbacks visible is the other half, and you did it.** Every `npm ci || npm install` now emits `::warning::npm ci failed; falling back to npm install` instead of silently papering over drift. That silence is precisely how the mobile job spent weeks failing at install and skipping every test on every commit, while its badge just said "failure" and everyone learned to ignore it. Two more lockfile desyncs surfaced in the last two days — the mobile one in `47be303` and a wallet one from the Angular adapter in `01672cf`.

Actions are SHA-pinned and the script has its own tests. Good, well-scoped work on unglamorous plumbing that will quietly stop a recurring class of failure.

* fix(mobile): remove secret scanner false positive

* chore: preserve reviewed passkey recovery flow

* fix(mobile): address recovery settings review

---------

Co-authored-by: Sakariyah Abdulhazeem <sakariyahabdulhazeem@gmail.com>
Co-authored-by: Sayandip Roy <161803450+shogun444@users.noreply.github.com>
Co-authored-by: CHKM001 <cnduka.2203652@stu.cu.edu.ng>
Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
Co-authored-by: Jimoh Abdullah <ahbiz2007@gmail.com>
Co-authored-by: Gojv-byte <vikkigaboj@gmail.com>
Co-authored-by: Jessicaayegh <157510475+Jessicaayegh@users.noreply.github.com>
Co-authored-by: Lex0865 <destinylawson007@gmail.com>
Co-authored-by: Umeokonkwo Samuel <73968540+Killerjunior@users.noreply.github.com>
Co-authored-by: rudrasatani <69194481+rudrasatani13@users.noreply.github.com>
Co-authored-by: Muhamed Fazal <newchannelid432@gmail.com>
Co-authored-by: Emmanuel <emmatech2204@gmail.com>
Co-authored-by: Ipramking <96977922+Ipramking@users.noreply.github.com>
Co-authored-by: fareed <einstein1101@proton.me>
Co-authored-by: Muokwe-kenneth Daniel <144173198+Ijatuyi@users.noreply.github.com>
Co-authored-by: DevScoopee <157647160+DevScoopee@users.noreply.github.com>
Co-authored-by: Onyema Amarachukwu <onyemaamara303@gmail.com>

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-checked at df6c359b. The two defects I flagged here are unchanged, and the address check has gone backwards.

The address is no longer validated at all

frontend/wallet/components/AddressRecovery.tsx:62

const selectedAddress = backup?.address ?? address.trim()

No StrKey, no isValidContract, no regex. The shape regex I objected to on
#852 is gone from this branch, but nothing replaced it — so where #852 accepts
56 characters that cannot exist on the network, this accepts anything at all.
Between the two PRs the check went from wrong to absent.

StrKey.isValidContract('C' + 'B'.repeat(55))   // false

That is the one-line fix, and StrKey is already imported in lib/multisig.ts,
lib/simulate.ts and lib/vault.ts.

The assertion is never bound to anything (#851 and #852, same code)

sdk/src/recovery/signerVerification.ts hashes clientDataJSON into the signed
message and never looks inside it:

  parses clientDataJSON : false
  checks "type"         : false   ← not asserted to be "webauthn.get"
  checks "origin"       : false   ← not asserted to be our origin
  checks "challenge"    : false   ← not compared to anything we issued

All it establishes is that some ECDSA signature verifies against a
registered key. That was carried over verbatim from /recover, where the user
had already proven possession another way — but it now sits on a login path,
so an assertion captured once is accepted again, from any origin, forever. A
challenge nobody checks is not a challenge.

This is the one I would fix first wherever this code lands.

establishFreshFeePayer() still mints unconditionally

  feePayer.ts:293  export function establishFreshFeePayer(): Keypair {
  feePayer.ts:294    const keypair = Keypair.random()

Every address or backup recovery overwrites whatever fee payer is stored. A user
with an existing funded one loses it, and the raw secret goes to localStorage,
which is still open finding C3. Read the stored one first and only mint when
there is genuinely nothing there.

Where this stands

The feature is worth having and the mechanism is sound. What is blocking it is a
login path that accepts a replay, an address nobody checks, and a function that
overwrites a funded account — all three small, all three in code that decides who
gets into a wallet.

@Miracle656 Miracle656 mentioned this pull request Oct 5, 2026

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-checked at df6c359b. Two of the three things I raised this morning were already fixed, and I was quoting stale code. My mistake, and I'm withdrawing both.

Withdrawn: "the address is no longer validated at all"

I looked at AddressRecovery.tsx:62, saw address.trim(), and stopped there instead of following the call. One line further in:

// sdk/src/recovery/signerVerification.ts:129
if (!StrKey.isValidContract(address)) throw new InvalidWalletAddressError();

That is the right check in the right place — one rule for both clients rather than a regex per screen. And sdk/tests/fixtures/addressRecoveryCases.ts has:

{ name: 'bad StrKey checksum', address: `C${'B'.repeat(55)}`, ..., expected: 'InvalidWalletAddressError' }

which is the exact counterexample I handed you, turned into a test. That was the correct response to the review and I then failed to read it.

Withdrawn: "establishFreshFeePayer() still mints unconditionally"

I quoted feePayer.ts:293 export function establishFreshFeePayer(). That function does not exist on this branch — you replaced it in b67aebd4 on 30 September, five days before I claimed it was unchanged. What is there now reads the stored key first and refuses rather than overwrites:

const existing = peekFeePayerSecret()
if (existing) {
  if (!replaceExisting) {
    if (!storedCredentialId || !recoveredCredentialId || storedCredentialId !== recoveredCredentialId) {
      throw new FeePayerConflictError()
    }
    ...

and Keypair.random() is now reachable only when there is no pinned mode and no PRF — every other path either derives or throws. That is a better fix than the one I asked for, because it distinguishes "nothing stored" from "stored but unreachable" instead of silently preferring a new key.

(walletLocal.setItem(SECRET, ...) in legacy mode still puts a raw secret in localStorage, but that is pre-existing finding C3 across the whole wallet, not something this PR introduces. Not blocking.)

Also fixed since I last looked: the mobile test now imports the module directly rather than the @veil/sdk barrel.


One thing still open, and it is the one that matters

sdk/src/recovery/signerVerification.ts — matchWebAuthnSigner hashes clientDataJSON into the signed message and never looks inside it:

const clientDataHash = new Uint8Array(await crypto.subtle.digest('SHA-256', clientDataJSON...))
const message = new Uint8Array(authData.length + clientDataHash.length)
message.set(authData)
message.set(clientDataHash, authData.length)

That is the whole of what the assertion is checked against. Nothing parses the JSON, so:

  • challenge is never compared. You do generate a fresh random one at all three call sites — crypto.getRandomValues(new Uint8Array(32)) on web, Crypto.getRandomBytes(32) on mobile. It is issued and then never verified, so an assertion produced against a different challenge verifies identically.
  • origin is never checked. An assertion for this credential obtained by any other site verifies here.
  • type is never asserted to be webauthn.get.

Taken together, what the function proves is "some ECDSA signature verifies against a key registered on this contract" — not "the holder of that key authenticated here, just now". On /recover that gap was tolerable because possession had been established another way. This PR puts the same function on a sign-in path, which is where a captured assertion becomes a permanent credential.

The fix is to parse clientDataJSON and assert all three before the signature check, with the expected challenge passed in by the caller that generated it. Tests worth having: a correct assertion, one with a stale challenge, and one with a foreign origin.

Housekeeping

frontend/mobile/jest.config.js now declares modulePaths twice:

modulePaths: ['<rootDir>/node_modules'],   // added, with a comment
setupFiles: [...],
modulePaths: ['<rootDir>/node_modules'],   // already there

Same value, so nothing breaks — the second simply wins and the added line does nothing. Keep the comment, drop the duplicate.

Note on CI

The only checks running here are Vercel (failing on fork-deploy authorization, not on your code) and GitGuardian. No test or typecheck job runs on a fork PR, so neither of us can point at CI for this one — please say in the PR what you ran locally and what passed.

One round left on this. The feature is sound and the last three fixes were the right ones; it is the assertion binding and the duplicate config line between here and merge.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

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.

The same address-based sign-in on the web wallet

2 participants