109 Commits
Author SHA1 Message Date
protom f30f98fe49 Merge pull request #10 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.5
2026-09-05 19:07:58 +02:00
github-actions[bot] 9f1c9a7976 chore(main): release sattle 0.0.5 2026-09-05 17:04:37 +00:00
protom d026533456 Merge pull request #9 from tompro/feat/lnurlcash-protocol-0.6
feat: adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets
2026-09-05 19:04:10 +02:00
protom e6fef1e9cb test: stub Web Locks where the runtime lacks them 2026-09-05 18:58:04 +02:00
protom 4caa075052 Formatting 2026-09-05 18:30:35 +02:00
protom 454a9db1d5 test(e2e): clean the e2e program's diagnostics 2026-09-05 15:56:23 +02:00
protom 3a7aa682c9 test(e2e): warm the dev server before the suite runs 2026-09-05 15:46:47 +02:00
protom 1beb474012 test(e2e): cover signed BIP-32 wallet flows 2026-09-05 15:22:02 +02:00
protom 5d6e092ae4 docs(lnurlcash): update the minting contract and test harness description 2026-09-05 15:21:55 +02:00
protom b2b5974dae chore: ignore Playwright MCP artifacts 2026-09-05 15:21:55 +02:00
protom f62e5d7ad4 build(deps): pin lnurlcash-kit 0.8.0 and conformance 0.7.0 2026-09-05 15:21:47 +02:00
protom a91ef30498 refactor(ops): require durable allocation and checkpoints 2026-09-05 11:59:34 +02:00
protom 8e9c9dcbbc feat(composables): route fund dialogs through the durable seam 2026-09-05 11:44:29 +02:00
protom 04e62110b6 fix(composables): commit carve checkpoints by output secret 2026-09-05 11:44:29 +02:00
protom 29830dc9e3 fix(nwc): await durable commits before responses 2026-09-05 11:38:29 +02:00
protom 22b1c5595c feat(ops): draw mint-touching output secrets from caller allocation 2026-09-05 11:34:01 +02:00
protom e6d0712258 feat(wallet): allocate operation secrets from the cash root 2026-09-05 11:33:42 +02:00
protom 3876791a0b feat(funds): recover staged mutations 2026-09-05 10:22:51 +02:00
protom a30c4bb2cc feat(trust): keep previous mint signing key verifiable 2026-09-05 10:22:24 +02:00
protom 7b7d4edaf3 test(stores): stub mutation locks in trust and nwc-drain harnesses 2026-09-05 09:42:51 +02:00
protom ff9e8a073e test(funds): fence harness on v2 owner install 2026-09-05 09:29:05 +02:00
protom e94549a16e fix(wallet): activate complete wallet material 2026-09-05 09:17:12 +02:00
protom 641608eff0 feat(backup): preserve BIP-32 counters 2026-09-05 08:22:21 +02:00
protom 0cec423d6f feat(funds): persist BIP-32 counters atomically 2026-09-05 08:22:02 +02:00
protom 7e4e67e1cd fix(unlock): bind factors to wallet material 2026-09-05 07:28:03 +02:00
protom aaee045916 test(passkeys): reverify exact-material slot binding 2026-09-05 07:27:56 +02:00
protom 9374a87d0d fix(passkeys): bind slots to wallet material 2026-09-04 21:27:14 +02:00
protom b7c0a33a69 feat(biometrics): unlock complete wallet material 2026-09-04 20:53:45 +02:00
protom 6099bb4b6b feat(passkeys): wrap complete wallet material 2026-09-04 20:19:48 +02:00
protom d48deeb6e0 feat(keys): persist BIP-32 wallet material 2026-09-04 19:46:15 +02:00
protom 0677b65016 docs: record lnurlcash 0.8 dependency pins 2026-09-04 18:48:41 +02:00
protom cbe49a83af build(deps): adopt lnurlcash-kit 0.8 contracts 2026-09-04 18:48:11 +02:00
protom 9408cf91d6 Merge pull request #7 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.4
2026-09-04 10:25:01 +02:00
github-actions[bot] dc59f2a08d chore(main): release sattle 0.0.4 2026-08-31 12:58:10 +00:00
protom 09f7fe0287 Merge pull request #8 from tompro/fix/carve-checkpoint
fix: commit carves before the settlement wait (#6)
2026-08-31 14:57:49 +02:00
protom 9e37fee841 fix: commit send, pay and move carves at the checkpoint (#6)
The three composables commit the carve inside onCarve via the existing
commitCarve helper instead of after the flow, falling back to a URL
lookup for no-mutation carves (which fire no hook). On
failed-funds-returned with a rotation, the re-secured note is added
before the burned carved note is marked spent.
2026-08-31 14:48:03 +02:00
protom 587dd11ba8 fix(lnurlcash): commit carves before the settlement wait (#6)
A flow that carves and then waits minutes on a settlement poll used to
commit the carve only after the whole flow resolved - an abort mid-wait
left burned inputs spendable locally and stranded the fresh outputs.
ensureExactAmount now reports a mutation-bearing carve through the new
FundOperationOptions.onCarve checkpoint the moment its outcome is known;
payWithBearers and transferBetweenMints thread it through.

Because the carve may now be committed before the flows' classification
rotates run, those rotations no longer rewrite carve.note in the result -
returned funds come back as a separate verified rotatedNote, so the
committed carve and the re-secured note can both be persisted. NWC's
payChangeset adapts to the rotatedNote field; its own commit timing is
unchanged (follow-up).
2026-08-31 14:48:03 +02:00
protom f3d5cc4b31 Merge pull request #5 from tompro/feat/named-mint-outputs
feat: support LUD-25 named mint outputs (lnurl-mint v0.4.0)
2026-08-31 14:19:59 +02:00
protom 50b2427b8d fix(lnurlcash): rescue landed mutations from retry refusals
A split, merge or rotate whose answer is 'Invalid or already spent k1.'
may be the refusal of a retried request that already LANDED - believing
it strands the fresh outputs server-side and desyncs the wallet (the
input is burned at the mint but still spendable locally, so every later
operation fails). All three flows now probe a would-be output when a
refusal carries fresh secrets: live outputs are adopted (or surfaced
unverified when the probe can't tell), a genuine refusal still fails.
2026-08-31 14:03:15 +02:00
protom 48be23dceb feat(lnurlcash): probe would-be outputs before believing a mutation refusal
The redeem callback is a GET and HTTP stacks retry GETs, so a mint can
execute the first attempt and refuse the repeat as an already-spent input
(LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh
output secrets to every refusal - probeMutationOutput tells a landed
mutation from a genuine refusal. The harness wrapper reproduces the retry
against the mock.
2026-08-31 14:03:15 +02:00
protom 4cc8e0aba2 fix: replace forgesworn with moneyer in the public mint suggestions
@mint.forgesworn.dev is shutting down; @moneyer.dev takes its place in the
curated quick-start list.
2026-08-31 13:31:36 +02:00
protom 6bb07ff9ec fix(lnurlcash): assert named claims land at the wallet's own secret
Review feedback: bind the claim answer to the secret that was polled
before building a note from it. The kit already guarantees this -
claimMintedNote returns the queried secret by construction and
fetchNoteInfo rejects a mismatched k1 echo - so the assert cannot trip
through the kit (and is therefore not separately testable); it exists so
a future kit regression fails loudly instead of tracking a wrong secret.
2026-08-31 12:54:46 +02:00
protom 637376e291 docs(lnurlcash): document the two mint output modes
Reword the rotate-after-claim invariant for named outputs and fix the
test-harness pointer (ops.testHarness.ts, not test-utils.ts).
2026-08-31 12:23:10 +02:00
protom 39a144cf50 feat(receive): accept named mints that serve no verify URL
Named mints claim through the wallet's own secret, so a missing LUD-21
verify URL no longer blocks receiving; unnamed mints still require it -
there the preimage it reveals IS the claim.
2026-08-31 12:23:10 +02:00
protom ae5064b69a feat(nwc): make_invoice against mints with wallet-named outputs
The named-mint branch claims by the wallet's secret and treats the verify
preimage as an ordinary payment receipt - but keeps the rotated guard
before handing it out, because the rescue path (a mint that credited the
preimage despite the binding) claims through claimFromPreimage, where a
failed rotate leaves that preimage a live note secret. possibleCopy is
committed exactly like the unnamed path.
2026-08-31 12:23:10 +02:00
protom c9963934aa feat(funds): keep the claimable note of a pending named transfer
An unknown-still-pending transfer to a named mint carries the note at the
wallet's own secret in its claim material - persist it unverified like the
settled-claim-failed path already does, instead of dropping it with the
result object.
2026-08-31 12:22:56 +02:00
protom 67b2122e52 feat(lnurlcash): transfer to mints with wallet-named outputs
A named target needs no verify URL: the note appearing at the wallet's own
secret is the settlement proof, and it is claimed without a rotate. The
claim material carries the secret - and the unverified note at it - from
the start, so a melt settling after the poll budget is never a lost note;
the source-note oracle classification is unchanged.
2026-08-31 12:22:56 +02:00
protom 58268b2921 feat(lnurlcash): claim mint outputs named by a wallet-chosen secret
lnurl-mint >= 0.4 refuses a mint quote without comment=sha256(secret) and
keys the note by that secret instead of the payment preimage. prepareMint
detects the capability per mint (commentAllowed >= 64 or mintToHash),
binds the quote to a wallet-drawn secret, and claimMintedNote polls the
note itself - no preimage, no rotate, since the secret never rode an
invoice. Mints that never advertised the capability get the byte-identical
old request and stay on the preimage path; a mint that took the hash but
credited the preimage is caught by the claim poll and rescued through the
quote's verify URL. The d.ts augments the conformance package's stale
published declarations for its comment-naming flags.
2026-08-31 12:22:45 +02:00
protom 9f4ae3b0f5 refactor(lnurlcash): generalize bounded polling beyond verify endpoints
pollVerifyUntilSettled keeps its exact behavior and messages, reimplemented
on a generic pollUntil so flows that watch something other than a LUD-21
verify (a named mint output appearing) share the same backoff, budget and
abort semantics.
2026-08-31 12:22:30 +02:00
protom bb147faef1 build(deps): bump lnurlcash-kit to 0.5.0 and lnurlcash-conformance to 0.4.0
The kit release line adds the LUD-25 output-naming protocol (comment/h on
the pay callback, claim polling, merge batching); the mock mint now
defaults to plain https withdraw links and serves the originally issued
invoice on verify, which the two adapted tests pinned the old shape of.
2026-08-31 12:22:21 +02:00
protom bfa0572090 Merge pull request #4 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.3
2026-08-24 19:07:58 +02:00
github-actions[bot] 6892a3cc07 chore(main): release sattle 0.0.3 2026-08-24 14:30:42 +00:00
protom 338b2812e3 Merge pull request #3 from tompro/refactor/wallet-integrity
fix(wallet): enforce owner-bound integrity and durable commits
2026-08-24 16:30:06 +02:00
protom e892a330fc fix: consume owner monitor transition failures 2026-08-24 16:26:29 +02:00
protom 72f387f6e5 docs: document wallet integrity boundaries 2026-08-22 16:56:50 +02:00
protom 76d59d8d08 test: harden NWC browser lifecycle coverage 2026-08-22 16:56:49 +02:00
protom a40079200d test: cover wallet lifecycle through the browser 2026-08-22 16:56:49 +02:00
protom 578613c869 refactor: split wallet onboarding panels 2026-08-22 16:56:49 +02:00
protom 3c2a2cc4a5 feat: await trusted mint management actions 2026-08-22 16:56:49 +02:00
protom 401a26dc6b fix: fence send and pay flows 2026-08-22 16:56:38 +02:00
protom f087ebe2f2 fix: surface receive post-commit trust failures 2026-08-22 16:56:38 +02:00
protom c59521a421 fix: commit carved wallet funds atomically 2026-08-22 16:56:38 +02:00
protom 3f6010b312 feat: restore Nostr backups through wallet lifecycle 2026-08-22 16:56:37 +02:00
protom 99e6ae1b12 fix: await wallet commits in the NWC store 2026-08-22 16:56:37 +02:00
protom 81c28ec16d feat: scope trusted mint state in Pinia 2026-08-22 16:56:25 +02:00
protom e98373491f fix: drain accepted NWC work before wallet lock 2026-08-22 16:56:25 +02:00
protom 2e0f0038f7 feat: invalidate stale wallet tabs 2026-08-22 16:56:25 +02:00
protom 3acc63e968 fix: clear runtime state after lifecycle failures 2026-08-22 16:56:25 +02:00
protom c319e11d3e feat: activate only proven wallet owners 2026-08-22 16:56:24 +02:00
protom 62b93baa05 feat: serialize wallet lifecycle transitions 2026-08-22 16:56:11 +02:00
protom 5b3a69e910 feat: apply wallet fund changes atomically 2026-08-22 16:56:11 +02:00
protom 9fa4abdaf3 feat: fence wallet mutations by lifecycle owner 2026-08-22 16:56:11 +02:00
protom 54031dc8de fix: publish activity only after persistence 2026-08-22 16:56:10 +02:00
protom e9bbb358aa fix: mirror trusted mint commits across tabs 2026-08-22 16:56:10 +02:00
protom 83c9e5b95f feat: converge trusted mints on storage events 2026-08-22 16:55:47 +02:00
protom d735aca885 feat: serialize trusted mint transactions 2026-08-22 16:55:47 +02:00
protom 82f008b33f feat: validate trusted mint registry envelopes 2026-08-22 16:55:47 +02:00
protom d890a69c40 feat: preserve trusted mint rekey transitions 2026-08-22 16:55:47 +02:00
protom 7890a492f3 refactor: export owner-aware NWC APIs 2026-08-22 16:55:47 +02:00
protom 3ed214c157 feat: drain tracked NWC handlers on stop 2026-08-22 16:55:36 +02:00
protom 513c67fc14 fix: hide invoice settlement until commit 2026-08-22 16:55:35 +02:00
protom 09b7b63c9d fix: await NWC payment bearer commits 2026-08-22 16:55:35 +02:00
protom fff24a75b2 fix: preserve conservative NWC budget debits 2026-08-22 16:55:35 +02:00
protom b52172c0cb fix: validate NWC requests before dispatch 2026-08-22 16:55:35 +02:00
protom 228a92b0f0 feat: create owner-bound NWC connections 2026-08-22 16:55:24 +02:00
protom 9f6bcad706 feat: scope NWC storage to wallet owners 2026-08-22 16:55:23 +02:00
protom 3054834cdb test: split NWC protocol harness 2026-08-22 16:55:23 +02:00
protom 845ec410e5 feat: expose fund ownership assertions 2026-08-22 16:55:23 +02:00
protom 02f901c87c fix: fence inter-mint transfer operations 2026-08-22 16:55:23 +02:00
protom c4c9d8a94a fix: fence invoice payment operations 2026-08-22 16:55:12 +02:00
protom 7f69f781f5 fix: fence mint and receive operations 2026-08-22 16:55:12 +02:00
protom 6fa4283ed6 fix: fence carve operations before mutation 2026-08-22 16:55:12 +02:00
protom 22420b9112 test: split wallet operation harness 2026-08-22 16:55:12 +02:00
protom f55f208836 feat: await owner-aware Nostr restores 2026-08-22 16:55:12 +02:00
protom 7607e47358 feat: apply owner-aware wallet backups 2026-08-22 16:55:00 +02:00
protom 0862a10b01 fix: validate auxiliary wallet storage 2026-08-22 16:55:00 +02:00
protom 586d12b666 feat: commit bearer changesets in one write 2026-08-22 16:55:00 +02:00
protom 69fbdb68ee feat: add persisted owner guards and events 2026-08-22 16:55:00 +02:00
protom 1dbf5788f9 fix: normalize storage lock failures 2026-08-22 16:55:00 +02:00
protom 12e4ec9199 feat: scope passkey slots to proven owners 2026-08-22 16:54:49 +02:00
protom d9be457221 feat: harden passkey ceremonies 2026-08-22 16:54:49 +02:00
protom 058b10ddf1 refactor: isolate passkey wrapping crypto 2026-08-22 16:54:48 +02:00
protom acfab83438 feat: bind saved keys to versioned wallet owners 2026-08-22 16:54:48 +02:00
protom 0e0133f550 test: split Nostr backup integration cases 2026-08-22 16:54:48 +02:00
protom b258d86af7 refactor: isolate Nostr backup publishing transport 2026-08-22 16:54:29 +02:00
protom 2a90343e7e refactor: validate Nostr backup event payloads 2026-08-22 16:54:29 +02:00
protom b6fff605a1 refactor: tighten protocol amount helpers 2026-08-22 16:54:29 +02:00
protom 8548afcd21 refactor: add strict JSON parsing helpers 2026-08-22 16:54:29 +02:00
protom 30d1d0aeae build: pin LNURLcash protocol packages to npm 0.1.1 2026-08-22 16:54:28 +02:00
204 changed files with 26087 additions and 7887 deletions
+1
View File
@@ -28,3 +28,4 @@ yarn-error.log*
# Playwright (e2e/ itself is tracked - only its artifacts are ignored)
/playwright-report/
/test-results/
/.playwright-mcp/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.0.2"
".": "0.0.5"
}
+32 -18
View File
@@ -25,25 +25,25 @@ e2e/ # Playwright (own AGENTS.md)
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state |
| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern |
| Native feature | `src/capabilities/` | never import plugins elsewhere |
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
| Task | Location | Notes |
| ------------------- | ------------------------------------------------------ | ---------------------------------------------- |
| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state |
| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern |
| Native feature | `src/capabilities/` | never import plugins elsewhere |
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
## CODE MAP
| Module | Role |
|--------|------|
| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer |
| `lnurlcash/storage/` | AES-GCM bearers+activity, backup (merge entry point), settings |
| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap |
| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key |
| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup |
| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) |
| `stores/wallet.ts` | state none/locked/unlocked; linking key in memory only while unlocked |
| Module | Role |
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer |
| `lnurlcash/storage/` | AES-GCM bearers+activity, owner-bound credentials and trusted mints, backup (merge entry point), settings |
| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap |
| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key |
| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup |
| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) |
| `stores/wallet.ts` | serialized owner lifecycle; state none/locked/unlocked; linking key in memory only while unlocked |
## CONVENTIONS
@@ -79,8 +79,22 @@ npm run cap:sync # build + cap sync android
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
`sass-embedded` is aliased to pure-JS `sass` via npm overrides.
- lnurlcash-kit comes from `github:TheCryptoDonkey/lnurlcash-kit` pinned to
a commit — the `prepare` script (our merged PR#1) builds dist on install.
- `lnurlcash-kit@0.8.0` and `lnurlcash-conformance@0.7.0` are exact npm
registry packages. Keep their manifest pins, registry tarball URLs, and lock
integrity values intact.
- Credential and authorization records are bound to the canonical owner ID
derived from the linking key. Ordinary writes require an exact match with the
persisted saved-key owner. Ownerless legacy records migrate only after a
proven unlock, never from a restored file claim or passkey-first attempt.
- Wallet create, unlock, lock, restore, and forget transitions are serialized.
Forget locks first, drains NWC, clears runtime keys and owner namespaces,
then removes the saved key only after biometric deletion succeeds.
- Storage events are wakeups, not authoritative payloads. Re-read matching
storage on an event, including `key === null`, before converging state or
invalidating a stale owner tab. Web Locks serialize supported browsers but
do not make another tab's localStorage cache current; trusted mints reconcile
through a durable IndexedDB commit mirror before success. `withStorageLock`
falls back to local execution without a cross-tab guarantee.
- tsconfig deliberately relaxed (`exactOptionalPropertyTypes` etc. off) to
keep the protocol core untouched; `src/lnurlcash` has an eslint override.
- Gitea remote dropped; origin = GitHub. Gitea mirror = pull-mirror on the
+116 -20
View File
@@ -1,33 +1,129 @@
# Changelog
## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
### Features
* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
* capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
* capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
* deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
* inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
* manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
* native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
* nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
* nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
* nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
* pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
* reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d))
* sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0))
* send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f))
* wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898))
* wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178))
* webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
* adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets ([d026533](https://github.com/tompro/sattle/commit/d02653345639539c9c9268c1b6f1fd2a0359bb30))
* **backup:** preserve BIP-32 counters ([641608e](https://github.com/tompro/sattle/commit/641608eff07464613570818cf1463ca28702d7c7))
* **biometrics:** unlock complete wallet material ([b7c0a33](https://github.com/tompro/sattle/commit/b7c0a33a6968756e372a8b99156d70b766c2691d))
* **composables:** route fund dialogs through the durable seam ([8e9c9dc](https://github.com/tompro/sattle/commit/8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071))
* **funds:** persist BIP-32 counters atomically ([0cec423](https://github.com/tompro/sattle/commit/0cec423d6fe6edafa1d4d3a6520d2bc659906a1c))
* **funds:** recover staged mutations ([3876791](https://github.com/tompro/sattle/commit/3876791a0bc12851d74f7dd98424c4b30b788207))
* **keys:** persist BIP-32 wallet material ([d48deeb](https://github.com/tompro/sattle/commit/d48deeb6e0b32ffd3f5abc5fbc2080198e4ecb5b))
* **ops:** draw mint-touching output secrets from caller allocation ([22b1c55](https://github.com/tompro/sattle/commit/22b1c5595cea73c8e796e22b381d8c6a5d7fd564))
* **passkeys:** wrap complete wallet material ([6099bb4](https://github.com/tompro/sattle/commit/6099bb4b6b994b682662a33ef18da388e675b07e))
* **trust:** keep previous mint signing key verifiable ([a30c4bb](https://github.com/tompro/sattle/commit/a30c4bb2cc60250a22c3c3f2c044393bd4d2d778))
* **wallet:** allocate operation secrets from the cash root ([e6d0712](https://github.com/tompro/sattle/commit/e6d0712258dd31d85690687824d25b48240ca16e))
### Bug Fixes
* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
* discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
* **composables:** commit carve checkpoints by output secret ([04e6211](https://github.com/tompro/sattle/commit/04e62110b66c4be50f07a97aec48d3f68f6c9989))
* **nwc:** await durable commits before responses ([29830dc](https://github.com/tompro/sattle/commit/29830dc9e3d34d2e626d0d6df52c86beca02d4b8))
* **passkeys:** bind slots to wallet material ([9374a87](https://github.com/tompro/sattle/commit/9374a87d0d64cfd70f5bf938cdf4582331cea87a))
* **unlock:** bind factors to wallet material ([7e4e67e](https://github.com/tompro/sattle/commit/7e4e67e1cddc836830add3baf787a0c5368102f7))
* **wallet:** activate complete wallet material ([e94549a](https://github.com/tompro/sattle/commit/e94549a16e983875eaf445287108b62212255d61))
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
### Features
- **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400))
- **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb))
- **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2))
- **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf))
- **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed))
- **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c))
- support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
### Bug Fixes
- commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62))
- commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306))
- **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927))
- **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86))
- **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df))
- replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7))
## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
### Features
- activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc))
- add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331))
- apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1))
- apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37))
- await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6))
- await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e))
- bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a))
- commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4))
- converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c))
- create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30))
- drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904))
- expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755))
- fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544))
- harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
- invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8))
- preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341))
- restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635))
- scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf))
- scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb))
- scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab))
- serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
- serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e))
- validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
### Bug Fixes
- await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece))
- await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205))
- clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329))
- commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354))
- consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6))
- drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18))
- fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5))
- fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a))
- fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60))
- fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6))
- fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c))
- hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382))
- mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff))
- normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a))
- preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155))
- publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c))
- surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa))
- validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
- validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504))
- **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8))
## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
### Features
- backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
- capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
- capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
- deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
- inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
- manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
- native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
- nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
- nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
- nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
- pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
- reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d))
- sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0))
- send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f))
- wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898))
- wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178))
- webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
### Bug Fixes
- bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
- discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
## Changelog
+40
View File
@@ -0,0 +1,40 @@
/// <reference types="node" />
import { chromium } from '@playwright/test';
import { execSync } from 'child_process';
// Warms the freshly booted dev server before any test runs. `quasar dev`
// answers HTTP the moment its port is up, but vite still transforms the
// module graph on the first page load - a window in which the first test's
// multi-step onboarding flow can race lazy compilation (observed as the
// create flow never reaching /#/). One full load plus a reload completes
// that work before the suite starts. Uses the same system-chromium
// resolution as the main config (CI falls back to the bundled browser).
const findSystemChromium = (): string | undefined => {
try {
return execSync('command -v chromium').toString().trim() || undefined;
} catch {
return undefined;
}
};
const globalSetup = async (): Promise<void> => {
const executablePath = process.env.CHROMIUM_PATH || findSystemChromium();
const browser = await chromium.launch({
...(executablePath ? { executablePath } : {}),
});
try {
const page = await (await browser.newContext({ baseURL: 'http://localhost:9333' })).newPage();
await page.goto('/');
await page
.getByRole('button', { name: 'Get started' })
.waitFor({ state: 'visible', timeout: 60_000 });
await page.reload();
await page
.getByRole('button', { name: 'Get started' })
.waitFor({ state: 'visible', timeout: 60_000 });
} finally {
await browser.close();
}
};
export default globalSetup;
+297 -36
View File
@@ -1,13 +1,26 @@
import type { Page, Route } from '@playwright/test';
import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
// The mock mint lives on a .test origin that never resolves - every request
// to it is intercepted by Playwright and fulfilled in-process, so the specs
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
// callback shape also answers a melt (meltNote sends k1 + pr to the same
// callback and only requires status "OK").
// mutating GET on the callback (rotate/split/merge/melt) expects
// {status: "OK"} plus the LUD-25 signature(s) over every minted output -
// the app forces the kit's requireSignatures policy, so an unsigned
// mutation answer is not a success the wallet accepts quietly.
//
// Signing: each origin has a stable secp256k1 signing key derived from its
// origin string, so a spec can pin the advertised mintPubkey via
// signingPubkey(origin). `invalidSignatures = true` signs every output with
// a foreign key instead - the notes are real mint-side, but the offline
// proof verifies against no key the mint advertises, which is exactly the
// "landed but unverifiable" case the wallet must refuse to mark spendable.
//
// Every method takes an optional origin, so a spec can stand up a SECOND
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
@@ -29,26 +42,43 @@ const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
});
};
// deterministic per-origin signing keys: the mock mints are stable across
// tests, and the key derivation needs nothing but the origin string
const signingKeyFor = (origin: string): Uint8Array =>
sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`));
// the key every output is signed with in invalid-signature mode: a real
// secp256k1 key, just never advertised by any mock mint
const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key'));
// a melt's settle proof reveals a payment receipt - derived from the paid
// invoice so it is stable per payment and never collides with a note secret
const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`)));
interface MockNoteInfoOptions {
// note value in msat, reported as maxWithdrawable
amountMsat?: number;
// when set, the mint answers the ERROR envelope with this reason instead
// (a reason matching /spent/i surfaces the "already been spent" UI)
spentReason?: string;
// the mint's signing pubkey, advertised as mintPubkey in responses
// the mint's signing pubkey, advertised as mintPubkey in responses -
// defaults to the mock origin's real signing key (kit 0.8 refuses a
// withdrawRequest that publishes none, so every mock mint has one)
mintPubkey?: string;
}
interface MockTargetMintOptions {
// the mint's signing pubkey (66 hex chars), advertised in the payRequest
// and note responses
mintPubkey: string;
// the mint's signing pubkey (66 hex chars), advertised on the mint-address
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
// LUD-25 announces it on the withdraw side only, and a too-generous mock
// once masked a real bug. Defaults to the origin's real signing key.
mintPubkey?: string;
// the invoice this mint hands out (and reports back as settled) - keep it
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
// amount cross-check
invoice: string;
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
// claimed note's secret)
// the payment preimage a settled verify reveals - a receipt distinct from
// the wallet-chosen secret committed in the invoice comment
preimage: string;
// value of the note a claim mints, in msat
noteAmountMsat: number;
@@ -60,10 +90,69 @@ interface MockTargetMintOptions {
export class MintMocker {
constructor(private page: Page) {}
// when true, every minted output is signed with a foreign key: the
// mutation lands mint-side, but the signature verifies against no key
// this mint advertises - the wallet must keep the outputs unverified
invalidSignatures = false;
// every signature this mock issued, in order - lets a spec assert the
// mint actually signed (the kit's requireSignatures policy makes an
// unsigned success impossible to confuse with a signed one)
readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = [];
// the mint's advertised signing pubkey for an origin (compressed, hex)
signingPubkey(origin = MINT_ORIGIN): string {
return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true));
}
// note id (sha256 of the secret) -> outstanding value in msat, per origin.
// A real mint keys its ledger by the hash; the mock does the same so a
// mutation can sign the output with the amount the inputs actually held.
private readonly outstanding = new Map<string, number>();
private readonly burned = new Set<string>();
// invoices this mint reports as settled on its verify endpoint, per origin
private readonly settledInvoices = new Map<string, string[]>();
private noteKey(origin: string, noteId: string): string {
return `${origin}|${noteId}`;
}
private sign(origin: string, h: string, amountMsat: number): string {
const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin);
// noble's 'recovered' layout is [recovery, r, s]; the wire layout is
// r || s || recovery (the kit accepts either, the trailing layout is
// what lnurl-mint emits)
const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, {
format: 'recovered',
prehash: false,
});
this.signaturesIssued.push({ origin, h, amountMsat });
return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]]));
}
// mint outputs at the given hashes, burning the inputs - the ledger
// transition every successful mutation makes
private remint(
origin: string,
inputIds: string[],
outputs: [h: string, amountMsat: number][],
): void {
for (const id of inputIds) {
this.outstanding.delete(this.noteKey(origin, id));
this.burned.add(this.noteKey(origin, id));
}
for (const [h, amountMsat] of outputs) {
this.outstanding.set(this.noteKey(origin, h), amountMsat);
}
}
// The note's informational GET: a spec-shaped withdrawRequest, or the
// ERROR envelope for a note the mint considers spent. The kit validates
// that the service echoes back the queried k1, so read it off the request.
// ERROR envelope for a note the mint considers spent or never issued. The
// kit validates that the service echoes back the queried k1, so read it
// off the request. A first-seen k1 registers at the configured amount -
// the mock's way of "issuing" the note a spec hands to the wallet.
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => {
@@ -72,54 +161,206 @@ export class MintMocker {
return;
}
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
if (!/^[0-9a-f]{64}$/i.test(k1)) {
// a by-hash lookup (h=) or a malformed k1 - this mock answers
// secret-keyed info only
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
const id = this.noteKey(origin, hashK1(k1));
if (this.burned.has(id)) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (!this.outstanding.has(id)) {
this.outstanding.set(id, options.amountMsat ?? 0);
}
const amountMsat = this.outstanding.get(id) ?? 0;
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`,
k1,
minWithdrawable: options.amountMsat ?? 0,
maxWithdrawable: options.amountMsat ?? 0,
minWithdrawable: amountMsat,
maxWithdrawable: amountMsat,
defaultDescription: 'mock mint note',
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
mintPubkey,
});
},
);
}
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
// params) both expect a plain {status: "OK"} confirmation. No sig is
// returned - a plain LUD-03-style service that does not sign notes.
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
// The settle proof a melt's verify URL serves: settled once the melt
// landed, carrying the paid invoice and its preimage receipt.
private async mockVerify(origin: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => {
await fulfillJson(route, { status: 'OK' });
const pr = new URL(route.request().url()).searchParams.get('pr') ?? '';
const settled = (this.settledInvoices.get(origin) ?? []).includes(pr);
await fulfillJson(route, {
settled,
...(settled ? { preimage: preimageFor(pr) } : {}),
pr,
});
},
);
}
// A mint that never answers its mint-address discovery endpoint with
// anything usable - prepareMint treats that as "no mint-address support"
// and falls back to the plain LNURL-pay guess.
private async mockNoMintAddress(origin: string): Promise<void> {
// The mutating callback GET, signed per LUD-25:
// - melt (k1 + pr): burns the input, answers OK with a settle-proof URL
// - rotate (k1 + h): burns the input, mints at h for its full value
// - split (k1... + amount + h + h2): carves amount off the inputs' total,
// minting h for it and h2 for the change
// - merge (k1... + h): burns all inputs, mints h for their total
// Every minted output's signature rides in the response (sig / sig2), and
// a mutation naming an unknown or burned input is refused like a real
// mint's. A byte-identical replay gets the same success - the mutations
// are deterministic in their inputs, so the replayed answer re-derives
// the same signatures.
async mockMutationEndpoint(origin = MINT_ORIGIN): Promise<void> {
await this.mockVerify(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
async (route: Route) => {
const url = new URL(route.request().url());
const k1s = url.searchParams.getAll('k1');
if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
const pr = url.searchParams.get('pr');
const h = url.searchParams.get('h');
const h2 = url.searchParams.get('h2');
const amountRaw = url.searchParams.get('amount');
const inputIds = k1s.map((k1) => hashK1(k1));
const lookup = (id: string): number | 'unknown' | 'burned' => {
const key = this.noteKey(origin, id);
if (this.burned.has(key)) return 'burned';
return this.outstanding.get(key) ?? 'unknown';
};
// melt: the note burns once the payment settles; the mock answers
// OK (payment in flight) and the verify endpoint reports it settled
if (pr !== null) {
for (const id of inputIds) {
const state = lookup(id);
if (state === 'burned') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (state === 'unknown') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
}
this.remint(origin, inputIds, []);
const paid = this.settledInvoices.get(origin) ?? [];
paid.push(pr);
this.settledInvoices.set(origin, paid);
await fulfillJson(route, {
status: 'OK',
verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`,
});
return;
}
// every other mutation mints outputs whose signatures the wallet
// must be able to verify offline
const total = inputIds.reduce((sum, id) => {
const state = lookup(id);
return typeof state === 'number' ? sum + state : sum;
}, 0);
for (const id of inputIds) {
const state = lookup(id);
if (state === 'burned') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (state === 'unknown') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
}
if (h !== null && h2 !== null && amountRaw !== null) {
// split: amount carved off, the rest is change
const amountMsat = Number(amountRaw);
this.remint(origin, inputIds, [
[h, amountMsat],
[h2, total - amountMsat],
]);
await fulfillJson(route, {
status: 'OK',
sig: this.sign(origin, h, amountMsat),
sig2: this.sign(origin, h2, total - amountMsat),
});
return;
}
if (h !== null) {
// rotate (one input) or merge (several): full value at h
this.remint(origin, inputIds, [[h, total]]);
await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) });
return;
}
await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' });
},
);
}
// The mint-address discovery endpoint (LUD-25): announces the signing key
// and the node stats a real lnurl-mint advertises. `nodeCapacity` is msat
// under its WIRE name (no suffix) - the kit has to map it onto
// nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises.
// The payLink points back at the lnurlp route below, as prepareMint treats
// it as the authoritative place to read the payRequest from.
private async mockMintAddress(origin: string, mintPubkey: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
async (route: Route) => {
await fulfillJson(route, { status: 'ERROR', reason: 'not supported' });
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`,
minWithdrawable: 1000,
maxWithdrawable: 100_000_000_000,
mintPubkey,
payLink: `${origin}/.well-known/lnurlp/mint`,
nodeCapacity: 500_000_000,
nodeNumChannels: 4,
nodeNumPeers: 6,
});
},
);
}
// Everything the target side of a transfer (or a Lightning receive)
// needs: the payRequest at the standard mint@ address, the invoice
// callback, an immediately-settled verify endpoint revealing the
// preimage, and the note info + rotate the claim then performs.
// needs: the mint-address discovery endpoint carrying the mint metadata,
// the payRequest at the standard mint@ address, the invoice callback, an
// immediately-settled verify endpoint revealing the payment receipt,
// and note info at the wallet-chosen committed secret.
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
await this.mockNoMintAddress(origin);
await this.mockNoteInfo(
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
origin,
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
let stagedHash: string | null = null;
let settled = false;
await this.mockMintAddress(origin, mintPubkey);
await this.mockMutationEndpoint(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => {
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`,
k1,
minWithdrawable: options.noteAmountMsat,
maxWithdrawable: options.noteAmountMsat,
defaultDescription: 'mock minted note',
mintPubkey,
});
},
);
await this.mockRotateOk(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
async (route: Route) => {
@@ -128,8 +369,8 @@ export class MintMocker {
callback: `${origin}/pay`,
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: `${origin}${NOTE_PATH}`,
mintPubkey: options.mintPubkey,
metadata: options.mintFeeMetadata ?? '[]',
});
},
@@ -137,18 +378,38 @@ export class MintMocker {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
async (route: Route) => {
const request = new URL(route.request().url());
const comment = request.searchParams.get('comment');
const commitment = request.searchParams.get('h');
if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) {
await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' });
return;
}
stagedHash = comment.toLowerCase();
await fulfillJson(route, {
pr: options.invoice,
verify: `${origin}/verify`,
});
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
async (route: Route) => {
const paidInvoice = new URL(route.request().url()).searchParams.get('pr');
if (paidInvoice !== options.invoice) {
await route.fallback();
return;
}
settled = true;
await fulfillJson(route, { status: 'OK' });
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => {
await fulfillJson(route, {
settled: true,
preimage: options.preimage,
settled,
...(settled ? { preimage: options.preimage } : {}),
pr: options.invoice,
});
},
+35
View File
@@ -1,4 +1,8 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
import { MINT_ORIGIN, NOTE_PATH } from './MintMocker';
import type { MintMocker } from './MintMocker';
// Drives onboarding end to end on a fresh browser context: /#/welcome ->
// "Create wallet" (empty password = stored unencrypted) -> confirm the
@@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise<void> => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.waitForURL(/#\/$/);
};
// Give a fresh wallet one verified, spendable note at the mock mint: the
// mock answers the note's informational GET and signs every mutation, and
// the receive's first-contact trust prompt is confirmed, pinning the mint's
// signing key so the wallet can verify what this mint mints.
export const fundMockMintWallet = async (
page: Page,
mint: MintMocker,
amountMsat: number,
): Promise<void> => {
await mint.mockNoteInfo({ amountMsat });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await dialog
.locator('textarea')
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat));
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible();
// the note advertises the mint's key, so the first-contact trust prompt
// opens as its own dialog - trust it (that also pins the signing key)
await page
.locator('.q-dialog', { hasText: 'New mint' })
.getByRole('button', { name: 'Trust this mint' })
.click();
await dialog.getByRole('button', { name: 'Done' }).click();
};
+167
View File
@@ -1,5 +1,9 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { bytesToHex } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
// platform authenticator, so the security page is asserted in its honest
@@ -12,6 +16,72 @@ const VALID_PHRASE =
const readSettings = async (page: import('@playwright/test').Page) =>
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
// A v2 encrypted wallet-material record built with the app's own wrap
// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that
// need a saved, locked wallet without driving the onboarding UI.
const PASSWORD = 'correct horse battery staple';
const PBKDF2_ITERATIONS = 210_000;
const encryptedWalletMaterialRecord = async (
password: string,
): Promise<{
enc: true;
salt: string;
iv: string;
ciphertext: string;
materialHash: string;
ownerId: string;
version: 2;
}> => {
const linkingKeyHex = '07'.repeat(32);
const material = {
version: 2,
linkingKeyHex,
cashRootHex: '08'.repeat(64),
};
const serialized = JSON.stringify(material);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(serialized),
),
);
return {
enc: true,
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)),
version: 2,
};
};
const hexToBytesLocal = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
};
test.describe('Backup page', () => {
test('renders all sections and exports a JSON backup file', async ({ page }) => {
await createFreshWallet(page);
@@ -79,6 +149,103 @@ test.describe('Security page', () => {
// auto-lock: display-only for now
await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible();
});
test('lists only passkeys owned by the current wallet', async ({ page }) => {
// Given a browser with a PRF-capable authenticator probe and an unlocked wallet
await page.addInitScript(() => {
Object.defineProperty(window, 'PublicKeyCredential', {
configurable: true,
value: {
isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true),
getClientCapabilities: () => Promise.resolve({ 'extension:prf': true }),
},
});
});
await createFreshWallet(page);
await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('ownerId' in saved) ||
typeof saved.ownerId !== 'string' ||
!('materialHash' in saved) ||
typeof saved.materialHash !== 'string'
) {
throw new Error('expected saved wallet owner');
}
const wrap = {
hkdfSalt: '11'.repeat(16),
iv: '22'.repeat(12),
materialHash: saved.materialHash,
wrappedMaterial: '33'.repeat(48),
createdAt: 1,
};
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
...wrap,
credentialId: '44'.repeat(16),
name: 'Current wallet passkey',
ownerId: saved.ownerId,
version: 2,
},
{
...wrap,
credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 2,
},
]),
);
});
// When the security management surface reads passkey slots
await page.goto('/#/settings/security');
// Then only the current owner's slot is rendered
await expect(page.getByText('Current wallet passkey')).toBeVisible();
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
});
test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
page,
}) => {
// Given an encrypted v2 saved wallet whose only passkey slot belongs to
// a DIFFERENT owner (a foreign wallet's residue on the same device)
const record = await encryptedWalletMaterialRecord(PASSWORD);
await page.addInitScript(
({ storedRecord }) => {
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
materialHash: '77'.repeat(32),
wrappedMaterial: '88'.repeat(48),
createdAt: 1,
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 2,
},
]),
);
},
{ storedRecord: record },
);
// When the locked wallet renders
await page.goto('/');
// Then passkey-first unlock is unavailable - the one slot on this
// device is not this wallet's
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
});
});
test.describe('Welcome: restore from nostr', () => {
+3 -2
View File
@@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise<void> => {
callback: 'https://mint.600.wtf/pay',
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: 'https://mint.600.wtf/note',
metadata: '[]',
}),
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
// hold a 21-sat note from the mock mint - holding funds locks the mint
// against removal
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: 21_000 });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
+6 -30
View File
@@ -1,13 +1,10 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
import { MINT2_ORIGIN } from '../helpers/MintMocker';
import { fundMockMintWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 50_000; // 50 sats
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
// 64 hex chars - a valid payment receipt, distinct from the claimed note secret
const PREIMAGE = 'ef'.repeat(32);
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
// amount cross-check against the requested value
@@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer';
// give the wallet a verified, spendable 50-sat note at the source mint
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await dialog
.locator('textarea')
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
// the note advertises a mint key, so the first-contact trust prompt opens
// as its own dialog - trust it (that also locks the mint)
await page
.locator('.q-dialog', { hasText: 'New mint' })
.getByRole('button', { name: 'Trust this mint' })
.click();
await dialog.getByRole('button', { name: 'Done' }).click();
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
};
// pick an option from a Quasar select identified by its label
@@ -78,7 +56,6 @@ test.describe('Move funds', () => {
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
await fundSourceMint(page, mint);
// the target mint: hands out the invoice, reports it settled with the
// preimage, and answers the claim's note info + rotation
// the target mint commits the wallet-chosen secret, hands out the invoice,
// and exposes that note only after the source-mint melt pays it
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
+128 -2
View File
@@ -1,17 +1,34 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
import { hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent } from 'nostr-tools/pure';
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04';
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
// test that runs the service injects a fake transport through the dev-only
// window hook (stores/nwc.ts) before enabling - every other test stays
// UI-only with the service off.
// a Nostr event as the fake transport carries it - the plain JSON shape
// NIP-47 requests and responses take on the wire
type WireEvent = {
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
};
type FakeSub = { closed: boolean; close: () => void };
declare global {
interface Window {
__sattleNwcTest: { setTransport: (transport: unknown) => void };
__nwcSubs: FakeSub[];
__nwcPublished: WireEvent[];
__nwcOnEvent?: (event: WireEvent) => void;
}
}
@@ -63,9 +80,10 @@ test.describe('NWC settings page', () => {
await expect(page.getByText('shown only once', { exact: false })).toBeVisible();
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
expect(uri).toBeTruthy();
if (!uri) throw new Error('Expected the one-time NWC connection string.');
// a well-formed NIP-47 connection string
const url = new URL(uri!);
const url = new URL(uri);
expect(url.protocol).toBe('nostr+walletconnect:');
expect(url.host).toMatch(/^[0-9a-f]{64}$/);
expect(url.searchParams.get('secret')).toMatch(/^[0-9a-f]{64}$/);
@@ -145,4 +163,112 @@ test.describe('NWC settings page', () => {
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
});
test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => {
// a funded wallet: 50 sats at the mock mint, its signing key pinned
await fundMockMintWallet(page, mint, 50_000);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// create a connection and read its one-time URI: the wallet service
// pubkey is the host, the client secret the `secret` param
await openNwcPage(page);
await page.getByRole('button', { name: 'Create connection' }).click();
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
expect(uri).toBeTruthy();
if (!uri) throw new Error('Expected the one-time NWC connection string.');
await page.getByRole('button', { name: "Done - I've saved it" }).click();
const connectionUrl = new URL(uri);
const walletServicePubkey = connectionUrl.host;
const clientSecret = connectionUrl.searchParams.get('secret') ?? '';
// a fake transport that captures the request handler and every
// published event, installed before the service starts
await page.evaluate(() => {
window.__nwcSubs = [];
window.__nwcPublished = [];
window.__sattleNwcTest.setTransport({
publish: (_relays: string[], event: WireEvent) => {
window.__nwcPublished.push(event);
return Promise.resolve();
},
subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => {
window.__nwcOnEvent = onEvent;
const sub: FakeSub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click();
await expect(page.getByText('Service running', { exact: false })).toBeVisible();
await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function');
// a 21-sat invoice the mint will settle; the request is exactly what a
// NIP-47 client sends: kind 23194, NIP-04 encrypted to the service,
// schnorr-signed by the connection's client secret
const invoice = 'lnbc210n1pjqrstuvwxyz';
const content = nip04Encrypt(
clientSecret,
walletServicePubkey,
JSON.stringify({ method: 'pay_invoice', params: { invoice } }),
);
const request = finalizeEvent(
{
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', walletServicePubkey],
['encryption', 'nip04'],
],
content,
},
hexToBytes(clientSecret),
);
await page.evaluate((event) => window.__nwcOnEvent?.(event), request);
// the service pays and answers on the same scheme
await expect
.poll(async () =>
page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
),
)
.not.toBeNull();
const responseEvent = await page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
);
if (!responseEvent) throw new Error('the service published no response');
const response: unknown = JSON.parse(
nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content),
);
if (typeof response !== 'object' || response === null) {
throw new Error('the service response is not an object');
}
const result = response as { result_type?: unknown; error?: unknown; result?: unknown };
expect(result.result_type).toBe('pay_invoice');
expect(result.error).toBeNull();
// the receipt is the melt's payment preimage from the mint's own
// settle proof - 64 hex chars, never a note secret
const payload = result.result as { preimage?: unknown };
expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/);
// the wallet paid 21 of its 50 sats; the change stays in the wallet
await page.goto('/#/');
await expect(page.locator('.balance-card .text-h2')).toHaveText('29');
});
});
+63 -1
View File
@@ -25,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise<void> => {
test.describe('Receive bearer note', () => {
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk();
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
@@ -37,6 +37,18 @@ test.describe('Receive bearer note', () => {
// were really consumed (the kit bug this guards against silently fell
// back to the declared amount without any request)
await expect(dialog.locator('.q-banner')).toHaveCount(0);
// the mint signed the rotation's output (LUD-25) - the wallet's
// requireSignatures policy accepted a signed answer, not a rescue
expect(
mint.signaturesIssued.some(
(entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT,
),
).toBe(true);
// the note advertised the mint's key, so the first-contact trust prompt
// opened - dismiss it, then close the success screen
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await dialog.getByRole('button', { name: 'Done' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
@@ -58,4 +70,54 @@ test.describe('Receive bearer note', () => {
await page.keyboard.press('Escape');
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
});
test('an already trusted current-owner mint bypasses the first-contact prompt', async ({
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await expect(trustDialog).toBeVisible();
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await expect(trustDialog).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(receiveDialog).toHaveCount(0);
await redeemNote(page, freshNoteUrl());
await expect(receiveDialog.getByText('Received 21 sats')).toBeVisible();
await expect(trustDialog).toHaveCount(0);
await expect(page.locator('.balance-card .text-h2')).toHaveText('42');
});
test('trust failure after commit keeps received funds and warns against retry', async ({
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
});
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
await expect(dialog.getByText('Received 21 sats')).toBeVisible();
await expect(
page.getByText(/Funds were saved.*receive succeeded.*do not retry/i),
).toBeVisible();
await expect(dialog.locator('.q-banner')).toHaveCount(0);
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await dialog.getByRole('button', { name: 'Done' }).click();
await page.reload();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import {
fromBech32Lnurl,
noteDeclaredAmount,
noteK1,
noteSignature,
verifyNoteSignature,
} from 'lnurlcash-kit';
import { fundMockMintWallet } from '../helpers/wallet';
const FUNDING_MSAT = 50_000; // 50 sats
const SEND_MSAT = 21_000; // 21 sats
// give the wallet a verified, spendable 50-sat note at the mock mint, with
// the mint's signing key pinned through the first-contact trust prompt
const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await fundMockMintWallet(page, mint, FUNDING_MSAT);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
};
// prepare a note hand-over and read the displayed note off the clipboard
const prepareNote = async (page: Page, sats: string): Promise<string> => {
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByLabel('Amount (sats)').fill(sats);
await dialog.getByRole('button', { name: 'Prepare note' }).click();
await expect(dialog.getByText('Tap to reveal')).toBeVisible();
await dialog.getByRole('button', { name: 'Copy' }).click();
return page.evaluate(() => navigator.clipboard.readText());
};
test.describe('Send a note', () => {
test('a carved note carries a signature that verifies against the mint', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint signed the split's two outputs (the carved note and the
// change), and the note the dialog hands over carries that signature
// in its URL
const beforeSend = mint.signaturesIssued.length;
const bech32 = await prepareNote(page, '21');
const issued = mint.signaturesIssued
.slice(beforeSend)
.map((entry) => entry.amountMsat)
.sort((a, b) => a - b);
expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]);
const url = fromBech32Lnurl(bech32.trim());
expect(url).not.toBeNull();
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
expect(k1).not.toBeNull();
expect(amountMsat).toBe(SEND_MSAT);
expect(signature).not.toBeNull();
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
// the offline proof verifies against the key the mint advertised and
// the wallet pinned - this is what marked the carved note spendable
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true);
// keep the note: nothing was handed over, the balance is untouched
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
test('outputs the mint signs with a foreign key never become spendable', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint's mutations land but are signed by a key it never advertised
mint.invalidSignatures = true;
const bech32 = await prepareNote(page, '21');
// the note exists (the split landed mint-side), but its offline proof
// verifies against no key this mint publishes
const url = fromBech32Lnurl(bech32.trim());
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false);
// keep the note: the balance is not corrupted by the bad proof
const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// the carve's outputs stayed unverified, so the wallet refuses to spend
// from them - a second preparation fails instead of moving bad notes
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const retry = page.locator('.q-dialog', { hasText: 'Send a note' });
await retry.getByLabel('Amount (sats)').fill('21');
await retry.getByRole('button', { name: 'Prepare note' }).click();
await expect(
retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }),
).toBeVisible();
await expect(retry.getByText('Tap to reveal')).toHaveCount(0);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
});
+216
View File
@@ -0,0 +1,216 @@
import type { Page, TestInfo } from '@playwright/test';
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
// the dev-only lifecycle hook (boot/wallet.ts) used to drive the forget
// transition, which has no UI surface
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const MINT_KEY_A = `02${'aa'.repeat(32)}`;
const MINT_KEY_B = `03${'bb'.repeat(32)}`;
const MINT_KEY_C = `02${'cc'.repeat(32)}`;
const VIEWPORT_WIDTHS = [375, 768, 1280] as const;
const CONCURRENT_ADD_ROUNDS = 10;
const mintsList = (page: Page) => page.locator('.q-list', { hasText: 'Your mints' });
const fillMintForm = async (page: Page, server: string, mintPubkey: string): Promise<void> => {
await page.getByLabel('Server').fill(server);
await page.getByLabel('Signing key (66 hex characters)').fill(mintPubkey);
};
const addMint = async (page: Page, server: string, mintPubkey: string): Promise<void> => {
await fillMintForm(page, server, mintPubkey);
await page.getByRole('button', { name: 'Trust this mint' }).click();
};
const ownerId = async (page: Page): Promise<string | null> =>
page.evaluate(() => {
const raw = localStorage.getItem('sattle_wallet_material_v2');
if (raw === null) return null;
const saved: unknown = JSON.parse(raw);
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null;
return typeof saved.ownerId === 'string' ? saved.ownerId : null;
});
const storedMintServers = async (page: Page): Promise<string[]> =>
page.evaluate(() => {
const raw = localStorage.getItem('sattle_trusted_mints');
if (raw === null) return [];
const registry: unknown = JSON.parse(raw);
if (
typeof registry !== 'object' ||
registry === null ||
!('mints' in registry) ||
!Array.isArray(registry.mints)
) {
return [];
}
return registry.mints
.map((mint) =>
typeof mint === 'object' &&
mint !== null &&
'server' in mint &&
typeof mint.server === 'string'
? mint.server
: '',
)
.sort();
});
const retainFailureEvidence = async (
testInfo: TestInfo,
pages: readonly Page[],
consoleMessages: readonly string[],
): Promise<void> => {
for (const [index, page] of pages.entries()) {
if (page.isClosed()) continue;
const path = testInfo.outputPath(`tab-${index + 1}-failure.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(`tab-${index + 1}-failure`, { path, contentType: 'image/png' });
}
await testInfo.attach('browser-console', {
body: consoleMessages.join('\n'),
contentType: 'text/plain',
});
};
test.describe('trusted mint tabs', () => {
test('remote updates converge and concurrent additions survive Web Locks', async ({
page,
}, testInfo) => {
await createFreshWallet(page);
test.skip(
!(await page.evaluate(() => 'locks' in navigator)),
'Web Locks are unavailable, so this browser provides no concurrent-write guarantee.',
);
const remote = await page.context().newPage();
const consoleMessages: string[] = [];
for (const [name, current] of [
['first', page],
['second', remote],
] as const) {
current.on('console', (message) => {
consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`);
});
}
try {
await Promise.all([page.goto('/#/settings/mints'), remote.goto('/#/settings/mints')]);
await expect(mintsList(page).getByText('No mints yet', { exact: false })).toBeVisible();
await expect(mintsList(remote).getByText('No mints yet', { exact: false })).toBeVisible();
// A real storage event from the second tab updates the first tab without reload.
await addMint(remote, 'remote.example', MINT_KEY_A);
await expect(mintsList(page).getByText('remote.example', { exact: true })).toBeVisible();
// Repeated UI races from separate pages must all survive Web Locks.
const expectedServers = ['remote.example'];
for (let round = 1; round <= CONCURRENT_ADD_ROUNDS; round++) {
const firstServer = `first-${round}.example`;
const secondServer = `second-${round}.example`;
expectedServers.push(firstServer, secondServer);
await Promise.all([
fillMintForm(page, firstServer, MINT_KEY_B),
fillMintForm(remote, secondServer, MINT_KEY_C),
]);
await Promise.all([
page.getByRole('button', { name: 'Trust this mint' }).click(),
remote.getByRole('button', { name: 'Trust this mint' }).click(),
]);
await expect.poll(() => storedMintServers(page)).toEqual([...expectedServers].sort());
}
for (const width of VIEWPORT_WIDTHS) {
await Promise.all([
page.setViewportSize({ width, height: 900 }),
remote.setViewportSize({ width, height: 900 }),
]);
await expect(mintsList(page).getByText('first-10.example', { exact: true })).toBeVisible();
await expect(mintsList(page).getByText('second-10.example', { exact: true })).toBeVisible();
await expect(
mintsList(remote).getByText('first-10.example', { exact: true }),
).toBeVisible();
await expect(
mintsList(remote).getByText('second-10.example', { exact: true }),
).toBeVisible();
}
expect(await storedMintServers(page)).toEqual([...expectedServers].sort());
} catch (error) {
await retainFailureEvidence(testInfo, [page, remote], consoleMessages);
throw error;
} finally {
await remote.close();
}
});
});
test.describe('wallet ownership', () => {
test('a stale owner tab locks and cannot recreate trust or NWC state', async ({
page,
}, testInfo) => {
await createFreshWallet(page);
const oldOwner = await ownerId(page);
expect(oldOwner).not.toBeNull();
const stale = await page.context().newPage();
const consoleMessages: string[] = [];
for (const [name, current] of [
['successor', page],
['stale', stale],
] as const) {
current.on('console', (message) => {
consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`);
});
}
try {
await stale.goto('/#/settings/mints');
await expect(mintsList(stale).getByText('No mints yet', { exact: false })).toBeVisible();
// The active page forgets owner A, then creates owner B through the rendered onboarding flow.
await page.evaluate(() => window.__sattleWalletTest.forget());
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await stale.goto('/#/');
await expect(stale.getByText('Wallet locked')).toBeVisible();
await page.getByRole('button', { name: 'Get started' }).click();
await page.getByRole('button', { name: 'Create wallet' }).click();
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
const successorOwner = await ownerId(page);
expect(successorOwner).not.toBe(oldOwner);
for (const width of VIEWPORT_WIDTHS) {
await stale.setViewportSize({ width, height: 900 });
await expect(stale.getByText('Wallet locked')).toBeVisible();
}
// A stale settings route cannot call owner-bound mutations after invalidation.
await stale.goto('/#/settings/mints');
await addMint(stale, 'stale.example', MINT_KEY_A);
await expect(stale.locator('.q-banner', { hasText: 'Wallet is locked.' })).toBeVisible();
await stale.goto('/#/settings/nwc');
await expect(stale.locator('.q-page', { hasText: 'Unlock your wallet first' })).toBeVisible();
const residue = await page.evaluate(() => ({
trustedMints: localStorage.getItem('sattle_trusted_mints'),
nwcConnections: localStorage.getItem('sattle_nwc_connections'),
nwcEnabled: localStorage.getItem('sattle_nwc_enabled'),
}));
expect(residue).toEqual({ trustedMints: null, nwcConnections: null, nwcEnabled: null });
} catch (error) {
await retainFailureEvidence(testInfo, [page, stale], consoleMessages);
throw error;
} finally {
await stale.close();
}
});
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from '../fixtures';
// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
// install - the linking-key-only record, the pre-v2 bearer store and their
// ownerless residue - is never migrated (the cash root is unrecoverable
// from a linking key), so boot resets the device to the uninstalled state
// before any route can expose it, and a successor wallet starts without
// any of the old residue.
// every namespace the alpha wallet could have touched
const LEGACY_KEYS = [
'sattle_linking_key',
'sattle_bearers',
'sattle_wallet_material_v2',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
'sattle_activity',
'sattle_settings',
] as const;
test.describe('wallet lifecycle', () => {
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
page,
}) => {
// Given a legacy encrypted linking-key record plus ownerless residue
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_bearers',
JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([
{
server: 'legacy.example',
mintPubkey: `02${'aa'.repeat(32)}`,
addedAt: 1,
locked: false,
},
]),
);
});
// When the app boots
await page.goto('/#/');
// Then the alpha reset landed on the uninstalled state - no unlock
// surface for a wallet that can never be derived again
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await expect(page.getByText('Wallet locked')).toHaveCount(0);
// ...and every namespace the alpha wallet could have touched is gone
const remaining = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[...LEGACY_KEYS],
);
expect(remaining).toEqual([]);
// When a successor wallet is created
await page.getByRole('button', { name: 'Get started' }).click();
await page.getByRole('button', { name: 'Create wallet' }).click();
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
// Then it unlocks into a working wallet with zero adopted residue
// (settings/activity are the live app's own namespaces - the residue
// check covers only the stores a predecessor could have planted)
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
const residue = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[
'sattle_linking_key',
'sattle_bearers',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
],
);
expect(residue).toEqual([]);
});
});
+10 -9
View File
@@ -1,12 +1,12 @@
{
"name": "sattle",
"version": "0.0.2",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sattle",
"version": "0.0.2",
"version": "0.0.5",
"hasInstallScript": true,
"dependencies": {
"@agicash/qr-scanner": "^0.1.2",
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
@@ -5961,8 +5961,9 @@
}
},
"node_modules/lnurlcash-conformance": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-conformance.git#3d63f8ac3f3b8b87f5b490ff4a55c310afe4e5e2",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz",
"integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5986,9 +5987,9 @@
}
},
"node_modules/lnurlcash-kit": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-kit.git#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"integrity": "sha512-xgO7ykIBD5SdGV7M6xyhDJcfgbkvlHxmBmXu2gBs/Cew/RAfH7GRZSCdeVexo6vRGDXhX6IjqqqzfFXrYqkM9A==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz",
"integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.3.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "sattle",
"version": "0.0.2",
"version": "0.0.5",
"description": "A Quasar Project",
"productName": "sattle",
"author": "protom <office@protom.eu>",
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
+3 -1
View File
@@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => {
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
@@ -21,7 +22,8 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:9333',
trace: 'on-first-retry',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
launchOptions: {
executablePath: process.env.CHROMIUM_PATH || findSystemChromium(),
+1 -3
View File
@@ -4,9 +4,7 @@
"target": {
"namespace": "android_app",
"package_name": "eu.protom.sattle",
"sha256_cert_fingerprints": [
"REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"
]
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"]
}
}
]
+2 -2
View File
@@ -1,4 +1,4 @@
import { register } from "register-service-worker";
import { register } from 'register-service-worker';
// The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments.
@@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, {
error(/* err */) {
// console.error('Error during service worker registration:', err)
}
},
});
+7 -13
View File
@@ -4,13 +4,13 @@
* quasar.config file > pwa > workboxMode is set to "InjectManifest"
*/
import { clientsClaim } from "workbox-core";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from 'workbox-routing';
import {
cleanupOutdatedCaches,
createHandlerBoundToURL,
precacheAndRoute
} from "workbox-precaching";
precacheAndRoute,
} from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope & typeof globalThis;
@@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) {
// Non-SSR/SSG fallbacks to index.html
// Production SSR/SSG fallbacks to offline.html (except for dev)
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML),
{
denylist: [
new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX),
/workbox-(.)*\.js$/
]
}
)
new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), {
denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
}),
);
}
+24 -3
View File
@@ -3,10 +3,22 @@ import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup';
import { useNwcStore } from '@/stores/nwc';
declare global {
interface Window {
__sattleWalletTest?: {
readonly state: () => ReturnType<typeof useWalletStore>['state'];
readonly forget: () => Promise<void>;
};
}
}
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away,
// a password-encrypted one lands on 'locked' for the unlock screen, and no
// key at all lands on 'none' for onboarding.
// wallet store at app start - a plaintext-stored material record unlocks
// straight away, a password-encrypted one lands on 'locked' for the unlock
// screen, and no record at all lands on 'none' for onboarding. init() first
// runs the alpha reset: unsupported legacy state (the linking-key-only
// record or the pre-v2 bearer namespace) is wiped, never migrated, before
// any route can expose it.
export default defineBoot(async () => {
const wallet = useWalletStore();
await wallet.init();
@@ -17,3 +29,12 @@ export default defineBoot(async () => {
// client requests; on lock it stops and drops the key-material closure
useNwcStore();
});
// dev-only e2e hook: lets a spec observe the wallet state and drive the
// forget transition, which has no UI surface. Never in production builds.
if (import.meta.env.DEV && typeof window !== 'undefined') {
window.__sattleWalletTest = {
state: () => useWalletStore().state,
forget: () => useWalletStore().forgetWallet(),
};
}
+470
View File
@@ -0,0 +1,470 @@
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
deriveWalletMaterial,
serializeWalletMaterial,
walletMaterialHash,
type WalletMaterialV2,
} from '@/lnurlcash/keys';
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage';
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
const pluginMocks = vi.hoisted(() => ({
authenticate: vi.fn<() => Promise<void>>(),
checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(),
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
secureRemove: vi.fn<(key: string) => Promise<void>>(),
secureSet: vi.fn<(key: string, value: string) => Promise<void>>(),
secureValues: new Map<string, string>(),
}));
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
AndroidBiometryStrength: { weak: 'weak' },
BiometricAuth: {
authenticate: pluginMocks.authenticate,
checkBiometry: pluginMocks.checkBiometry,
},
BiometryError: class BiometryError extends Error {},
BiometryErrorType: { userCancel: 'userCancel' },
}));
vi.mock('@aparajita/capacitor-secure-storage', () => ({
SecureStorage: {
get: pluginMocks.secureGet,
remove: pluginMocks.secureRemove,
set: pluginMocks.secureSet,
},
}));
vi.mock('./platform', () => ({ isNative: () => true }));
import {
disableBiometricUnlock,
enableBiometricUnlock,
unlockWalletMaterialWithBiometrics,
} from './biometricUnlock';
const SEED =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL = deriveWalletMaterial(SEED);
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED);
// same linking key (same owner) as MATERIAL, but a different, VALID cash root
const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = {
...MATERIAL,
cashRootHex: OTHER_MATERIAL.cashRootHex,
};
const WRAP_SECRET = new Uint8Array(32).fill(3);
const WRAP_RECORD_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2');
const persistedState = () => ({
legacyKey: localStorage.getItem('sattle_linking_key'),
legacyNwc: localStorage.getItem('sattle_nwc_connections'),
legacyPasskeys: localStorage.getItem('sattle_passkey_slots'),
legacyTrust: localStorage.getItem('sattle_trusted_mints'),
material: localStorage.getItem(MATERIAL_STORAGE_KEY),
secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null,
wrap: localStorage.getItem(WRAP_RECORD_KEY),
});
const deriveFixtureWrapKey = async (
secret: Uint8Array,
hkdfSalt: Uint8Array,
): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(hkdfSalt),
info: new Uint8Array(WRAP_KEY_HKDF_INFO),
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
};
const storePayload = async (
plaintext: Uint8Array,
pubkey: string,
materialHash: string,
secret = WRAP_SECRET,
): Promise<void> => {
const hkdfSalt = new Uint8Array(16).fill(4);
const iv = new Uint8Array(12).fill(5);
const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)),
);
localStorage.setItem(
WRAP_RECORD_KEY,
JSON.stringify({
version: 2,
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
pubkey,
materialHash,
createdAt: 1,
}),
);
pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret));
};
const storeMaterialPayload = async (
material: WalletMaterialV2,
pubkey = walletMaterialOwnerId(material),
secret = WRAP_SECRET,
materialHash = walletMaterialHash(material),
): Promise<void> =>
storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret);
beforeEach(() => {
vi.clearAllMocks();
stubLocalStorage();
pluginMocks.secureValues.clear();
pluginMocks.authenticate.mockResolvedValue();
pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true });
pluginMocks.secureGet.mockImplementation((key) =>
Promise.resolve(pluginMocks.secureValues.get(key) ?? null),
);
pluginMocks.secureSet.mockImplementation((key, value) => {
pluginMocks.secureValues.set(key, value);
return Promise.resolve();
});
pluginMocks.secureRemove.mockImplementation((key) => {
pluginMocks.secureValues.delete(key);
return Promise.resolve();
});
});
describe('biometric wallet material', () => {
it('rejects foreign enrollment before writing either biometric store', async () => {
// Given one proven saved owner and complete material for another owner
await saveWalletMaterial(MATERIAL);
const before = persistedState();
// When enrollment is attempted with the foreign material
const attempt = enableBiometricUnlock(OTHER_MATERIAL);
// Then ownership fails before native or local persistence changes
await expect(attempt).rejects.toThrow('proven saved wallet owner');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('does not create a local wrap when secure-secret persistence fails', async () => {
// Given valid material and a rejecting native secure store
await saveWalletMaterial(MATERIAL);
const before = persistedState();
pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed'));
// When enrollment reaches native secret persistence
const attempt = enableBiometricUnlock(MATERIAL);
// Then failure is reported without a misleading local enrollment record
await expect(attempt).rejects.toThrow('secure write failed');
expect(persistedState()).toEqual(before);
});
it('round-trips both key parts when enrollment belongs to the saved owner', async () => {
// Given complete canonical material persisted for its proven owner
await saveWalletMaterial(MATERIAL);
// When the capability enrolls and unlocks it through the native adapter
await enableBiometricUnlock(MATERIAL);
const beforeUnlock = persistedState();
const unlocked = await unlockWalletMaterialWithBiometrics();
// Then both key parts return together and unlock does not rewrite persistence
expect(unlocked).toEqual(MATERIAL);
expect(persistedState()).toEqual(beforeUnlock);
});
it.each([
['malformed JSON', utf8ToBytes('{')],
['legacy key-only payload', new Uint8Array(32).fill(7)],
[
'altered root',
utf8ToBytes(
JSON.stringify({
...MATERIAL,
cashRootHex: '00'.repeat(64),
}),
),
],
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
// Given an authenticated non-canonical payload bound to the saved owner
await saveWalletMaterial(MATERIAL);
await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL));
const before = persistedState();
// When biometric unlock decrypts the payload
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither key part crosses the boundary and persistence is byte-identical
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for a wrong secure secret and preserves state', async () => {
// Given a valid wrap whose secure secret has been replaced
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32));
const before = persistedState();
// When biometric unlock authenticates with the wrong wrapping secret
const attempt = unlockWalletMaterialWithBiometrics();
// Then authenticated decryption fails without changing either store
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for altered ciphertext and preserves state', async () => {
// Given a valid wrap with one ciphertext nibble altered
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}');
const wrappedKey = record.wrappedKey;
if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext');
localStorage.setItem(
WRAP_RECORD_KEY,
JSON.stringify({
...record,
wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`,
}),
);
const before = persistedState();
// When biometric unlock verifies the AES-GCM tag
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither key part returns and persistence remains untouched
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => {
// Given material wrapped for one owner and saved material replaced by another
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
await saveWalletMaterial(OTHER_MATERIAL);
const before = persistedState();
// When the stale biometric record is used
const attempt = unlockWalletMaterialWithBiometrics();
// Then no old material returns and both stores remain byte-identical
await expect(attempt).rejects.toThrow('different wallet');
expect(persistedState()).toEqual(before);
});
it('rejects authenticated material owned by another linking key', async () => {
// Given the saved owner and wrap claim owner A but authenticated material owner B
await saveWalletMaterial(MATERIAL);
localStorage.setItem('sattle_linking_key', 'legacy-key-residue');
localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue');
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue');
await storeMaterialPayload(
OTHER_MATERIAL,
walletMaterialOwnerId(MATERIAL),
WRAP_SECRET,
walletMaterialHash(MATERIAL),
);
const before = persistedState();
// When biometric unlock checks the decrypted linking-key owner
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither foreign key part returns and persistence is unchanged
await expect(attempt).rejects.toThrow('different wallet');
expect(persistedState()).toEqual(before);
});
});
describe('biometric exact-material commitment', () => {
it('rejects same-owner altered-root enrollment before writing either biometric store', async () => {
// Given one proven saved owner and well-formed material with only the cash root swapped
await saveWalletMaterial(MATERIAL);
const before = persistedState();
// When enrollment is attempted with the same-owner altered-root material
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
// Then the exact-material commitment fails before native or local persistence changes
await expect(attempt).rejects.toThrow('saved wallet material');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects same-owner altered-root enrollment against encrypted saved material', async () => {
// Given the saved material envelope is password-encrypted (no plaintext available)
await saveWalletMaterial(MATERIAL, 'password');
const before = persistedState();
// When enrollment is attempted with the same-owner altered-root material
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
// Then the hash commitment alone rejects it before either store changes
await expect(attempt).rejects.toThrow('saved wallet material');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects enrollment when same-owner saved material changes during the prompt', async () => {
// Given valid enrollment input and a prompt-time swap of only the saved cash root
await saveWalletMaterial(MATERIAL);
pluginMocks.authenticate.mockImplementationOnce(async () => {
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
});
// When enrollment runs the biometric prompt
const attempt = enableBiometricUnlock(MATERIAL);
// Then the post-prompt commitment recheck fails and no biometric store is written
await expect(attempt).rejects.toThrow('changed');
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects an authenticated well-formed same-owner altered-root payload', async () => {
// Given an authenticated wrap that claims the saved commitment but whose
// decrypted material swaps only the cash root
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(
ALTERED_ROOT_MATERIAL,
walletMaterialOwnerId(ALTERED_ROOT_MATERIAL),
WRAP_SECRET,
walletMaterialHash(MATERIAL),
);
const before = persistedState();
// When biometric unlock decrypts the well-formed same-owner payload
const attempt = unlockWalletMaterialWithBiometrics();
// Then the material commitment rejects it and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(persistedState()).toEqual(before);
});
it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => {
// Given an authenticated wrap committed to same-owner altered-root material
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(ALTERED_ROOT_MATERIAL);
const before = persistedState();
// When biometric unlock inspects the record commitment
const attempt = unlockWalletMaterialWithBiometrics();
// Then it rejects before any prompt and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(pluginMocks.authenticate).not.toHaveBeenCalled();
expect(persistedState()).toEqual(before);
});
it('rejects a stale wrap after same-owner saved material replacement', async () => {
// Given an enrolled wrap and a later saved-material swap of only the cash root
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
const before = persistedState();
// When the now-stale biometric wrap is used
const attempt = unlockWalletMaterialWithBiometrics();
// Then the stale commitment rejects and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(persistedState()).toEqual(before);
});
it('rejects unlock when same-owner saved material changes during the prompt', async () => {
// Given an enrolled wrap and a prompt-time swap of only the saved cash root
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
let swapped: ReturnType<typeof persistedState> | null = null;
pluginMocks.authenticate.mockImplementationOnce(async () => {
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
swapped = persistedState();
});
// When biometric unlock runs the prompt
const attempt = unlockWalletMaterialWithBiometrics();
// Then the post-prompt commitment recheck rejects and the ceremony writes nothing
await expect(attempt).rejects.toThrow('changed');
expect(swapped).not.toBeNull();
expect(persistedState()).toEqual(swapped);
});
it('rejects unlock when the wrap record is replaced during the prompt', async () => {
// Given an enrolled wrap replaced mid-prompt by another valid same-owner record
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
let swapped: ReturnType<typeof persistedState> | null = null;
pluginMocks.authenticate.mockImplementationOnce(async () => {
const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY);
if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret');
await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret));
swapped = persistedState();
});
// When biometric unlock runs the prompt
const attempt = unlockWalletMaterialWithBiometrics();
// Then the record recheck rejects and the ceremony writes nothing itself
await expect(attempt).rejects.toThrow('changed');
expect(swapped).not.toBeNull();
expect(persistedState()).toEqual(swapped);
});
});
describe('biometric deletion ordering', () => {
it('keeps native and local state retryable when secure-secret deletion fails', async () => {
// Given a complete enrolled biometric wrap
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
const before = persistedState();
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed'));
// When native secure-secret deletion fails
const attempt = disableBiometricUnlock();
// Then failure is reported before either persisted part is removed
await expect(attempt).rejects.toThrow('native deletion failed');
expect(persistedState()).toEqual(before);
});
it('supports repeated delete interruption followed by a successful retry', async () => {
// Given two interrupted deletion attempts for an enrolled wrap
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
const before = persistedState();
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption'));
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption'));
// When deletion is retried until native removal succeeds
await expect(disableBiometricUnlock()).rejects.toThrow('first interruption');
expect(persistedState()).toEqual(before);
await expect(disableBiometricUnlock()).rejects.toThrow('second interruption');
expect(persistedState()).toEqual(before);
await disableBiometricUnlock();
// Then native state is gone before the local wrap is removed
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material);
});
});
+187 -49
View File
@@ -1,23 +1,25 @@
// Native biometric unlock: a THIRD wrap of the same linking key, alongside
// the password wrap (keys.ts) and the passkey slots (passkeys.ts).
// Native biometric unlock: a THIRD wrap of the same canonical wallet material,
// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts).
//
// Why native: Android WebView has no usable WebAuthn platform authenticator
// for this app (passkeys need Play Services / Digital Asset Links wiring we
// cannot rely on), so the biometric path on native is a device-credential
// prompt instead of a passkey ceremony.
//
// Design (mirrors the passkey-slot construction, reusing its wrap
// primitives verbatim): on enrollment a random 32-byte wrap secret is
// generated, the linking key is AES-GCM-wrapped under an HKDF of that
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32
// bytes of IKM), and only the SECRET goes into biometric-gated secure
// Design (mirrors the passkey-slot construction with a biometric-specific
// HKDF domain): on enrollment a random 32-byte wrap secret is generated,
// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret,
// and only the SECRET goes into biometric-gated secure
// storage (Android Keystore-backed AES-GCM via
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
// localStorage as a record shaped like a passkey slot, plus the linking
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale
// wrap behind, and unlocking with it must fail loudly (never activate the
// old wallet silently), so unlock verifies the unwrapped key against the
// recorded pubkey and tells the holder to re-enroll.
// localStorage as a versioned AES-GCM record, plus the linking
// pubkey as an identity check AND the SHA-256 commitment of the canonical
// saved material: restoring a DIFFERENT seed leaves a stale wrap behind,
// and so does replacing only the saved cash root with same-owner material.
// Unlocking with either must fail loudly (never activate the old wallet
// silently), so unlock verifies the record commitment and the unwrapped
// material against the saved wallet material and tells the holder to
// re-enroll.
//
// The biometric gate is app-level (a BiometricPrompt before the secure
// read), not a keystore key invalidated on biometric re-enrollment - 04
@@ -36,39 +38,90 @@ import {
BiometryErrorType,
} from '@aparajita/capacitor-biometric-auth';
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { linkingPubKeyHex } from '@/lnurlcash/keys';
import type { PasskeyWrap } from '@/lnurlcash/passkeys';
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import {
getPlainWalletMaterial,
parseWalletMaterial,
savedWalletMaterialHash,
savedWalletMaterialOwnerId,
serializeWalletMaterial,
walletMaterialHash,
type WalletMaterialV2,
} from '@/lnurlcash/keys';
import { isJsonObject } from '@/lnurlcash/jsonParsing';
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
import { isNative } from './platform';
type BiometricWrapRecord = PasskeyWrap & {
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps
createdAt: number;
type BiometricWrapRecord = {
readonly version: 2;
readonly hkdfSalt: string;
readonly iv: string;
readonly wrappedKey: string;
readonly pubkey: string;
readonly materialHash: string;
readonly createdAt: number;
};
// the wrapped blob is useless without the secure-storage secret, so (like
// the passkey slots) this record sits in plain localStorage
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
const BIOMETRIC_WRAP_VERSION = 2 as const;
const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2';
const WRAP_RECORD_KEYS = [
'version',
'hkdfSalt',
'iv',
'wrappedKey',
'pubkey',
'materialHash',
'createdAt',
] as const;
const deriveBiometricWrapKey = async (
secret: Uint8Array,
hkdfSalt: Uint8Array,
): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(hkdfSalt),
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)),
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
};
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
if (typeof record !== 'object' || record === null) return false;
const r = record as Record<string, unknown>;
if (!isJsonObject(record)) return false;
return (
typeof r.hkdfSalt === 'string' &&
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) &&
typeof r.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(r.iv) &&
typeof r.wrappedKey === 'string' &&
r.wrappedKey.length > 0 &&
r.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(r.wrappedKey) &&
typeof r.pubkey === 'string' &&
/^[0-9a-f]{66}$/i.test(r.pubkey) &&
typeof r.createdAt === 'number'
Object.keys(record).length === WRAP_RECORD_KEYS.length &&
Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) &&
record.version === BIOMETRIC_WRAP_VERSION &&
typeof record.hkdfSalt === 'string' &&
/^[0-9a-f]{32}$/.test(record.hkdfSalt) &&
typeof record.iv === 'string' &&
/^[0-9a-f]{24}$/.test(record.iv) &&
typeof record.wrappedKey === 'string' &&
record.wrappedKey.length > 0 &&
record.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/.test(record.wrappedKey) &&
typeof record.pubkey === 'string' &&
/^[0-9a-f]{66}$/.test(record.pubkey) &&
typeof record.materialHash === 'string' &&
/^[0-9a-f]{64}$/.test(record.materialHash) &&
typeof record.createdAt === 'number' &&
Number.isSafeInteger(record.createdAt) &&
record.createdAt >= 0
);
};
@@ -83,6 +136,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => {
}
};
const biometricWrapRecordsEqual = (
left: BiometricWrapRecord,
right: BiometricWrapRecord,
): boolean =>
left.version === right.version &&
left.hkdfSalt === right.hkdfSalt &&
left.iv === right.iv &&
left.wrappedKey === right.wrappedKey &&
left.pubkey === right.pubkey &&
left.materialHash === right.materialHash &&
left.createdAt === right.createdAt;
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
// the security page ask this during render/setup
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
@@ -121,52 +186,125 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
}
};
export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<void> => {
export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise<void> => {
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
const serialized = serializeWalletMaterial(material);
const canonicalMaterial = parseWalletMaterial(serialized);
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
const ownerId = walletMaterialOwnerId(canonicalMaterial);
if (savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('Biometric enrollment requires the proven saved wallet owner.');
}
// exact-material commitment (same check as passkey slots): the enrolled
// bytes must equal the canonical saved material, not just share its owner
const materialHash = savedWalletMaterialHash();
const plaintext = getPlainWalletMaterial();
if (
materialHash === null ||
walletMaterialHash(canonicalMaterial) !== materialHash ||
(plaintext !== null && serializeWalletMaterial(plaintext) !== serialized)
) {
throw new Error('Biometric enrollment requires the exact saved wallet material.');
}
const biometry = await BiometricAuth.checkBiometry();
if (!biometry.isAvailable) {
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
}
// prove presence before storing anything under the biometric gate
await authenticateOrThrow('Set up biometric unlock for your wallet');
// the prompt yields time: re-verify the commitment before writing anything
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
throw new Error('Saved wallet material changed during biometric enrollment.');
}
const secret = crypto.getRandomValues(new Uint8Array(32));
const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey);
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await deriveBiometricWrapKey(secret, hkdfSalt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
wrapKey,
new Uint8Array(utf8ToBytes(serialized)),
),
);
// secure storage first: if it fails, no record is written and the wallet
// simply stays unenrolled instead of carrying an unwrap-able-nothing
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
const record: BiometricWrapRecord = {
...wrap,
pubkey: linkingPubKeyHex(linkingKey),
version: BIOMETRIC_WRAP_VERSION,
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
pubkey: ownerId,
materialHash,
createdAt: Date.now(),
};
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
};
export const unlockWithBiometrics = async (): Promise<Uint8Array> => {
export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMaterialV2> => {
const record = readWrapRecord();
if (!isNative() || !record) {
throw new Error('Biometric unlock is not set up on this device.');
}
await authenticateOrThrow('Unlock your sattle wallet');
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
}
const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record);
if (linkingPubKeyHex(linkingKey) !== record.pubkey) {
const ownerId = savedWalletMaterialOwnerId();
if (ownerId === null || ownerId !== record.pubkey) {
throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
);
}
return linkingKey;
// the stored commitment must match the canonical saved material, or the
// wrap is stale (same-owner cash-root replacement) and must not unlock
const materialHash = savedWalletMaterialHash();
if (materialHash === null || record.materialHash !== materialHash) {
throw new Error(
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
);
}
await authenticateOrThrow('Unlock your sattle wallet');
// the prompt yields time: re-read the record and the saved commitment
// before any decrypted plaintext can reach the caller
const currentRecord = readWrapRecord();
if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) {
throw new Error('The biometric wrap changed during the unlock ceremony.');
}
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
throw new Error('Saved wallet material changed during the unlock ceremony.');
}
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
}
const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt));
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) },
wrapKey,
new Uint8Array(hexToBytes(record.wrappedKey)),
);
const material = parseWalletMaterial(new TextDecoder().decode(plaintext));
if (
material === null ||
savedWalletMaterialOwnerId() !== ownerId ||
walletMaterialOwnerId(material) !== ownerId
) {
throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
);
}
// the decrypted material itself must carry the committed cash root
if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) {
throw new Error(
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
);
}
return material;
};
// the localStorage record goes first and synchronously: even if the caller
// fire-and-forgets this (forgetWallet), a later unlock attempt can never
// reach the secure secret with a stale record
// Native deletion goes first so a failure leaves the complete enrollment
// available for the locked lifecycle to retry safely.
export const disableBiometricUnlock = async (): Promise<void> => {
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
if (isNative()) {
await SecureStorage.remove(SECURE_SECRET_KEY);
}
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
};
+4 -9
View File
@@ -19,19 +19,14 @@ onMounted(async () => {
return;
}
if (!videoEl.value) return;
scanner = new QrScanner(
videoEl.value,
(result) => emit('decode', result.data),
{ highlightScanRegion: true },
);
scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), {
highlightScanRegion: true,
});
await scanner.start();
starting.value = false;
} catch (err) {
starting.value = false;
emit(
'error',
err instanceof Error ? err.message : 'Camera access failed.',
);
emit('error', err instanceof Error ? err.message : 'Camera access failed.');
}
});
+4 -4
View File
@@ -50,9 +50,7 @@
</div>
<div class="col q-ml-md">
<div class="text-body1 text-weight-medium">Bearer note</div>
<div class="text-caption text-grey-5">
Paste or scan a note someone sent you
</div>
<div class="text-caption text-grey-5">Paste or scan a note someone sent you</div>
</div>
<q-icon name="chevron_right" color="grey-5" size="24px" />
</div>
@@ -108,7 +106,9 @@ const onReceived = () => {
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.action-row:hover,
+33 -238
View File
@@ -113,6 +113,7 @@
{{ claimError }}
</q-banner>
<q-btn
v-if="claimRetryable"
unelevated
color="primary"
text-color="dark"
@@ -202,19 +203,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import QrCode from '../QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useReceiveLightningDialog } from '@/composables/useReceiveLightningDialog';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
@@ -222,232 +212,37 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const CUSTOM_MINT = '__custom__';
// whole-sat display for received amounts (msat remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
type Step = 'form' | 'invoice' | 'success';
const step = ref<Step>('form');
// ---- form ----
const amountSats = ref<number | null>(null);
const mintChoice = ref('');
const customMint = ref('');
const preparing = ref(false);
const formError = ref('');
type MintOption = { label: string; value: string };
const mintOptions = computed<MintOption[]>(() => {
const options: MintOption[] = [];
const seen = new Set<string>();
for (const mint of mints.mints) {
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
if (seen.has(address)) continue;
seen.add(address);
const label = mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address;
options.push({ label, value: address });
}
for (const publicMint of mints.PUBLIC_MINTS) {
if (seen.has(publicMint)) continue;
seen.add(publicMint);
options.push({ label: publicMint, value: publicMint });
}
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
return options;
});
const defaultChoice = (): string => {
const options = mintOptions.value;
if (mints.defaultMint) {
const match = options.find((o) => o.value.endsWith(`@${mints.defaultMint}`));
if (match) return match.value;
}
const first = options[0];
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
};
const formValid = computed(() => {
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
return mintChoice.value === CUSTOM_MINT
? customMint.value.trim() !== ''
: mintChoice.value !== '';
});
const createInvoice = async () => {
const sats = amountSats.value;
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
try {
const mintInput = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const preparedMint = await prepareMint(mintInput, satsToMsat(sats));
if (!preparedMint.verifyUrl) {
// without a verify URL the payment can never be auto-claimed - showing
// a payable invoice here would strand the sats at the mint
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
prepared.value = preparedMint;
claimRun = null;
claimError.value = '';
step.value = 'invoice';
beginClaim();
} catch (err) {
formError.value = errorMessage(err);
Notify.create({ type: 'negative', message: formError.value });
} finally {
preparing.value = false;
}
};
// ---- invoice ----
const prepared = ref<PreparedMint | null>(null);
const waiting = ref(false);
const claimError = ref('');
// single in-flight claim; "stop waiting" only detaches the UI from it - the
// claim itself always runs to completion so a settled payment is never
// abandoned unclaimed
let claimRun: Promise<void> | null = null;
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
const netSats = computed(() =>
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
);
const feeSats = computed(() => grossSats.value - netSats.value);
const copyInvoice = async () => {
if (!prepared.value) return;
try {
await writeClipboard(prepared.value.invoice);
Notify.create({ type: 'positive', message: 'Invoice copied.' });
} catch (err) {
Notify.create({ type: 'negative', message: errorMessage(err) });
}
};
const beginClaim = () => {
if (!prepared.value || claimRun) return;
waiting.value = true;
claimError.value = '';
const current = prepared.value;
claimRun = (async () => {
try {
const claimed = await claimMintedNote(current);
await onClaimed(claimed, current);
} catch (err) {
claimError.value = `${errorMessage(err)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(err) });
} finally {
waiting.value = false;
}
})();
};
// after a failed claim the run is over - allow a fresh attempt
const retryClaim = () => {
claimRun = null;
beginClaim();
};
const stopWaiting = () => {
waiting.value = false;
};
const resumeWaiting = () => {
if (claimRun) waiting.value = true;
};
// ---- success ----
const receivedSats = ref(0);
const receivedServer = ref('');
const rotationWarning = ref('');
const onClaimed = async (claimed: ClaimedNote, from: PreparedMint) => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await wallet.addBearers(notes);
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
);
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
emit('received');
if (props.modelValue) step.value = 'success';
if (!wasTrusted && claimed.note.mintPubkey) {
trustServer.value = server;
trustPubkey.value = claimed.note.mintPubkey;
trustNodeAlias.value = from.nodeInfo?.nodeAlias ?? '';
showTrust.value = true;
}
};
// ---- trust prompt ----
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustNodeAlias = ref('');
const trustMint = () => {
try {
mints.trust(trustServer.value, trustPubkey.value, {
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
});
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (err) {
Notify.create({ type: 'negative', message: errorMessage(err) });
} finally {
showTrust.value = false;
}
};
const skipTrust = () => {
showTrust.value = false;
Notify.create({
type: 'warning',
message:
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
});
};
// fresh form every time the dialog opens; a claim already in flight keeps
// running in the background regardless
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'form';
amountSats.value = null;
customMint.value = '';
mintChoice.value = defaultChoice();
preparing.value = false;
formError.value = '';
prepared.value = null;
waiting.value = false;
rotationWarning.value = '';
},
);
const {
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
feeSats,
formError,
formValid,
grossSats,
mintChoice,
mintOptions,
netSats,
prepared,
preparing,
receivedSats,
receivedServer,
resumeWaiting,
retryClaim,
rotationWarning,
showTrust,
skipTrust,
step,
stopWaiting,
trustMint,
trustNodeAlias,
trustServer,
waiting,
} = useReceiveLightningDialog(props, emit);
</script>
<style lang="scss" scoped>
+29 -214
View File
@@ -14,12 +14,7 @@
<!-- input -->
<q-card-section v-if="step === 'input'" class="q-pt-sm">
<qr-scanner
v-if="scanning"
class="q-mb-md"
@decode="onScan"
@error="onScanError"
/>
<qr-scanner v-if="scanning" class="q-mb-md" @decode="onScan" @error="onScanError" />
<q-input
v-model="input"
@@ -82,16 +77,16 @@
<template #avatar>
<q-icon name="info" color="info" />
</template>
The mint could not be reached, so the note is stored unconfirmed at the
sender's declared amount. Refresh it later to confirm.
The mint could not be reached, so the note is stored unconfirmed at the sender's declared
amount. Refresh it later to confirm.
</q-banner>
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
The note is in your wallet, but it could not be fully secured yet — the
sender may still hold a copy. You can secure it later.
The note is in your wallet, but it could not be fully secured yet — the sender may still
hold a copy. You can secure it later.
</q-banner>
<q-btn
@@ -114,8 +109,8 @@
<strong>{{ trustServer }}</strong>
</div>
<div class="text-caption text-grey-5 q-mb-md">
Trusting saves the mint so it is offered next time. You can manage trusted
mints in Settings.
Trusting saves the mint so it is offered next time. You can manage trusted mints in
Settings.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
@@ -134,22 +129,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
isValidNoteInput,
} from 'lnurlcash-kit';
import QrScanner from '../QrScanner.vue';
import { receiveBearer } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useReceiveTokenDialog } from '@/composables/useReceiveTokenDialog';
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
const emit = defineEmits<{
@@ -157,193 +138,27 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
// whole-sat display for received amounts (msat remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errMsg = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
// distinct, jargon-free error states for the definitive service answers
type ReceiveErrorKind =
| 'spent'
| 'unknown'
| 'pending'
| 'duplicate'
| 'invalid'
| 'generic'
| '';
const ERROR_TEXT: Record<Exclude<ReceiveErrorKind, ''>, string> = {
spent: 'This note has already been spent.',
unknown: "The mint doesn't know this note.",
pending: 'This note is locked mid-payment — try again shortly.',
duplicate: 'This note is already in your wallet.',
invalid: 'Not a valid bearer note.',
generic: '',
};
const ERROR_ICON: Record<Exclude<ReceiveErrorKind, ''>, string> = {
spent: 'money_off',
unknown: 'help_outline',
pending: 'hourglass_top',
duplicate: 'content_copy',
invalid: 'error_outline',
generic: 'error_outline',
};
const errorText = computed(() =>
errorKind.value === 'generic'
? errorMessageText.value
: errorKind.value === ''
? ''
: ERROR_TEXT[errorKind.value],
);
const errorIcon = computed(() =>
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
);
type Step = 'input' | 'success';
const step = ref<Step>('input');
const input = ref('');
const scanning = ref(false);
const busy = ref(false);
const errorKind = ref<ReceiveErrorKind>('');
const errorMessageText = ref('');
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
const clearError = () => {
errorKind.value = '';
errorMessageText.value = '';
};
const classifyError = (err: unknown): void => {
let kind: Exclude<ReceiveErrorKind, ''>;
if (err instanceof NoteSpentError) {
kind = 'spent';
} else if (err instanceof NoteUnknownError) {
kind = 'unknown';
} else if (err instanceof PendingNoteError) {
kind = 'pending';
} else if (err instanceof Error && err.message.includes('already in your wallet')) {
kind = 'duplicate';
} else if (err instanceof Error && err.message.includes('Not an LNURLcash bearer note')) {
kind = 'invalid';
} else {
kind = 'generic';
errorMessageText.value = errMsg(err);
}
errorKind.value = kind;
Notify.create({
type: 'negative',
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
});
};
// re-entrancy guard: a scanner double-fire or Enter+click landing together
// must not run two receives for the same note - both would pass the
// duplicate check before either addBearers landed
const receive = async () => {
const value = input.value.trim();
if (busy.value || value === '') return;
busy.value = true;
clearError();
try {
const claimed = await receiveBearer(value, wallet.bearers);
const note = claimed.note;
const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [note, claimed.possibleCopy]
: [note];
await wallet.addBearers(notes);
receivedSats.value = displaySats(note.amount);
receivedServer.value = server;
unverifiedNote.value = !note.verified;
rotationWarning.value = claimed.rotationError ?? '';
activity.log(
'receive',
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
emit('received');
scanning.value = false;
step.value = 'success';
if (!wasTrusted && note.mintPubkey) {
trustServer.value = server;
trustPubkey.value = note.mintPubkey;
showTrust.value = true;
}
} catch (err) {
classifyError(err);
} finally {
busy.value = false;
}
};
const onScan = (text: string) => {
input.value = text;
scanning.value = false;
void receive();
};
const onScanError = (message: string) => {
scanning.value = false;
Notify.create({ type: 'negative', message });
};
// ---- success ----
const receivedSats = ref(0);
const receivedServer = ref('');
const unverifiedNote = ref(false);
const rotationWarning = ref('');
// ---- trust prompt ----
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustMint = () => {
try {
mints.trust(trustServer.value, trustPubkey.value);
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (err) {
Notify.create({ type: 'negative', message: errMsg(err) });
} finally {
showTrust.value = false;
}
};
const skipTrust = () => {
showTrust.value = false;
Notify.create({
type: 'warning',
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
});
};
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'input';
input.value = props.initialInput ?? '';
scanning.value = false;
busy.value = false;
clearError();
unverifiedNote.value = false;
rotationWarning.value = '';
},
);
const {
busy,
errorIcon,
errorKind,
errorText,
input,
inputValid,
onScan,
onScanError,
receive,
receivedSats,
receivedServer,
rotationWarning,
scanning,
showTrust,
skipTrust,
step,
trustMint,
trustServer,
unverifiedNote,
} = useReceiveTokenDialog(props, emit);
</script>
<style lang="scss" scoped>
+23 -236
View File
@@ -7,18 +7,8 @@
//
// Fund-safety order when applying the carve: fresh notes are added to the
// wallet BEFORE consumed inputs are marked spent.
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
import QrScanner from '@/components/QrScanner.vue';
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity';
import { usePayInvoiceDialog } from '@/composables/usePayInvoiceDialog';
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
const emit = defineEmits<{
@@ -26,231 +16,28 @@ const emit = defineEmits<{
sent: [];
}>();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
// guarded: the Notify plugin registration lives in quasar.config, outside
// this component's control - a missing registration must not break a flow
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
type Step = 'input' | 'confirm' | 'working' | 'result';
type TargetKind = 'invoice' | 'address';
type PendingPayment = {
kind: TargetKind;
input: string;
amountMsat: number;
};
type Result = {
outcome: PayOutcome;
amountMsat: number;
};
const step = ref<Step>('input');
const input = ref('');
const addressAmountSats = ref('');
const showScanner = ref(false);
const inlineError = ref<string | null>(null);
const stage = ref('');
const pendingPayment = ref<PendingPayment | null>(null);
const result = ref<Result | null>(null);
const formatSats = (sats: number): string =>
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
const classify = (value: string): TargetKind | null => {
const trimmed = value.trim();
if (!trimmed) return null;
if (isBolt11Invoice(trimmed)) return 'invoice';
// resolveLnurlInput also accepts Lightning Addresses, bech32 LNURLs and
// lightning= deep links - anything it resolves can be paid
if (resolveLnurlInput(trimmed) !== null) return 'address';
return null;
};
const targetKind = computed<TargetKind | null>(() => classify(input.value));
const truncatedInput = computed(() => {
const p = pendingPayment.value;
if (!p) return '';
if (p.kind === 'address') return p.input;
return p.input.length > 30 ? `${p.input.slice(0, 18)}…${p.input.slice(-8)}` : p.input;
});
const resultAmountSats = computed(() =>
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
);
const reset = () => {
step.value = 'input';
input.value = props.initialInput ?? '';
addressAmountSats.value = '';
showScanner.value = false;
inlineError.value = null;
stage.value = '';
pendingPayment.value = null;
result.value = null;
};
watch(
() => props.modelValue,
(open) => {
if (open) reset();
},
);
const onScan = (text: string) => {
input.value = text.replace(/^lightning:/i, '').trim();
showScanner.value = false;
};
const onScanError = (message: string) => {
showScanner.value = false;
toast('negative', message);
};
const paste = async () => {
try {
const text = await readClipboard();
if (text) input.value = text.trim();
} catch {
toast('negative', "Couldn't read the clipboard - paste manually.");
}
};
// input -> confirm: classify and validate everything that can be checked
// before any network call (amount present, within balance)
const proceed = () => {
inlineError.value = null;
const value = input.value.trim();
if (!value) {
inlineError.value = 'Paste an invoice or a Lightning Address first.';
return;
}
const kind = classify(value);
if (kind === null) {
inlineError.value = "That doesn't look like a Lightning invoice or address.";
return;
}
let amountMsat: number;
if (kind === 'invoice') {
const decoded = decodeBolt11AmountMsat(value);
if (decoded === null || decoded <= 0) {
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
return;
}
amountMsat = decoded;
} else {
const sats = Number(addressAmountSats.value);
if (!Number.isInteger(sats) || sats <= 0) {
inlineError.value = 'Enter how many sats to send to this address.';
return;
}
amountMsat = satsToMsat(sats);
}
if (amountMsat > wallet.balanceMsat) {
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
return;
}
pendingPayment.value = { kind, input: value, amountMsat };
step.value = 'confirm';
};
const friendlyError = (err: unknown): string => {
const message = err instanceof Error ? err.message : 'Something went wrong.';
if (message.startsWith('No mint holds enough')) {
return 'Not enough spendable balance to cover that payment.';
}
return message;
};
// Applies a carve to the wallet in the only safe order: add the fresh
// note(s) first, mark the consumed inputs spent after. Returns the wallet
// id of the carved note (for the exact-match path the note already exists
// in the wallet, so it is found by url instead of re-added).
const applyCarve = async (carve: CarveResult): Promise<string> => {
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
const toAdd: NewBearer[] = [];
if (!existing) toAdd.push(carve.note);
if (carve.change) toAdd.push(carve.change);
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
for (const consumed of carve.consumed) {
await wallet.markSpent(consumed.id);
}
return existing ? existing.id : added[0].id;
};
const pay = async () => {
const p = pendingPayment.value;
if (!p) return;
step.value = 'working';
stage.value = 'Preparing the exact amount and sending the payment…';
try {
const payResult = await payWithBearers(
wallet.bearers,
p.input,
p.kind === 'address' ? { amountMsat: p.amountMsat } : {},
);
stage.value = 'Confirming the result…';
const noteId = await applyCarve(payResult.carve);
if (payResult.rescuedNote) {
await wallet.addBearers([payResult.rescuedNote]);
}
const sats = formatSats(msatToSats(payResult.amountMsat));
if (payResult.outcome === 'settled') {
await wallet.markSpent(noteId);
activity.log('melt', `Paid ${sats} sats over Lightning.`);
toast('positive', `Paid ${sats} sats.`);
emit('sent');
} else if (payResult.outcome === 'failed-funds-returned') {
// the payment never happened and the note is spendable again - it
// stays in the wallet, deliberately NOT marked spent
activity.log('transfer', `A ${sats} sat payment failed - funds are back in your wallet.`);
toast('warning', 'Payment failed - funds are back in your wallet.');
} else if (payResult.outcome === 'unknown-still-pending') {
await wallet.markSpent(noteId);
activity.log('melt', `Payment of ${sats} sats is still in flight - the note is locked.`);
emit('sent');
} else {
// note-already-spent: the mint says the note is gone; nothing was
// paid. Lock it locally so it can't be tried again.
await wallet.markSpent(noteId);
activity.log('spent', `A ${sats} sat note was already spent at the mint.`);
}
result.value = { outcome: payResult.outcome, amountMsat: payResult.amountMsat };
step.value = 'result';
} catch (err) {
if (err instanceof UncertainOutcomeError) {
await wallet.addBearers(err.possibleOutputs);
activity.log(
'transfer',
'A payment preparation could not be confirmed - possible notes stored unverified.',
);
inlineError.value =
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
} else {
inlineError.value = friendlyError(err);
toast('negative', inlineError.value);
}
step.value = 'input';
}
};
const closeResult = () => {
show.value = false;
};
const {
addressAmountSats,
closeResult,
formatSats,
inlineError,
input,
msatToSats,
onScan,
onScanError,
paste,
pay,
pendingPayment,
proceed,
result,
resultAmountSats,
show,
showScanner,
stage,
step,
targetKind,
truncatedInput,
} = usePayInvoiceDialog(props, emit);
</script>
<template>
+6 -1
View File
@@ -34,7 +34,12 @@ const onSent = () => emit('sent');
</script>
<template>
<q-dialog v-model="show" position="bottom" transition-show="slide-up" transition-hide="slide-down">
<q-dialog
v-model="show"
position="bottom"
transition-show="slide-up"
transition-hide="slide-down"
>
<q-card class="sattle-card drawer-card full-width">
<q-card-section class="row items-center q-pb-sm">
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
+23 -176
View File
@@ -7,19 +7,8 @@
// Fund-safety order when applying the carve: the replacement notes are
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
// mid-way strands a duplicate, never a secret.
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { toBech32Lnurl } from 'lnurlcash-kit';
import QrCode from '@/components/QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { CarveResult } from '@/lnurlcash/ops';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity';
import { useSendTokenDialog } from '@/composables/useSendTokenDialog';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
@@ -27,170 +16,28 @@ const emit = defineEmits<{
sent: [];
}>();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
// guarded: the Notify plugin registration lives in quasar.config, outside
// this component's control - a missing registration must not break a flow
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
type Step = 'amount' | 'ready';
const step = ref<Step>('amount');
const amountSats = ref('');
const preparing = ref(false);
const removing = ref(false);
const errorMessage = ref<string | null>(null);
const prepared = ref<Bearer | null>(null);
const revealed = ref(false);
const formatSats = (sats: number): string =>
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
const parsedAmount = computed<number | null>(() => {
const n = Number(amountSats.value);
return Number.isInteger(n) && n > 0 ? n : null;
});
const amountError = computed<string | null>(() => {
if (parsedAmount.value === null) return null;
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
}
return null;
});
const canPrepare = computed(
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
);
const noteDisplayValue = computed(() => (prepared.value ? toBech32Lnurl(prepared.value.url) : ''));
const canShare = canShareText();
const reset = () => {
step.value = 'amount';
amountSats.value = '';
preparing.value = false;
removing.value = false;
errorMessage.value = null;
prepared.value = null;
revealed.value = false;
};
watch(
() => props.modelValue,
(open) => {
if (open) reset();
},
);
// Applies a carve to the wallet in the only safe order: add the fresh
// note(s) first, mark the consumed inputs spent after. Returns the wallet
// id of the carved note (for the exact-match path the note already exists
// in the wallet, so it is found by url instead of re-added).
const applyCarve = async (carve: CarveResult): Promise<Bearer> => {
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
const toAdd: NewBearer[] = [];
if (!existing) toAdd.push(carve.note);
if (carve.change) toAdd.push(carve.change);
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
for (const consumed of carve.consumed) {
await wallet.markSpent(consumed.id);
}
return existing ?? added[0];
};
const prepare = async () => {
const sats = parsedAmount.value;
if (sats === null || amountError.value !== null) return;
preparing.value = true;
errorMessage.value = null;
try {
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats));
const note = await applyCarve(carve);
if (carve.change) {
activity.log('split', `Prepared a ${formatSats(sats)} sat note to hand over.`);
} else if (carve.consumed.length > 1) {
activity.log('combine', `Combined notes into a ${formatSats(sats)} sat note.`);
}
prepared.value = note;
revealed.value = false;
step.value = 'ready';
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the mutation may have landed - the possible outputs carry fresh
// secrets and must be tracked alongside the (kept) originals
await wallet.addBearers(err.possibleOutputs);
activity.log(
'transfer',
'A note preparation could not be confirmed - possible notes stored unverified.',
);
errorMessage.value =
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
return;
}
const message = err instanceof Error ? err.message : 'Something went wrong.';
errorMessage.value = message.startsWith('No mint holds enough')
? 'Not enough spendable balance to cover that amount.'
: message;
toast('negative', errorMessage.value);
} finally {
preparing.value = false;
}
};
const copyNote = async () => {
try {
await writeClipboard(noteDisplayValue.value);
toast('positive', 'Note copied to clipboard.');
} catch {
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
}
};
const shareNote = async () => {
try {
await shareText('sattle bearer note', noteDisplayValue.value);
} catch (err) {
// the user dismissing the share sheet is not an error
if (err instanceof DOMException && err.name === 'AbortError') return;
await copyNote();
}
};
const finishRemove = async () => {
const note = prepared.value;
if (!note) return;
removing.value = true;
try {
await wallet.markSpent(note.id);
activity.log('spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`);
toast('positive', 'Removed from your balance.');
emit('sent');
show.value = false;
} catch (err) {
const message = err instanceof Error ? err.message : 'Something went wrong.';
toast('negative', message);
} finally {
removing.value = false;
}
};
const finishKeep = () => {
toast('info', 'Note kept in your wallet.');
show.value = false;
};
const {
amountError,
amountSats,
canPrepare,
canShare,
copyNote,
errorMessage,
finishKeep,
finishRemove,
formatSats,
msatToSats,
noteDisplayValue,
prepare,
prepared,
preparing,
removing,
revealed,
shareNote,
show,
step,
wallet,
} = useSendTokenDialog(props, emit);
</script>
<template>
@@ -0,0 +1,109 @@
<template>
<div class="text-body1 q-mb-md">
Sets this device up from a downloaded backup file — no recovery phrase needed, as long as you
still know the password the backup was encrypted with. Notes from the file are merged into
storage either way.
</div>
<q-banner v-if="keySkipped" class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
This device already has a wallet, so the backup's own key was <strong>not</strong> installed.
Its notes were merged and will appear if the existing wallet is the one this backup belongs to.
</q-banner>
<template v-if="keyRestored">
<q-banner class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
The backup's key was installed. Whoever wrote that file may know it — only continue if you
trust the file's source completely. Otherwise set up a fresh wallet from your own recovery
phrase instead.
</q-banner>
<q-btn
unelevated
color="primary"
text-color="dark"
label="I trust this file — continue"
class="full-width"
@click="proceed"
/>
</template>
<template v-else>
<div v-if="result" class="text-positive q-mb-md">
Backup restored: {{ result.added }} note(s) added, {{ result.skipped }} already present.
<span v-if="!result.linkingKeyRestored">
The file carried no usable key of its own — restore its recovery phrase to unlock the notes.
</span>
</div>
<div v-if="error" class="text-negative q-mb-md">{{ error }}</div>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="restoreFile"
/>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Choose backup file"
icon="upload_file"
class="full-width"
:loading="busy"
@click="fileInput?.click()"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
import type { RestoreResult } from '@/lnurlcash/storage';
import { useWalletStore } from '@/stores/wallet';
const wallet = useWalletStore();
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const busy = ref(false);
const error = ref('');
const result = ref<RestoreResult | null>(null);
const keySkipped = ref(false);
const keyRestored = ref(false);
const restoreFile = async (event: Event): Promise<void> => {
if (!(event.currentTarget instanceof HTMLInputElement)) return;
const input = event.currentTarget;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
error.value = '';
result.value = null;
keySkipped.value = false;
try {
if (file.size > MAX_BACKUP_FILE_BYTES) {
throw new Error('That file is far too large to be a wallet backup.');
}
const data: unknown = JSON.parse(await file.text());
const restored = await wallet.restoreFromBackup(data);
if (restored.linkingKeyRestored) {
keyRestored.value = true;
return;
}
if (restored.linkingKeySkipped) {
keySkipped.value = true;
return;
}
result.value = restored;
Notify.create({ type: 'positive', message: 'Backup restored.' });
} catch (caught) {
error.value = caught instanceof Error ? caught.message : 'Something went wrong.';
Notify.create({ type: 'negative', message: error.value });
} finally {
busy.value = false;
}
};
const proceed = async (): Promise<void> => {
await wallet.init();
await router.push('/');
};
</script>
@@ -0,0 +1,121 @@
<template>
<template v-if="!createdPhrase">
<div class="text-body1 q-mb-md">
A fresh recovery phrase is generated in your browser. It is the master key to your wallet —
and the only way to recover your notes on another device.
</div>
<div class="text-caption text-grey-5 q-mb-sm">
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
</div>
<q-input
v-model="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters — this password is the only thing standing
between an offline brute-force and your notes.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Create wallet"
class="full-width q-mt-sm"
:loading="busy"
:disable="!passwordValid(password, confirmation)"
@click="createWallet"
/>
</template>
<template v-else>
<div class="text-body1 text-weight-medium q-mb-sm">
Your recovery phrase — shown once, never stored:
</div>
<div class="row q-gutter-xs q-mb-md">
<div v-for="(word, index) in createdPhrase.split(' ')" :key="index" class="word-chip">
<span class="text-grey-5 q-mr-xs">{{ index + 1 }}.</span>{{ word }}
</div>
</div>
<q-banner class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
Write these 12 words down and keep them somewhere safe. Anyone who knows them can spend your
notes; if you lose them, your notes are gone forever.
</q-banner>
<q-checkbox v-model="phraseConfirmed" color="primary" label="I wrote it down" class="q-mb-md" />
<q-btn
unelevated
color="primary"
text-color="dark"
label="Continue"
class="full-width"
:disable="!phraseConfirmed"
@click="router.push('/')"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { useWalletStore } from '@/stores/wallet';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const createdPhrase = ref<string | null>(null);
const phraseConfirmed = ref(false);
const createWallet = async (): Promise<void> => {
busy.value = true;
try {
createdPhrase.value = await wallet.create(password.value || undefined);
phraseConfirmed.value = false;
} catch (error) {
Notify.create({
type: 'negative',
message: error instanceof Error ? error.message : 'Something went wrong.',
});
} finally {
busy.value = false;
}
};
</script>
<style scoped>
.word-chip {
background: rgba(85, 255, 204, 0.08);
border: 1px solid rgba(85, 255, 204, 0.25);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,163 @@
<template>
<div class="text-body1 q-mb-md">
If this wallet used nostr backup before, your notes, mints and settings are waiting on your
relays - encrypted so only your recovery phrase can read them.
</div>
<q-input
v-model="phrase"
type="textarea"
rows="3"
dark
outlined
color="primary"
label="Your 12-word recovery phrase"
placeholder="twelve words separated by spaces"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
data-1p-ignore
data-lpignore="true"
class="q-mb-md"
/>
<div class="text-caption text-grey-5 q-mb-xs">Relays to look on</div>
<RelaysEditor v-model="relays" />
<div v-if="error" class="text-negative q-my-sm">{{ error }}</div>
<template v-if="!found">
<div v-if="looked" class="text-warning q-my-sm">
No backup found for this phrase on those relays. Check the phrase and the relay list - or
restore from a backup file instead.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Look for a backup"
class="full-width q-mt-md"
:loading="busy"
:disable="!phraseValid || relays.length === 0"
@click="lookForBackup"
/>
</template>
<template v-else>
<div class="text-body2 text-grey-4 q-my-md">
Found a backup: {{ found.notes }} note(s), {{ found.mints }} mint(s)<template
v-if="found.settings"
>, settings</template
>.
</div>
<div class="text-caption text-grey-5 q-mb-sm">
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
</div>
<q-input
v-model="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Restore this backup"
class="full-width q-mt-sm"
:loading="busy"
:disable="!passwordValid(password, confirmation)"
@click="restoreBackup"
/>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
import { backupPubkey, deriveBackupKey, fetchBackup } from '@/lnurlcash/nostrBackup';
import { useWalletStore } from '@/stores/wallet';
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
import RelaysEditor from '@/components/RelaysEditor.vue';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const phrase = ref('');
const relays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const error = ref('');
const looked = ref(false);
const found = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
const phraseValid = computed(() => isValidSeedPhrase(phrase.value));
const linkingKey = (): Uint8Array => deriveWalletLinkingKey(phrase.value.trim().toLowerCase());
const errorMessage = (caught: unknown): string =>
caught instanceof Error ? caught.message : 'Something went wrong.';
const lookForBackup = async (): Promise<void> => {
busy.value = true;
error.value = '';
found.value = null;
looked.value = false;
try {
const secretKey = deriveBackupKey(linkingKey());
const parts = await fetchBackup(backupPubkey(secretKey), relays.value, { secretKey });
looked.value = true;
if (parts.notes || parts.mints || parts.settings) {
found.value = {
notes: parts.notes?.bearers.length ?? 0,
mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined,
};
}
} catch (caught) {
error.value = errorMessage(caught);
} finally {
busy.value = false;
}
};
const restoreBackup = async (): Promise<void> => {
busy.value = true;
error.value = '';
try {
await wallet.restoreFromNostr(
phrase.value.trim().toLowerCase(),
relays.value,
password.value || undefined,
);
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
void router.push('/');
} catch (caught) {
error.value = errorMessage(caught);
Notify.create({ type: 'negative', message: error.value });
} finally {
busy.value = false;
}
};
</script>
@@ -0,0 +1,96 @@
<template>
<q-input
v-model="phrase"
type="textarea"
rows="3"
dark
outlined
color="primary"
label="Your 12-word recovery phrase"
placeholder="twelve words separated by spaces"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
data-1p-ignore
data-lpignore="true"
class="q-mb-md"
/>
<div class="text-caption text-grey-5 q-mb-sm">
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
</div>
<q-input
v-model="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<div v-if="restoreError" class="text-negative q-mt-sm">{{ restoreError }}</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Restore wallet"
class="full-width q-mt-sm"
:loading="busy"
:disable="!phrase.trim() || !passwordValid(password, confirmation)"
@click="restoreWallet"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { useWalletStore } from '@/stores/wallet';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const phrase = ref('');
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const restoreError = ref('');
const restoreWallet = async (): Promise<void> => {
busy.value = true;
restoreError.value = '';
try {
await wallet.restoreFromSeed(phrase.value.trim().toLowerCase(), password.value || undefined);
Notify.create({ type: 'positive', message: 'Wallet restored.' });
void router.push('/');
} catch (error) {
restoreError.value = error instanceof Error ? error.message : 'Something went wrong.';
Notify.create({ type: 'negative', message: restoreError.value });
} finally {
busy.value = false;
}
};
</script>
+152
View File
@@ -0,0 +1,152 @@
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import {
fetchMintAddress,
fetchPayRequest,
lightningAddressUsername,
mintAddressUrl,
resolveMintInput,
serverOf,
} from 'lnurlcash-kit';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { useMintsStore } from '@/stores/mints';
import { useWalletStore } from '@/stores/wallet';
export const useManageMintsPage = () => {
const router = useRouter();
const $q = useQuasar();
const mints = useMintsStore();
const wallet = useWalletStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const fingerprint = (pubkey: string): string =>
pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey;
const balanceAt = (server: string): string =>
(wallet.balanceByMintSats.get(server) ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 3,
});
const suggestions = computed(() =>
mints.PUBLIC_MINTS.filter(
(address) => !mints.mints.some((mint) => mint.server === address.replace(/^@/, '')),
),
);
const banner = ref('');
const addServer = ref('');
const addPubkey = ref('');
const discovering = ref('');
const confirmingRemove = ref(false);
const removeTarget = ref('');
const trustResult = (result: string, server: string): void => {
if (result === 'rekey-pending') {
toast('warning', `${server} advertised a different signing key - review it above.`);
} else if (result === 'unchanged') {
toast('info', `${server} is already trusted.`);
} else {
toast('positive', `${server} is now trusted.`);
}
};
const trustManual = async (): Promise<void> => {
banner.value = '';
try {
const result = await mints.trust(addServer.value, addPubkey.value);
trustResult(result, addServer.value.trim());
addServer.value = '';
addPubkey.value = '';
} catch (error) {
banner.value = error instanceof Error ? error.message : 'Could not trust that mint.';
}
};
const trustSuggestion = async (address: string): Promise<void> => {
if (discovering.value) return;
banner.value = '';
discovering.value = address;
try {
const url = resolveMintInput(address);
if (!url) throw new Error('That mint address cannot be resolved.');
let nodeInfo = null;
let payUrl = url;
const addressUrl = mintAddressUrl(url);
if (addressUrl) {
try {
nodeInfo = await fetchMintAddress(addressUrl);
payUrl = nodeInfo.payLink;
} catch (error) {
if (!(error instanceof Error)) throw error;
}
}
const info = await fetchPayRequest(payUrl);
const announcedKey = nodeInfo?.nodePubkey ?? info.mintPubkey;
if (!announcedKey) {
throw new Error("This mint didn't announce its signing key - add it manually instead.");
}
const server = serverOf(payUrl);
const result = await mints.trust(
server,
announcedKey,
mintAddressCacheInfo(nodeInfo, lightningAddressUsername(payUrl)),
);
trustResult(result, server);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not reach that mint.';
banner.value = `Could not add ${address}: ${message}`;
} finally {
discovering.value = '';
}
};
const askRemove = (server: string): void => {
banner.value = '';
removeTarget.value = server;
confirmingRemove.value = true;
};
const doRemove = async (): Promise<void> => {
confirmingRemove.value = false;
try {
await mints.remove(removeTarget.value);
if (mints.defaultMint === removeTarget.value) mints.setDefaultMint(null);
toast('positive', `${removeTarget.value} removed.`);
} catch (error) {
if (!(error instanceof Error)) throw error;
banner.value = `${removeTarget.value} can't be removed while you hold notes from it - move or spend them first.`;
}
};
const confirmRekey = async (server: string): Promise<void> => {
banner.value = '';
try {
await mints.confirmRekey(server);
} catch (error) {
banner.value = error instanceof Error ? error.message : 'Could not confirm that signing key.';
}
};
const dismissRekey = async (server: string): Promise<void> => {
banner.value = '';
try {
await mints.dismissRekey(server);
} catch (error) {
banner.value = error instanceof Error ? error.message : 'Could not dismiss that signing key.';
}
};
return {
addPubkey,
addServer,
askRemove,
balanceAt,
banner,
confirmingRemove,
confirmRekey,
discovering,
dismissRekey,
doRemove,
fingerprint,
mints,
removeTarget,
router,
suggestions,
trustManual,
trustSuggestion,
};
};
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue';
import { Notify } from 'quasar';
import { useMintsStore } from '@/stores/mints';
export const useMintTrustPrompt = () => {
const mints = useMintsStore();
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustNodeAlias = ref('');
const openTrust = (server: string, pubkey: string, nodeAlias = ''): void => {
trustServer.value = server;
trustPubkey.value = pubkey;
trustNodeAlias.value = nodeAlias;
showTrust.value = true;
};
const trustMint = async (): Promise<void> => {
try {
await mints.trust(trustServer.value, trustPubkey.value, {
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
});
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (error) {
const caught = error instanceof Error ? error : new Error(String(error));
Notify.create({ type: 'negative', message: caught.message });
} finally {
showTrust.value = false;
}
};
const skipTrust = (): void => {
showTrust.value = false;
Notify.create({
type: 'warning',
message:
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
});
};
return {
openTrust,
showTrust,
skipTrust,
trustMint,
trustNodeAlias,
trustServer,
};
};
+280
View File
@@ -0,0 +1,280 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
type Option = Readonly<{ label: string; value: string }>;
type TransferResult = Readonly<{
outcome: TransferOutcome;
requestedSats: number;
feeSats: number;
sourceServer: string;
targetServer: string;
claimNoteValueSats?: number;
}>;
export const useMoveFundsPage = () => {
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const warnCommitted = (message: string): void => toast('warning', message);
watch(
() => wallet.state,
(state) => {
if (state !== 'unlocked') void router.replace('/');
},
{ immediate: true },
);
const CUSTOM_TARGET = '__custom__';
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const spendableByServerMsat = computed(() => {
const byServer = new Map<string, number>();
for (const bearer of wallet.bearers) {
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
continue;
const server = serverOf(bearer.url);
byServer.set(server, (byServer.get(server) ?? 0) + bearer.amount);
}
return byServer;
});
const sourceOptions = computed<Option[]>(() =>
[...spendableByServerMsat.value.entries()].map(([server, msat]) => ({
label: `${server} - ${displaySats(msat).toLocaleString()} sats available`,
value: server,
})),
);
const step = ref<'form' | 'confirm' | 'working' | 'result'>('form');
const sourceServer = ref('');
const targetChoice = ref('');
const customTarget = ref('');
const amountSats = ref<number | null>(null);
const inlineError = ref('');
const stage = ref('');
const result = ref<TransferResult | null>(null);
const targetOptions = computed<Option[]>(() => {
const options: Option[] = [];
for (const mint of mints.mints) {
if (mint.server === sourceServer.value) continue;
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
options.push({
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
value: address,
});
}
options.push({ label: 'Another mint…', value: CUSTOM_TARGET });
return options;
});
const targetInput = computed(() =>
targetChoice.value === CUSTOM_TARGET ? customTarget.value.trim() : targetChoice.value,
);
const formFilled = computed(
() =>
sourceServer.value !== '' &&
targetInput.value !== '' &&
Number.isInteger(amountSats.value) &&
(amountSats.value ?? 0) >= 1,
);
const targetFee = ref<MintFee | null>(null);
let quoteTimer: ReturnType<typeof setTimeout> | null = null;
watch(targetInput, (input) => {
targetFee.value = null;
if (quoteTimer) clearTimeout(quoteTimer);
if (input === '') return;
quoteTimer = setTimeout(() => {
void quoteMintFee(input).then((fee) => {
if (targetInput.value === input) targetFee.value = fee;
});
}, 400);
});
const targetFeeText = computed(() =>
targetFee.value
? `This mint charges a receive fee (${describeMintFee(targetFee.value)}) - Max already accounts for it.`
: '',
);
const setMax = (): void => {
const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value));
};
const proceed = (): void => {
inlineError.value = '';
const sats = amountSats.value;
if (!sats || !Number.isInteger(sats) || sats < 1) {
inlineError.value = 'Enter how many sats to move.';
return;
}
const sourceMsat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
if (satsToMsat(sats) > sourceMsat) {
inlineError.value = `That's more than the ${displaySats(sourceMsat).toLocaleString()} sats spendable at ${sourceServer.value}.`;
return;
}
step.value = 'confirm';
};
const move = async (): Promise<void> => {
const sats = amountSats.value;
if (!sats) return;
step.value = 'working';
stage.value = 'Asking the target mint for an invoice…';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence, warn: warnCommitted };
let stagedOutput: Bearer | undefined;
let carvedOutput: Bearer | undefined;
// the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints(
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats),
targetInput.value,
{
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
persistOutput: async (note) => {
[stagedOutput] = await addCommittedBearers(wallet, [note], commitContext);
},
onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
},
onMeltReady: async (carve, sourceRecoverySecret) => {
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
carvedOutput =
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!carvedOutput) throw new Error('The carved note was not tracked.');
await wallet.reserveStagedMintSource(
stagedOutput.id,
carvedOutput.id,
sourceRecoverySecret,
ownerFence,
);
},
},
);
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
stage.value = 'Confirming the result…';
// a carve that mutated nothing fires no hook - the note is already
// tracked; look it up
const carved =
carvedOutput ??
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.');
if (transfer.rescuedNote) {
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
}
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') {
const claimed = transfer.mintedAtTarget;
if (claimed) {
try {
await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
}
await activity.log(
'transfer',
`Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`,
(error) => warnCommitted(error.message),
);
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) {
await wallet.restoreStagedMintSource(stagedOutput.id, transfer.rotatedNote, ownerFence);
}
await activity.log(
'transfer',
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
(error) => warnCommitted(error.message),
);
} else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence);
await activity.log(
'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
(error) => warnCommitted(error.message),
);
} else if (transfer.outcome === 'settled-claim-failed') {
await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
await activity.log(
'transfer',
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
(error) => warnCommitted(error.message),
);
} else {
await wallet.markSpent(carved.id, ownerFence);
await activity.log(
'spent',
`A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`,
(error) => warnCommitted(error.message),
);
}
const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
result.value = {
outcome: transfer.outcome,
requestedSats: sats,
feeSats,
sourceServer: transfer.sourceServer,
targetServer: transfer.targetServer,
...(claimNote ? { claimNoteValueSats: displaySats(claimNote.amount) } : {}),
};
step.value = 'result';
} catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong.';
inlineError.value = message.startsWith('No mint holds enough')
? 'Not enough spendable balance at the source mint to cover that move.'
: message;
toast('negative', inlineError.value);
step.value = 'form';
} finally {
fundOperation?.complete();
}
};
return {
CUSTOM_TARGET,
amountSats,
customTarget,
formFilled,
inlineError,
move,
proceed,
result,
router,
setMax,
sourceOptions,
sourceServer,
stage,
step,
targetChoice,
targetFeeText,
targetInput,
targetOptions,
};
};
+298
View File
@@ -0,0 +1,298 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import {
decodeBolt11AmountMsat,
isBolt11Invoice,
resolveLnurlInput,
withNewK1,
} from 'lnurlcash-kit';
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
type PayInvoiceProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
type PayInvoiceEmit = {
(event: 'update:modelValue', value: boolean): void;
(event: 'sent'): void;
};
type TargetKind = 'invoice' | 'address';
type PendingPayment = Readonly<{ kind: TargetKind; input: string; amountMsat: number }>;
type PaymentResult = Readonly<{ outcome: PayOutcome; amountMsat: number }>;
export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit) => {
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const warnCommitted = (message: string): void => toast('warning', message);
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const step = ref<'input' | 'confirm' | 'working' | 'result'>('input');
const input = ref('');
const addressAmountSats = ref('');
const showScanner = ref(false);
const inlineError = ref<string | null>(null);
const stage = ref('');
const pendingPayment = ref<PendingPayment | null>(null);
const result = ref<PaymentResult | null>(null);
const formatSats = (sats: number): string =>
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
const classify = (value: string): TargetKind | null => {
const trimmed = value.trim();
if (!trimmed) return null;
if (isBolt11Invoice(trimmed)) return 'invoice';
return resolveLnurlInput(trimmed) === null ? null : 'address';
};
const targetKind = computed<TargetKind | null>(() => classify(input.value));
const truncatedInput = computed(() => {
const payment = pendingPayment.value;
if (!payment) return '';
if (payment.kind === 'address') return payment.input;
return payment.input.length > 30
? `${payment.input.slice(0, 18)}…${payment.input.slice(-8)}`
: payment.input;
});
const resultAmountSats = computed(() =>
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
);
const reset = (): void => {
step.value = 'input';
input.value = props.initialInput ?? '';
addressAmountSats.value = '';
showScanner.value = false;
inlineError.value = null;
stage.value = '';
pendingPayment.value = null;
result.value = null;
};
watch(
() => props.modelValue,
(open) => {
if (open) reset();
},
);
const onScan = (text: string): void => {
input.value = text.replace(/^lightning:/i, '').trim();
showScanner.value = false;
};
const onScanError = (message: string): void => {
showScanner.value = false;
toast('negative', message);
};
const paste = async (): Promise<void> => {
try {
const text = await readClipboard();
if (text) input.value = text.trim();
} catch (error) {
if (!(error instanceof Error)) throw error;
toast('negative', "Couldn't read the clipboard - paste manually.");
}
};
const proceed = (): void => {
inlineError.value = null;
const value = input.value.trim();
if (!value) {
inlineError.value = 'Paste an invoice or a Lightning Address first.';
return;
}
const kind = classify(value);
if (kind === null) {
inlineError.value = "That doesn't look like a Lightning invoice or address.";
return;
}
let amountMsat: number;
if (kind === 'invoice') {
const decoded = decodeBolt11AmountMsat(value);
if (decoded === null || decoded <= 0) {
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
return;
}
amountMsat = decoded;
} else {
const sats = Number(addressAmountSats.value);
if (!Number.isInteger(sats) || sats <= 0) {
inlineError.value = 'Enter how many sats to send to this address.';
return;
}
amountMsat = satsToMsat(sats);
}
if (amountMsat > wallet.balanceMsat) {
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
return;
}
pendingPayment.value = { kind, input: value, amountMsat };
step.value = 'confirm';
};
const friendlyError = (error: unknown): string => {
const message = error instanceof Error ? error.message : 'Something went wrong.';
return message.startsWith('No mint holds enough')
? 'Not enough spendable balance to cover that payment.'
: message;
};
const pay = async (): Promise<void> => {
const payment = pendingPayment.value;
if (!payment) return;
step.value = 'working';
stage.value = 'Preparing the exact amount and sending the payment…';
let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence: fence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {};
let returnStage: Bearer | undefined;
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
};
const allocateOutputSecrets = (server: string, count: number) =>
wallet.allocateOutputSecrets(server, count, fence);
const mintSignatureKeys = (server: string) => getTrustedMintVerificationKeys(server, ownerId);
// the pay-return recovery output: staged durably (allocated secret,
// zero value, pendingMint) and linked to the carved source BEFORE the
// classification rotate can burn it
const onReturnReady = async (
carve: Parameters<typeof commitCarve>[1],
recoverySecret: string,
): Promise<void> => {
const source =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.');
[returnStage] = await addCommittedBearers(
wallet,
[
{
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
callback: '',
amount: 0,
verified: false,
pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 },
},
],
commitContext,
);
if (!returnStage) throw new Error('The payment recovery output was not persisted.');
await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence);
};
const paid = await payWithBearers(wallet.bearers, payment.input, {
...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}),
assertOwner: fence,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
});
stage.value = 'Confirming the result…';
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
if (returnStage) {
const returned = paid.rotatedNote ?? paid.rescuedNote;
if (returned) {
await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence);
} else if (paid.outcome !== 'unknown-still-pending') {
await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence);
}
}
const sats = formatSats(msatToSats(paid.amountMsat));
if (paid.outcome === 'settled') {
await wallet.markSpent(committed.id, ownerFence);
await activity.log('melt', `Paid ${sats} sats over Lightning.`, (error) =>
warnCommitted(error.message),
);
toast('positive', `Paid ${sats} sats.`);
emit('sent');
} else if (paid.outcome === 'failed-funds-returned') {
await activity.log(
'transfer',
`A ${sats} sat payment failed - funds are back in your wallet.`,
(error) => warnCommitted(error.message),
);
toast('warning', 'Payment failed - funds are back in your wallet.');
} else if (paid.outcome === 'unknown-still-pending') {
await wallet.markSpent(committed.id, ownerFence);
await activity.log(
'melt',
`Payment of ${sats} sats is still in flight - the note is locked.`,
(error) => warnCommitted(error.message),
);
emit('sent');
} else {
await wallet.markSpent(committed.id, ownerFence);
await activity.log('spent', `A ${sats} sat note was already spent at the mint.`, (error) =>
warnCommitted(error.message),
);
}
result.value = { outcome: paid.outcome, amountMsat: paid.amountMsat };
step.value = 'result';
} catch (error) {
if (error instanceof UncertainOutcomeError) {
if (!ownerFence) throw error;
await addCommittedBearers(wallet, error.possibleOutputs, {
ownerFence,
warn: warnCommitted,
});
await activity.log(
'transfer',
'A payment preparation could not be confirmed - possible notes stored unverified.',
(activityError) => warnCommitted(activityError.message),
);
inlineError.value =
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
} else {
inlineError.value = friendlyError(error);
toast('negative', inlineError.value);
}
step.value = 'input';
} finally {
fundOperation?.complete();
}
};
const closeResult = (): void => {
show.value = false;
};
return {
addressAmountSats,
closeResult,
formatSats,
inlineError,
input,
msatToSats,
onScan,
onScanError,
paste,
pay,
pendingPayment,
proceed,
result,
resultAmountSats,
show,
showScanner,
stage,
step,
targetKind,
truncatedInput,
};
};
@@ -0,0 +1,269 @@
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
import { useMintTrustPrompt } from './useMintTrustPrompt';
type ReceiveLightningProps = Readonly<{ modelValue: boolean }>;
type ReceiveLightningEmit = (event: 'received') => void;
type MintOption = Readonly<{ label: string; value: string }>;
export const useReceiveLightningDialog = (
props: ReceiveLightningProps,
emit: ReceiveLightningEmit,
) => {
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const CUSTOM_MINT = '__custom__';
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'Something went wrong.';
const step = ref<'form' | 'invoice' | 'success'>('form');
const amountSats = ref<number | null>(null);
const mintChoice = ref('');
const customMint = ref('');
const preparing = ref(false);
const formError = ref('');
const prepared = ref<PreparedMint | null>(null);
const stagedOutput = ref<Bearer | null>(null);
const waiting = ref(false);
const claimError = ref('');
const claimRetryable = ref(true);
const receivedSats = ref(0);
const receivedServer = ref('');
const rotationWarning = ref('');
const trustPrompt = useMintTrustPrompt();
let claimRun: Promise<void> | null = null;
const mintOptions = computed<MintOption[]>(() => {
const options: MintOption[] = [];
const seen = new Set<string>();
for (const mint of mints.mints) {
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
if (seen.has(address)) continue;
seen.add(address);
options.push({
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
value: address,
});
}
for (const publicMint of mints.PUBLIC_MINTS) {
if (seen.has(publicMint)) continue;
seen.add(publicMint);
options.push({ label: publicMint, value: publicMint });
}
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
return options;
});
const defaultChoice = (): string => {
const options = mintOptions.value;
if (mints.defaultMint) {
const match = options.find((option) => option.value.endsWith(`@${mints.defaultMint}`));
if (match) return match.value;
}
const first = options[0];
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
};
const formValid = computed(() => {
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
return mintChoice.value === CUSTOM_MINT
? customMint.value.trim() !== ''
: mintChoice.value !== '';
});
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
const netSats = computed(() =>
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
);
const feeSats = computed(() => grossSats.value - netSats.value);
const onClaimed = async (
claimed: ClaimedNote,
from: PreparedMint,
staged: Bearer,
ownerFence: WalletOwnerFence,
): Promise<void> => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
let trustWarning = '';
try {
await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = '';
await activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
(error) => {
trustWarning = error.message;
},
);
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
if (nodeInfo) {
try {
await mints.cacheNodeInfo(server, nodeInfo);
} catch (error) {
if (!(error instanceof Error)) throw error;
trustWarning = `Funds were saved, but mint details could not be updated: ${errorMessage(error)}`;
}
}
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
emit('received');
if (props.modelValue) step.value = 'success';
if (!wasTrusted && claimed.note.mintPubkey) {
trustPrompt.openTrust(server, claimed.note.mintPubkey, from.nodeInfo?.nodeAlias ?? '');
}
};
const beginClaim = (): void => {
if (!prepared.value || !stagedOutput.value || claimRun) return;
waiting.value = true;
claimError.value = '';
claimRetryable.value = true;
const current = prepared.value;
const staged = stagedOutput.value;
claimRun = (async () => {
const fundOperation = wallet.beginFundOperation();
try {
const ownerFence = fundOperation.ownerFence;
await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current,
staged,
ownerFence,
);
} catch (error) {
if (!(error instanceof Error)) throw error;
if (error instanceof MintedNoteSpentError) {
await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence);
claimRetryable.value = false;
claimError.value = errorMessage(error);
Notify.create({ type: 'negative', message: claimError.value });
return;
}
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) });
} finally {
fundOperation.complete();
waiting.value = false;
}
})();
};
const createInvoice = async (): Promise<void> => {
const sats = amountSats.value;
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
let persisted: Bearer | undefined;
const next = await prepareMint(input, satsToMsat(sats), {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
persistOutput: async (note) => {
[persisted] = await wallet.addBearers([note], ownerFence);
},
});
if (!persisted) throw new Error('The pending mint output was not persisted.');
stagedOutput.value = persisted;
prepared.value = next;
claimRun = null;
claimError.value = '';
claimRetryable.value = true;
step.value = 'invoice';
beginClaim();
} catch (error) {
if (!(error instanceof Error)) throw error;
formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value });
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
const copyInvoice = async (): Promise<void> => {
if (!prepared.value) return;
try {
await writeClipboard(prepared.value.invoice);
Notify.create({ type: 'positive', message: 'Invoice copied.' });
} catch (error) {
if (!(error instanceof Error)) throw error;
Notify.create({ type: 'negative', message: errorMessage(error) });
}
};
const retryClaim = (): void => {
claimRun = null;
beginClaim();
};
const stopWaiting = (): void => {
waiting.value = false;
};
const resumeWaiting = (): void => {
if (claimRun) waiting.value = true;
};
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'form';
amountSats.value = null;
customMint.value = '';
mintChoice.value = defaultChoice();
preparing.value = false;
formError.value = '';
prepared.value = null;
stagedOutput.value = null;
waiting.value = false;
rotationWarning.value = '';
},
);
return {
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
feeSats,
formError,
formValid,
grossSats,
mintChoice,
mintOptions,
netSats,
prepared,
preparing,
receivedSats,
receivedServer,
resumeWaiting,
retryClaim,
rotationWarning,
showTrust: trustPrompt.showTrust,
skipTrust: trustPrompt.skipTrust,
step,
stopWaiting,
trustMint: trustPrompt.trustMint,
trustNodeAlias: trustPrompt.trustNodeAlias,
trustServer: trustPrompt.trustServer,
waiting,
};
};
+214
View File
@@ -0,0 +1,214 @@
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
isValidNoteInput,
} from 'lnurlcash-kit';
import { receiveBearer } from '@/lnurlcash/ops';
import type { Bearer } from '@/lnurlcash/types';
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useMintTrustPrompt } from './useMintTrustPrompt';
type ReceiveTokenProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
type ReceiveTokenEmit = {
(event: 'received'): void;
};
type ReceiveErrorKind = 'spent' | 'unknown' | 'pending' | 'duplicate' | 'invalid' | 'generic' | '';
const ERROR_TEXT: Readonly<Record<Exclude<ReceiveErrorKind, ''>, string>> = {
spent: 'This note has already been spent.',
unknown: "The mint doesn't know this note.",
pending: 'This note is locked mid-payment — try again shortly.',
duplicate: 'This note is already in your wallet.',
invalid: 'Not a valid bearer note.',
generic: '',
};
const ERROR_ICON: Readonly<Record<Exclude<ReceiveErrorKind, ''>, string>> = {
spent: 'money_off',
unknown: 'help_outline',
pending: 'hourglass_top',
duplicate: 'content_copy',
invalid: 'error_outline',
generic: 'error_outline',
};
export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTokenEmit) => {
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'Something went wrong.';
const step = ref<'input' | 'success'>('input');
const input = ref('');
const scanning = ref(false);
const busy = ref(false);
const errorKind = ref<ReceiveErrorKind>('');
const errorMessageText = ref('');
const receivedSats = ref(0);
const receivedServer = ref('');
const unverifiedNote = ref(false);
const rotationWarning = ref('');
const trustPrompt = useMintTrustPrompt();
const errorText = computed(() =>
errorKind.value === 'generic'
? errorMessageText.value
: errorKind.value === ''
? ''
: ERROR_TEXT[errorKind.value],
);
const errorIcon = computed(() =>
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
);
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
const clearError = (): void => {
errorKind.value = '';
errorMessageText.value = '';
};
const classifyError = (error: unknown): void => {
let kind: Exclude<ReceiveErrorKind, ''>;
if (error instanceof NoteSpentError) kind = 'spent';
else if (error instanceof NoteUnknownError) kind = 'unknown';
else if (error instanceof PendingNoteError) kind = 'pending';
else if (error instanceof Error && error.message.includes('already in your wallet')) {
kind = 'duplicate';
} else if (error instanceof Error && error.message.includes('Not an LNURLcash bearer note')) {
kind = 'invalid';
} else {
kind = 'generic';
errorMessageText.value = errorMessage(error);
}
errorKind.value = kind;
Notify.create({
type: 'negative',
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
});
};
const receive = async (): Promise<void> => {
const value = input.value.trim();
if (busy.value || value === '') return;
busy.value = true;
clearError();
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
try {
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
// the rotate's fresh secret is reserved and its future note staged
// before the mutation can land - a crash mid-rotate never loses the
// money (wallet recovery reconciles the staged record)
const claimed = await receiveBearer(value, wallet.bearers, {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
stageRotation: async (note) => {
[staged] = await wallet.addBearers([note], ownerFence);
},
});
const note = claimed.note;
const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server);
let trustWarning = '';
try {
if (claimed.stage === 'finalize') {
// the rotate landed: the staged record becomes the confirmed note
if (!staged) throw new Error('The rotated note was not staged.');
await wallet.finalizeStagedMintOutput(staged.id, note, ownerFence);
} else if (claimed.stage === 'discard') {
// the rotate provably never landed: drop the worthless stage and
// keep the original (exposed - see rotationWarning)
if (staged) await wallet.removeNote(staged.id, ownerFence);
await wallet.addBearers([note], ownerFence);
} else {
// 'keep': the staged record is the possible rotated copy; the
// original is tracked unverified alongside it. 'none': no rotate
// was attempted - the note goes in as received.
await wallet.addBearers([note], ownerFence);
}
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(note.amount);
receivedServer.value = server;
unverifiedNote.value = !note.verified;
rotationWarning.value = claimed.rotationError ?? '';
await activity.log(
'receive',
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
(error) => Notify.create({ type: 'warning', message: error.message }),
);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
emit('received');
scanning.value = false;
step.value = 'success';
if (!wasTrusted && note.mintPubkey) {
trustPrompt.openTrust(server, note.mintPubkey);
}
} catch (error) {
// a definitive failure thrown after staging leaves the staged record
// behind - drop it best-effort (wallet recovery restores the original
// note from it if this discard itself is impossible right now)
if (staged && fundOperation) {
await wallet.removeNote(staged.id, fundOperation.ownerFence).catch(() => undefined);
}
classifyError(error instanceof Error ? error : new Error(String(error)));
} finally {
fundOperation?.complete();
busy.value = false;
}
};
const onScan = (text: string): void => {
input.value = text;
scanning.value = false;
void receive();
};
const onScanError = (message: string): void => {
scanning.value = false;
Notify.create({ type: 'negative', message });
};
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'input';
input.value = props.initialInput ?? '';
scanning.value = false;
busy.value = false;
clearError();
unverifiedNote.value = false;
rotationWarning.value = '';
},
);
return {
busy,
errorIcon,
errorKind,
errorText,
input,
inputValid,
onScan,
onScanError,
receive,
receivedSats,
receivedServer,
rotationWarning,
scanning,
showTrust: trustPrompt.showTrust,
skipTrust: trustPrompt.skipTrust,
step,
trustMint: trustPrompt.trustMint,
trustServer: trustPrompt.trustServer,
unverifiedNote,
};
};
+221
View File
@@ -0,0 +1,221 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { toBech32Lnurl } from 'lnurlcash-kit';
import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
type SendTokenProps = Readonly<{ modelValue: boolean }>;
type SendTokenEmit = {
(event: 'update:modelValue', value: boolean): void;
(event: 'sent'): void;
};
export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) => {
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const warnCommitted = (message: string): void => toast('warning', message);
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const step = ref<'amount' | 'ready'>('amount');
const amountSats = ref('');
const preparing = ref(false);
const removing = ref(false);
const errorMessage = ref<string | null>(null);
const prepared = ref<Bearer | null>(null);
const revealed = ref(false);
const formatSats = (sats: number): string =>
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
const parsedAmount = computed<number | null>(() => {
const amount = Number(amountSats.value);
return Number.isInteger(amount) && amount > 0 ? amount : null;
});
const amountError = computed<string | null>(() => {
if (parsedAmount.value === null) return null;
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
}
return null;
});
const canPrepare = computed(
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
);
const noteDisplayValue = computed(() =>
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
);
const canShare = canShareText();
const reset = (): void => {
step.value = 'amount';
amountSats.value = '';
preparing.value = false;
removing.value = false;
errorMessage.value = null;
prepared.value = null;
revealed.value = false;
};
watch(
() => props.modelValue,
(open) => {
if (open) reset();
},
);
const prepare = async (): Promise<void> => {
const sats = parsedAmount.value;
if (sats === null || amountError.value !== null) return;
preparing.value = true;
errorMessage.value = null;
let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
// the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: fence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, fence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence,
warn: warnCommitted,
});
},
});
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
const note = committed;
if (carve.change) {
await activity.log(
'split',
`Prepared a ${formatSats(sats)} sat note to hand over.`,
(error) => warnCommitted(error.message),
);
} else if (carve.consumed.length > 1) {
await activity.log(
'combine',
`Combined notes into a ${formatSats(sats)} sat note.`,
(error) => warnCommitted(error.message),
);
}
prepared.value = note;
revealed.value = false;
step.value = 'ready';
} catch (error) {
if (error instanceof UncertainOutcomeError) {
if (!ownerFence) throw error;
await addCommittedBearers(wallet, error.possibleOutputs, {
ownerFence,
warn: warnCommitted,
});
await activity.log(
'transfer',
'A note preparation could not be confirmed - possible notes stored unverified.',
(activityError) => warnCommitted(activityError.message),
);
errorMessage.value =
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
return;
}
const message = error instanceof Error ? error.message : 'Something went wrong.';
errorMessage.value = message.startsWith('No mint holds enough')
? 'Not enough spendable balance to cover that amount.'
: message;
toast('negative', errorMessage.value);
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
const copyNote = async (): Promise<void> => {
try {
await writeClipboard(noteDisplayValue.value);
toast('positive', 'Note copied to clipboard.');
} catch (error) {
if (!(error instanceof Error)) throw error;
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
}
};
const shareNote = async (): Promise<void> => {
try {
await shareText('sattle bearer note', noteDisplayValue.value);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
await copyNote();
}
};
const finishRemove = async (): Promise<void> => {
const note = prepared.value;
if (!note) return;
removing.value = true;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
fundOperation = wallet.beginFundOperation();
await wallet.markSpent(note.id, fundOperation.ownerFence);
await activity.log(
'spent',
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
(error) => warnCommitted(error.message),
);
toast('positive', 'Removed from your balance.');
emit('sent');
show.value = false;
} catch (error) {
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
} finally {
fundOperation?.complete();
removing.value = false;
}
};
const finishKeep = (): void => {
toast('info', 'Note kept in your wallet.');
show.value = false;
};
return {
amountError,
amountSats,
canPrepare,
canShare,
copyNote,
errorMessage,
finishKeep,
finishRemove,
formatSats,
msatToSats,
noteDisplayValue,
prepare,
prepared,
preparing,
removing,
revealed,
shareNote,
show,
step,
wallet,
};
};
+172
View File
@@ -0,0 +1,172 @@
import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys';
import type { CarveResult } from '@/lnurlcash/ops';
import { loadBearers } from '@/lnurlcash/storage';
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types';
import { commitCarve } from './walletCarveCommit';
import { useWalletStore } from '../stores/wallet';
const note = (secret: string): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
});
const failOnBearerWrite = (occurrence: number): void => {
const setItem = localStorage.setItem.bind(localStorage);
let writes = 0;
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
if (key === FUNDS_STORAGE_KEY) {
writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable');
}
setItem(key, value);
});
};
// a present-but-non-serializing LockManager fake: walletFunds serializes its
// own mutations, so single-store tests only need locks to EXIST
const stubPassthroughLocks = (): void => {
vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
});
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
stubPassthroughLocks();
stubLocalStorage();
setActivePinia(createPinia());
});
describe('commitCarve', () => {
it('commits the carve additions and spent marks in one bearer write', async () => {
// Given a wallet holding the carve's input note
const wallet = useWalletStore();
await wallet.create();
const ownerFence = wallet.captureOwnerFence();
const [input] = await wallet.addBearers([note('aa')], ownerFence);
if (!input) throw new Error('Expected the input bearer.');
const carve: CarveResult = {
note: note('bb'),
change: note('cc'),
consumed: [input],
};
const writes = vi.spyOn(localStorage, 'setItem');
// When the carve is committed
const committed = await commitCarve(wallet, carve, {
ownerFence,
warn: () => undefined,
});
// Then the whole rotation landed as ONE durable write
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
expect(committed.url).toBe(carve.note.url);
expect(wallet.bearers).toHaveLength(3);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
const persisted = await loadBearers(key);
expect(persisted).toHaveLength(3);
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
});
it('survives a failure that would have hit the old split commit second write', async () => {
// Given a wallet holding the carve's input note, with the second
// funds-document write poisoned (the old add-then-markSpent split wrote
// twice; the single-write commit never reaches a second write)
const wallet = useWalletStore();
await wallet.create();
const ownerFence = wallet.captureOwnerFence();
const [input] = await wallet.addBearers([note('aa')], ownerFence);
if (!input) throw new Error('Expected the input bearer.');
failOnBearerWrite(2);
const carve: CarveResult = {
note: note('bb'),
change: note('cc'),
consumed: [input],
};
// When the carve is committed
const committed = await commitCarve(wallet, carve, {
ownerFence,
warn: () => undefined,
});
// Then the rotation committed completely: additions tracked, input spent
expect(committed.url).toBe(carve.note.url);
expect(wallet.bearers).toHaveLength(3);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
const persisted = await loadBearers(key);
expect(persisted).toHaveLength(3);
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
});
it('leaves no partial carve behind when the commit write itself fails', async () => {
// Given bearer storage that fails the very next write
const wallet = useWalletStore();
await wallet.create();
const ownerFence = wallet.captureOwnerFence();
const [input] = await wallet.addBearers([note('aa')], ownerFence);
if (!input) throw new Error('Expected the input bearer.');
failOnBearerWrite(1);
// When the carve commit fails
await expect(
commitCarve(
wallet,
{ note: note('bb'), change: note('cc'), consumed: [input] },
{ ownerFence, warn: () => undefined },
),
).rejects.toThrow('bearer storage unavailable');
// Then nothing moved: not in storage, not in the reactive list
expect(wallet.bearers).toHaveLength(1);
expect(wallet.bearers[0]?.spent).toBeUndefined();
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
const persisted = await loadBearers(key);
expect(persisted).toHaveLength(1);
expect(persisted[0]?.spent).toBeUndefined();
});
it('commits the staged and landed phases of the same output as one record', async () => {
// Given a wallet holding the carve's input note
const wallet = useWalletStore();
await wallet.create();
const ownerFence = wallet.captureOwnerFence();
const [input] = await wallet.addBearers([note('aa')], ownerFence);
if (!input) throw new Error('Expected the input bearer.');
const context = { ownerFence, warn: () => undefined };
// When the pre-wire phase stages the outputs (sig-less URLs), then the
// landed phase re-reports them with the mint's signature in the URL
await commitCarve(wallet, { note: note('bb'), change: note('cc'), consumed: [] }, context);
expect(wallet.bearers).toHaveLength(3);
const stagedNote = note('bb');
const committed = await commitCarve(
wallet,
{
note: { ...stagedNote, url: `${stagedNote.url}&sig=${'ab'.repeat(64)}`, verified: true },
consumed: [input],
},
context,
);
// Then the staged record was refreshed in place: still one record per
// output, the input retired, no duplicate from the URL difference
expect(wallet.bearers).toHaveLength(3);
expect(committed.verified).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
expect(wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32)))).toHaveLength(1);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toHaveLength(3);
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { CarveResult } from '@/lnurlcash/ops';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = {
readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
};
type CarveCommitContext = Readonly<{
ownerFence: WalletOwnerFence;
warn: (message: string) => void;
}>;
export const addCommittedBearers = async (
wallet: CarveWallet,
notes: NewBearer[],
context: CarveCommitContext,
): Promise<Bearer[]> => {
try {
return await wallet.addBearers(notes, context.ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message);
return error.committedBearers;
}
};
// A carve is ONE logical rotation: the fresh notes (target + change) and the
// spent marks of the burned inputs must land together or not at all - the
// mint already destroyed the inputs server-side, so a partial commit (added
// but not spent, or vice versa) would strand or double-show money. Both
// checkpoint phases (pre-wire staging, landed retirement) go through the
// wallet's k1-aware one-write boundary: the landed phase's URLs carry the
// mint's signature the staged phase's could not have, so the store matches
// checkpoints by output SECRET and refreshes the staged record in place -
// a replayed checkpoint is a no-op, never a duplicate.
export const commitCarve = async (
wallet: CarveWallet,
carve: CarveResult,
context: CarveCommitContext,
): Promise<Bearer> => {
try {
return await wallet.commitCarve(carve, context.ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message);
const committed = error.committedBearers[0];
if (!committed) throw error;
return committed;
}
};
+4
View File
@@ -0,0 +1,4 @@
export const MIN_PASSWORD_LENGTH = 8;
export const passwordValid = (password: string, confirmation: string): boolean =>
password === '' || (password.length >= MIN_PASSWORD_LENGTH && password === confirmation);
+26 -6
View File
@@ -6,7 +6,11 @@ lnurlcash-kit directly.
## FUND-CRITICAL INVARIANTS (non-negotiable)
- Rotate on every receive, and immediately after claiming a fresh mint.
- Rotate every externally received bearer. Minted notes are different:
current LUD-25 requires `commentAllowed >= 64`, so the wallet durably
stages its chosen secret before requesting the invoice and claims at
that secret without rotating. `mintToHash` is only an additive receipt
extension, never a capability fallback.
- A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or
@@ -22,8 +26,9 @@ lnurlcash-kit directly.
```
ops.ts / ops/ # flows: carve (exact-amount), mint, pay, receiveBearer,
# transfer (inter-mint); ops.ts is the façade
storage/ # encrypted bearers + activity log, settings, backup,
# nwcConnections, passkeySlots; storage.ts is the façade
storage/ # encrypted bearers + activity log, owner-bound NWC,
# passkey and trusted-mint records, settings, backup;
# storage.ts is the façade
keys.ts # BIP39, LUD-05 linking key derivation, password wrap
passkeys.ts + passkeyWrap.ts # WebAuthn PRF wrap (same linking key)
nostrBackup.ts + nostr/ # kind-30078 backup, NIP-44 self-encryption
@@ -38,8 +43,21 @@ test-utils.ts # mock mint harness used by *.test.ts
- Style: NO semicolons, 2-space indent, single quotes, `{braced}` imports
without inner spaces — deliberately different from the rest of the app
(eslint override); keep the tested core diffable against its lineage.
- Storage: localStorage keys `sattle_*`; strict shape validation on read,
malformed entries dropped; read-modify-write under `withStorageLock`.
- Storage: localStorage keys `sattle_*`; strict shape validation on read.
Credential, NWC, passkey, and trusted-mint records belong to the canonical
saved-key owner. Normal writes require that exact persisted owner; migration
of ownerless legacy records has its own proof-gated API.
- Concurrency: read-modify-write uses `withStorageLock` where Web Locks are
available, but lock handoff is not a localStorage visibility barrier. The
trusted-mint repository reconciles from a durable IndexedDB commit mirror
before one successful localStorage write and before resolving. Its fallback
is local execution only, with no cross-tab serialization guarantee. Storage
events are wakeups, so listeners re-read current storage instead of trusting
`event.newValue`, including on clears.
- Lifecycle: wallet transitions serialize create, restore, unlock, lock, and
forget. Activation completes proven-owner migration before exposing unlocked
state. Forget locks, drains NWC, clears runtime and owner-bound state, then
removes the saved key after biometric deletion succeeds.
- Network: kit calls only; injectable transport/options so tests never
touch the network. No WebSocket at import time (lazy `import()`).
- Every module header comment explains the WHY, including failure models.
@@ -47,4 +65,6 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS
`npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via test-utils.
The adversarial mock mint from lnurlcash-conformance is wrapped by
`ops.testHarness.ts`; its default contract is mandatory comment-bound
minting.
+29 -28
View File
@@ -22,30 +22,30 @@ import {
grossUpForMintFee,
mintAddressUrl,
resolveMintInput,
serverOf
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
import {ceilMsatToSat, floorMsatToSat} from './units'
serverOf,
} from 'lnurlcash-kit';
import type { LnurlcashOptions, MintFee } from 'lnurlcash-kit';
import { ceilMsatToSat, floorMsatToSat } from './units';
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
applyMintFee(grossMsat, fee)
applyMintFee(grossMsat, fee);
export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
ceilMsatToSat(grossUpForMintFee(netMsat, fee))
ceilMsatToSat(grossUpForMintFee(netMsat, fee));
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat;
// Fee quotes are per-mint and change rarely; cache briefly by server.
// nulls are cached too - an unreachable mint shouldn't be retried on
// every render either.
const QUOTE_TTL_MS = 60_000
const QUOTE_TIMEOUT_MS = 5_000
const QUOTE_TTL_MS = 60_000;
const QUOTE_TIMEOUT_MS = 5_000;
const quoteCache = new Map<string, {at: number; fee: MintFee | null}>()
const quoteCache = new Map<string, { at: number; fee: MintFee | null }>();
// tests can reset the cache between cases
export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
export const clearMintFeeQuoteCache = (): void => quoteCache.clear();
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting
// an invoice - the same resolution chain as prepareMint (mint-address
@@ -55,30 +55,31 @@ export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
// reached right now.
export const quoteMintFee = async (
mintInput: string,
options: LnurlcashOptions = {}
options: LnurlcashOptions = {},
): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput)
if (!url) return null
const server = serverOf(url)
const cached = quoteCache.get(server)
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee
const url = resolveMintInput(mintInput);
if (!url) return null;
const server = serverOf(url);
const cached = quoteCache.get(server);
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee;
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options}
let payUrl = url
const addressUrl = mintAddressUrl(url)
const opts: LnurlcashOptions = { timeoutMs: QUOTE_TIMEOUT_MS, ...options };
let payUrl = url;
const addressUrl = mintAddressUrl(url);
if (addressUrl) {
try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink
} catch {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
} catch (error) {
// no mint-address support - the plain payRequest guess still works
if (!(error instanceof Error)) throw error;
}
}
let fee: MintFee | null
let fee: MintFee | null;
try {
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null;
} catch {
fee = null
fee = null;
}
quoteCache.set(server, {at: Date.now(), fee})
return fee
}
quoteCache.set(server, { at: Date.now(), fee });
return fee;
};
+20
View File
@@ -0,0 +1,20 @@
export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseJsonObject = (source: string): Record<string, unknown> => {
const parsed: unknown = JSON.parse(source);
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.');
return parsed;
};
export const parseJsonArray = (source: string): unknown[] => {
const parsed: unknown = JSON.parse(source);
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.');
return Array.from(parsed, (value: unknown) => value);
};
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
parseJsonArray(source).map((value) => {
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.');
return value;
});
@@ -0,0 +1,199 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { beforeEach, describe, expect, it } from 'vitest';
import {
decryptSavedWalletMaterial,
deriveWalletMaterial,
encryptSecretParts,
ensureSavedWalletMaterialOwner,
getPlainWalletMaterial,
isValidStoredWalletMaterial,
linkingPubKeyHex,
restoreWalletMaterialStored,
saveWalletMaterial,
serializeWalletMaterial,
savedWalletMaterialOwnerId,
walletMaterialLinkingKey,
WalletMaterialProofMismatchError,
} from './keys';
import { walletMaterialOwnerId } from './storage/storedSecret';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const OTHER_MNEMONIC =
'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const PASSWORD = 'correct horse battery staple';
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage();
});
describe('baseline: wallet material identity', () => {
it('derives identity only from the linking key when the cash root changes', () => {
const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC);
const alteredRoot = { ...material, cashRootHex: other.cashRootHex };
expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material));
expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material));
});
});
describe('saved wallet material commitment', () => {
it('stores the canonical material commitment on plaintext and encrypted saves', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const expectedHash = materialHash(serializeWalletMaterial(material));
await saveWalletMaterial(material);
const plaintext = readRawMaterialRecord();
await saveWalletMaterial(material, PASSWORD);
const encrypted = readRawMaterialRecord();
expect(plaintext.materialHash).toBe(expectedHash);
expect(encrypted.materialHash).toBe(expectedHash);
});
it('rejects authenticated encrypted material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) }),
);
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError,
);
});
it('rejects plaintext material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material);
const before = JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) });
localStorage.setItem(MATERIAL_STORAGE_KEY, before);
expect(getPlainWalletMaterial()).toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it('refuses to stamp same-owner material with an altered cash root', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
expect(() =>
ensureSavedWalletMaterialOwner({ ...material, cashRootHex: other.cashRootHex }),
).toThrow(WalletMaterialProofMismatchError);
});
it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const record = {
enc: false,
value: serialized,
materialHash: materialHash(serialized),
ownerId: walletMaterialOwnerId(material),
version: 2,
};
expect(isValidStoredWalletMaterial(record)).toBe(true);
expect(
isValidStoredWalletMaterial({ ...record, materialHash: record.materialHash.toUpperCase() }),
).toBe(false);
expect(
isValidStoredWalletMaterial({
enc: record.enc,
value: record.value,
ownerId: record.ownerId,
version: record.version,
}),
).toBe(false);
});
it('rejects malformed authenticated material after its correct commitment matches', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const malformed = JSON.stringify({ ...material, cashRootHex: '00'.repeat(64) });
const parts = await encryptSecretParts(malformed, PASSWORD);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: true,
...parts,
materialHash: materialHash(malformed),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
}),
);
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow('valid v2 wallet material');
});
it('preserves a verified restored commitment while discarding its owner claim', () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
restoreWalletMaterialStored({
enc: false,
value: serialized,
materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
});
expect(readRawMaterialRecord()).toEqual({
enc: false,
value: serialized,
materialHash: materialHash(serialized),
});
expect(getPlainWalletMaterial()).toEqual(material);
});
it('proves an encrypted restored commitment before owner stamping', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({
enc: true,
...parts,
materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
});
expect(savedWalletMaterialOwnerId()).toBeNull();
const proven = await decryptSavedWalletMaterial(PASSWORD);
ensureSavedWalletMaterialOwner(proven);
expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material));
});
it('rejects an unproven restored hash that contradicts encrypted material', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({
enc: true,
...parts,
materialHash: '00'.repeat(32),
ownerId: walletMaterialOwnerId(material),
version: 2,
});
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError,
);
expect(savedWalletMaterialOwnerId()).toBeNull();
});
});
@@ -0,0 +1,212 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { describe, expect, it } from 'vitest';
import {
decryptSavedWalletMaterial,
deriveWalletMaterial,
encryptSecretParts,
ensureSavedWalletMaterialOwner,
getPlainWalletMaterial,
isValidStoredWalletMaterial,
linkingPubKeyHex,
restoreWalletMaterialStored,
savedWalletMaterialExists,
savedWalletMaterialIsEncrypted,
savedWalletMaterialOwnerId,
saveWalletMaterial,
serializeWalletMaterial,
walletMaterialLinkingKey,
} from './keys';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const PASSWORD = 'correct horse battery staple';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const LEGACY_STORAGE_KEY = 'sattle_linking_key';
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw);
};
describe('WalletMaterialV2 persistence', () => {
it('round-trips canonical plaintext material in the new namespace', async () => {
// Given complete wallet material and an unrelated legacy record
stubLocalStorage();
localStorage.setItem(
LEGACY_STORAGE_KEY,
JSON.stringify({ enc: false, value: '07'.repeat(32) }),
);
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is saved without a password
await saveWalletMaterial(material);
// Then only the new namespace carries the exact complete material
expect(savedWalletMaterialExists()).toBe(true);
expect(savedWalletMaterialIsEncrypted()).toBe(false);
expect(getPlainWalletMaterial()).toEqual(material);
expect(readRawMaterialRecord()).toEqual({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
});
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC);
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"');
});
it('round-trips canonical password-encrypted material without plaintext fields', async () => {
// Given complete wallet material
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is password protected
await saveWalletMaterial(material, PASSWORD);
// Then only authenticated decryption returns both parts
expect(savedWalletMaterialIsEncrypted()).toBe(true);
expect(getPlainWalletMaterial()).toBeNull();
expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material);
await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow();
const raw = JSON.stringify(readRawMaterialRecord());
expect(raw).not.toContain(LINKING_KEY_HEX);
expect(raw).not.toContain(CASH_ROOT_HEX);
expect(raw).not.toContain(FIXED_MNEMONIC);
});
it('strips restored owner claims until the material proves its real owner', () => {
// Given plaintext material carrying a foreign owner claim
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9));
// When the record is restored and subsequently proven by plaintext unlock
restoreWalletMaterialStored({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: foreignOwner,
version: 2,
});
const beforeProof = readRawMaterialRecord();
const unlocked = getPlainWalletMaterial();
if (unlocked === null) throw new Error('expected restored plaintext material');
ensureSavedWalletMaterialOwner(unlocked);
// Then the claim was absent before proof and only the derived owner was stamped
expect(beforeProof).toEqual({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
});
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)));
});
it('rejects a plaintext owner mismatch without returning or stamping material', () => {
// Given canonical material stamped with a foreign current owner
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
}),
);
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When it is read or offered for proof
const unlocked = getPlainWalletMaterial();
const stamp = () => ensureSavedWalletMaterialOwner(material);
// Then neither operation trusts or rewrites the foreign record
expect(unlocked).toBeNull();
expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it('rejects an encrypted owner mismatch after authentication', async () => {
// Given valid encrypted material whose owner marker was replaced
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
const foreign = {
...readRawMaterialRecord(),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
};
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign));
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When the authenticated material contradicts the current owner marker
const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then no material is returned or marker changed
await expect(unlock).rejects.toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it.each([
['legacy key-only plaintext', LINKING_KEY_HEX, 2],
['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1],
[
'malformed material plaintext',
JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00' }),
2,
],
])('rejects %s records at the storage boundary', (_name, value, version) => {
// Given an invalid record under the v2 material namespace
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
const record = {
enc: false,
value,
materialHash: materialHash(value),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version,
};
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record));
// When storage helpers parse it
// Then the record is unusable as wallet material
expect(isValidStoredWalletMaterial(record)).toBe(false);
expect(savedWalletMaterialExists()).toBe(false);
expect(getPlainWalletMaterial()).toBeNull();
});
it('rejects authenticated ciphertext containing malformed material', async () => {
// Given valid AES-GCM parts whose plaintext is not a v2 material envelope
stubLocalStorage();
const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD);
const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: true,
...parts,
materialHash: materialHash(LINKING_KEY_HEX),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
}),
);
// When the password successfully authenticates the malformed plaintext
const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then it still fails closed as invalid material
await expect(unlock).rejects.toThrow('valid v2 wallet material');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { getPublicKey } from 'nostr-tools/pure';
import { cashNodeToHex } from 'lnurlcash-kit';
import {
deriveBearerAesKey,
deriveWalletLinkingKey,
deriveWalletMaterial,
linkingPubKeyHex,
parseWalletMaterial,
serializeWalletMaterial,
walletMaterialCashRoot,
walletMaterialLinkingKey,
} from './keys';
import { backupPubkey, deriveBackupKey } from './nostrBackup';
import { deriveNwcWalletKey, nwcWalletPubkey } from './nwc';
const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17));
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
describe('fixed mnemonic identity characterization', () => {
it('pins every public linking-key-derived identity and bearer encryption behavior', async () => {
// Given a mnemonic accepted by the existing wallet derivation
const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC);
const bearerKey = await deriveBearerAesKey(linkingKey);
// When its owner, bearer, Nostr, and NWC observables are derived
const bearerCiphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: new Uint8Array(12) },
bearerKey,
new TextEncoder().encode('sattle-vector-v1'),
),
);
// Then they match the pre-v2 wallet's independently captured vectors
expect(linkingPubKeyHex(linkingKey)).toBe(
'0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481',
);
expect(bytesToHex(bearerCiphertext)).toBe(
'4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78',
);
expect(backupPubkey(deriveBackupKey(linkingKey))).toBe(
'6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577',
);
expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe(
'3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587',
);
});
});
describe('WalletMaterialV2 derivation and serialization', () => {
it('derives the unchanged linking key and kit cash root from one mnemonic', () => {
// Given the fixed recovery mnemonic
// When its complete wallet material is derived
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// Then both independently captured vectors and the exact schema are stable
expect(material).toEqual({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX,
});
expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']);
expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC));
expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX);
});
it('round-trips only the canonical exact serialization', () => {
// Given valid v2 material
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When it crosses the serialization boundary
const serialized = serializeWalletMaterial(material);
// Then its bytes and parsed value are canonical
expect(serialized).toBe(
`{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`,
);
expect(parseWalletMaterial(serialized)).toEqual(material);
});
it.each([
['legacy key only', LINKING_KEY_HEX],
[
'wrong version',
JSON.stringify({ version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX }),
],
['missing field', JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX })],
[
'extra field',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX,
seed: 'forbidden',
}),
],
[
'uppercase linking key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX.toUpperCase(),
cashRootHex: CASH_ROOT_HEX,
}),
],
[
'truncated linking key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX.slice(2),
cashRootHex: CASH_ROOT_HEX,
}),
],
[
'uppercase cash root',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX.toUpperCase(),
}),
],
[
'truncated cash root',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX.slice(2),
}),
],
[
'invalid cash root private key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: '00'.repeat(32) + CASH_ROOT_HEX.slice(64),
}),
],
[
'non-canonical field order',
JSON.stringify({ cashRootHex: CASH_ROOT_HEX, linkingKeyHex: LINKING_KEY_HEX, version: 2 }),
],
])('rejects %s', (_name, serialized) => {
// Given malformed or non-canonical serialized material
// When it crosses the parser
const parsed = parseWalletMaterial(serialized);
// Then no trusted material is returned
expect(parsed).toBeNull();
});
});
+316
View File
@@ -0,0 +1,316 @@
// Saved linking-key record tests. The baseline describes pin the observable
// behavior of saveLinkingKey/decryptSavedLinkingKey/getPlainLinkingKey/
// restoreLinkingKeyStored as it existed before the owner marker (they must
// keep passing unchanged); the owner-marker describes cover the ownerId
// field that binds the saved key to its one proven wallet identity.
// Node env: in-memory localStorage stub, native WebCrypto.
import { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
decryptSavedLinkingKey,
encryptSecretParts,
ensureSavedKeyOwner,
getPlainLinkingKey,
linkingPubKeyHex,
restoreLinkingKeyStored,
savedKeyExists,
savedKeyIsEncrypted,
savedKeyOwnerMatches,
savedKeyOwnerId,
saveLinkingKey,
} from './keys';
import { isWalletOwnerId } from './storage/walletOwner';
import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.version.cases';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const PASSWORD = 'hunter2';
const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage();
});
// hand-written records in the pre-owner-marker shape - what every wallet
// created before this change has on disk
const saveLegacyPlaintext = (key: Uint8Array): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ enc: false, value: bytesToHex(key) }));
};
const saveLegacyEncrypted = async (
key: Uint8Array,
password: string,
): Promise<Record<string, unknown>> => {
const parts = await encryptSecretParts(bytesToHex(key), password);
const record: Record<string, unknown> = { enc: true, ...parts };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
return record;
};
describe('baseline: saved-key record behavior', () => {
it('saves a plaintext key and reads it back', async () => {
await saveLinkingKey(LINKING_KEY);
expect(savedKeyExists()).toBe(true);
expect(savedKeyIsEncrypted()).toBe(false);
expect(readRawRecord()).toMatchObject({ enc: false, value: bytesToHex(LINKING_KEY) });
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
});
it('saves a password-encrypted key and decrypts it with the password', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyIsEncrypted()).toBe(true);
const record = readRawRecord();
expect(record.enc).toBe(true);
expect(record.value).toBeUndefined();
// an encrypted record never reads through the plaintext path
expect(getPlainLinkingKey()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('rejects the wrong password via the GCM auth tag', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD);
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow();
});
it('throws when asked to decrypt a plaintext record', async () => {
await saveLinkingKey(LINKING_KEY);
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
'No encrypted linking key saved.',
);
});
it('restores an ownerless record verbatim and reads it back', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
const record = { enc: true as const, ...parts };
restoreLinkingKeyStored(record);
expect(readRawRecord()).toEqual(record);
expect(savedKeyIsEncrypted()).toBe(true);
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('drops a malformed stored record instead of trusting it', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }),
);
expect(savedKeyExists()).toBe(false);
localStorage.setItem(STORAGE_KEY, 'not json');
expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull();
});
});
describe('owner marker on new writes', () => {
it('matches the canonical owner derived from the saved linking key', async () => {
// Given a newly saved owner-bearing key
await saveLinkingKey(LINKING_KEY);
// When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY);
// Then the saved owner matches
expect(matches).toBe(true);
});
it('does not match a different linking key', async () => {
// Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY);
// When a foreign freshly derived linking key is compared
const matches = savedKeyOwnerMatches(OTHER_KEY);
// Then the foreign key is rejected
expect(matches).toBe(false);
});
it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
await saveLinkingKey(LINKING_KEY);
const plainOwner = savedKeyOwnerId();
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBe(plainOwner);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
});
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
await saveLinkingKey(LINKING_KEY);
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/);
});
});
describe('owner marker on legacy records', () => {
it('reads a legacy record without ownerId as ownerless', async () => {
saveLegacyPlaintext(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull();
// the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('rejects malformed owner-bearing records instead of treating them as legacy', () => {
const real = linkingPubKeyHex(LINKING_KEY);
const junk: unknown[] = [
real.slice(1), // wrong length (65)
real + '00', // wrong length (68)
real.toUpperCase(), // uppercase hex
'zz' + real.slice(2), // non-hex
'04' + real.slice(2), // not a compressed-pubkey prefix
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point
42, // wrong type
null,
{ pubkey: real },
'',
];
for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
// the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull();
// ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull();
}
});
it('stamps the owner after a password unlock without touching the ciphertext', async () => {
// Given a legacy encrypted record with no owner marker
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
// When ownership is proven by a successful unlock and then stamped
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey);
// Then the marker names the proven key and every ciphertext byte is
// preserved
const after = readRawRecord();
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
expect(after.ciphertext).toBe(before.ciphertext);
expect(after.salt).toBe(before.salt);
expect(after.iv).toBe(before.iv);
});
it('stamps a plaintext legacy record after a plaintext unlock', () => {
saveLegacyPlaintext(LINKING_KEY);
const linkingKey = getPlainLinkingKey();
if (linkingKey === null) throw new Error('expected a plaintext key');
ensureSavedKeyOwner(linkingKey);
expect(readRawRecord()).toEqual({
enc: false,
value: bytesToHex(LINKING_KEY),
version: 1,
ownerId: linkingPubKeyHex(LINKING_KEY),
});
});
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey);
const afterFirst = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(linkingKey);
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst);
expect(readRawRecord().ciphertext).toBe(before.ciphertext);
});
it('writes nothing when a new-format record is already correctly stamped', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD);
const raw = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(LINKING_KEY);
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw);
});
it('refuses to restamp a record owned by a different wallet', async () => {
await saveLinkingKey(OTHER_KEY, PASSWORD);
const before = localStorage.getItem(STORAGE_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
// the failed stamp leaves the record - marker included - untouched
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY));
});
it('refuses to stamp a key that contradicts a plaintext record', () => {
saveLegacyPlaintext(OTHER_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
expect(savedKeyOwnerId()).toBeNull();
});
it('does not adopt a record carrying a junk owner marker', () => {
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }),
);
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull();
expect(savedKeyExists()).toBe(false);
});
it('is a no-op when no record is saved at all', () => {
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyExists()).toBe(false);
});
});
describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => {
// a backup file can claim any marker - only a freshly derived key may
// establish ownership, so restore installs the secret parts alone
restoreLinkingKeyStored({
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY),
});
expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
// the first proven unlock then establishes the true owner
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
});
it('strips a junk ownerId from a restored encrypted record', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
restoreLinkingKeyStored({ enc: true, ...parts, ownerId: 42 });
expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
});
describe('isWalletOwnerId', () => {
it('accepts exactly what linkingPubKeyHex produces', () => {
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true);
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true);
});
it('rejects everything else', () => {
const real = linkingPubKeyHex(LINKING_KEY);
expect(isWalletOwnerId(real.toUpperCase())).toBe(false);
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false);
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false);
expect(isWalletOwnerId(66)).toBe(false);
expect(isWalletOwnerId(undefined)).toBe(false);
expect(isWalletOwnerId(null)).toBe(false);
});
});
+187 -212
View File
@@ -1,288 +1,263 @@
import { mnemonicToSeedSync, generateMnemonic, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey, HARDENED_OFFSET } from '@scure/bip32';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode } from 'lnurlcash-kit';
import {
mnemonicToSeedSync,
generateMnemonic,
validateMnemonic
} from '@scure/bip39'
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {HDKey, HARDENED_OFFSET} from '@scure/bip32'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {secp256k1} from '@noble/curves/secp256k1.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
parseStoredSecret,
stampStoredSecretOwner,
storedSecretClaimedOwnerId,
storedSecretOwnerId,
stripStoredSecretOwner,
STORED_SECRET_VERSION,
type StoredSecret,
type WalletMaterialV2,
} from './storage/storedSecret';
import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
export {
isValidStoredSecret,
isValidStoredWalletMaterial,
parseWalletMaterial,
serializeWalletMaterial,
walletMaterialHash,
} from './storage/storedSecret';
export type { StoredSecret, StoredWalletMaterial, WalletMaterialV2 } from './storage/storedSecret';
export { decryptSecretParts, encryptSecretParts } from './passwordWrap';
export type { EncryptedSecretParts } from './passwordWrap';
export * from './walletMaterialStorage';
// The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where
// this static build happens to be hosted - github.io, a mirror, file://.
export const WALLET_DOMAIN = 'sattle'
export const WALLET_DOMAIN = 'sattle';
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128)
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128);
export const isValidSeedPhrase = (phrase: string): boolean =>
validateMnemonic(phrase.trim().toLowerCase(), wordlist)
validateMnemonic(phrase.trim().toLowerCase(), wordlist);
const readUint32BE = (bytes: Uint8Array, offset: number): number =>
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0
0;
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
// a seed restored there or here produces the same identity for a given domain
export const deriveLud05LinkingKey = (
seedPhrase: string,
domain: string
): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
const master = HDKey.fromMasterSeed(seed)
const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
const master = HDKey.fromMasterSeed(seed);
const hashingKeyNode = master.derive("m/138'/0")
if (!hashingKeyNode.privateKey)
throw new Error('Could not derive hashing key')
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
const hashingKeyNode = master.derive("m/138'/0");
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key');
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain);
// path suffix longs are raw BIP32 child indices: whether each level ends up
// hardened depends solely on its own magnitude (>= 2^31), never forced
let node = master.deriveChild(138 + HARDENED_OFFSET)
let node = master.deriveChild(138 + HARDENED_OFFSET);
for (const index of suffix) {
node = node.deriveChild(index)
node = node.deriveChild(index);
}
if (!node.privateKey) throw new Error('Could not derive linking key')
return node.privateKey
}
if (!node.privateKey) throw new Error('Could not derive linking key');
return node.privateKey;
};
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array =>
deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain);
// the HMAC half of the derivation, split out so the LUD-05 test vector
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
// directly - see keys.test.ts
export const lud05PathSuffix = (
hashingKey: Uint8Array,
domain: string
): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
return [0, 4, 8, 12].map(i => readUint32BE(material, i))
}
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain));
return [0, 4, 8, 12].map((i) => readUint32BE(material, i));
};
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN);
export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase());
try {
return {
version: 2,
linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)),
cashRootHex: cashNodeToHex(deriveCashRoot(seed)),
};
} finally {
seed.fill(0);
}
};
export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array =>
hexToBytes(material.linkingKeyHex);
export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode =>
cashNodeFromHex(material.cashRootHex);
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true))
// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the
// stored value is either plaintext or, if the holder opted in with a
// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password -
// GCM's auth tag doubles as the "wrong password" check on decrypt.
const PBKDF2_ITERATIONS = 210_000
export type StoredSecret =
| {enc: false; value: string}
| {enc: true; salt: string; iv: string; ciphertext: string}
// strict shape check on a StoredSecret - a plaintext form must be exactly a
// 32-byte hex key, an encrypted form must carry hex salt/iv/ciphertext of
// the sizes encryptSecretParts produces. Guards the backup-restore path
// (storage.ts's applyBackup), where a crafted file would otherwise get an
// arbitrary "linking key" installed verbatim.
export const isValidStoredSecret = (
stored: unknown
): stored is StoredSecret => {
if (typeof stored !== 'object' || stored === null) return false
const s = stored as Record<string, unknown>
if (s.enc === false) {
return typeof s.value === 'string' && /^[0-9a-f]{64}$/i.test(s.value)
}
if (s.enc === true) {
return (
typeof s.salt === 'string' &&
/^[0-9a-f]{32}$/i.test(s.salt) &&
typeof s.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(s.iv) &&
typeof s.ciphertext === 'string' &&
s.ciphertext.length > 0 &&
s.ciphertext.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(s.ciphertext)
)
}
return false
}
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true));
const readSecret = (storageKey: string): StoredSecret | null => {
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw)
return isValidStoredSecret(parsed) ? parsed : null
const parsed: unknown = JSON.parse(raw);
return parseStoredSecret(parsed)?.secret ?? null;
} catch {
return null
return null;
}
}
};
const deriveAesKeyFromPassword = (
password: string,
salt: Uint8Array
): Promise<CryptoKey> =>
crypto.subtle
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
.then(baseKey =>
crypto.subtle.deriveKey(
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
{
name: 'PBKDF2',
salt: new Uint8Array(salt),
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256'
},
baseKey,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt']
)
)
// The legacy alpha lifecycle below persists only the linking key under its old
// namespace. Task 5 replaces that lifecycle with the v2 material repository;
// these APIs stay byte-compatible until the serialized reset can switch every
// caller atomically rather than creating a split-key wallet.
//
// The record also carries an ownerId marker: the lowercase compressed
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
// other wallet-owned record (passkeys, NWC, trusted mints) to this exact
// identity. Failure modes of the marker API:
// - new writes always carry the marker derived from the key being saved;
// - a record restored from a backup/relay is installed OWNERLESS - its
// file-carried marker is an unproven claim and is stripped on restore;
// - an ownerless legacy record stays usable but cannot establish ownership;
// malformed or unsupported owner-bearing metadata rejects the whole record;
// - ensureSavedKeyOwner stamps the marker after the caller proved the key
// (successful password/plaintext unlock or matching biometric unwrap),
// preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record.
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null;
export type EncryptedSecretParts = {
salt: string
iv: string
ciphertext: string
}
export const encryptSecretParts = async (
value: string,
password: string
): Promise<EncryptedSecretParts> => {
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const aesKey = await deriveAesKeyFromPassword(password, salt)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
aesKey,
utf8ToBytes(value)
)
)
return {
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext)
}
}
// rejects (WebCrypto's own auth-tag check) if the password is wrong
export const decryptSecretParts = async (
parts: EncryptedSecretParts,
password: string
): Promise<string> => {
const salt = hexToBytes(parts.salt)
const iv = hexToBytes(parts.iv)
const aesKey = await deriveAesKeyFromPassword(password, salt)
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
aesKey,
hexToBytes(parts.ciphertext)
)
return new TextDecoder().decode(plaintext)
}
// The linking key is the only secret this wallet persists - the seed phrase
// it was derived from is shown once at setup and never stored. Everything
// else at rest (the bearer tokens) is encrypted with a key derived from it,
// so protecting this one record with a password protects the whole wallet.
const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
export const savedKeyExists = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyIsEncrypted = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true;
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY)
readSecret(LINKING_KEY_STORAGE_KEY);
// the proven owner of the saved key, or null when there is no record or it
// carries no current version-1 marker (ownerless or compatible unversioned)
export const savedKeyOwnerId = (): string | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
return stored === null ? null : storedSecretOwnerId(stored);
};
// Compares only against an owner freshly derived from a linking key. An
// ownerless or unversioned saved marker never matches.
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
savedKeyOwnerId() === linkingPubKeyHex(linkingKey);
// Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext
// read / a biometric unwrap whose stored pubkey matched) - the marker is
// derived from that key, never from a stored claim. No saved record or an
// already-correct marker: no write. A DIFFERENT valid owner, or a key that
// contradicts a plaintext record, throws and leaves storage untouched.
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null) return;
const ownerId = linkingPubKeyHex(linkingKey);
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) {
throw new Error('Proven key does not match the saved wallet key.');
}
const claimedOwnerId = storedSecretClaimedOwnerId(stored);
if (storedSecretOwnerId(stored) === ownerId) return;
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
throw new Error('Saved wallet key is owned by a different wallet.');
}
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
);
};
export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null || stored.enc === true) return null
return hexToBytes(stored.value)
}
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null || stored.enc === true) return null;
return hexToBytes(stored.value);
};
export const saveLinkingKey = async (
linkingPrivKey: Uint8Array,
password?: string
password?: string,
): Promise<void> => {
const hex = bytesToHex(linkingPrivKey)
const hex = bytesToHex(linkingPrivKey);
const ownerId = linkingPubKeyHex(linkingPrivKey);
if (!password) {
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex})
)
return
JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }),
);
return;
}
const parts = await encryptSecretParts(hex, password)
const parts = await encryptSecretParts(hex, password);
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: true, ...parts})
)
}
JSON.stringify({ enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION }),
);
};
// installs a record from a backup/relay. Any ownerId it carries is an
// unproven claim by whoever produced that file, so the marker is stripped
// here - the first proven unlock re-establishes it (see the failure-model
// comment above)
export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored))
}
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)));
};
export const decryptSavedLinkingKey = async (
password: string
): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
return hexToBytes(await decryptSecretParts(stored, password))
}
export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.');
return hexToBytes(await decryptSecretParts(stored, password));
};
export const clearSavedLinkingKey = (): void => {
localStorage.removeItem(LINKING_KEY_STORAGE_KEY)
}
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
};
// The bearer-encryption key is derived (not random): sha256 over the linking
// key plus a fixed context string. Deterministic derivation is what makes
// backup/restore work with nothing but the seed phrase - restore the seed on
// a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1';
export const deriveBearerAesKey = (
linkingPrivKey: Uint8Array
): Promise<CryptoKey> => {
const material = sha256(
new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])
)
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, [
'encrypt',
'decrypt'
])
}
export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]));
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']);
};
export type EncryptedRecordParts = {iv: string; ciphertext: string}
export type EncryptedRecordParts = { iv: string; ciphertext: string };
export const encryptRecord = async (
aesKey: CryptoKey,
value: object
value: object,
): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
{ name: 'AES-GCM', iv },
aesKey,
utf8ToBytes(JSON.stringify(value))
)
)
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
}
utf8ToBytes(JSON.stringify(value)),
),
);
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
export const decryptRecord = async <T>(
export const decryptRecord = async (
aesKey: CryptoKey,
parts: EncryptedRecordParts
): Promise<T> => {
parts: EncryptedRecordParts,
): Promise<unknown> => {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: hexToBytes(parts.iv)},
{ name: 'AES-GCM', iv: hexToBytes(parts.iv) },
aesKey,
hexToBytes(parts.ciphertext)
)
return JSON.parse(new TextDecoder().decode(plaintext)) as T
}
hexToBytes(parts.ciphertext),
);
return JSON.parse(new TextDecoder().decode(plaintext));
};
+106
View File
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
ensureSavedKeyOwner,
getPlainLinkingKey,
isValidStoredSecret,
linkingPubKeyHex,
savedKeyExists,
savedKeyOwnerId,
saveLinkingKey,
} from './keys';
import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.material.cases';
import './keys.material-commitment.cases';
import './keys.material-storage.cases';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage();
});
describe('saved-key schema version', () => {
it('writes version 1 on current plaintext and encrypted records', async () => {
// Given a linking key saved through each current persistence path
await saveLinkingKey(LINKING_KEY);
const plaintext = readRawRecord();
await saveLinkingKey(LINKING_KEY, 'correct horse');
const encrypted = readRawRecord();
// When the persisted schema metadata is inspected
// Then both owner-bearing records carry the recognized discriminator
expect(plaintext.version).toBe(1);
expect(encrypted.version).toBe(1);
});
it('upgrades an unversioned same-owner record only after key proof', () => {
// Given the valid owner-bearing shape written before schema versioning
const ownerId = linkingPubKeyHex(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId }),
);
// When it is read before and then stamped after the plaintext key proves ownership
expect(savedKeyOwnerId()).toBeNull();
const provenKey = getPlainLinkingKey();
if (provenKey === null) throw new Error('expected the compatible plaintext key');
ensureSavedKeyOwner(provenKey);
// Then it becomes an explicitly versioned current record
expect(readRawRecord()).toEqual({
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId,
version: 1,
});
});
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
// Given an otherwise valid owner-bearing record with unrecognized metadata
const record = {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY),
version,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
// When the saved-key boundary parses it
// Then the record is neither usable nor eligible for legacy adoption
expect(isValidStoredSecret(record)).toBe(false);
expect(savedKeyExists()).toBe(false);
expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toBeNull();
});
it('rejects a foreign current owner at the proven-key stamping boundary', () => {
// Given a versioned record whose owner conflicts with its plaintext key
const record = {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
const before = localStorage.getItem(STORAGE_KEY);
// When the actual key is proven
const stamp = () => ensureSavedKeyOwner(LINKING_KEY);
// Then the foreign claim fails closed without rewriting storage
expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
});
});
+306
View File
@@ -0,0 +1,306 @@
// Wire-contract coverage for the pinned lnurlcash-kit 0.8.0 /
// lnurlcash-conformance 0.7.0 artifacts. Contracts the app's mint flows
// rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
// the typed field read undefined forever); a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
// mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`)
// only when the payRequest advertises commentAllowed >= 64. The wallet's
// output secret must be durably staged before that quote request is sent;
// mintToHash alone is only an additive receipt extension, not permission
// to mint.
import { afterEach, describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { buildNoteUrl, fetchMintAddress, hashK1 } from 'lnurlcash-kit';
import { claimMintedNote, prepareMint } from './ops';
import { mintAddressCacheInfo } from './trustedMints';
import './mintContract.v08.cases';
type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()));
});
// paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step)
const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1);
if (!paymentHash) throw new Error('no invoice requested yet');
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`);
const invoice = m.state.invoices.get(paymentHash);
if (!invoice) throw new Error('settled invoice vanished from the mock');
return invoice.preimage;
};
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path
let allocatedCounter = 0;
const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => {
allocatedCounter += 1;
return `03${allocatedCounter.toString(16).padStart(62, '0')}`;
});
return Promise.resolve(secrets);
};
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// node stats under their WIRE names - `nodeCapacity` is msat like every
// other amount, named without the suffix on the wire
const mintAddressFixture = {
tag: 'withdrawRequest',
callback: 'https://mint.example/w/cb',
minWithdrawable: 1_000,
maxWithdrawable: 100_000_000,
defaultDescription: 'fixture mint',
payLink: 'https://mint.example/.well-known/lnurlp/mint',
mintPubkey: MINT_PUBKEY,
nodeAlias: 'fixture-mint',
nodeCapacity: 500_000_000,
nodeNumChannels: 4,
nodeNumPeers: 6,
};
const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
// a fetch that serves fixture bodies by URL prefix and 404s everything else,
// so a test drives the real kit HTTP boundary without any network
const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => {
const impl: typeof fetch = (input, _init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
for (const [prefix, body] of routes) {
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body));
}
return Promise.resolve(new Response('not found', { status: 404 }));
};
return impl;
};
// fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire
const capturingFetch = (
routes: ReadonlyArray<readonly [string, unknown]>,
seen: string[],
): typeof fetch => {
const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
seen.push(url);
return fixtureFetch(routes)(input, init);
};
return impl;
};
describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
});
// renamed fields have to be mapped, not spread: the spread carries the
// wire name through and the typed one reads undefined forever
expect(info.nodePubkey).toBe(MINT_PUBKEY);
expect(info.nodeCapacityMsat).toBe(500_000_000);
expect(info.nodeNumChannels).toBe(4);
expect(info.nodeNumPeers).toBe(6);
});
it('carries node stats into the cached trusted-mint display metadata', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
});
const cached = mintAddressCacheInfo(info, 'mint');
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
expect(cached?.nodeNumChannels).toBe(4);
expect(cached?.nodeNumPeers).toBe(6);
});
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
const m = await mint();
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
// the metadata is advertised at the mint-address endpoint itself -
// the payRequest never carried it
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey);
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000);
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4);
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6);
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username);
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
});
});
describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
// conformance 0.7.0 defaults to plain https links; lnurlw:// is an
// explicit LUD-17 transport form
const m = await mint({ testHooks: true, withdrawLinkForm: 'lnurlw' });
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//);
// and the link is fully usable: settle the invoice, claim the note
const preimage = await settleLastInvoice(m);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
});
expect(claimed.note.amount).toBe(21_000);
expect(m.state.noteState(preimage)).toBeNull();
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding');
});
it('accepts an HTTPS withdraw link', async () => {
const fetch = fixtureFetch([
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
[
'https://mint.example/.well-known/lnurlp/mint',
{
tag: 'payRequest',
callback: 'https://mint.example/pay',
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
commentAllowed: 64,
metadata: '[]',
},
],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
]);
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch,
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toBe('https://mint.example/note');
// the mint-address payLink is authoritative - the payRequest came from it
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint');
});
it('builds the same note URL from both withdraw-link forms', () => {
const k1 = 'ab'.repeat(32);
expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe(
buildNoteUrl('https://mint.example/note', k1, 21_000),
);
});
});
describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({
tag: 'payRequest',
callback: 'https://mint.example/pay',
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
metadata: '[]',
...extra,
});
const routesFor = (payRequest: Record<string, unknown>) =>
[
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
] as const;
const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'));
if (!hit) throw new Error('the quote request never hit the wire');
return new URL(hit);
};
it('rejects a mint that cannot carry the mandatory output commitment', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
persistOutput,
allocateOutputSecrets,
}),
).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
it('persists the wallet output before sending its hash as comment and h', async () => {
const seen: string[] = [];
const events: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, {
allocateOutputSecrets,
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery');
return capturingFetch(
routesFor(payRequestFixture({ commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}` })),
seen,
)(input, init);
},
persistOutput: async (note) => {
events.push('persist');
expect(note.verified).toBe(false);
expect(note.callback).toBe('');
expect(note.amount).toBe(0);
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY);
},
});
const noteSecret = prepared.noteSecret;
const url = callbackUrl(seen);
// both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment');
const h = url.searchParams.get('h');
expect(comment).toMatch(/^[0-9a-f]{64}$/);
expect(h).toBe(comment);
expect(comment).toBe(hashK1(noteSecret));
expect(prepared.mintPubkey).toBe(MINT_PUBKEY);
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0);
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'));
});
it('does not request an invoice when output persistence fails', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen),
persistOutput: () => Promise.reject(new Error('storage full')),
allocateOutputSecrets,
}),
).rejects.toThrow(/storage full/);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
it('rejects mintToHash without the mandatory comment capacity', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
persistOutput,
allocateOutputSecrets,
}),
).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
// The wallet's deterministic-secret and signed-output work depends on this
// exact installed API surface, so package drift must fail before funds code runs.
import { describe, expect, it } from 'vitest';
import {
cashNodeFromHex,
cashNodeToHex,
cashSecretSource,
deriveCashRoot,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
} from 'lnurlcash-kit';
import type { LnurlcashOptions } from 'lnurlcash-kit';
import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json';
import kitPackage from '../../node_modules/lnurlcash-kit/package.json';
const retryOptions = { mutationRetries: 1 } satisfies LnurlcashOptions;
const requiredExports = [
deriveCashRoot,
cashNodeToHex,
cashNodeFromHex,
cashSecretSource,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
] as const;
describe('lnurlcash 0.8 dependency contract', () => {
it('exposes the BIP-32, recovery, signature, and retry contracts', () => {
expect(requiredExports.every((value) => typeof value === 'function')).toBe(true);
expect(retryOptions.mutationRetries).toBe(1);
});
it('runs against the exact installed package versions', () => {
expect(kitPackage.version).toBe('0.8.0');
expect(conformancePackage.version).toBe('0.7.0');
});
});
+117 -102
View File
@@ -9,33 +9,36 @@
//
// Three d-tags carry three payloads in separate replaceable slots, so a
// notes publish never clobbers settings:
// notes - the encrypted bearer records exactly as they sit in
// localStorage (each note is already an AES-GCM ciphertext
// under the seed-derived bearer key, so the blob goes up
// as-is; the linking key itself is NEVER part of a payload -
// the seed phrase is its recovery path)
// notes - the funds projection: encrypted bearer records exactly as
// they sit in the funds document (each note is already an
// AES-GCM ciphertext under the seed-derived bearer key, so the
// blob goes up as-is; the linking key itself is NEVER part of a
// payload - the seed phrase is its recovery path) plus the
// per-host BIP-32 next-unused counters, so a restored device
// never reissues a burned index. Pending journal state is
// device-local recovery data and is never published.
// mints - the trusted-mint registry
// settings - plaintext wallet settings
import {sha256} from '@noble/hashes/sha2.js'
import {utf8ToBytes} from '@noble/hashes/utils.js'
import type {Event as NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import type { Event as NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import type {EncryptedBearerRecord} from '../storage/bearers'
import type {TrustedMint} from '../trustedMints'
import type {WalletSettings} from '../storage/settings'
import type { EncryptedBearerRecord } from '../storage/bearers';
import type { TrustedMint } from '../trustedMints';
import type { WalletSettings } from '../storage/settings';
export type {NostrEvent}
export type { NostrEvent };
// addressable (parametrized-replaceable) app-data event - relays keep only
// the newest event per (pubkey, kind, d-tag)
export const BACKUP_EVENT_KIND = 30078
export const BACKUP_EVENT_KIND = 30078;
export type BackupPart = 'notes' | 'mints' | 'settings'
export type BackupPart = 'notes' | 'mints' | 'settings';
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings']
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'];
// the backup key is dedicated to this wallet (derived with a
// sattle-specific context, see below), so its pubkey is ours alone and the
@@ -43,153 +46,165 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'
export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes',
mints: 'mints',
settings: 'settings'
}
settings: 'settings',
};
// the notes payload: bearers plus the counter map, from one consistent
// funds-document read
export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number>;
};
// the decrypted payload of each part, without its envelope
export type BackupPartPayload = {
notes: EncryptedBearerRecord[]
mints: TrustedMint[]
settings: WalletSettings
}
notes: NotesBackupPayload;
mints: TrustedMint[];
settings: WalletSettings;
};
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1'
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1';
// Deterministic: sha256(linking key || context), mirroring keys.ts's
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
// backup - it signs and decrypts backup events, nothing else.
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]))
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]));
// the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string =>
getPublicKey(secretKey)
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey);
// NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else
const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
// a relay could serve a multi-megabyte content string; JSON.parse and
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of
// kilobytes per part - this is far beyond generous (applyBackup's own
// per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024;
export const dTagOf = (event: NostrEvent): string =>
event.tags.find(t => t[0] === 'd')?.[1] ?? ''
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
const envelopeFor = (
part: BackupPart,
payload: BackupPartPayload[BackupPart]
): string => {
// per-part envelope versions: the notes part went v2 with the funds
// document (counters joined the payload - a breaking change); mints and
// settings envelopes are unchanged
const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
notes: 2,
mints: 1,
settings: 1,
};
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) {
case 'notes':
return JSON.stringify({version: 1, bearers: payload})
case 'notes': {
const notes = payload as NotesBackupPayload;
return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
}
case 'mints':
return JSON.stringify({version: 1, trustedMints: payload})
return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings':
return JSON.stringify({version: 1, settings: payload})
return JSON.stringify({ version: 1, settings: payload });
}
}
};
// signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>(
secretKey: Uint8Array,
part: P,
payload: BackupPartPayload[P],
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: createdAt,
tags: [['d', BACKUP_D_TAGS[part]]],
content: nip44v2.encrypt(
envelopeFor(part, payload),
selfConversationKey(secretKey)
)
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey
)
secretKey,
);
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
secretKey: Uint8Array,
parts: Partial<BackupPartPayload>,
createdAt?: number
createdAt?: number,
): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000)
const events: NostrEvent[] = []
const at = createdAt ?? Math.floor(Date.now() / 1000);
const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) {
const payload = parts[part]
if (payload === undefined) continue
events.push(buildBackupEvent(secretKey, part, payload, at))
const payload = parts[part];
if (payload === undefined) continue;
events.push(buildBackupEvent(secretKey, part, payload, at));
}
return events
}
return events;
};
export type ParsedBackupEvent =
| {part: 'notes'; bearers: EncryptedBearerRecord[]}
| {part: 'mints'; trustedMints: TrustedMint[]}
| {part: 'settings'; settings: WalletSettings}
| { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
| { part: 'mints'; trustedMints: TrustedMint[] }
| { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, pubkey patterns) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file
// (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / commitFundsRestore on the restore path, same as file
// backups.
const parsePayload = (
dTag: BackupPart,
data: unknown
): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null
const envelope = data as Record<string, unknown>
if (envelope.version !== 1) return null
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null;
const envelope = data as Record<string, unknown>;
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) {
case 'notes': {
if (!Array.isArray(envelope.bearers)) return null
const bearers = envelope.bearers as unknown[]
if (!Array.isArray(envelope.bearers)) return null;
const bearers = envelope.bearers as unknown[];
if (
!bearers.every(
r =>
(r) =>
typeof (r as EncryptedBearerRecord)?.id === 'string' &&
typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string'
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
)
) {
return null
return null;
}
return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]}
if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
const counters = envelope.nextByHost as Record<string, unknown>;
if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
return {
part: 'notes',
bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>,
};
}
case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null
const mints = envelope.trustedMints as unknown[]
if (!Array.isArray(envelope.trustedMints)) return null;
const mints = envelope.trustedMints as unknown[];
if (
!mints.every(
m =>
(m) =>
typeof (m as TrustedMint)?.server === 'string' &&
typeof (m as TrustedMint)?.mintPubkey === 'string'
typeof (m as TrustedMint)?.mintPubkey === 'string',
)
) {
return null
return null;
}
return {part: 'mints', trustedMints: mints as TrustedMint[]}
return { part: 'mints', trustedMints: mints as TrustedMint[] };
}
case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
return null
return null;
}
const settings = envelope.settings as Record<string, unknown>
if (
settings.defaultMint !== undefined &&
typeof settings.defaultMint !== 'string'
) {
return null
const settings = envelope.settings as Record<string, unknown>;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null;
}
return {part: 'settings', settings: settings as WalletSettings}
return { part: 'settings', settings: settings as WalletSettings };
}
}
}
};
// Validates and decrypts one backup event. Returns null for anything that
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
@@ -197,25 +212,25 @@ const parsePayload = (
// skip nulls - one junk event must never sink a restore.
export const parseBackupEvent = (
secretKey: Uint8Array,
event: NostrEvent
event: NostrEvent,
): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null
if (event.pubkey !== getPublicKey(secretKey)) return null
const dTag = dTagOf(event)
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null
if (!verifyEvent(event)) return null
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null
let plaintext: string
if (event.kind !== BACKUP_EVENT_KIND) return null;
if (event.pubkey !== getPublicKey(secretKey)) return null;
const dTag = dTagOf(event);
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
if (!verifyEvent(event)) return null;
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
let plaintext: string;
try {
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey))
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch {
return null
return null;
}
let data: unknown
let data: unknown;
try {
data = JSON.parse(plaintext)
data = JSON.parse(plaintext);
} catch {
return null
return null;
}
return parsePayload(dTag, data)
}
return parsePayload(dTag, data);
};
+39 -37
View File
@@ -2,74 +2,76 @@
// edits (a mint plus a receive plus a reorder) collapse into one publish
// of the final state. Trailing-edge debounce with in-flight coalescing.
import type {BackupPartPayload} from './events'
import type { BackupPartPayload } from './events';
export type BackupPublisher = {
// record that local state changed - the latest snapshot wins, and only
// one publish fires per quiet window no matter how many changes landed
schedule: (parts: Partial<BackupPartPayload>) => void
schedule: (parts: Partial<BackupPartPayload>) => void;
// publish any pending snapshot immediately (app backgrounding, logout)
flush: () => Promise<void>
flush: () => Promise<void>;
// drop any pending snapshot without publishing
cancel: () => void
}
cancel: () => void;
};
export type BackupPublisherOptions = {
publish: (parts: Partial<BackupPartPayload>) => Promise<void>
delayMs: number
publish: (parts: Partial<BackupPartPayload>) => Promise<void>;
delayMs: number;
// a debounced publish has no caller to throw to - errors surface here;
// the next scheduled change retries
onError?: (error: unknown) => void
}
onError?: (error: unknown) => void;
};
export const createBackupPublisher = (
options: BackupPublisherOptions
): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null
let pending: Partial<BackupPartPayload> | null = null
let running: Promise<void> | null = null
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Partial<BackupPartPayload> | null = null;
let running: Promise<void> | null = null;
const clearTimer = (): void => {
if (timer !== null) {
clearTimeout(timer)
timer = null
clearTimeout(timer);
timer = null;
}
}
};
const drain = async (): Promise<void> => {
// a change can land mid-publish - keep looping until the newest
// snapshot is the one that's out
while (pending !== null) {
const snapshot = pending
pending = null
const snapshot = pending;
pending = null;
try {
await options.publish(snapshot)
await options.publish(snapshot);
} catch (error) {
options.onError?.(error)
options.onError?.(
error instanceof Error
? error
: new Error('Backup publication failed.', { cause: error }),
);
}
}
}
};
const fire = (): Promise<void> => {
clearTimer()
clearTimer();
// an in-flight drain picks up anything pending itself - a second drain
// would double-publish the same snapshot
running ??= drain().finally(() => {
running = null
})
return running
}
running = null;
});
return running;
};
return {
schedule: parts => {
pending = parts
clearTimer()
timer = setTimeout(() => void fire(), options.delayMs)
schedule: (parts) => {
pending = parts;
clearTimer();
timer = setTimeout(() => void fire(), options.delayMs);
},
flush: fire,
cancel: () => {
clearTimer()
pending = null
}
}
}
clearTimer();
pending = null;
},
};
};
+77 -79
View File
@@ -2,20 +2,17 @@
//
// v1 is single-device last-writer-wins at the relay (addressable events
// replace). Restore merges locally through storage/backup.ts's applyBackup
// - the SAME entry point as file restore (union by record id, mints merged
// unconfirmed, settings fill-only) - so the two restore paths can't drift
// apart. The note-level dedupe (same note under different record ids,
// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly
// as with a file backup.
// - the SAME entry point as file restore (union by record id, counters
// merged upward-only, mints merged unconfirmed, settings fill-only) - so the
// two restore paths can't drift apart. The note-level dedupe (same note
// under different record ids, spent-wins) happens after decrypt in
// bearers.ts's mergeBearers, exactly as with a file backup.
import type {RestoreResult} from '../storage/backup'
import {applyBackup} from '../storage/backup'
import type { RestoreResult } from '../storage/backup';
import { applyBackup } from '../storage/backup';
import { linkingPubKeyHex } from '../keys';
import type {
BackupPart,
BackupPartPayload,
NostrEvent
} from './events'
import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
import {
BACKUP_EVENT_KIND,
BACKUP_PARTS,
@@ -23,50 +20,47 @@ import {
buildBackupEvent,
deriveBackupKey,
dTagOf,
parseBackupEvent
} from './events'
import type {BackupTransport} from './transport'
import {defaultTransport} from './transport'
parseBackupEvent,
} from './events';
import type { BackupTransport } from './transport';
import { defaultTransport } from './transport';
export type PublishBackupOptions = {
transport?: BackupTransport
createdAt?: number
}
transport?: BackupTransport;
createdAt?: number;
};
export type PublishBackupResult = {
published: BackupPart[]
}
published: BackupPart[];
};
// builds and publishes one event per part present in `parts`
export const publishBackup = async (
secretKey: Uint8Array,
parts: Partial<BackupPartPayload>,
relays: string[],
options: PublishBackupOptions = {}
options: PublishBackupOptions = {},
): Promise<PublishBackupResult> => {
const at = options.createdAt ?? Math.floor(Date.now() / 1000)
const present = BACKUP_PARTS.filter(part => parts[part] !== undefined)
if (present.length === 0) return {published: []}
const transport = options.transport ?? (await defaultTransport())
const published: BackupPart[] = []
const at = options.createdAt ?? Math.floor(Date.now() / 1000);
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
if (present.length === 0) return { published: [] };
const transport = options.transport ?? (await defaultTransport());
const published: BackupPart[] = [];
for (const part of present) {
const payload = parts[part]
if (payload === undefined) continue
await transport.publish(
relays,
buildBackupEvent(secretKey, part, payload, at)
)
published.push(part)
const payload = parts[part];
if (payload === undefined) continue;
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
published.push(part);
}
return {published}
}
return { published };
};
export type FetchBackupOptions = {
// the pubkey alone can fetch the ciphertext, but only the key holder
// reads it - decryption is part of fetching
secretKey: Uint8Array
transport?: BackupTransport
}
secretKey: Uint8Array;
transport?: BackupTransport;
};
// fetches the newest valid event per d-tag and decrypts it. Honest relays
// already replace addressable events, but a stale or misbehaving relay may
@@ -75,51 +69,51 @@ export type FetchBackupOptions = {
export const fetchBackup = async (
pubkey: string,
relays: string[],
options: FetchBackupOptions
options: FetchBackupOptions,
): Promise<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport())
const transport = options.transport ?? (await defaultTransport());
const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND],
authors: [pubkey]
})
const byTag = new Map<string, NostrEvent[]>()
authors: [pubkey],
});
const byTag = new Map<string, NostrEvent[]>();
for (const event of events) {
const d = dTagOf(event)
if (!d) continue
const candidates = byTag.get(d)
if (candidates) candidates.push(event)
else byTag.set(d, [event])
const d = dTagOf(event);
if (!d) continue;
const candidates = byTag.get(d);
if (candidates) candidates.push(event);
else byTag.set(d, [event]);
}
const parts: Partial<BackupPartPayload> = {}
const parts: Partial<BackupPartPayload> = {};
for (const candidates of byTag.values()) {
// a hostile relay may serve a tampered "newest" copy - walk
// newest-first and take the first that validates and decrypts
candidates.sort((a, b) => b.created_at - a.created_at)
candidates.sort((a, b) => b.created_at - a.created_at);
for (const event of candidates) {
const parsed = parseBackupEvent(options.secretKey, event)
if (!parsed) continue
const parsed = parseBackupEvent(options.secretKey, event);
if (!parsed) continue;
switch (parsed.part) {
case 'notes':
parts.notes = parsed.bearers
break
parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
break;
case 'mints':
parts.mints = parsed.trustedMints
break
parts.mints = parsed.trustedMints;
break;
case 'settings':
parts.settings = parsed.settings
break
parts.settings = parsed.settings;
break;
}
break
break;
}
}
return parts
}
return parts;
};
export type NostrRestoreResult = RestoreResult & {
// which d-tags carried a valid payload at all - lets the caller
// distinguish "nothing backed up yet" from "restored an empty wallet"
found: BackupPart[]
}
found: BackupPart[];
};
// The restore path end to end: re-derive the backup key from the linking
// key (the holder already re-entered the seed phrase to get that far),
@@ -130,23 +124,27 @@ export type NostrRestoreResult = RestoreResult & {
export const restoreFromNostr = async (
linkingPrivKey: Uint8Array,
relays: string[],
options: {transport?: BackupTransport} = {}
options: { transport?: BackupTransport } = {},
): Promise<NostrRestoreResult> => {
const secretKey = deriveBackupKey(linkingPrivKey)
const secretKey = deriveBackupKey(linkingPrivKey);
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey,
transport: options.transport
})
const result = applyBackup({
type: 'sattle-backup',
version: 1,
createdAt: Date.now(),
bearers: parts.notes ?? [],
trustedMints: parts.mints,
settings: parts.settings
})
transport: options.transport,
});
const result = await applyBackup(
{
type: 'sattle-backup',
version: 2,
createdAt: Date.now(),
bearers: parts.notes?.bearers ?? [],
nextByHost: parts.notes?.nextByHost ?? {},
trustedMints: parts.mints,
settings: parts.settings,
},
linkingPubKeyHex(linkingPrivKey),
);
return {
...result,
found: BACKUP_PARTS.filter(part => parts[part] !== undefined)
}
}
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
};
};
+14 -14
View File
@@ -3,30 +3,30 @@
// lazily: merely importing the backup module must never open (or even
// reference) a WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter'
import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './events'
import type { NostrEvent } from './events';
export type {NostrFilter}
export type { NostrFilter };
// publish throws when the event was accepted NOWHERE
export type BackupTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void>
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>
}
publish: (relays: string[], event: NostrEvent) => Promise<void>;
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>;
};
export const defaultTransport = async (): Promise<BackupTransport> => {
const {SimplePool} = await import('nostr-tools/pool')
const pool = new SimplePool()
const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool();
return {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway
if (!results.some(r => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.')
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.');
}
},
fetch: (relays, filter) => pool.querySync(relays, filter)
}
}
fetch: (relays, filter) => pool.querySync(relays, filter),
};
};
+214
View File
@@ -0,0 +1,214 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers();
});
describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY);
const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below
{ id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
];
const notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
const mints = [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true }];
const settings = { defaultMint: 'mint.example' };
it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
expect(events).toHaveLength(3);
expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
]);
expect(events.map((e) => e.tags)).toEqual([
[['d', 'notes']],
[['d', 'mints']],
[['d', 'settings']],
]);
expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true);
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes',
bearers: records,
nextByHost: { 'mint.example': 3 },
});
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints',
trustedMints: mints,
});
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings',
settings,
});
});
it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', notes);
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
expect(event.content).not.toContain('ciphertext');
});
it('builds events only for the parts present', () => {
const events = buildBackupEvents(secretKey, { settings }, 1000);
expect(events).toHaveLength(1);
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
});
it('rejects a payload encrypted for a different key', () => {
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
});
it('rejects the wrong kind', () => {
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
});
it('rejects an unknown d-tag', () => {
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
});
it('rejects a modified ciphertext - the signature no longer matches', () => {
const event = buildBackupEvent(secretKey, 'settings', settings);
const tampered = { ...event, content: tamperContent(event.content) };
expect(parseBackupEvent(secretKey, tampered)).toBeNull();
});
it('rejects an event signed by a different key', () => {
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
expect(parseBackupEvent(secretKey, foreign)).toBeNull();
});
it('rejects a validly signed event whose payload is not a backup envelope', () => {
// a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const event = finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'settings']],
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
},
secretKey,
);
expect(parseBackupEvent(secretKey, event)).toBeNull();
});
it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const build = (payload: unknown): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'notes']],
content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
},
secretKey,
);
// v1 notes carried bare bearer arrays and no counters - pre-funds-document
expect(parseBackupEvent(secretKey, build({ version: 1, bearers: records }))).toBeNull();
expect(parseBackupEvent(secretKey, build({ version: 2, bearers: records }))).toBeNull();
// a v2 envelope whose counters are malformed is rejected, not coerced
expect(
parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })),
).toBeNull();
});
});
+112
View File
@@ -0,0 +1,112 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers();
});
describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => {
// pinned: changing the context string or the construction would
// silently orphan every backup ever published - the wallet would
// derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
);
});
it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY)),
);
});
});
@@ -0,0 +1,214 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers();
});
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
});
publisher.schedule({ settings: { defaultMint: 'a' } });
publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.schedule({ settings: { defaultMint: 'c' } });
await vi.advanceTimersByTimeAsync(999);
expect(published).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
});
it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
});
publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000);
publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
{ settings: { defaultMint: 'a' } },
{ settings: { defaultMint: 'b' } },
]);
});
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
const holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p);
// a local change lands while the first publish is in flight
if (published.length === 1) {
holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
}
return Promise.resolve();
},
delayMs: 1000,
});
holder.publisher = publisher;
publisher.schedule({ settings: { defaultMint: 'first' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
{ settings: { defaultMint: 'first' } },
{ settings: { defaultMint: 'mid-flight' } },
]);
});
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p);
return Promise.resolve();
},
delayMs: 60_000,
});
publisher.schedule({ settings: { defaultMint: 'a' } });
await publisher.flush();
expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.cancel();
await vi.advanceTimersByTimeAsync(60_000);
expect(published).toHaveLength(1);
});
it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const errors: unknown[] = [];
let failing = true;
const publisher = createBackupPublisher({
publish: (p) => {
if (failing) return Promise.reject(new Error('relay down'));
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
onError: (e) => {
errors.push(e);
},
});
publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([]);
expect(errors).toHaveLength(1);
failing = false;
publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
});
});
+302
View File
@@ -0,0 +1,302 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {
deriveBearerAesKey,
deriveWalletMaterial,
linkingPubKeyHex,
saveWalletMaterial,
} from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
readNextByHost,
} from './storage';
import { readFundsDocument, writeFundsDocument } from './storage/bearers';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
// full v2 material for the fixed harness linking key (real BIP-32 cash root)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
};
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers();
});
describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A: one note, one trusted mint, one setting - all published
await saveWalletMaterial(MATERIAL);
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
persistSettings({ defaultMint: 'mint.example' });
await publishBackup(
secretKey,
{
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
mints: readTrustedMints(OWNER_ID),
settings: loadSettings(),
},
RELAYS,
{ transport },
);
// device B: the same seed on empty storage
stubLocalStorage();
await saveWalletMaterial(MATERIAL);
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes', 'mints', 'settings']);
expect(result.added).toBe(1);
expect(result.trustedMintsAdded).toBe(1);
expect(result.settingsRestored).toBe(true);
// the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path
expect(result.linkingKeyRestored).toBe(false);
// the note decrypts under this device's bearer key - same seed
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
// a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged
expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true);
});
it('unions records by id and lets a spent copy win after decrypt', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport,
createdAt: 1000,
},
);
// device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store
stubLocalStorage();
await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport,
createdAt: 2000,
},
);
// device C restores from the final published state
stubLocalStorage();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed
expect(result.added).toBe(2);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['rec-a', 'rec-b']);
// after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money
const merged = mergeBearers([], await loadBearers(aesKey));
expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('rec-b');
expect(requiredValue(merged[0]).spent).toBe(true);
});
it('never overwrites local state: records union, settings keep local values', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
persistSettings({ defaultMint: 'remote.example' });
await publishBackup(
secretKey,
{
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
settings: loadSettings(),
},
RELAYS,
{ transport, createdAt: 1000 },
);
// this device already has its own wallet state
stubLocalStorage();
await persistBearer(
aesKey,
bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
);
persistSettings({ defaultMint: 'local.example' });
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.added).toBe(1);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['local', 'remote']);
expect(result.settingsRestored).toBe(false);
expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
});
it('reports nothing found when the relays hold no backup', async () => {
const { transport } = createRecordingTransport();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual([]);
expect(result.added).toBe(0);
});
it('merges relayed counters upward-only and never carries pending state', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A: counter at 5 for mint.example, one note, all published
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
writeFundsDocument({
version: 2,
bearers: readEncryptedBearers(),
pending: [
{
id: 'device-local-pending',
kind: 'cash-allocation',
phase: 'reserved',
iv: '00',
ciphertext: '00',
},
],
nextByHost: { 'mint.example': 5 },
revision: 3,
});
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{ transport, createdAt: 1000 },
);
// device B: same seed, a HIGHER local counter for one host and a lower
// incoming one for the other; the pending record must not cross over
stubLocalStorage();
writeFundsDocument({
version: 2,
bearers: [],
pending: [],
nextByHost: { 'mint.example': 2, 'local-only.example': 7 },
revision: 1,
});
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes']);
expect(result.added).toBe(1);
expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5,
'local-only.example': 7,
});
expect(readFundsDocument().pending).toEqual([]);
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
});
});
+5 -530
View File
@@ -1,530 +1,5 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import {deriveBearerAesKey} from './keys'
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers
} from './storage'
import type {Bearer} from './types'
import {
addTrustedMint,
clearTrustedMints,
isMintUnconfirmed,
readTrustedMints
} from './trustedMints'
import {stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides
})
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
} => {
const events: NostrEvent[] = []
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
e =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey))
)
)
}
return {transport, events}
}
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
beforeEach(() => {
stubLocalStorage()
// the trusted-mint registry caches module-level - reset it alongside
// the storage stub
clearTrustedMints()
})
afterEach(() => {
vi.useRealTimers()
})
describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => {
// pinned: changing the context string or the construction would
// silently orphan every backup ever published - the wallet would
// derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9'
)
})
it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY))
)
})
})
describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below
{id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}
]
const mints = [
{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}
]
const settings = {defaultMint: 'mint.example'}
it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000)
expect(events).toHaveLength(3)
expect(events.map(e => e.kind)).toEqual([
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND
])
expect(events.map(e => e.tags)).toEqual([
[['d', 'notes']],
[['d', 'mints']],
[['d', 'settings']]
])
expect(events.every(e => e.pubkey === backupPubkey(secretKey))).toBe(true)
expect(parseBackupEvent(secretKey, events[0]!)).toEqual({
part: 'notes',
bearers: records
})
expect(parseBackupEvent(secretKey, events[1]!)).toEqual({
part: 'mints',
trustedMints: mints
})
expect(parseBackupEvent(secretKey, events[2]!)).toEqual({
part: 'settings',
settings
})
})
it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', records)
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
expect(event.content).not.toContain('ciphertext')
})
it('builds events only for the parts present', () => {
const events = buildBackupEvents(secretKey, {settings}, 1000)
expect(events).toHaveLength(1)
expect(events[0]!.tags).toEqual([['d', 'settings']])
})
it('rejects a payload encrypted for a different key', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull()
})
it('rejects the wrong kind', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull()
})
it('rejects an unknown d-tag', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull()
})
it('rejects a modified ciphertext - the signature no longer matches', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
const tampered = {...event, content: tamperContent(event.content)}
expect(parseBackupEvent(secretKey, tampered)).toBeNull()
})
it('rejects an event signed by a different key', () => {
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings)
expect(parseBackupEvent(secretKey, foreign)).toBeNull()
})
it('rejects a validly signed event whose payload is not a backup envelope', () => {
// a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope
const conversationKey = nip44v2.utils.getConversationKey(
secretKey,
getPublicKey(secretKey)
)
const event = finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'settings']],
content: nip44v2.encrypt(
JSON.stringify({version: 2, settings: {}}),
conversationKey
)
},
secretKey
)
expect(parseBackupEvent(secretKey, event)).toBeNull()
})
})
describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport()
const parts = {
notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}],
mints: [
{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}
],
settings: {defaultMint: 'mint.example'}
}
const published = await publishBackup(secretKey, parts, RELAYS, {transport})
expect(published.published).toEqual(['notes', 'mints', 'settings'])
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport
})
expect(fetched).toEqual(parts)
})
it('publishes nothing when no parts are given', async () => {
const {transport, events} = createRecordingTransport()
const result = await publishBackup(secretKey, {}, RELAYS, {transport})
expect(result.published).toEqual([])
expect(events).toEqual([])
})
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
const {transport} = createRecordingTransport()
await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, {
transport,
createdAt: 1000
})
await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, {
transport,
createdAt: 2000
})
// the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport
})
expect(parts.settings).toEqual({defaultMint: 'new.example'})
})
it('falls back to an older valid copy when the newest event is tampered', async () => {
const {transport, events} = createRecordingTransport()
await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, {
transport,
createdAt: 1000
})
events.push({
...events[0]!,
content: tamperContent(events[0]!.content),
created_at: 3000
})
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport
})
expect(parts.settings).toEqual({defaultMint: 'mint.example'})
})
})
describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
// device A: one note, one trusted mint, one setting - all published
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
addTrustedMint('mint.example', MINT_PUBKEY)
persistSettings({defaultMint: 'mint.example'})
await publishBackup(
secretKey,
{
notes: readEncryptedBearers(),
mints: readTrustedMints(),
settings: loadSettings()
},
RELAYS,
{transport}
)
// device B: the same seed on empty storage
stubLocalStorage()
clearTrustedMints()
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.found).toEqual(['notes', 'mints', 'settings'])
expect(result.added).toBe(1)
expect(result.trustedMintsAdded).toBe(1)
expect(result.settingsRestored).toBe(true)
// the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path
expect(result.linkingKeyRestored).toBe(false)
// the note decrypts under this device's bearer key - same seed
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})])
expect(loadSettings()).toEqual({defaultMint: 'mint.example'})
// a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged
expect(isMintUnconfirmed('mint.example')).toBe(true)
})
it('unions records by id and lets a spent copy win after decrypt', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
// device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
transport,
createdAt: 1000
})
// device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store
stubLocalStorage()
clearTrustedMints()
await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
transport,
createdAt: 2000
})
// device C restores from the final published state
stubLocalStorage()
clearTrustedMints()
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
// union by record id: both copies landed
expect(result.added).toBe(2)
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['rec-a', 'rec-b'])
// after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money
const merged = mergeBearers([], await loadBearers(aesKey))
expect(merged).toHaveLength(1)
expect(merged[0]!.id).toBe('rec-b')
expect(merged[0]!.spent).toBe(true)
})
it('never overwrites local state: records union, settings keep local values', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
await persistBearer(aesKey, bearerFixture({id: 'remote'}))
persistSettings({defaultMint: 'remote.example'})
await publishBackup(
secretKey,
{notes: readEncryptedBearers(), settings: loadSettings()},
RELAYS,
{transport, createdAt: 1000}
)
// this device already has its own wallet state
stubLocalStorage()
clearTrustedMints()
await persistBearer(
aesKey,
bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
)
persistSettings({defaultMint: 'local.example'})
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.added).toBe(1)
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['local', 'remote'])
expect(result.settingsRestored).toBe(false)
expect(loadSettings()).toEqual({defaultMint: 'local.example'})
})
it('reports nothing found when the relays hold no backup', async () => {
const {transport} = createRecordingTransport()
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.found).toEqual([])
expect(result.added).toBe(0)
})
})
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: p => {
published.push(p)
return Promise.resolve()
},
delayMs: 1000
})
publisher.schedule({settings: {defaultMint: 'a'}})
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.schedule({settings: {defaultMint: 'c'}})
await vi.advanceTimersByTimeAsync(999)
expect(published).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(published).toEqual([{settings: {defaultMint: 'c'}}])
})
it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: p => {
published.push(p)
return Promise.resolve()
},
delayMs: 1000
})
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([
{settings: {defaultMint: 'a'}},
{settings: {defaultMint: 'b'}}
])
})
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {}
const publisher = createBackupPublisher({
publish: p => {
published.push(p)
// a local change lands while the first publish is in flight
if (published.length === 1) {
holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}})
}
return Promise.resolve()
},
delayMs: 1000
})
holder.publisher = publisher
publisher.schedule({settings: {defaultMint: 'first'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([
{settings: {defaultMint: 'first'}},
{settings: {defaultMint: 'mid-flight'}}
])
})
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: p => {
published.push(p)
return Promise.resolve()
},
delayMs: 60_000
})
publisher.schedule({settings: {defaultMint: 'a'}})
await publisher.flush()
expect(published).toEqual([{settings: {defaultMint: 'a'}}])
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.cancel()
await vi.advanceTimersByTimeAsync(60_000)
expect(published).toHaveLength(1)
})
it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const errors: unknown[] = []
let failing = true
const publisher = createBackupPublisher({
publish: p => {
if (failing) return Promise.reject(new Error('relay down'))
published.push(p)
return Promise.resolve()
},
delayMs: 1000,
onError: e => {
errors.push(e)
}
})
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([])
expect(errors).toHaveLength(1)
failing = false
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([{settings: {defaultMint: 'b'}}])
})
})
import './nostrBackup.keys.cases';
import './nostrBackup.codec.cases';
import './nostrBackup.transport.cases';
import './nostrBackup.restore.cases';
import './nostrBackup.publisher.cases';
@@ -0,0 +1,163 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers();
});
describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => {
const { transport } = createRecordingTransport();
const parts = {
notes: {
bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
nextByHost: { 'mint.example': 2 },
},
mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
settings: { defaultMint: 'mint.example' },
};
const published = await publishBackup(secretKey, parts, RELAYS, { transport });
expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
});
expect(fetched).toEqual(parts);
});
it('publishes nothing when no parts are given', async () => {
const { transport, events } = createRecordingTransport();
const result = await publishBackup(secretKey, {}, RELAYS, { transport });
expect(result.published).toEqual([]);
expect(events).toEqual([]);
});
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
const { transport } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport,
createdAt: 1000,
});
await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
transport,
createdAt: 2000,
});
// the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
});
expect(parts.settings).toEqual({ defaultMint: 'new.example' });
});
it('falls back to an older valid copy when the newest event is tampered', async () => {
const { transport, events } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport,
createdAt: 1000,
});
const latest = requiredValue(events[0]);
events.push({
...latest,
content: tamperContent(latest.content),
created_at: 3000,
});
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
});
expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
});
});
+9 -14
View File
@@ -24,24 +24,19 @@ export {
backupPubkey,
buildBackupEvent,
buildBackupEvents,
parseBackupEvent
} from './nostr/events'
export type {
NostrEvent,
BackupPart,
BackupPartPayload,
ParsedBackupEvent
} from './nostr/events'
parseBackupEvent,
} from './nostr/events';
export type { NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent } from './nostr/events';
export type {BackupTransport, NostrFilter} from './nostr/transport'
export type { BackupTransport, NostrFilter } from './nostr/transport';
export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync'
export { publishBackup, fetchBackup, restoreFromNostr } from './nostr/sync';
export type {
PublishBackupOptions,
PublishBackupResult,
FetchBackupOptions,
NostrRestoreResult
} from './nostr/sync'
NostrRestoreResult,
} from './nostr/sync';
export {createBackupPublisher} from './nostr/publisher'
export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher'
export { createBackupPublisher } from './nostr/publisher';
export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
+144
View File
@@ -0,0 +1,144 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
'wss://relay-a.example',
'wss://relay-b.example/path?q=1',
]);
expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
);
expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
});
});
it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull();
expect(parseConnectionString('https://example.com')).toBeNull();
// missing secret
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
).toBeNull();
// missing relay
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
).toBeNull();
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
),
).toBeNull();
});
it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
});
const parsed = parseConnectionString(connection.connectionString);
expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32),
relays: RELAYS,
});
// the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored
const records = readNwcConnections(OWNER_ID);
expect(records).toHaveLength(1);
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
});
});
describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
);
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
);
});
it('derives a distinct key per client and per linking key', () => {
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY));
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
base,
);
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base);
});
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
});
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
);
});
});
+66
View File
@@ -0,0 +1,66 @@
// Stale-owner fencing end to end: a service accepted work while its owner
// was still installed, and a second tab replaced the saved key AFTER the
// fence's last safe point - mid-melt, past the boundary where aborting is
// no longer possible. What must not happen is any stale-owner WRITE:
// no bearer changeset, no budget debit, no success response.
import { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { readNwcConnections } from './nwc';
import {
methodRequest,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
waitFor,
} from './nwc.testProtocol';
import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: post-boundary stale-owner fencing', () => {
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
// Given a running service whose next mint call coincides with a second
// tab installing its own wallet (the first fetch is the melt itself:
// the exact-match carve performs no mint call of its own)
const m = await mint();
let swapped = false;
const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) {
swapped = true;
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: false,
value: bytesToHex(OTHER_LINKING_KEY),
ownerId: OTHER_OWNER_ID,
version: 1,
}),
);
}
return fetch(input, init);
};
const { relay, walletServicePubkey, state, stop } = await startTestService({
kit: { fetch: swappingFetch },
});
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
// When the pay flows past the irreversible boundary and reaches the
// first owner-bound persistence (the conservative budget debit)
relay.emit(request);
await waitFor(() => state.errors.length > 0);
// Then the melt genuinely happened (we are past the boundary) ...
expect(swapped).toBe(true);
expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
// ... but nothing under the stale owner moved: no budget debit, no
// bearer changeset, no success response (the failure surfaced through
// onError instead)
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
await stop();
});
});
+222
View File
@@ -0,0 +1,222 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import { invoiceRetireAfter } from './nwc/invoices';
export const SIGNED_BOLT11_FIXTURE =
'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz';
describe('service: make_invoice / lookup_invoice', () => {
it('derives a safe retirement time from a signed BOLT11 invoice', () => {
expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0);
});
it('rejects a retirement time outside the safe integer range', () => {
const timestamp = 'q'.repeat(7);
const expiryTag = `xqv${'l'.repeat(12)}`;
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
});
it('stages an output, settles it in the background, and reports the preimage', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
description: 'nwc test',
expiry: 3600,
});
expect(made.error).toBeNull();
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
amount: 21_000,
description: 'nwc test',
created_at: nowSeconds(),
expires_at: nowSeconds() + 3600,
});
const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice');
}
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash');
}
expect(invoice).toMatch(/^lnbc/);
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/);
const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')));
expect(staged.verified).toBe(false);
expect(staged.callback).toBe('');
// before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
});
expect(pending.error).toBeNull();
expect(pending.result?.state).toBe('pending');
expect(pending.result?.preimage).toBeUndefined();
// the "payer" pays the invoice; the background claim settles and
// mints the note
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleRes.ok).toBe(true);
await waitFor(() => state.bearers.some((bearer) => bearer.verified));
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
});
expect(settled.error).toBeNull();
expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds());
const preimage = requiredString(settled.result?.preimage);
expect(preimage).toMatch(/^[0-9a-f]{64}$/);
// The staged record was updated in place before settlement became
// visible. The payment preimage is only a receipt and keys no note.
expect(m.state.noteState(preimage)).toBeNull();
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
expect(minted.amount).toBe(21_000);
expect(minted.verified).toBe(true);
expect(noteK1(minted.url)).not.toBe(preimage);
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
await stop();
});
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({ testHooks: true });
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
});
expect(pending.result?.state).toBe('pending');
expect(pending.result?.settled_at).toBeUndefined();
expect(pending.result?.preimage).toBeUndefined();
commit.resolve();
await waitFor(() => state.changesets.length === 1);
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
});
expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds());
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
await stop();
});
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
const m = await mint({ testHooks: true });
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false;
const firstStop = stop().then(() => {
stopped = true;
return state.changesets.length;
});
const repeatedStop = stop();
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
expect(state.changesets).toHaveLength(0);
commit.resolve();
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
expect(changesetsAtStop).toBe(1);
expect(state.changesets).toHaveLength(1);
});
});
+327
View File
@@ -0,0 +1,327 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import { SIGNED_BOLT11_FIXTURE } from './nwc.invoice-a.cases';
describe('service: make_invoice / lookup_invoice (continued)', () => {
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
const m = await mint({ testHooks: true });
const commit = deferred();
const commitError = new Error('invoice commit rejected during stop');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false;
const stopping = stop().then(() => {
stopped = true;
return state.errors.length;
});
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
commit.reject(commitError);
expect(await stopping).toBe(1);
expect(state.errors).toEqual([commitError]);
expect(state.changesets).toHaveLength(0);
await stop();
});
it('does not block stop on an invoice whose claim is still polling', async () => {
// an unpaid invoice's claim poll can legally run for minutes (the
// client pays whenever it pays) - stop must interrupt the wait, not
// sit on it; a settlement that already REACHED the commit phase is
// still awaited (see the drain tests above)
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
});
expect(made.error).toBeNull();
// nobody pays the invoice; the claim keeps polling. stop must resolve
// promptly regardless (an un-interrupted stop would wait out the
// whole 60s claim budget)
await stop();
expect(state.changesets).toHaveLength(0);
expect(state.errors).toHaveLength(0);
});
it('does not start invoice settlement after stop begins during preparation', async () => {
const m = await mint({ testHooks: true });
const prepare = deferred();
let prepareStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
kit: {
fetch: async (input, init) => {
prepareStarted = true;
await prepare.promise;
return fetch(input, init);
},
},
});
const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
relay.emit(request);
await waitFor(() => prepareStarted);
let stopped = false;
const stopping = stop().then(() => {
stopped = true;
});
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
prepare.resolve();
await stopping;
expect(state.changesets).toHaveLength(0);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
});
it('marks a paid invoice failed when its bearer commit rejects', async () => {
const m = await mint({ testHooks: true });
const commit = deferred();
const commitError = new Error('invoice bearer commit failed');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
commit.reject(commitError);
await waitFor(() => state.errors.length === 1);
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
});
expect(failed.result?.state).toBe('failed');
expect(failed.result?.settled_at).toBeUndefined();
expect(failed.result?.preimage).toBeUndefined();
expect(state.errors).toEqual([commitError]);
await stop();
});
it('finds an invoice by its invoice string too', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
});
const invoice = made.result?.invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice');
}
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: invoice.toUpperCase(),
});
expect(found.error).toBeNull();
expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
await stop();
});
it('answers an unknown invoice with NOT_FOUND', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32),
});
expect(response.error?.code).toBe('NOT_FOUND');
await stop();
});
it('answers make_invoice without a default mint with INTERNAL', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: null,
});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
expect(response.error?.code).toBe('INTERNAL');
await stop();
});
it('rejects invoice creation when the durable pending-output cap is reached', async () => {
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: 'mint@example.com',
});
for (let index = 0; index < 20; index += 1) {
state.bearers.push({
id: `pending-${index}`,
url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`,
callback: '',
amount: 0,
verified: false,
pendingMint: {},
createdAt: index,
updatedAt: index,
});
}
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
expect(state.bearers).toHaveLength(20);
await stop();
});
it('removes staged outputs when invoice preparation fails before publication', async () => {
const m = await mint({ testHooks: true });
let rejectQuotes = true;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
kit: {
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (rejectQuotes && url.includes('comment=')) {
return Promise.resolve(new Response('quote failed', { status: 500 }));
}
return fetch(input, init);
},
},
});
for (let attempt = 0; attempt < 20; attempt += 1) {
const failed = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(failed.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0);
}
rejectQuotes = false;
const made = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(made.error).toBeNull();
expect(state.bearers).toHaveLength(1);
await stop();
});
it('removes an unpublished stage when retirement persistence fails', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
kit: {
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('comment=')) {
return Promise.resolve(
new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
return fetch(input, init);
},
},
});
const response = await call(relay, walletServicePubkey, 'make_invoice', { amount: 25_000 });
expect(response.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0);
await stop();
});
it('answers a make_invoice with a bad amount with OTHER', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5,
});
expect(response.error?.code).toBe('OTHER');
await stop();
});
});
+181
View File
@@ -0,0 +1,181 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service ownership', () => {
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
});
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
storeForeignConnection(foreign.record);
const relay = createFakeRelay();
const service = await startService(LINKING_KEY, {
assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
persistMintOutput: () => Promise.reject(new Error('unused')),
discardMintOutput: () => Promise.reject(new Error('unused')),
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
finalizeMintOutput: () => Promise.reject(new Error('unused')),
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
});
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
expect(service.connections).not.toContainEqual(foreign);
await service.stop();
});
it('ignores foreign records handed in through the records snapshot', async () => {
// the injected-records path bypasses storage, so the service's own
// owner filter is the only boundary here (stale-ownership probe: a
// snapshot from a previous wallet must not be served)
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
});
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
const relay = createFakeRelay();
const service = await startService(
LINKING_KEY,
{
assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
persistMintOutput: () => Promise.reject(new Error('unused')),
discardMintOutput: () => Promise.reject(new Error('unused')),
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
finalizeMintOutput: () => Promise.reject(new Error('unused')),
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
},
[current.record, foreign.record],
);
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1);
// the foreign snapshot record must not be persisted for the new owner
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
await service.stop();
});
});
describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
expect(info.pubkey).toBe(walletServicePubkey);
expect(info.content).toContain('pay_invoice');
expect(info.content).toContain('make_invoice');
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
await stop();
});
it('answers get_info with the connection identity and method list', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_info', {});
expect(response.error).toBeNull();
expect(response.result_type).toBe('get_info');
expect(response.result).toMatchObject({
alias: 'sattle',
pubkey: walletServicePubkey,
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
});
await stop();
});
it('answers get_balance with the spendable total only', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000),
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
];
const response = await call(relay, walletServicePubkey, 'get_balance', {});
expect(response.error).toBeNull();
expect(response.result).toEqual({ balance: 26_000 });
await stop();
});
});
+235
View File
@@ -0,0 +1,235 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice', () => {
it('applies the settled changeset before publishing success', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error).toBeNull();
expect(state.changesets).toHaveLength(1);
expect(state.bearers[0]?.spent).toBe(true);
await stop();
});
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000,
});
const foreign = foreignConnectionFixture(
{ maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000,
);
storeForeignConnection(foreign.record);
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error).toBeNull();
expect(requiredString(response.result?.preimage)).toHaveLength(64);
// the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
expect(requiredValue(state.bearers[0]).spent).toBe(true);
// the spend was recorded against the budget, persisted
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
await stop();
});
it('waits for a deferred bearer commit before publishing payment success', async () => {
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
relay.emit(request);
await waitFor(() => commitStarted);
// a non-awaiting engine publishes within milliseconds of the commit
// call (one local verify re-read + encrypt); this window is far wider
// than that, so an early response here can only mean a missing barrier
await new Promise((resolve) => setTimeout(resolve, 250));
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
commit.resolve();
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
await stop();
});
it('reports a rejected bearer commit without publishing payment success', async () => {
const m = await mint();
const commit = deferred();
const commitError = new Error('bearer commit failed');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
relay.emit(request);
await waitFor(() => commitStarted);
commit.reject(commitError);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
expect(state.errors).toEqual([commitError]);
// the conservative budget debit is persisted separately from bearer
// storage and deliberately NOT rolled back: the budget was debited,
// the bearer was never locked spent
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
expect(state.bearers[0]?.spent).toBeUndefined();
await stop();
});
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true;
return commit.promise;
},
});
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
relay.emit(request);
await waitFor(() => commitStarted);
// stop closes subscriptions; in-flight handlers still finish (their
// changesets hold money) - a repeated stop interrupts nothing twice
let stopped = false;
const firstStop = stop().then(() => {
stopped = true;
});
const repeatedStop = stop();
await Promise.resolve();
expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes
expect(relay.subscriptionCount()).toBe(0);
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(afterStop);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
commit.resolve();
await Promise.all([firstStop, repeatedStop]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
expect(state.bearers[0]?.spent).toBe(true);
});
it('rejects a stale saved owner before touching the mint', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: false,
value: bytesToHex(OTHER_LINKING_KEY),
ownerId: OTHER_OWNER_ID,
version: 1,
}),
);
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
relay.emit(request);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
);
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
await stop();
});
});
+185
View File
@@ -0,0 +1,185 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice (continued)', () => {
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000,
});
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
// nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop();
});
it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 21_000,
periodMs: 60_000,
});
// simulate a fully spent budget from a period that ended long ago
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]);
writeNwcConnections(OWNER_ID, [
{ ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
]);
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error).toBeNull();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
await stop();
});
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
await stop();
});
it('rejects a request amount that mismatches the invoice amount', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000,
});
expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/match/i);
await stop();
});
it('rejects an amount-less invoice instead of guessing', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/amount/i);
await stop();
});
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
// a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
const original = await makeBearer(m, '01'.repeat(32), 21_000);
state.bearers = [original];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('PAYMENT_FAILED');
expect(m.state.noteState('01'.repeat(32))).toBe('burned');
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
expect(
state.bearers.find(
(bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
),
).toBeDefined();
expect(
state.bearers
.filter((bearer) => !bearer.spent)
.reduce((sum, bearer) => sum + bearer.amount, 0),
).toBe(21_000);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop();
});
it('conserves balance when a split payment fails and returns its carved funds', async () => {
const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('PAYMENT_FAILED');
expect(
state.bearers
.filter((bearer) => !bearer.spent)
.reduce((sum, bearer) => sum + bearer.amount, 0),
).toBe(30_000);
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined();
await stop();
});
});
+173
View File
@@ -0,0 +1,173 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('storage validation', () => {
it('drops malformed records instead of throwing', () => {
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{ clientPubkey: 'nope' },
{
version: 1,
ownerId: OWNER_ID,
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: { maxMsat: 1000, periodMs: 1000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
);
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
});
it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{');
expect(readNwcConnections(OWNER_ID)).toEqual([]);
});
it('returns only strictly parsed records belonging to the requested owner', () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 10,
}).record;
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
...raw,
foreign,
{ ...current, ownerId: 'malformed' },
{
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
relays: RELAYS,
budget: { maxMsat: 3000, periodMs: 3000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 30,
},
]),
);
expect(readNwcConnections(OWNER_ID)).toEqual([current]);
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
});
it('adopts ownerless connections and enabled state only after owner proof', async () => {
await saveLinkingKey(LINKING_KEY);
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
if (typeof saved !== 'object' || saved === null) {
throw new TypeError('Expected a saved linking-key record');
}
Reflect.deleteProperty(saved, 'ownerId');
Reflect.deleteProperty(saved, 'version');
localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: { maxMsat: 1000, periodMs: 1000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
expect(readNwcConnections(OWNER_ID)).toEqual([]);
expect(readNwcEnabled(OWNER_ID)).toBe(false);
ensureSavedKeyOwner(LINKING_KEY);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 1,
enabled: true,
});
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 0,
enabled: false,
});
});
it('does not expose one wallet enabled state to another owner', () => {
writeNwcEnabled(OWNER_ID, true);
expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
});
it('treats malformed owner-bearing enabled records as disabled', () => {
for (const value of [
{ version: 1, ownerId: 'malformed', enabled: true },
{ version: 2, ownerId: OWNER_ID, enabled: true },
{ version: 1, ownerId: OWNER_ID, enabled: 'true' },
]) {
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
expect(readNwcEnabled(OWNER_ID)).toBe(false);
}
});
});
+9 -816
View File
@@ -1,816 +1,9 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
createConnection,
deriveNwcWalletKey,
parseConnectionString,
readNwcConnections,
startService,
writeNwcConnections
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const CLIENT_SECRET = hexToBytes('11'.repeat(32))
const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
const STRANGER_SECRET = hexToBytes('22'.repeat(32))
// never connected - the in-memory relay below stands in
const RELAYS = ['wss://relay-a.example']
let NOW = 1_800_000_000
const nowSeconds = (): number => NOW
const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
// an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records
const createFakeRelay = (): {
transport: NwcTransport
published: NostrEvent[]
emit: (event: NostrEvent) => void
} => {
const published: NostrEvent[] = []
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
const transport: NwcTransport = {
publish: (_relays, event) => {
published.push(event)
return Promise.resolve()
},
subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent}
subs.push(sub)
return {
close: () => {
const index = subs.indexOf(sub)
if (index >= 0) subs.splice(index, 1)
}
}
}
}
const emit = (event: NostrEvent): void => {
for (const sub of [...subs]) {
const kindsMatch =
!sub.filter.kinds || sub.filter.kinds.includes(event.kind)
const wanted = sub.filter['#p']
const pMatch =
!wanted ||
event.tags.some(t => t[0] === 'p' && wanted.includes(t[1] ?? ''))
const sinceMatch =
sub.filter.since === undefined || event.created_at >= sub.filter.since
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
}
}
return {transport, published, emit}
}
type Encryption = 'nip44_v2' | 'nip04' | 'none'
// a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret
const clientRequest = (
walletServicePubkey: string,
content: string,
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW
): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]]
if (scheme !== 'none') tags.push(['encryption', scheme])
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: createdAt,
tags,
content:
scheme === 'nip44_v2'
? nip44v2.encrypt(
content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey)
)
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content)
},
CLIENT_SECRET
)
}
const methodRequest = (
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2',
createdAt?: number
): NostrEvent =>
clientRequest(
walletServicePubkey,
JSON.stringify({method, params}),
scheme,
createdAt
)
// generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out
const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) {
await new Promise(resolve => setTimeout(resolve, 5))
}
expect(cond()).toBe(true)
}
type NwcResponsePayload = {
result_type: string
error: {code: string; message: string} | null
result: Record<string, unknown> | null
}
const readResponse = (
published: NostrEvent[],
requestId: string,
scheme: Encryption
): NwcResponsePayload | null => {
const event = published.find(
e =>
e.kind === NWC_RESPONSE_KIND &&
e.tags.some(t => t[0] === 'e' && t[1] === requestId)
)
if (!event) return null
const plaintext =
scheme === 'nip44_v2'
? nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey)
)
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content)
return JSON.parse(plaintext) as NwcResponsePayload
}
// drives one full request/response round trip over the fake relay
const call = async (
relay: ReturnType<typeof createFakeRelay>,
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2'
): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme)
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null)
return readResponse(relay.published, request.id, scheme)!
}
type Mint = Awaited<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
const mint = async (
options: Parameters<typeof createMockMint>[0] = {}
): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
afterEach(async () => {
await Promise.all(mints.splice(0).map(m => m.close()))
})
let bearerCounter = 0
const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat)
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
const info = await fetchNoteInfo(url)
bearerCounter += 1
return {
id: `bearer-${bearerCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now()
}
}
// the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret
const startTestService = async (options: {
budgetMsat?: number
periodMs?: number
defaultMint?: string | null
linkingKey?: Uint8Array
poll?: typeof FAST_POLL
}): Promise<{
relay: ReturnType<typeof createFakeRelay>
walletServicePubkey: string
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]}
stop: () => void
}> => {
const budgetMsat = options.budgetMsat ?? 1_000_000_000
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000},
clientSecret: CLIENT_SECRET,
now: NOW * 1000
})
const relay = createFakeRelay()
const state = {
bearers: [] as Bearer[],
changesets: [] as NwcChangeset[],
errors: [] as unknown[]
}
const service = await startService(options.linkingKey ?? LINKING_KEY, {
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
applyChangeset: (changeset: NwcChangeset) => {
state.changesets.push(changeset)
for (const id of changeset.markSpent) {
const bearer = state.bearers.find(b => b.id === id)
if (bearer) bearer.spent = true
}
for (const note of changeset.add) {
bearerCounter += 1
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now()
})
}
},
onError: err => {
state.errors.push(err)
},
transport: relay.transport,
poll: options.poll ?? FAST_POLL,
claimPoll: FAST_POLL,
nowSeconds
})
return {
relay,
walletServicePubkey: connection.walletServicePubkey,
state,
stop: service.stop
}
}
beforeEach(() => {
stubLocalStorage()
NOW = 1_800_000_000
})
describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString(
'ab'.repeat(32),
'cd'.repeat(32),
['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
)
expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`
)
expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
})
})
it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull()
expect(parseConnectionString('https://example.com')).toBeNull()
// missing secret
expect(
parseConnectionString(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`
)
).toBeNull()
// missing relay
expect(
parseConnectionString(
`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`
)
).toBeNull()
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32)
)
).toBeNull()
})
it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
clientSecret: CLIENT_SECRET
})
const parsed = parseConnectionString(connection.connectionString)
expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32),
relays: RELAYS
})
// the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored
const records = readNwcConnections()
expect(records).toHaveLength(1)
expect(records[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
})
})
describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d'
)
expect(
getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
).toBe('bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf')
})
it('derives a distinct key per client and per linking key', () => {
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
expect(
bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))
).not.toBe(base)
expect(
bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))
).not.toBe(base)
})
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
clientSecret: CLIENT_SECRET
})
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf'
)
})
})
describe('storage validation', () => {
it('drops malformed records instead of throwing', () => {
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{clientPubkey: 'nope'},
{
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
spent: {periodStart: 0, msat: 0},
createdAt: 0
}
])
)
expect(readNwcConnections()).toHaveLength(1)
expect(readNwcConnections()[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
})
it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{')
expect(readNwcConnections()).toEqual([])
})
})
describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const info = relay.published.find(e => e.kind === NWC_INFO_KIND)
expect(info).toBeDefined()
expect(info!.pubkey).toBe(walletServicePubkey)
expect(info!.content).toContain('pay_invoice')
expect(info!.content).toContain('make_invoice')
expect(info!.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
stop()
})
it('answers get_info with the connection identity and method list', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'get_info', {})
expect(response.error).toBeNull()
expect(response.result_type).toBe('get_info')
expect(response.result).toMatchObject({
alias: 'sattle',
pubkey: walletServicePubkey,
methods: [
'get_info',
'get_balance',
'make_invoice',
'pay_invoice',
'lookup_invoice'
]
})
stop()
})
it('answers get_balance with the spendable total only', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000),
{...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true}
]
const response = await call(relay, walletServicePubkey, 'get_balance', {})
expect(response.error).toBeNull()
expect(response.result).toEqual({balance: 26_000})
stop()
})
})
describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'get_payments', {})
expect(response.result_type).toBe('get_payments')
expect(response.error?.code).toBe('NOT_IMPLEMENTED')
expect(response.result).toBeNull()
stop()
})
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, 'this is not json')
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
)
const response = readResponse(relay.published, request.id, 'nip44_v2')!
expect(response.error?.code).toBe('OTHER')
stop()
})
it('answers a JSON request without a method with an error', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}}))
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
)
expect(readResponse(relay.published, request.id, 'nip44_v2')!.error?.code).toBe(
'OTHER'
)
stop()
})
it('ignores a request signed by a stranger key - silently', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const content = nip44v2.encrypt(
JSON.stringify({method: 'get_balance', params: {}}),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey)
)
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: NOW,
tags: [['p', walletServicePubkey], ['encryption', 'nip44_v2']],
content
},
STRANGER_SECRET
)
relay.emit(forged)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
stop()
})
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
JSON.stringify({method: 'get_balance', params: {}})
)
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: NOW,
tags: [['p', walletServicePubkey], ['encryption', 'nip17']],
content
},
CLIENT_SECRET
)
relay.emit(request)
// the error answer goes out in the legacy scheme every client reads
await waitFor(
() => readResponse(relay.published, request.id, 'nip04') !== null
)
expect(
readResponse(relay.published, request.id, 'nip04')!.error?.code
).toBe('UNSUPPORTED_ENCRYPTION')
stop()
})
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme)
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip04') !== null
)
const response = readResponse(relay.published, request.id, 'nip04')!
expect(response.error).toBeNull()
expect(response.result).toEqual({balance: 0})
// the response mirrors the request's scheme
const event = relay.published.find(
e =>
e.kind === NWC_RESPONSE_KIND &&
e.tags.some(t => t[0] === 'e' && t[1] === request.id)
)!
expect(event.tags).toContainEqual(['encryption', 'nip04'])
}
stop()
})
it('drops requests older than the replay window unanswered', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const stale = methodRequest(
walletServicePubkey,
'get_balance',
{},
'nip44_v2',
NOW - 1200
)
relay.emit(stale)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
stop()
})
it('picks up no new requests after stop', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
stop()
const request = methodRequest(walletServicePubkey, 'get_balance', {})
relay.emit(request)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
})
})
describe('service: pay_invoice', () => {
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
budgetMsat: 50_000
})
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error).toBeNull()
expect(typeof response.result?.preimage).toBe('string')
expect((response.result?.preimage as string).length).toBe(64)
// the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
expect(state.bearers[0]!.spent).toBe(true)
// the spend was recorded against the budget, persisted
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
stop()
})
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
budgetMsat: 20_000
})
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
// nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
expect(state.bearers[0]!.spent).toBeUndefined()
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
stop()
})
it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
budgetMsat: 21_000,
periodMs: 60_000
})
// simulate a fully spent budget from a period that ended long ago
const record: NwcConnectionRecord = readNwcConnections()[0]!
writeNwcConnections([
{...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}}
])
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error).toBeNull()
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
stop()
})
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
stop()
})
it('rejects a request amount that mismatches the invoice amount', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/match/i)
stop()
})
it('rejects an amount-less invoice instead of guessing', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/amount/i)
stop()
})
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({meltAlwaysFails: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
// a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('PAYMENT_FAILED')
// the funds came back, re-secured: the old secret burned, a fresh one
// tracked unspent via the changeset - and no budget spend recorded
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
const returned = state.bearers.find(b => b.id.startsWith('added-'))
expect(returned).toBeDefined()
expect(returned!.spent).toBeUndefined()
expect(returned!.amount).toBe(21_000)
expect(m.state.noteState(noteK1(returned!.url)!)).toBe('outstanding')
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
stop()
})
})
describe('service: make_invoice / lookup_invoice', () => {
it('issues an invoice, settles it in the background, and reports the preimage', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
description: 'nwc test',
expiry: 3600
})
expect(made.error).toBeNull()
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
amount: 21_000,
description: 'nwc test',
created_at: NOW,
expires_at: NOW + 3600
})
const invoice = made.result!.invoice as string
const paymentHash = made.result!.payment_hash as string
expect(invoice).toMatch(/^lnbc/)
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
// before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash
})
expect(pending.error).toBeNull()
expect(pending.result?.state).toBe('pending')
expect(pending.result?.preimage).toBeUndefined()
// the "payer" pays the invoice; the background claim settles and
// mints the note
const settleRes = await fetch(
`${m.url}/_test/settle?payment_hash=${paymentHash}`
)
expect(settleRes.ok).toBe(true)
await waitFor(() =>
state.changesets.some(c => c.add.length > 0)
)
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
expect(settled.result?.settled_at).toBe(NOW)
const preimage = settled.result?.preimage as string
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
// the minted note was claimed AND rotated before settlement was
// recorded: the preimage the client just learned is a burned secret,
// and the wallet's fresh note is the only live one
expect(m.state.noteState(preimage)).toBe('burned')
const minted = state.bearers.find(b => b.id.startsWith('added-'))!
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
expect(noteK1(minted.url)).not.toBe(preimage)
expect(m.state.noteState(noteK1(minted.url)!)).toBe('outstanding')
stop()
})
it('finds an invoice by its invoice string too', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000
})
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: (made.result!.invoice as string).toUpperCase()
})
expect(found.error).toBeNull()
expect(found.result?.payment_hash).toBe(made.result!.payment_hash)
stop()
})
it('answers an unknown invoice with NOT_FOUND', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32)
})
expect(response.error?.code).toBe('NOT_FOUND')
stop()
})
it('answers make_invoice without a default mint with INTERNAL', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({
defaultMint: null
})
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000
})
expect(response.error?.code).toBe('INTERNAL')
stop()
})
it('answers a make_invoice with a bad amount with OTHER', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5
})
expect(response.error?.code).toBe('OTHER')
stop()
})
})
import './nwc.connection.cases';
import './nwc.storage.cases';
import './nwc.ownership.cases';
import './nwc.validation.cases';
import './nwc.pay-a.cases';
import './nwc.pay-b.cases';
import './nwc.fencing.cases';
import './nwc.invoice-a.cases';
import './nwc.invoice-b.cases';
+188
View File
@@ -0,0 +1,188 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
export const LINKING_KEY = new Uint8Array(32).fill(7);
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
// never connected - the in-memory relay below stands in
export const RELAYS = ['wss://relay-a.example'];
let NOW = 1_800_000_000;
export const nowSeconds = (): number => NOW;
export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, {
version: 1,
ownerId: OTHER_OWNER_ID,
clientPubkey: getPublicKey(STRANGER_SECRET),
relays: RELAYS,
budget,
spent: { periodStart: now, msat: 0 },
createdAt: now,
});
export const storeForeignConnection = (record: NwcConnectionRecord): void => {
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
};
// an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records
export const createFakeRelay = (): {
transport: NwcTransport;
published: NostrEvent[];
emit: (event: NostrEvent) => void;
emitAfterClose: (event: NostrEvent) => void;
subscriptionCount: () => number;
} => {
const published: NostrEvent[] = [];
const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const transport: NwcTransport = {
publish: (_relays, event) => {
published.push(event);
return Promise.resolve();
},
subscribe: (_relays, filter, onEvent) => {
const sub = { filter, onEvent };
subs.push(sub);
allSubs.push(sub);
return {
close: () => {
const index = subs.indexOf(sub);
if (index >= 0) subs.splice(index, 1);
},
};
},
};
const deliver = (
targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
event: NostrEvent,
): void => {
for (const sub of [...targets]) {
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
const wanted = sub.filter['#p'];
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''));
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
}
};
const emit = (event: NostrEvent): void => deliver(subs, event);
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
return {
transport,
published,
emit,
emitAfterClose,
subscriptionCount: () => subs.length,
};
};
export type Encryption = 'nip44_v2' | 'nip04' | 'none';
// a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret
export const clientRequest = (
walletServicePubkey: string,
content: string,
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW,
): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]];
if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: createdAt,
tags,
content:
scheme === 'nip44_v2'
? nip44v2.encrypt(
content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey),
)
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
},
CLIENT_SECRET,
);
};
export const methodRequest = (
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2',
createdAt?: number,
): NostrEvent =>
clientRequest(walletServicePubkey, JSON.stringify({ method, params }), scheme, createdAt);
// generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out
export const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(cond()).toBe(true);
};
export const deferred = (): {
promise: Promise<void>;
resolve: () => void;
reject: (reason: Error) => void;
} => {
let resolve = (): void => undefined;
let reject = (_reason: Error): void => undefined;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
export const resetNow = (): void => {
NOW = 1_800_000_000;
};
+327
View File
@@ -0,0 +1,327 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import { isJsonObject } from './jsonParsing';
import {
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OWNER_ID,
RELAYS,
createFakeRelay,
methodRequest,
nowSeconds,
resetNow,
waitFor,
} from './nwc.testProtocol';
import type { Encryption } from './nwc.testProtocol';
export type NwcResponsePayload = {
result_type: string;
error: { code: string; message: string } | null;
result: Record<string, unknown> | null;
};
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) &&
typeof value.result_type === 'string' &&
(value.error === null ||
(isJsonObject(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result));
export const readResponse = (
published: NostrEvent[],
requestId: string,
scheme: Encryption,
): NwcResponsePayload | null => {
const event = published.find(
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId),
);
if (!event) return null;
const plaintext =
scheme === 'nip44_v2'
? nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
)
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
const parsed: unknown = JSON.parse(plaintext);
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
return parsed;
};
// drives one full request/response round trip over the fake relay
export const call = async (
relay: ReturnType<typeof createFakeRelay>,
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2',
): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme);
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
return requiredValue(readResponse(relay.published, request.id, scheme));
};
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()));
});
let bearerCounter = 0;
let allocationCounter = 0;
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat);
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
const info = await fetchNoteInfo(url);
bearerCounter += 1;
return {
id: `bearer-${bearerCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
};
};
// the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret
export const startTestService = async (options: {
budgetMsat?: number;
periodMs?: number;
defaultMint?: string | null;
linkingKey?: Uint8Array;
poll?: typeof FAST_POLL;
claimPoll?: typeof FAST_POLL;
kit?: NwcServiceDeps['kit'];
commitChangeset?: (changeset: NwcChangeset) => Promise<void>;
setMintOutputRetirement?: () => Promise<void>;
}): Promise<{
relay: ReturnType<typeof createFakeRelay>;
walletServicePubkey: string;
state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
stop: () => Promise<void>;
}> => {
const budgetMsat = options.budgetMsat ?? 1_000_000_000;
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS,
budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
clientSecret: CLIENT_SECRET,
now: nowSeconds() * 1000,
});
const relay = createFakeRelay();
const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
bearers: [],
changesets: [],
errors: [],
};
const service = await startService(options.linkingKey ?? LINKING_KEY, {
assertCurrentOwner: () => undefined,
// deterministic stand-in for the wallet's BIP-32 allocation path
allocateOutputSecrets: (_server, count) => {
const secrets = Array.from({ length: count }, () => {
allocationCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)));
});
return Promise.resolve(secrets);
},
// the keys a production caller serves from the trusted-mint registry:
// each created mock mint's current key plus any it retired
mintSignatureKeys: (server) =>
mints.flatMap((m) =>
new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [],
),
recoverPendingMints: () => Promise.resolve(),
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
persistMintOutput: (note) => {
bearerCounter += 1;
const bearer: Bearer = {
...note,
id: `staged-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
};
state.bearers.push(bearer);
return Promise.resolve(bearer);
},
discardMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve();
},
setMintOutputRetirement: (staged, retireAfter) => {
const override = options.setMintOutputRetirement?.();
if (override) return override;
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'));
const updated: Bearer = {
...staged,
pendingMint: { ...staged.pendingMint, retireAfter },
updatedAt: Date.now(),
};
state.bearers[index] = updated;
return Promise.resolve();
},
reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'));
source.spent = true;
state.bearers[index] = {
...state.bearers[index]!,
pendingMint: {
...state.bearers[index]!.pendingMint,
sourceBearerId: sourceId,
sourceRecoverySecret: recoverySecret,
},
};
return Promise.resolve();
},
finalizeMintOutput: async (staged, note) => {
const changeset: NwcChangeset = { add: [], markSpent: [] };
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.');
state.bearers[index] = { ...staged, ...note, pendingMint: undefined, updatedAt: Date.now() };
},
finalizePaymentReturn: async (staged, note) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.');
const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId;
if (sourceId) {
const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (source) source.spent = true;
}
state.bearers[index] = {
...state.bearers[index]!,
...note,
pendingMint: undefined,
updatedAt: Date.now(),
};
},
finalizeSpentMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve();
},
commitCarve: async (carve) => {
// a checkpoint is identified by its output secret: the landed phase's
// URL carries a signature the staged phase's did not have yet
const sameOutput = (url: string, noteUrl: string): boolean =>
serverOf(url) === serverOf(noteUrl) &&
noteK1(url) !== null &&
noteK1(url) === noteK1(noteUrl);
const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
(note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
);
const changeset: NwcChangeset = {
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
};
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
for (const bearer of state.bearers) {
if (changeset.markSpent.includes(bearer.id)) bearer.spent = true;
}
for (const note of additions) {
bearerCounter += 1;
state.bearers.unshift({
...note,
id: `carve-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url));
if (!committed) throw new Error('The carved note was not committed.');
return committed;
},
applyChangeset: async (changeset: NwcChangeset) => {
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
for (const id of changeset.markSpent) {
const bearer = state.bearers.find((b) => b.id === id);
if (bearer) bearer.spent = true;
}
for (const note of changeset.add) {
bearerCounter += 1;
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
},
onError: (err) => {
state.errors.push(err);
},
transport: relay.transport,
kit: options.kit,
poll: options.poll ?? FAST_POLL,
claimPoll: options.claimPoll ?? FAST_POLL,
nowSeconds,
});
return {
relay,
walletServicePubkey: connection.walletServicePubkey,
state,
stop: service.stop,
};
};
beforeEach(async () => {
stubLocalStorage();
await saveLinkingKey(LINKING_KEY);
resetNow();
});
+24 -23
View File
@@ -37,17 +37,18 @@
// per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts.
export type {
NwcBudget,
NwcBudgetSpend,
NwcConnectionRecord
} from './storage/nwcConnections'
export type { NwcBudget, NwcBudgetSpend, NwcConnectionRecord } from './storage/nwcConnections';
export {
clearNwcStorageForOwner,
clearUnownedNwcStorage,
migrateLegacyNwcStorage,
persistNwcConnection,
readNwcConnections,
removeNwcConnection,
writeNwcConnections
} from './storage/nwcConnections'
writeNwcConnections,
} from './storage/nwcConnections';
export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export {
buildConnectionString,
@@ -55,14 +56,14 @@ export {
createConnection,
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString
} from './nwc/connection'
parseConnectionString,
} from './nwc/connection';
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString
} from './nwc/connection'
ParsedConnectionString,
} from './nwc/connection';
export {
NWC_INFO_KIND,
@@ -73,8 +74,8 @@ export {
buildResponseEvent,
decryptRequest,
errResult,
okResult
} from './nwc/protocol'
okResult,
} from './nwc/protocol';
export type {
DecryptedNwcRequest,
NostrEvent,
@@ -82,16 +83,16 @@ export type {
NwcErrorCode,
NwcMethod,
NwcRequest,
NwcResponse
} from './nwc/protocol'
NwcResponse,
} from './nwc/protocol';
export {defaultNwcTransport} from './nwc/transport'
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport'
export { defaultNwcTransport } from './nwc/transport';
export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
export {budgetRemainingMsat, recordSpend} from './nwc/budget'
export type {PendingInvoice, RequestContext} from './nwc/context'
export {invoiceResult, resolvePaymentHash} from './nwc/invoices'
export {payChangeset} from './nwc/pay'
export { budgetRemainingMsat, recordSpend } from './nwc/budget';
export type { PendingInvoice, RequestContext } from './nwc/context';
export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
export { payChangeset } from './nwc/pay';
export {startService} from './nwc/service'
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service'
export { startService } from './nwc/service';
export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
+187
View File
@@ -0,0 +1,187 @@
// The NWC wallet service end to end: connection strings and key
// derivation, the request/response cycle over an in-memory relay (the
// transport is injected - no network), every method against the
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
migrateLegacyNwcStorage,
parseConnectionString,
readNwcEnabled,
readNwcConnections,
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
CLIENT_SECRET,
FAST_POLL,
LINKING_KEY,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
RELAYS,
STRANGER_SECRET,
clientRequest,
createFakeRelay,
deferred,
foreignConnectionFixture,
methodRequest,
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_payments', {});
expect(response.result_type).toBe('get_payments');
expect(response.error?.code).toBe('NOT_IMPLEMENTED');
expect(response.result).toBeNull();
await stop();
});
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, 'this is not json');
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
expect(response.error?.code).toBe('OTHER');
await stop();
});
it('answers a JSON request without a method with an error', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
'OTHER',
);
await stop();
});
it('ignores a request signed by a stranger key - silently', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip44v2.encrypt(
JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
);
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: nowSeconds(),
tags: [
['p', walletServicePubkey],
['encryption', 'nip44_v2'],
],
content,
},
STRANGER_SECRET,
);
relay.emit(forged);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop();
});
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
JSON.stringify({ method: 'get_balance', params: {} }),
);
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: nowSeconds(),
tags: [
['p', walletServicePubkey],
['encryption', 'nip17'],
],
content,
},
CLIENT_SECRET,
);
relay.emit(request);
// the error answer goes out in the legacy scheme every client reads
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION',
);
await stop();
});
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
expect(response.error).toBeNull();
expect(response.result).toEqual({ balance: 0 });
// the response mirrors the request's scheme
const event = requiredValue(
relay.published.find(
(e) =>
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id),
),
);
expect(event.tags).toContainEqual(['encryption', 'nip04']);
}
await stop();
});
it('drops requests older than the replay window unanswered', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
const stale = methodRequest(
walletServicePubkey,
'get_balance',
{},
'nip44_v2',
nowSeconds() - 1200,
);
relay.emit(stale);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop();
});
it('picks up no new requests after stop', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({});
await stop();
const request = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(request);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
});
});
+16 -18
View File
@@ -5,33 +5,31 @@
// check + record below atomic enough: no two pays of one connection ever
// run this concurrently.
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import { persistNwcConnection } from '../storage/nwcConnections';
export const budgetRemainingMsat = (
record: NwcConnectionRecord,
nowMs: number
): number => {
const {maxMsat, periodMs} = record.budget
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
return Math.max(0, maxMsat - record.spent.msat)
}
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
const { maxMsat, periodMs } = record.budget;
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
return Math.max(0, maxMsat - record.spent.msat);
};
// rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write)
export const recordSpend = (
ownerId: string,
record: NwcConnectionRecord,
amountMsat: number,
nowMs: number
nowMs: number,
): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
return persistNwcConnection({
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
return persistNwcConnection(ownerId, {
...record,
spent: expired
? {periodStart: nowMs, msat: amountMsat}
? { periodStart: nowMs, msat: amountMsat }
: {
periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat
}
})
}
msat: record.spent.msat + amountMsat,
},
});
};
+63 -71
View File
@@ -20,138 +20,130 @@
// The connection string follows NIP-47 exactly:
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPublicKey} from 'nostr-tools/pure'
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { getPublicKey } from 'nostr-tools/pure';
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
import { linkingPubKeyHex } from '../keys';
import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
import { persistNwcConnection } from '../storage/nwcConnections';
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1'
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1';
const HEX_64 = /^[0-9a-f]{64}$/i
const HEX_64 = /^[0-9a-f]{64}$/i;
// Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's
// wallet-service identity - it signs and decrypts NIP-47 events for this
// one client, nothing else.
export const deriveNwcWalletKey = (
linkingPrivKey: Uint8Array,
clientPubkey: string
): Uint8Array =>
export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array =>
sha256(
new Uint8Array([
...linkingPrivKey,
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey)
])
)
...hexToBytes(clientPubkey),
]),
);
// the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
getPublicKey(walletSecretKey)
getPublicKey(walletSecretKey);
export type NwcConnectionInfo = {
record: NwcConnectionRecord
walletServicePubkey: string
}
record: NwcConnectionRecord;
walletServicePubkey: string;
};
// the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity
export const connectionInfoOf = (
linkingPrivKey: Uint8Array,
record: NwcConnectionRecord
record: NwcConnectionRecord,
): NwcConnectionInfo => ({
record,
walletServicePubkey: nwcWalletPubkey(
deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
)
})
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
});
export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store
connectionString: string
}
connectionString: string;
};
export type CreateConnectionOptions = {
relays: string[]
budget: NwcBudget
relays: string[];
budget: NwcBudget;
// test hook: a fixed client secret (32 bytes) instead of a random one
clientSecret?: Uint8Array
now?: number
}
clientSecret?: Uint8Array;
now?: number;
};
// Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above.
export const createConnection = (
linkingPrivKey: Uint8Array,
options: CreateConnectionOptions
options: CreateConnectionOptions,
): CreatedConnection => {
if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.')
throw new Error('A connection needs at least one relay.');
}
const clientSecret =
options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientPubkey = getPublicKey(clientSecret)
const record = persistNwcConnection({
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
const clientPubkey = getPublicKey(clientSecret);
const ownerId = linkingPubKeyHex(linkingPrivKey);
const record = persistNwcConnection(ownerId, {
version: 1,
ownerId,
clientPubkey,
relays: options.relays,
budget: options.budget,
spent: {periodStart: options.now ?? Date.now(), msat: 0},
createdAt: options.now ?? Date.now()
})
const info = connectionInfoOf(linkingPrivKey, record)
spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(),
});
const info = connectionInfoOf(linkingPrivKey, record);
return {
...info,
connectionString: buildConnectionString(
info.walletServicePubkey,
bytesToHex(clientSecret),
record.relays
)
}
}
record.relays,
),
};
};
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: string,
relays: string[]
relays: string[],
): string => {
const query = relays
.map(relay => `relay=${encodeURIComponent(relay)}`)
.join('&')
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
}
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
};
export type ParsedConnectionString = {
walletServicePubkey: string
clientSecret: string
relays: string[]
}
walletServicePubkey: string;
clientSecret: string;
relays: string[];
};
// parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse)
export const parseConnectionString = (
uri: string
): ParsedConnectionString | null => {
let url: URL
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
let url: URL;
try {
url = new URL(uri.trim())
url = new URL(uri.trim());
} catch {
return null
return null;
}
if (url.protocol !== 'nostr+walletconnect:') return null
if (url.protocol !== 'nostr+walletconnect:') return null;
// the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex)
const walletServicePubkey = url.host.toLowerCase()
if (!HEX_64.test(walletServicePubkey)) return null
const secret = url.searchParams.get('secret')
if (!secret || !HEX_64.test(secret)) return null
const relays = url.searchParams
.getAll('relay')
.filter(relay => /^wss?:\/\//.test(relay))
if (relays.length === 0) return null
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
}
const walletServicePubkey = url.host.toLowerCase();
if (!HEX_64.test(walletServicePubkey)) return null;
const secret = url.searchParams.get('secret');
if (!secret || !HEX_64.test(secret)) return null;
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
if (relays.length === 0) return null;
return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
};
+86 -39
View File
@@ -5,72 +5,119 @@
// no logic, so every other nwc/ module can import from here without
// cycles.
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import type {PreparedMint} from '../ops'
import type {PollOptions} from '../ops/shared'
import type { Bearer, NewBearer } from '../types';
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import type { PreparedMint } from '../ops';
import type { CarveResult } from '../ops';
import type { OutputSecretAllocator } from '../ops/allocation';
import type { MintSignatureKeys } from '../ops/shared';
import type { PollOptions } from '../ops/shared';
import type {NwcConnectionInfo} from './connection'
import type {NwcMethod} from './protocol'
import type {NwcTransport} from './transport'
import type { NwcConnectionInfo } from './connection';
import type { NwcMethod } from './protocol';
import type { NwcTransport } from './transport';
// the state delta the caller's store applies after an op ran: new notes
// to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state
export type NwcChangeset = {
add: NewBearer[]
markSpent: string[]
}
add: NewBearer[];
markSpent: string[];
};
export type NwcServiceDeps = {
getBearers: () => Bearer[]
getBearers: () => Bearer[];
// the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null
getDefaultMint: () => string | null;
assertCurrentOwner: () => void;
// the wallet's durable allocation path (BIP-32 reservation) for every
// note output secret a request stages
allocateOutputSecrets: (
server: string,
count: number,
assertOwner: () => void,
) => Promise<readonly string[]>;
// trusted current+previous signing keys a landed carve output verifies
// against - the trusted-mint registry, owner-scoped
mintSignatureKeys: MintSignatureKeys;
recoverPendingMints: (assertOwner: () => void) => Promise<void>;
persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer>;
discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
setMintOutputRetirement: (
staged: Bearer,
retireAfter: number,
assertOwner: () => void,
) => Promise<void>;
reserveMintOutputSource: (
staged: Bearer,
sourceId: string,
recoverySecret: string,
assertOwner: () => void,
) => Promise<void>;
finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void>;
finalizePaymentReturn: (
staged: Bearer,
note: NewBearer,
assertOwner: () => void,
) => Promise<void>;
finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer>;
applyChangeset: (
changeset: NwcChangeset,
connection: NwcConnectionInfo,
method: NwcMethod
) => void
transport?: NwcTransport
method: NwcMethod,
assertOwner: () => void,
) => Promise<void>;
transport?: NwcTransport;
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
kit?: LnurlcashOptions;
// verify-poll budget for pay_invoice outcome classification
poll?: PollOptions
poll?: PollOptions;
// verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it
claimPoll?: PollOptions
claimPoll?: PollOptions;
// background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here
onError?: (error: unknown, connection: NwcConnectionInfo) => void
onError?: (error: unknown, connection: NwcConnectionInfo) => void;
// test hook: pinned clock for event timestamps and expirations
nowSeconds?: () => number
}
nowSeconds?: () => number;
};
export type PendingInvoice = {
invoice: string
paymentHash: string
invoice: string;
paymentHash: string;
// the gross invoiced amount (net + mint fee) - what the payer pays
amountMsat: number
description?: string
createdAt: number // unix seconds
expiresAt?: number
prepared: PreparedMint
state: 'pending' | 'settled' | 'failed'
preimage?: string
settledAt?: number
}
amountMsat: number;
description?: string;
createdAt: number; // unix seconds
expiresAt?: number;
prepared: PreparedMint;
stagedOutput: Bearer;
state: 'pending' | 'settled' | 'failed';
preimage?: string;
settledAt?: number;
};
// what a method handler sees of its connection's runtime: the deps, the
// (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = {
deps: NwcServiceDeps
connection: () => NwcConnectionInfo
updateRecord: (record: NwcConnectionRecord) => void
invoices: Map<string, PendingInvoice>
nowSeconds: () => number
}
deps: NwcServiceDeps;
connection: () => NwcConnectionInfo;
updateRecord: (record: NwcConnectionRecord) => void;
invoices: Map<string, PendingInvoice>;
nowSeconds: () => number;
assertOwner: () => void;
// Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup.
startBackground: (work: () => Promise<void>) => boolean;
// fires when the service stops: long OBSERVATION waits (the invoice
// claim poll) must interrupt themselves on it. Work that has already
// reached a fund-critical commit must NOT consult it - stop awaits
// those tasks through its drain
stopSignal: AbortSignal;
};
+132 -84
View File
@@ -4,25 +4,25 @@
// results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself.
import {noteK1, sameInvoice} from 'lnurlcash-kit'
import { noteK1, sameInvoice } from 'lnurlcash-kit';
import type {Bearer} from '../types'
import type {PreparedMint} from '../ops'
import {prepareMint} from '../ops'
import type { Bearer } from '../types';
import type { PreparedMint } from '../ops';
import { prepareMint } from '../ops';
import { PollAbortedError } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context'
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
import {handlePayInvoice} from './pay'
import type {NwcRequest, NwcResponse} from './protocol'
import {NWC_METHODS, errResult, okResult} from './protocol'
import type { PendingInvoice, RequestContext } from './context';
import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
import { handlePayInvoice } from './pay';
import type { NwcRequest, NwcResponse } from './protocol';
import { NWC_METHODS, errResult, okResult } from './protocol';
const MAX_PENDING_MINT_OUTPUTS = 20;
// the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean =>
!bearer.spent &&
bearer.callback !== '' &&
!bearer.deviceId &&
!!noteK1(bearer.url)
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url);
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', {
@@ -34,42 +34,97 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
// block height/hash exists, so those fields are simply absent.
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: []
})
notifications: [],
});
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', {
balance: ctx.deps
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0)
})
.reduce((sum, b) => sum + b.amount, 0),
});
const handleMakeInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const amountMsat = Number(params.amount)
const amountMsat = Number(params.amount);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
return errResult(
'make_invoice',
'OTHER',
'Amount must be a positive whole number of msat.'
)
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.');
}
const mint = ctx.deps.getDefaultMint()
const mint = ctx.deps.getDefaultMint();
if (!mint) {
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.');
}
let prepared: PreparedMint
const now = ctx.nowSeconds();
if (
ctx.deps
.getBearers()
.some(
(bearer) =>
!bearer.spent &&
bearer.pendingMint?.retireAfter !== undefined &&
bearer.pendingMint.retireAfter <= now,
)
) {
await ctx.deps.recoverPendingMints(ctx.assertOwner);
}
const pendingOutputs = ctx.deps
.getBearers()
.filter((bearer) => bearer.pendingMint && !bearer.spent).length;
if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.');
}
let prepared: PreparedMint;
let stagedOutput: Bearer | undefined;
try {
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
prepared = await prepareMint(mint, amountMsat, {
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
allocateOutputSecrets: (server, count) =>
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
persistOutput: async (note) => {
stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner);
},
});
} catch (err) {
return errResult(
'make_invoice',
'INTERNAL',
err instanceof Error ? err.message : String(err)
)
if (stagedOutput) {
try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) {
return errResult(
'make_invoice',
'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
);
}
}
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err));
}
if (!stagedOutput) {
return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.');
}
const retireAfter = invoiceRetireAfter(prepared.invoice);
if (retireAfter !== null) {
try {
await ctx.deps.setMintOutputRetirement(stagedOutput, retireAfter, ctx.assertOwner);
} catch (error) {
try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) {
return errResult(
'make_invoice',
'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
);
}
return errResult(
'make_invoice',
'INTERNAL',
error instanceof Error ? error.message : String(error),
);
}
}
const entry: PendingInvoice = {
invoice: prepared.invoice,
@@ -77,77 +132,70 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
state: 'pending'
}
stagedOutput,
state: 'pending',
};
if (typeof params.description === 'string' && params.description) {
entry.description = params.description
entry.description = params.description;
}
const expiry = Number(params.expiry)
const expiry = Number(params.expiry);
if (Number.isInteger(expiry) && expiry > 0) {
entry.expiresAt = entry.createdAt + expiry
entry.expiresAt = entry.createdAt + expiry;
}
ctx.invoices.set(entry.paymentHash, entry)
ctx.invoices.set(entry.paymentHash, entry);
// phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the claim observes
void settleAndClaim(ctx, entry).catch(err => {
entry.state = 'failed'
ctx.deps.onError?.(err, ctx.connection())
})
return okResult('make_invoice', invoiceResult(entry))
}
const handleLookupInvoice = (
ctx: RequestContext,
params: Record<string, unknown>
): NwcResponse => {
const invoiceParam =
typeof params.invoice === 'string' ? params.invoice : undefined
const hashParam =
typeof params.payment_hash === 'string'
? params.payment_hash.toLowerCase()
: undefined
if (!invoiceParam && !hashParam) {
return errResult(
'lookup_invoice',
'OTHER',
'Provide an invoice or a payment hash.'
)
// lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed';
// an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing
if (err instanceof PollAbortedError) return;
ctx.deps.onError?.(err, ctx.connection());
}),
);
if (!started) {
entry.state = 'failed';
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
return okResult('make_invoice', invoiceResult(entry));
};
const handleLookupInvoice = (ctx: RequestContext, params: Record<string, unknown>): NwcResponse => {
const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
const hashParam =
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined;
if (!invoiceParam && !hashParam) {
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined;
if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) {
entry = candidate
break
entry = candidate;
break;
}
}
}
if (!entry) {
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.')
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
}
return okResult('lookup_invoice', invoiceResult(entry))
}
return okResult('lookup_invoice', invoiceResult(entry));
};
export const dispatch = async (
ctx: RequestContext,
request: NwcRequest
): Promise<NwcResponse> => {
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
switch (request.method) {
case 'get_info':
return handleGetInfo(ctx)
return handleGetInfo(ctx);
case 'get_balance':
return handleGetBalance(ctx)
return handleGetBalance(ctx);
case 'make_invoice':
return await handleMakeInvoice(ctx, request.params)
return await handleMakeInvoice(ctx, request.params);
case 'pay_invoice':
return await handlePayInvoice(ctx, request.params)
return await handlePayInvoice(ctx, request.params);
case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params)
return handleLookupInvoice(ctx, request.params);
default:
return errResult(
request.method,
'NOT_IMPLEMENTED',
`Unknown method: ${request.method}.`
)
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
}
}
};
+108 -96
View File
@@ -1,49 +1,78 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims
// the minted note (rotating it immediately), and only THEN records the
// settlement the lookup reports.
//
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted
// note's initial secret. NIP-47 clients expect it as the settlement
// receipt, so it is handed out - but only after claimFromPreimage has
// rotated the fresh note, when that secret is burned and worthless. If
// the rotation failed (claimed.rotated === false) the preimage is
// withheld: fund safety over spec comfort.
// shares. A minted note always lives at the wallet-chosen secret that was
// durably staged before quote creation; the payment preimage is only a
// receipt. Settlement becomes visible only after the staged bearer has
// been replaced with the confirmed note details.
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import { isPreimage, sameInvoice } from 'lnurlcash-kit';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops'
import {claimFromPreimage} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared'
import type { PreparedMint } from '../ops';
import { MintedNoteSpentError } from '../ops';
import { claimFromSecret } from '../ops/mint';
import { pollVerifyUntilSettled } from '../ops/shared';
import type { PollOptions } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context'
import type { PendingInvoice, RequestContext } from './context';
export const DEFAULT_CLAIM_POLL: Required<PollOptions> = {
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000
}
maxWaitMs: 15 * 60_000,
};
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
// convention) - that suffix is the invoice's real payment hash. A service
// that shapes its verify URL differently gets a wallet-local correlation
// id instead (sha256 of the invoice): still unique and stable for
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
// The kit has already validated the invoice before this parser runs. Read
// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
// durable forever rather than risking retirement while it could still settle.
export const invoiceRetireAfter = (invoice: string): number | null => {
const normalized = invoice.toLowerCase();
const separator = normalized.lastIndexOf('1');
if (separator < 1 || normalized.length - separator <= 6) return null;
const words = [...normalized.slice(separator + 1, -6)].map((character) =>
BECH32_CHARSET.indexOf(character),
);
if (words.length < 7 || words.some((word) => word < 0)) return null;
const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(timestamp)) return null;
let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS;
let offset = 7;
// Conformance invoices omit the 65-byte signature; real BOLT11 invoices
// include its fixed 104 words after the tagged fields.
const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length;
while (offset + 3 <= taggedEnd) {
const tag = words[offset];
const firstLength = words[offset + 1];
const secondLength = words[offset + 2];
if (tag === undefined || firstLength === undefined || secondLength === undefined) return null;
const length = firstLength * 32 + secondLength;
const valueStart = offset + 3;
const valueEnd = valueStart + length;
if (valueEnd > taggedEnd) return null;
if (tag === BOLT11_EXPIRY_TAG) {
expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(expiry)) return null;
}
offset = valueEnd;
}
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS;
return Number.isSafeInteger(retireAfter) ? retireAfter : null;
};
// LUD-21 verify URLs conventionally end in the payment hash. A service
// that uses another shape gets a stable wallet-local correlation id.
export const resolvePaymentHash = (prepared: PreparedMint): string => {
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
if (fromVerify) return fromVerify.toLowerCase()
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
}
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
if (fromVerify) return fromVerify.toLowerCase();
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
};
// the NIP-47 transaction object make_invoice and lookup_invoice share
export const invoiceResult = (
entry: PendingInvoice
): Record<string, unknown> => {
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
const result: Record<string, unknown> = {
type: 'incoming',
state: entry.state,
@@ -51,66 +80,49 @@ export const invoiceResult = (
payment_hash: entry.paymentHash,
amount: entry.amountMsat,
created_at: entry.createdAt,
metadata: {}
}
if (entry.description) result.description = entry.description
if (entry.expiresAt) result.expires_at = entry.expiresAt
if (entry.state === 'settled' && entry.preimage) {
result.preimage = entry.preimage
}
if (entry.settledAt) result.settled_at = entry.settledAt
return result
}
metadata: {},
};
if (entry.description) result.description = entry.description;
if (entry.expiresAt) result.expires_at = entry.expiresAt;
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
if (entry.settledAt) result.settled_at = entry.settledAt;
return result;
};
// the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh
// bearer to the caller. Settlement is recorded LAST - after the rotate -
// so a preimage lookup can only ever reveal an already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports
// through deps.onError.
export const settleAndClaim = async (
ctx: RequestContext,
entry: PendingInvoice
): Promise<void> => {
if (!entry.prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
)
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
const prepared = entry.prepared;
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
let preimage: string | null = null;
if (prepared.verifyUrl) {
// Observation remains interruptible. Once settlement is observed, the
// claim and durable update ignore shutdown while stop drains the task.
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
{ ...claimPoll, signal: ctx.stopSignal },
ctx.deps.kit ?? {},
);
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.");
}
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage;
}
const result = await pollVerifyUntilSettled(
entry.prepared.verifyUrl,
ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL,
ctx.deps.kit ?? {}
)
// a settled report only means this wallet's invoice was paid if it's
// for the invoice this wallet actually requested
if (!sameInvoice(result.pr, entry.prepared.invoice)) {
throw new Error(
"The service's verify response is for a different invoice than requested."
)
let claimed;
try {
claimed = await claimFromSecret(
prepared,
prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
},
);
} catch (error) {
if (!(error instanceof MintedNoteSpentError)) throw error;
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
throw error;
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error(
'The payment settled but the service did not reveal the preimage.'
)
}
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
// other half) - invoked in two steps here because NWC needs the
// preimage, which claimMintedNote deliberately discards
const claimed = await claimFromPreimage(
entry.prepared,
preimage,
ctx.deps.kit ?? {}
)
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
if (claimed.rotated) {
// see the header: only a rotated note makes the preimage a worthless
// secret, safe to hand out as the settlement receipt
entry.preimage = preimage
}
ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice')
}
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
entry.settledAt = ctx.nowSeconds();
entry.state = 'settled';
if (preimage) entry.preimage = preimage;
};
+143 -83
View File
@@ -9,161 +9,221 @@ import {
decodeBolt11AmountMsat,
fetchInvoiceVerification,
isBolt11Invoice,
noteK1
} from 'lnurlcash-kit'
noteK1,
withNewK1,
} from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types'
import type {PayResult} from '../ops'
import {UncertainOutcomeError, payWithBearers} from '../ops'
import type { Bearer, NewBearer } from '../types';
import type { PayResult } from '../ops';
import { UncertainOutcomeError, payWithBearers } from '../ops';
import {budgetRemainingMsat, recordSpend} from './budget'
import type {NwcChangeset, RequestContext} from './context'
import type {NwcResponse} from './protocol'
import {errResult, okResult} from './protocol'
import { budgetRemainingMsat, recordSpend } from './budget';
import type { NwcChangeset, RequestContext } from './context';
import type { NwcResponse } from './protocol';
import { errResult, okResult } from './protocol';
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds
// are tracked UNMARKED, rescued secrets are tracked unverified. One
// are tracked UNMARKED, rescued secrets are tracked unverified. The
// returned funds live in `rotatedNote` when the classification rotate
// re-secured them (burning the carved note in the process); one
// deliberate divergence: in the exact-match + funds-returned case the
// original bearer (old k1, burned server-side by the classification
// rotate) is left for the next refresh to reconcile, exactly as the UI
// leaves it - the money itself sits in the re-secured note, which IS
// tracked.
export const payChangeset = (
bearers: Bearer[],
result: PayResult
): NwcChangeset => {
const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map(b => b.id)
if (result.carve.change) add.push(result.carve.change)
// original bearer (old k1, burned server-side by that rotate) is left for
// the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = [];
const markSpent: string[] = result.carve.consumed.map((b) => b.id);
// the staged change's URL may lack the signature the landed result's URL
// carries - identify outputs by their secret, not the whole URL
const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null;
if (
result.carve.change &&
!bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
) {
add.push(result.carve.change);
}
const carvedK1 = noteK1(result.carve.note.url);
const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined;
if (result.outcome === 'failed-funds-returned') {
add.push(result.carve.note)
if (existing) {
const index = markSpent.indexOf(existing.id);
if (index >= 0) markSpent.splice(index, 1);
}
} else {
// settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match
// carve), lock that bearer; a freshly carved note is never added -
// it was born spent
const carvedK1 = noteK1(result.carve.note.url)
const existing = carvedK1
? bearers.find(b => noteK1(b.url) === carvedK1)
: undefined
if (existing) markSpent.push(existing.id)
if (existing) markSpent.push(existing.id);
}
if (result.rescuedNote) add.push(result.rescuedNote)
return {add, markSpent}
}
return { add, markSpent };
};
export const handlePayInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const invoice =
typeof params.invoice === 'string' ? params.invoice.trim() : ''
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
if (!isBolt11Invoice(invoice)) {
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.');
}
// the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request
const amountMsat = decodeBolt11AmountMsat(invoice)
const amountMsat = decodeBolt11AmountMsat(invoice);
if (amountMsat === null || amountMsat <= 0) {
return errResult(
'pay_invoice',
'OTHER',
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
)
"Could not read this invoice's amount - amount-less invoices are not supported.",
);
}
if (params.amount !== undefined && params.amount !== amountMsat) {
return errResult(
'pay_invoice',
'OTHER',
'The request\'s amount does not match the invoice.'
)
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
}
if (
amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())
) {
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
'This payment exceeds the connection\'s budget.'
)
"This payment exceeds the connection's budget.",
);
}
const bearers = ctx.deps.getBearers()
let result: PayResult
const bearers = ctx.deps.getBearers();
let result: PayResult;
let committedCarve: Bearer | undefined;
let returnStage: Bearer | undefined;
try {
result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {}
})
kit: ctx.deps.kit ?? {},
assertOwner: ctx.assertOwner,
allocateOutputSecrets: (server, count) =>
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
mintSignatureKeys: ctx.deps.mintSignatureKeys,
onCarve: async (carve) => {
committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner);
},
onReturnReady: async (carve, recoverySecret) => {
const source =
committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.');
returnStage = await ctx.deps.persistMintOutput(
{
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
callback: '',
amount: 0,
verified: false,
pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 },
},
ctx.assertOwner,
);
await ctx.deps.reserveMintOutputSource(
returnStage,
source.id,
recoverySecret,
ctx.assertOwner,
);
},
});
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the
// possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped
ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []},
await ctx.deps.applyChangeset(
{ add: err.possibleOutputs, markSpent: [] },
ctx.connection(),
'pay_invoice'
)
'pay_invoice',
ctx.assertOwner,
);
return errResult(
'pay_invoice',
'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.'
)
'The payment preparation could not be confirmed; possible new notes were stored unverified.',
);
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
return errResult(
'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message
)
message,
);
}
if (returnStage) {
const returned = result.rotatedNote ?? result.rescuedNote;
if (returned) {
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
} else if (result.outcome !== 'unknown-still-pending') {
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
}
}
const spendRecorded = (): void => {
ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now()))
}
// This conservative budget debit is persisted separately from bearer
// storage. A later bearer commit failure does not roll it back.
const connection = ctx.connection().record;
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
};
switch (result.outcome) {
case 'settled': {
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
);
// the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one.
let preimage = ''
let preimage = '';
if (result.verifyUrl) {
try {
const proof = await fetchInvoiceVerification(
result.verifyUrl,
ctx.deps.kit ?? {}
)
preimage = proof.preimage ?? ''
} catch {
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
preimage = proof.preimage ?? '';
} catch (error) {
// the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome
if (!(error instanceof Error)) throw error;
}
}
return okResult('pay_invoice', {preimage})
return okResult('pay_invoice', { preimage });
}
case 'failed-funds-returned':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
);
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.'
)
'The payment failed; the funds are back in the wallet.',
);
case 'note-already-spent':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
);
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.'
)
'The note backing this payment was already spent; nothing was paid.',
);
case 'unknown-still-pending':
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
);
return errResult(
'pay_invoice',
'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.'
)
'The payment is still in flight; the note stays locked until it reconciles.',
);
}
}
};
+71 -84
View File
@@ -13,26 +13,26 @@
// - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes
import type {Event as NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import type { Event as NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
export type {NostrEvent}
export type { NostrEvent };
export const NWC_REQUEST_KIND = 23194
export const NWC_RESPONSE_KIND = 23195
export const NWC_INFO_KIND = 13194
export const NWC_REQUEST_KIND = 23194;
export const NWC_RESPONSE_KIND = 23195;
export const NWC_INFO_KIND = 13194;
export const NWC_METHODS = [
'get_info',
'get_balance',
'make_invoice',
'pay_invoice',
'lookup_invoice'
] as const
'lookup_invoice',
] as const;
export type NwcMethod = (typeof NWC_METHODS)[number]
export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode =
| 'RATE_LIMITED'
@@ -45,67 +45,61 @@ export type NwcErrorCode =
| 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED'
| 'NOT_FOUND'
| 'OTHER'
| 'OTHER';
export type NwcRequest = {
method: string
params: Record<string, unknown>
}
method: string;
params: Record<string, unknown>;
};
export type NwcResponse = {
result_type: string
error: {code: NwcErrorCode; message: string} | null
result: unknown
}
result_type: string;
error: { code: NwcErrorCode; message: string } | null;
result: unknown;
};
export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method,
error: null,
result
})
result,
});
export const errResult = (
method: string,
code: NwcErrorCode,
message: string
): NwcResponse => ({
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method,
error: {code, message},
result: null
})
error: { code, message },
result: null,
});
// the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04'
export type NwcEncryption = 'nip44_v2' | 'nip04';
const conversationKey = (
walletSecretKey: Uint8Array,
clientPubkey: string
): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
plaintext: string
plaintext: string,
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
content: string
content: string,
): string =>
scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content)
: nip04Decrypt(walletSecretKey, clientPubkey, content);
const tagValue = (event: NostrEvent, name: string): string | undefined =>
event.tags.find(t => t[0] === name)?.[1]
event.tags.find((t) => t[0] === name)?.[1];
// The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can
@@ -118,34 +112,34 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// dropped silently, exactly what a relay full of strangers' traffic
// demands
export type DecryptedNwcRequest =
| {respond: false; request: NwcRequest; encryption: NwcEncryption}
| {respond: true; response: NwcResponse; encryption: NwcEncryption}
| { respond: false; request: NwcRequest; encryption: NwcEncryption }
| { respond: true; response: NwcResponse; encryption: NwcEncryption };
export const decryptRequest = (
walletSecretKey: Uint8Array,
walletServicePubkey: string,
clientPubkey: string,
event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000)
nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => {
if (event.kind !== NWC_REQUEST_KIND) return null
if (event.kind !== NWC_REQUEST_KIND) return null;
// only the authorized client may talk to this connection, and the
// request must actually be addressed to it
if (event.pubkey !== clientPubkey) return null
if (tagValue(event, 'p') !== walletServicePubkey) return null
if (!verifyEvent(event)) return null
if (event.pubkey !== clientPubkey) return null;
if (tagValue(event, 'p') !== walletServicePubkey) return null;
if (!verifyEvent(event)) return null;
// an expired request is ignored, never answered (NIP-47)
const expiration = tagValue(event, 'expiration')
const expiration = tagValue(event, 'expiration');
if (expiration !== undefined && Number(expiration) < nowSeconds) {
return null
return null;
}
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
const advertised = tagValue(event, 'encryption')
let encryption: NwcEncryption
const advertised = tagValue(event, 'encryption');
let encryption: NwcEncryption;
if (advertised === undefined || advertised === 'nip04') {
encryption = 'nip04'
encryption = 'nip04';
} else if (advertised === 'nip44_v2') {
encryption = 'nip44_v2'
encryption = 'nip44_v2';
} else {
// the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read
@@ -155,26 +149,26 @@ export const decryptRequest = (
response: errResult(
'',
'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`
)
}
`Unsupported encryption scheme: ${advertised}.`,
),
};
}
let plaintext: string
let plaintext: string;
try {
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
} catch {
// undecryptable - indistinguishable from relay noise; stay silent
return null
return null;
}
let data: unknown
let data: unknown;
try {
data = JSON.parse(plaintext)
data = JSON.parse(plaintext);
} catch {
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.')
}
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
};
}
if (
typeof data !== 'object' ||
@@ -185,16 +179,14 @@ export const decryptRequest = (
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.')
}
response: errResult('', 'OTHER', 'The request has no method.'),
};
}
const request = data as NwcRequest
const request = data as NwcRequest;
const params =
typeof request.params === 'object' && request.params !== null
? request.params
: {}
return {respond: false, request: {method: request.method, params}, encryption}
}
typeof request.params === 'object' && request.params !== null ? request.params : {};
return { respond: false, request: { method: request.method, params }, encryption };
};
// signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag
@@ -204,7 +196,7 @@ export const buildResponseEvent = (
encryption: NwcEncryption,
requestEventId: string,
response: NwcResponse,
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
@@ -213,29 +205,24 @@ export const buildResponseEvent = (
tags: [
['p', clientPubkey],
['e', requestEventId],
['encryption', encryption]
['encryption', encryption],
],
content: encryptFor(
encryption,
walletSecretKey,
clientPubkey,
JSON.stringify(response)
)
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
},
walletSecretKey
)
walletSecretKey,
);
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
walletSecretKey: Uint8Array,
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
kind: NWC_INFO_KIND,
created_at: createdAt,
tags: [['encryption', 'nip44_v2 nip04']],
content: NWC_METHODS.join(' ')
content: NWC_METHODS.join(' '),
},
walletSecretKey
)
walletSecretKey,
);
+161 -91
View File
@@ -14,177 +14,247 @@
// foreground-only design itself is documented in the nwc.ts façade
// header.
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {readNwcConnections} from '../storage/nwcConnections'
import { linkingPubKeyHex } from '../keys';
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import { readNwcConnections } from '../storage/nwcConnections';
import { assertSavedKeyOwner } from '../storage/currentOwner';
import type {NwcConnectionInfo} from './connection'
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
import {dispatch} from './dispatch'
import type {
NostrEvent,
NwcEncryption,
NwcRequest,
NwcResponse
} from './protocol'
import {
NWC_REQUEST_KIND,
buildInfoEvent,
buildResponseEvent,
decryptRequest
} from './protocol'
import type {NwcSubscription, NwcTransport} from './transport'
import {defaultNwcTransport} from './transport'
import type { NwcConnectionInfo } from './connection';
import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
import { dispatch } from './dispatch';
import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
import type { NwcSubscription } from './transport';
import { defaultNwcTransport } from './transport';
export type {NwcConnectionInfo}
export type {NwcChangeset, NwcServiceDeps} from './context'
export type { NwcConnectionInfo };
export type { NwcChangeset, NwcServiceDeps } from './context';
// requests older than this are dropped unanswered (see the header)
const MAX_REQUEST_AGE_SECONDS = 600
const MAX_REQUEST_AGE_SECONDS = 600;
type ConnectionRuntime = {
info: NwcConnectionInfo
walletSecret: Uint8Array
info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains
walletSecret: Uint8Array | null;
// invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet
invoices: Map<string, PendingInvoice>
invoices: Map<string, PendingInvoice>;
// serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave
queue: Promise<unknown>
sub: NwcSubscription
}
queue: Promise<unknown>;
sub: NwcSubscription;
};
export type NwcService = {
// a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state
connections: NwcConnectionInfo[]
connections: NwcConnectionInfo[];
// closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up
stop: () => void
}
stop: () => Promise<void>;
};
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records: NwcConnectionRecord[] = readNwcConnections()
records?: NwcConnectionRecord[],
): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport())
const nowSeconds = (): number =>
deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
const transport = deps.transport ?? (await defaultNwcTransport());
const ownerId = linkingPubKeyHex(linkingPrivKey);
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId,
);
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
const publishResponse = async (
runtime: ConnectionRuntime,
walletSecret: Uint8Array,
requestEventId: string,
encryption: NwcEncryption,
response: NwcResponse
response: NwcResponse,
): Promise<void> => {
await transport.publish(
runtime.info.record.relays,
buildResponseEvent(
runtime.walletSecret,
walletSecret,
runtime.info.record.clientPubkey,
encryption,
requestEventId,
response,
nowSeconds()
)
)
}
nowSeconds(),
),
);
};
// pay_invoice goes through the connection's queue (budget atomicity);
// everything else dispatches directly
let invoiceQueue: Promise<unknown> = Promise.resolve();
// Payments serialize per connection for budget atomicity. Invoice
// creation serializes across the service so different clients cannot
// race the wallet-wide durable pending-output cap.
const dispatchSerialized = (
runtime: ConnectionRuntime,
ctx: RequestContext,
request: NwcRequest
request: NwcRequest,
): Promise<NwcResponse> => {
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
if (request.method === 'make_invoice') {
const run = invoiceQueue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request),
);
invoiceQueue = run.catch(() => undefined);
return run;
}
if (request.method !== 'pay_invoice') return dispatch(ctx, request);
const run = runtime.queue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request)
)
runtime.queue = run.catch(() => undefined)
return run
}
() => dispatch(ctx, request),
);
runtime.queue = run.catch(() => undefined);
return run;
};
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent
event: NostrEvent,
): Promise<void> => {
const at = nowSeconds()
const walletSecret = runtime.walletSecret;
if (walletSecret === null) return;
const at = nowSeconds();
// replay safety (see the header): too-old requests are dropped
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return;
const decrypted = decryptRequest(
runtime.walletSecret,
walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at
)
if (decrypted === null) return
at,
);
if (decrypted === null) return;
if (decrypted.respond) {
await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response)
return
await publishResponse(
runtime,
walletSecret,
event.id,
decrypted.encryption,
decrypted.response,
);
return;
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, event.id, decrypted.encryption, response)
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request);
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
};
const runtimes = records.map(record => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
let accepting = true;
// interrupts long observation waits (the invoice claim poll) at stop;
// the drain below still awaits tasks that reached a fund-critical commit
const stopController = new AbortController();
const inFlight = new Set<Promise<void>>();
const track = (task: Promise<void>): void => {
inFlight.add(task);
void task.then(
() => inFlight.delete(task),
() => inFlight.delete(task),
);
};
const startBackground = (work: () => Promise<void>): boolean => {
if (!accepting) return false;
track(work());
return true;
};
let runtimes = ownedRecords.map((record) => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
const runtime: ConnectionRuntime = {
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
walletSecret,
invoices: new Map(),
queue: Promise.resolve(),
// replaced below, immediately - the field exists because the
// subscription callback closes over the runtime
sub: {close: () => undefined}
}
sub: { close: () => undefined },
};
const ctx: RequestContext = {
deps,
connection: () => runtime.info,
updateRecord: updated => {
runtime.info = {...runtime.info, record: updated}
updateRecord: (updated) => {
runtime.info = { ...runtime.info, record: updated };
},
invoices: runtime.invoices,
nowSeconds
}
nowSeconds,
assertOwner: () => {
deps.assertCurrentOwner();
assertSavedKeyOwner(ownerId);
},
startBackground,
stopSignal: stopController.signal,
};
runtime.sub = transport.subscribe(
record.relays,
{
kinds: [NWC_REQUEST_KIND],
'#p': [runtime.info.walletServicePubkey],
since: nowSeconds()
since: nowSeconds(),
},
event => {
void handleEvent(runtime, ctx, event).catch(err =>
deps.onError?.(err, runtime.info)
)
}
)
return runtime
})
(event) => {
if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info);
});
track(handler);
},
);
return runtime;
});
// info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) {
const walletSecret = runtime.walletSecret;
if (walletSecret === null) continue;
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(runtime.walletSecret, nowSeconds())
)
} catch (err) {
deps.onError?.(err, runtime.info)
buildInfoEvent(walletSecret, nowSeconds()),
);
} catch (error) {
deps.onError?.(
error instanceof Error
? error
: new Error('NWC info publication failed.', { cause: error }),
runtime.info,
);
}
}
const connections = runtimes.map((runtime) => runtime.info);
let stopPromise: Promise<void> | null = null;
const stop = (): Promise<void> => {
if (stopPromise !== null) return stopPromise;
accepting = false;
for (const runtime of runtimes) runtime.sub.close();
stopController.abort();
stopPromise = Promise.allSettled([...inFlight])
.then((results) => {
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
})
.finally(() => {
for (const runtime of runtimes) {
runtime.walletSecret?.fill(0);
runtime.walletSecret = null;
runtime.invoices.clear();
}
runtimes = [];
});
return stopPromise;
};
return {
connections: runtimes.map(r => r.info),
stop: () => {
for (const runtime of runtimes) runtime.sub.close()
}
}
}
connections,
stop,
};
};
+17 -17
View File
@@ -4,37 +4,37 @@
// importing the service module never opens (or even references) a
// WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter'
import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './protocol'
import type { NostrEvent } from './protocol';
export type {NostrFilter}
export type { NostrFilter };
export type NwcSubscription = {
close: () => void
}
close: () => void;
};
export type NwcTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void>
publish: (relays: string[], event: NostrEvent) => Promise<void>;
subscribe: (
relays: string[],
filter: NostrFilter,
onEvent: (event: NostrEvent) => void
) => NwcSubscription
}
onEvent: (event: NostrEvent) => void,
) => NwcSubscription;
};
export const defaultNwcTransport = async (): Promise<NwcTransport> => {
const {SimplePool} = await import('nostr-tools/pool')
const pool = new SimplePool()
const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool();
return {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay accepting is enough - same rule as the backup
if (!results.some(r => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.')
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.');
}
},
subscribe: (relays, filter, onEvent) =>
pool.subscribeMany(relays, filter, {onevent: onEvent})
}
}
pool.subscribeMany(relays, filter, { onevent: onEvent }),
};
};
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import type { CarveResult } from './ops';
import { ensureExactAmount, UnsupportedMultiBatchMergeError } from './ops';
import { requiredValue } from './test-utils';
import { makeBearer, mint, secret } from './ops.testHarness';
const allocatedSecrets = (
...secrets: string[]
): ((server: string, count: number) => Promise<readonly string[]>) => {
let served = false;
return (_server, count) => {
if (served) throw new Error('The test expected a single allocation.');
served = true;
return Promise.resolve(secrets.slice(0, count));
};
};
describe('ensureExactAmount crash safety', () => {
it('stages both split secrets before either hash reaches the mint', async () => {
const instance = await mint();
const sourceSecret = secret('70');
const targetSecret = secret('71');
const changeSecret = secret('72');
const bearer = await makeBearer(instance, sourceSecret, 21_000);
let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret);
expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret);
expect(instance.state.noteState(sourceSecret)).toBe('outstanding');
}
return fetch(input, init);
};
const result = await ensureExactAmount([bearer], 5_000, {
fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret),
onCarve: (prepared) => {
staged = prepared;
},
});
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
});
it('stages the merge secret before its hash reaches the mint', async () => {
const instance = await mint();
const firstSecret = secret('73');
const secondSecret = secret('74');
const mergedSecret = secret('75');
const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000);
let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret);
expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding');
}
return fetch(input, init);
};
await ensureExactAmount([first, second], 7_000, {
fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(mergedSecret),
onCarve: (prepared) => {
staged = prepared;
},
});
expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]);
});
it('does not expose a staged split output to a second ambiguous rotation', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('76'), 21_000);
let mutationCount = 0;
const dropSecondMutation: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('/w/cb')) return fetch(input, init);
mutationCount += 1;
const response = await fetch(input, init);
if (mutationCount === 2) {
await response.arrayBuffer();
throw new Error('second mutation response lost');
}
return response;
};
const result = await ensureExactAmount([bearer], 5_000, {
fetch: dropSecondMutation,
mutationRetries: 0,
allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')),
onCarve: () => undefined,
});
expect(mutationCount).toBe(1);
const targetSecret = requiredValue(noteK1(result.note.url));
const changeSecret = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(targetSecret)).toBe('outstanding');
expect(instance.state.noteState(changeSecret)).toBe('outstanding');
});
it('rejects a multi-batch merge before any partial fold can land', async () => {
const instance = await mint();
const bearers = await Promise.all(
Array.from({ length: 21 }, (_, index) =>
makeBearer(instance, secret((128 + index).toString(16)), 1_000),
),
);
let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init);
};
const failure = ensureExactAmount(bearers, 21_000, {
fetch: countingFetch,
onCarve: () => undefined,
});
await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError);
expect(mutationCount).toBe(0);
expect(
bearers.every(
(bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding',
),
).toBe(true);
});
it('checks ownership again immediately before a merge mutation', async () => {
const instance = await mint();
const firstSecret = secret('79');
const secondSecret = secret('7a');
const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000);
let ownerChecks = 0;
let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init);
};
await expect(
ensureExactAmount([first, second], 7_000, {
fetch: countingFetch,
allocateOutputSecrets: allocatedSecrets(secret('7b')),
assertOwner: () => {
ownerChecks += 1;
if (ownerChecks === 3) throw new Error('wallet owner changed');
},
onCarve: () => undefined,
}),
).rejects.toThrow(/owner changed/);
expect(mutationCount).toBe(0);
expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding');
});
});
+361
View File
@@ -0,0 +1,361 @@
import { describe, expect, it } from 'vitest';
import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
import type { Bearer } from './types';
import type { CarveResult } from './ops';
import {
CarveCheckpointRequiredError,
OutputSecretAllocationRequiredError,
UncertainOutcomeError,
ensureExactAmount,
} from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
secret,
signatureKeysFor,
} from './ops.testHarness';
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const instance = await mint();
const k1 = secret('01');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 21_000);
expect(noteK1(result.note.url)).toBe(k1);
expect(result.consumed).toEqual([]);
expect(result.change).toBeUndefined();
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('split path: carves an exact note off a larger one, with change', async () => {
const instance = await mint();
const k1 = secret('02');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.amount).toBe(5_000);
expect(result.note.verified).toBe(true);
expect(result.change?.amount).toBe(16_000);
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
});
it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('03'), 3_000);
const second = await makeBearer(instance, secret('04'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
expect(result.note.amount).toBe(7_000);
expect(result.change).toBeUndefined();
expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000);
});
it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('05'), 3_000);
const second = await makeBearer(instance, secret('06'), 4_000);
const result = await ensureExactAmount([first, second], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
expect(result.note.amount).toBe(5_000);
expect(result.change?.amount).toBe(2_000);
expect(result.consumed).toHaveLength(2);
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000);
});
it('excludes spent and unverified notes from selection', async () => {
const instance = await mint();
const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
verified: false,
};
await expect(
ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
).rejects.toThrow(/enough/);
});
it('refuses an amount no mint can cover', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('09'), 5_000);
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/);
});
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
const instance = await mint({ dropAfterMutation: true });
const k1 = secret('10');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(partK1).not.toBe(k1);
expect(instance.state.noteState(k1)).toBe('burned');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
});
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint();
const k1 = secret('11');
const bearer = await makeBearer(instance, k1, 21_000);
// every mutation answer is lost - the first attempt's AND the forced
// byte-identical replay's (the mint executed both; the replay is the
// recorded success served again, then dropped by the wrapper) - and
// every informational probe is unreachable, so neither the mutation
// nor the probe can be confirmed
const everythingDyingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
const response = await fetch(input, init);
await response.arrayBuffer();
throw new Error('mutation response lost');
}
return Promise.reject(new Error('probe unreachable'));
};
const checkpoints: CarveResult[] = [];
const failure = await ensureExactAmount([bearer], 5_000, {
fetch: everythingDyingFetch,
allocateOutputSecrets,
onCarve: (carve) => {
checkpoints.push(carve);
},
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(UncertainOutcomeError);
if (!(failure instanceof UncertainOutcomeError)) throw failure;
expect(failure.possibleOutputs).toEqual([]);
expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]);
expect(checkpoints[0]?.note.amount).toBe(5_000);
expect(checkpoints[0]?.change?.amount).toBe(16_000);
});
it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('12'), 21_000);
const seen: CarveResult[] = [];
const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => {
seen.push(carve);
},
mintSignatureKeys: signatureKeysFor(instance),
});
expect(seen).toHaveLength(2);
expect(seen[0]?.note.verified).toBe(false);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined();
expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url));
expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
noteK1(requiredValue(result.change).url),
);
// the landed checkpoint re-reports the SAME staged output (same secret)
// - its URL has since gained the mint's signature
expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url));
expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull();
expect(seen[1]?.note.verified).toBe(true);
expect(seen[1]?.change).toBeUndefined();
expect(seen[1]?.consumed).toEqual(result.consumed);
expect(result.consumed).toHaveLength(1);
});
it('does not stage the split change again during the landed checkpoint', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('17'), 21_000);
const trackedSecrets = new Set<string>();
const retiredIds = new Set<string>();
const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => {
// checkpoints identify an output by its secret: the landed phase's
// URL has gained a signature the staged phase could not have had
const k1 = noteK1(carve.note.url);
if (k1) trackedSecrets.add(k1);
const changeK1 = carve.change ? noteK1(carve.change.url) : null;
if (changeK1) trackedSecrets.add(changeK1);
for (const consumed of carve.consumed) retiredIds.add(consumed.id);
},
});
expect(trackedSecrets).toEqual(
new Set([
requiredValue(noteK1(result.note.url)),
requiredValue(noteK1(requiredValue(result.change).url)),
]),
);
expect(retiredIds).toEqual(new Set([bearer.id]));
});
it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
const instance = await mint();
const sourceSecret = secret('15');
const bearer = await makeBearer(instance, sourceSecret, 21_000);
const seen: CarveResult[] = [];
await expect(
ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => {
seen.push(carve);
if (carve.consumed.length > 0) throw new Error('final commit failed');
},
}),
).rejects.toThrow(/final commit failed/);
expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined();
expect(instance.state.noteState(sourceSecret)).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
'outstanding',
);
expect(
instance.state.noteState(
requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
),
).toBe('outstanding');
});
it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('13'), 21_000);
let called = false;
const result = await ensureExactAmount([bearer], 21_000, {
onCarve: () => {
called = true;
},
});
expect(called).toBe(false);
expect(result.consumed).toEqual([]);
});
it('requires a typed durable checkpoint before a mutating carve', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('16'), 21_000);
await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
CarveCheckpointRequiredError,
);
expect(instance.state.noteState(secret('16'))).toBe('outstanding');
});
it('propagates an onCarve failure before the carve reaches the mint', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('14'), 21_000);
await expect(
ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: () => {
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/);
expect(instance.state.noteState(secret('14'))).toBe('outstanding');
});
it('draws the split outputs from the caller allocation path, exactly two', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('80'), 21_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('81'), secret('82')];
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 2 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]);
expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]);
});
it('draws the merge output from the caller allocation path, exactly one', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('83'), 3_000);
const second = await makeBearer(instance, secret('84'), 4_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('85')];
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]);
});
it('allocates nothing when one note already holds the exact amount', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('86'), 21_000);
let allocations = 0;
await ensureExactAmount([bearer], 21_000, {
allocateOutputSecrets: () => {
allocations += 1;
return Promise.resolve([]);
},
});
expect(allocations).toBe(0);
});
it('refuses a mutating carve without an allocation path, before any staging', async () => {
const instance = await mint();
const split = await makeBearer(instance, secret('87'), 21_000);
const mergeA = await makeBearer(instance, secret('88'), 3_000);
const mergeB = await makeBearer(instance, secret('89'), 4_000);
let mutations = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutations += 1;
return fetch(input, init);
};
await expect(
ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
await expect(
ensureExactAmount([mergeA, mergeB], 7_000, {
fetch: countingFetch,
onCarve: () => undefined,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(mutations).toBe(0);
expect(instance.state.noteState(secret('87'))).toBe('outstanding');
expect(instance.state.noteState(secret('88'))).toBe('outstanding');
expect(instance.state.noteState(secret('89'))).toBe('outstanding');
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
import { requiredValue } from './test-utils';
import type { Mint } from './ops.testHarness';
import {
allocateOutputSecrets,
mint,
persistOutput,
secret,
settleLastInvoice,
} from './ops.testHarness';
const HEX32 = /^[0-9a-f]{64}$/;
const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo);
describe('mint -> claim, comment-bound output', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.noteSecret).toMatch(HEX32);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
});
it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
const preimage = await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
});
expect(claimed.note.verified).toBe(true);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
expect(instance.state.noteState(preimage)).toBeNull();
});
it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
await expect(
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
).rejects.toThrow(/not confirmed/i);
});
it('uses the required comment even when the optional h extension is advertised', async () => {
const instance = await mint({ mintToHash: true, testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
});
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
});
it('draws the staged note secret from the caller allocation path', async () => {
const instance = await mint({ testHooks: true });
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('90')];
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(prepared.noteSecret).toBe(allocated[0]);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0])));
});
it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => {
const instance = await mint({ testHooks: true });
let persisted = 0;
await expect(
prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput: () => {
persisted += 1;
},
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(persisted).toBe(0);
expect(instance.state.invoices.size).toBe(0);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import {
NoteSpentError,
PendingNoteError,
fetchNoteInfo,
meltNote,
noteK1,
rotateNote,
} from 'lnurlcash-kit';
import {
claimMintedNote,
OutputSecretAllocationRequiredError,
prepareMint,
receiveBearer,
ReceiveRotationStagingRequiredError,
} from './ops';
import { requiredValue } from './test-utils';
import type { NewBearer } from './types';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
persistOutput,
secret,
settleLastInvoice,
stageRotation,
} from './ops.testHarness';
describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => {
const instance = await mint({ testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000 });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.grossMsat).toBeGreaterThan(100_000);
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
});
// the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000);
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
});
});
describe('receiveBearer', () => {
it('stages the rotation output before the rotate can reach the mint', async () => {
const instance = await mint();
const senderK1 = secret('94');
instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('95');
let staged: NewBearer | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) expect(staged).toBeDefined();
return fetch(input, init);
};
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: observingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => {
staged = note;
},
});
expect(received.stage).toBe('finalize');
expect(received.rotated).toBe(true);
expect(noteK1(received.note.url)).toBe(rotationSecret);
expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret);
expect(requiredValue(staged).verified).toBe(false);
expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(rotationSecret)).toBe('outstanding');
});
it('reports discard when the rotate provably never landed', async () => {
const instance = await mint();
const senderK1 = secret('96');
instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('97');
let staged: NewBearer | undefined;
const failingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'));
return fetch(input, init);
};
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: failingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => {
staged = note;
},
});
expect(received.stage).toBe('discard');
expect(received.rotated).toBe(false);
expect(received.note.verified).toBe(true);
expect(received.rotationError).toBeDefined();
// nothing landed: both the sender's copy and the would-be output live on
expect(instance.state.noteState(senderK1)).toBe('outstanding');
expect(instance.state.noteState(rotationSecret)).toBeNull();
expect(staged).toBeDefined();
});
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
const instance = await mint();
const senderK1 = secret('20');
instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
allocateOutputSecrets,
stageRotation,
});
expect(received.rotated).toBe(true);
expect(received.stage).toBe('finalize');
expect(received.note.amount).toBe(21_000);
expect(received.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(received.note.url));
expect(newK1).not.toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(newK1)).toBe('outstanding');
});
it('refuses a note the wallet already holds', async () => {
const instance = await mint();
const senderK1 = secret('21');
const existing = await makeBearer(instance, senderK1, 21_000);
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
/already/,
);
});
it('surfaces a spent note as definitively spent', async () => {
const instance = await mint();
const k1 = secret('22');
const bearer = await makeBearer(instance, k1, 21_000);
const info = await fetchNoteInfo(bearer.url);
await rotateNote(info.callback, k1);
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError,
);
});
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const instance = await mint({ meltNeverSettles: true });
const k1 = secret('23');
const bearer = await makeBearer(instance, k1, 21_000);
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
await expect(
receiveBearer(noteUrl(instance, k1, 21_000), [], {
allocateOutputSecrets,
stageRotation,
}),
).rejects.toBeInstanceOf(PendingNoteError);
});
it('refuses to rotate without a durable staging checkpoint', async () => {
const instance = await mint();
const senderK1 = secret('9a');
instance.state.creditNote(senderK1, 21_000);
await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }),
).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError);
// the sender's copy is untouched - no rotation was attempted
expect(instance.state.noteState(senderK1)).toBe('outstanding');
});
it('refuses to rotate without an allocation path', async () => {
const instance = await mint();
const senderK1 = secret('9b');
instance.state.creditNote(senderK1, 21_000);
await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(senderK1)).toBe('outstanding');
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import {
OutputSecretAllocationRequiredError,
PayReturnCheckpointRequiredError,
payWithBearers,
} from './ops';
import { requiredValue } from './test-utils';
import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('30'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
});
expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('30'))).toBe('burned');
});
it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint();
const bearer = await makeBearer(payer, secret('31'), 21_000);
const payeeFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
return Promise.resolve(
Response.json({
tag: 'payRequest',
callback: 'https://payee.example/invoice',
minSendable: 1_000,
maxSendable: 100_000_000,
metadata: '[]',
}),
);
}
if (url.startsWith('https://payee.example/invoice')) {
return Promise.resolve(Response.json({ pr: 'lnbc210n1pjqrstuvwxyz' }));
}
return fetch(input, init);
};
const result = await payWithBearers([bearer], 'alice@payee.example', {
amountMsat: 21_000,
kit: { fetch: payeeFetch },
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
});
expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/);
expect(payer.state.noteState(secret('31'))).toBe('burned');
});
it('carves the exact amount out of a larger note before melting', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('32'), 50_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets,
onCarve: () => undefined,
});
expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('32'))).toBe('burned');
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.carve.change?.amount).toBe(29_000);
const change = requiredValue(result.carve.change);
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding');
});
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('33'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: () => undefined,
});
expect(result.outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('33'))).toBe('burned');
const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true);
expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
});
it('waits for durable recovery-secret staging before rotating returned funds', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('39'), 21_000);
let releaseStage: (() => void) | undefined;
const stageGate = new Promise<void>((resolve) => {
releaseStage = resolve;
});
let stageStarted: (() => void) | undefined;
const stageEntered = new Promise<void>((resolve) => {
stageStarted = resolve;
});
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: async () => {
stageStarted?.();
await stageGate;
},
});
await stageEntered;
expect(instance.state.noteState(secret('39'))).toBe('outstanding');
releaseStage?.();
expect((await payment).outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('39'))).toBe('burned');
});
it('classifies a never-settling melt as unknown-still-pending', async () => {
const instance = await mint({ meltNeverSettles: true });
const bearer = await makeBearer(instance, secret('34'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: () => undefined,
});
expect(result.outcome).toBe('unknown-still-pending');
expect(instance.state.noteState(secret('34'))).toBe('pending');
});
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('35'), 21_000);
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/);
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i);
});
it('aborts before the melt when the carve commit hook fails', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('36'), 21_000);
// paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start
await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
allocateOutputSecrets,
onCarve: () => {
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the mint.
expect(instance.state.noteState(secret('36'))).toBe('outstanding');
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
});
it('draws the return recovery secret from the caller allocation path', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8a'), 21_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('8b')];
const stagedSecrets: string[] = [];
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
onReturnReady: (_carve, recoverySecret) => {
stagedSecrets.push(recoverySecret);
},
});
expect(result.outcome).toBe('failed-funds-returned');
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(stagedSecrets).toEqual(allocated);
expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]);
});
it('allocates nothing while the melt settles cleanly', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('8c'), 21_000);
let allocations = 0;
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets: () => {
allocations += 1;
return Promise.resolve([]);
},
});
expect(result.outcome).toBe('settled');
expect(allocations).toBe(0);
});
it('refuses the return classification without its durable checkpoint', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8d'), 21_000);
await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
}),
).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError);
// the payment failed and the mint restored the note; the refusal came
// before the classification rotate, so nothing else moved
expect(instance.state.noteState(secret('8d'))).toBe('outstanding');
});
it('refuses the return classification without an allocation path', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8e'), 21_000);
await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
onReturnReady: () => undefined,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(secret('8e'))).toBe('outstanding');
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import type { CarveResult } from './ops';
import { ensureExactAmount, receiveBearer } from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
stageRotation,
} from './ops.testHarness';
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
// repeat as an already-spent input, which is the only answer the wallet
// ever sees. Every mutation that carries fresh secrets must probe the
// would-be outputs before believing such a refusal: landed means the
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint();
const k1 = secret('60');
const bearer = await makeBearer(instance, k1, 50_000);
const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined,
});
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned');
expect(result.consumed).toHaveLength(1);
const partK1 = requiredValue(noteK1(result.note.url));
expect(partK1).not.toBe(k1);
expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(partK1)).toBe('outstanding');
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(changeK1)).toBe('outstanding');
});
it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint();
const a = await makeBearer(instance, secret('61'), 20_000);
const b = await makeBearer(instance, secret('62'), 5_000);
const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined,
});
expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(secret('61'))).toBe('burned');
expect(instance.state.noteState(secret('62'))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(mergedK1)).toBe('outstanding');
});
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint();
const senderK1 = secret('63');
instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
allocateOutputSecrets,
stageRotation,
});
expect(received.rotated).toBe(true);
expect(received.note.amount).toBe(21_000);
expect(instance.state.noteState(senderK1)).toBe('burned');
const k1 = requiredValue(noteK1(received.note.url));
expect(k1).not.toBe(senderK1);
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint();
const k1 = secret('64');
const bearer = await makeBearer(instance, k1, 50_000);
instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = [];
await expect(
ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: (carve) => {
checkpoints.push(carve);
},
}),
).rejects.toThrow(/spent/i);
expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { noteK1, noteSignature } from 'lnurlcash-kit';
import { ensureExactAmount } from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
secret,
signatureKeysFor,
} from './ops.testHarness';
import { fetchNoteInfo } from 'lnurlcash-kit';
// Landed split/merge outputs carry the mint's signature in their URL (sig
// param), and earn verified:true only when that signature checks against a
// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or
// wrong-key signatures - and mints with no trusted keys at all - keep the
// staged outputs unverified; the money is tracked either way (the notes
// exist at the mint), only the offline-verifiable badge is withheld.
describe('landed mutation signature verification', () => {
it('verifies a landed split against the current key and preserves its signature', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a0'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.verified).toBe(true);
const signature = noteSignature(result.note.url);
expect(signature).not.toBeNull();
expect(noteSignature(requiredValue(result.change).url)).not.toBeNull();
// change stays unverified (its true post-fee value is unknown until a
// refresh) even though its signature is preserved
expect(result.change?.verified).toBe(false);
});
it('verifies a landed merge against the current key and preserves its signature', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('a1'), 3_000);
const second = await makeBearer(instance, secret('a2'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull();
});
it('verifies against the previous key after the mint rotated its signing key', async () => {
// the mint signs every output under its RETIRED key while advertising
// the current one - exactly the rekey window a holder must tolerate
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
});
const bearer = await makeBearer(instance, secret('a3'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(instance.state.previousPubkeys).toHaveLength(1);
expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull();
});
it('rejects verification when the previous key is not trusted', async () => {
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
});
const bearer = await makeBearer(instance, secret('a4'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// only the current key is trusted - the signature under the retired
// key must not verify
mintSignatureKeys: () => [instance.state.pubkey],
});
expect(result.note.verified).toBe(false);
// the note and its signature are still tracked, recoverable by refresh
expect(noteSignature(result.note.url)).not.toBeNull();
const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding');
expect(instance.state.noteState(secret('a4'))).toBe('burned');
});
it('keeps a wrong-key signature staged unverified', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a5'), 21_000);
const stranger = await mint({ privateKey: '33'.repeat(32) });
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// the only trusted key on file belongs to a DIFFERENT mint
mintSignatureKeys: () => [stranger.state.pubkey],
});
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull();
expect(instance.state.noteState(secret('a5'))).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
});
it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => {
const instance = await mint({ signatures: false });
const k1 = secret('a6');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
// the mutation landed (the kit refused the missing signature, the
// rescue probe proved the burn): the inputs are retired, the staged
// outputs are tracked at exactly their staged secrets, unverified
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).toBeNull();
expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
});
it('never verifies a landed mutation without any trusted keys on file', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a7'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// no key source at all: nothing may be trusted blindly
});
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull();
expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
});
});
+8 -606
View File
@@ -1,606 +1,8 @@
// The operations engine against the conformance mock mint - a real HTTP
// server that can be told to misbehave. The happy paths matter, but the
// adversarial modes (dropped mutations, failed melts) are what prove the
// fund-safety invariants: fresh secrets are never lost, and melt outcomes
// are classified by proof, not by hope.
import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import {
NoteSpentError,
PendingNoteError,
buildNoteUrl,
fetchNoteInfo,
meltNote,
noteK1,
rotateNote
} from 'lnurlcash-kit'
import type {Bearer} from './types'
import {
UncertainOutcomeError,
claimMintedNote,
ensureExactAmount,
payWithBearers,
prepareMint,
receiveBearer,
transferBetweenMints
} from './ops'
type Mint = Awaited<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
afterEach(async () => {
await Promise.all(mints.splice(0).map(m => m.close()))
})
const secret = (seed: string) =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
const noteUrl = (m: Mint, k1: string, amountMsat?: number) =>
buildNoteUrl(`${m.url}/w`, k1, amountMsat)
// a verified, ready-to-spend bearer fixture: funded on the mock mint and
// read back through the informational GET, exactly as a real receive would
// learn its callback and authoritative amount
let fixtureCounter = 0
const makeBearer = async (
m: Mint,
k1: string,
amountMsat: number
): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat)
const url = noteUrl(m, k1, amountMsat)
const info = await fetchNoteInfo(url)
fixtureCounter += 1
return {
id: `fixture-${fixtureCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now()
}
}
// paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step).
// Returns the paid invoice's preimage, which IS the fresh note's secret.
const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1)!
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`)
return m.state.invoices.get(paymentHash)!.preimage
}
// waits for a mint to have an invoice at all, then settles it - for flows
// that request the invoice deep inside a single awaited call (transfer),
// where the test has to play the arriving payment mid-flight
const settleWhenRequested = async (m: Mint): Promise<string> => {
for (let i = 0; i < 200 && m.state.invoices.size === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
return settleLastInvoice(m)
}
// the mock burns a melted note 20ms after the melt - a transfer can
// resolve off the TARGET's settlement faster than that, so source-burn
// assertions wait for the mock's own timer instead of racing it
const expectBurned = async (m: Mint, k1: string): Promise<void> => {
for (let i = 0; i < 200 && m.state.noteState(k1) !== 'burned'; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(m.state.noteState(k1)).toBe('burned')
}
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const m = await mint()
const k1 = secret('01')
const bearer = await makeBearer(m, k1, 21_000)
const result = await ensureExactAmount([bearer], 21_000)
expect(noteK1(result.note.url)).toBe(k1)
expect(result.consumed).toEqual([])
expect(result.change).toBeUndefined()
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('split path: carves an exact note off a larger one, with change', async () => {
const m = await mint()
const k1 = secret('02')
const bearer = await makeBearer(m, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
expect(result.consumed.map(b => b.id)).toEqual([bearer.id])
// the input is burned; both outputs are live and worth what the result claims
expect(m.state.noteState(k1)).toBe('burned')
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000)
})
it('merge path: combines notes summing exactly to the target', async () => {
const m = await mint()
const a = await makeBearer(m, secret('03'), 3_000)
const b = await makeBearer(m, secret('04'), 4_000)
const result = await ensureExactAmount([a, b], 7_000)
expect(result.note.amount).toBe(7_000)
expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
expect(m.state.noteState(noteK1(a.url)!)).toBe('burned')
expect(m.state.noteState(noteK1(b.url)!)).toBe('burned')
const mergedK1 = noteK1(result.note.url)!
expect((await fetchNoteInfo(noteUrl(m, mergedK1))).maxWithdrawable).toBe(7_000)
})
it('merge+split path: splits the target off several notes in one request', async () => {
const m = await mint()
const a = await makeBearer(m, secret('05'), 3_000)
const b = await makeBearer(m, secret('06'), 4_000)
const result = await ensureExactAmount([a, b], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(2_000)
})
it('excludes spent and unverified notes from selection', async () => {
const m = await mint()
const spentBearer = await makeBearer(m, secret('07'), 50_000)
const unverified: Bearer = {
...(await makeBearer(m, secret('08'), 50_000)),
callback: ''
}
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000)
).rejects.toThrow(/enough/)
})
it('refuses an amount no mint can cover', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('09'), 5_000)
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/)
})
it('rescues the fresh secrets when a split\'s answer is lost (probe: gone)', async () => {
const m = await mint({dropAfterMutation: true})
const k1 = secret('10')
const bearer = await makeBearer(m, k1, 21_000)
// the split's response never arrives - but the mutation landed, so the
// probe resolves the ambiguity and the carried secrets are adopted
const result = await ensureExactAmount([bearer], 5_000)
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect(partK1).not.toBe(k1)
expect(m.state.noteState(k1)).toBe('burned')
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000)
})
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const m = await mint({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(m, k1, 21_000)
// mutations go to the mint (and land, dropped); every informational GET
// fails, so the probe cannot resolve the ambiguity either
const probeKillingFetch: typeof fetch = (input, init) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
if (url.includes('/w/cb')) return fetch(input, init)
return Promise.reject(new Error('probe unreachable'))
}
const err = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch
}).catch((e: unknown) => e)
expect(err).toBeInstanceOf(UncertainOutcomeError)
const outputs = (err as UncertainOutcomeError).possibleOutputs
expect(outputs).toHaveLength(2)
// both possible outputs carry their fresh secrets, at the expected
// amounts - if the split landed, these are the only money left
expect(outputs[0]!.amount).toBe(5_000)
expect(outputs[1]!.amount).toBe(16_000)
expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[0]!.url)!))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[1]!.url)!))).maxWithdrawable).toBe(16_000)
})
})
describe('mint -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(m)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
// the preimage IS the initial note secret - after the rotate, that
// secret (which the mint necessarily saw) is worthless, and the
// wallet's fresh secret is the only live note
expect(m.state.noteState(preimage)).toBe('burned')
const k1 = noteK1(claimed.note.url)!
expect(k1).not.toBe(preimage)
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('grosses the invoice up for an advertised mint fee', async () => {
const m = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(m)
// the service's fee math is authoritative - the credited note nets
// roughly what was asked for (within fee-rounding slack), never more
// than the gross
const info = await fetchNoteInfo(
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat)
)
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000)
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat)
// this mock regenerates the proof's pr from the NET amount rather than
// echoing the stored invoice, so the strict same-invoice guard in
// claimMintedNote correctly refuses to bind it - the guard working as
// designed against a mismatched proof
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500})
).rejects.toThrow(/different invoice/)
})
it('times out cleanly when the invoice is never paid', async () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100})
).rejects.toThrow(/not confirmed/i)
})
})
describe('receiveBearer', () => {
it('verifies an incoming note and rotates it, burning the sender\'s copy', async () => {
const m = await mint()
// the "sender" hands over this URL - they know its secret
const senderK1 = secret('20')
m.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(m, senderK1, 21_000), [])
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(received.note.verified).toBe(true)
const newK1 = noteK1(received.note.url)!
expect(newK1).not.toBe(senderK1)
expect(m.state.noteState(senderK1)).toBe('burned')
expect(m.state.noteState(newK1)).toBe('outstanding')
})
it('refuses a note the wallet already holds', async () => {
const m = await mint()
const senderK1 = secret('21')
const existing = await makeBearer(m, senderK1, 21_000)
await expect(
receiveBearer(noteUrl(m, senderK1, 21_000), [existing])
).rejects.toThrow(/already/)
})
it('surfaces a spent note as definitively spent', async () => {
const m = await mint()
const k1 = secret('22')
const bearer = await makeBearer(m, k1, 21_000)
// burn it server-side (a rotate by the "other" copy of the wallet)
const info = await fetchNoteInfo(bearer.url)
await rotateNote(info.callback, k1)
await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError
)
})
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const m = await mint({meltNeverSettles: true})
const k1 = secret('23')
const bearer = await makeBearer(m, k1, 21_000)
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz')
await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf(
PendingNoteError
)
})
})
describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('30'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
})
expect(result.outcome).toBe('settled')
expect(m.state.noteState(secret('30'))).toBe('burned')
})
it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint()
const payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers(
[bearer],
`mint@127.0.0.1:${payee.port}`,
{amountMsat: 21_000, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}}
)
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(payer.state.noteState(secret('31'))).toBe('burned')
})
it('carves the exact amount out of a larger note before melting', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('32'), 50_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
})
expect(result.outcome).toBe('settled')
// the split happened: input burned, the 21000 sat note melted, and the
// change note is tracked for the wallet to keep
expect(m.state.noteState(secret('32'))).toBe('burned')
expect(result.carve.consumed.map(b => b.id)).toEqual([bearer.id])
expect(result.carve.change?.amount).toBe(29_000)
expect(m.state.noteState(noteK1(result.carve.change!.url)!)).toBe('outstanding')
})
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const m = await mint({meltAlwaysFails: true})
const bearer = await makeBearer(m, secret('33'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
expect(result.outcome).toBe('failed-funds-returned')
// the mint restored the note, and the classification rotate re-secured
// it (the melt had put its k1 on the wire): the old secret is burned,
// the fresh one in the result is outstanding at the full amount
expect(m.state.noteState(secret('33'))).toBe('burned')
const returnedK1 = noteK1(result.carve.note.url)!
expect(m.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
it('classifies a never-settling melt as unknown-still-pending', async () => {
const m = await mint({meltNeverSettles: true})
const bearer = await makeBearer(m, secret('34'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
expect(result.outcome).toBe('unknown-still-pending')
expect(m.state.noteState(secret('34'))).toBe('pending')
})
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('35'), 21_000)
await expect(
payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')
).rejects.toThrow(/amount/)
await expect(
payWithBearers([bearer], 'not-an-invoice')
).rejects.toThrow(/not a valid/i)
})
})
describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
it('moves value to another mint: melt at source, claim + rotate at target', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('40')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
// the transfer is now waiting on the target invoice settling - the
// mock mints can't actually pay each other, so the settle hook plays
// the melt's payment arriving
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0
})
expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1)
const claimed = result.mintedAtTarget!
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
// the preimage is the secret the target mint necessarily saw - after
// the rotate it is worthless there, and the wallet's fresh secret is
// the only live note
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = noteK1(claimed.note.url)!
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
it('refuses an amount no source mint can cover', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000)
await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`)
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('rejects a transfer onto the mint the notes are already on', async () => {
const m = await mint()
const k1 = secret('42')
const bearer = await makeBearer(m, k1, 21_000)
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${m.port}`)
).rejects.toThrow(/different target/)
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint()
// not via the mint() helper - a dead server stays out of afterEach
const dead = await createMockMint()
const deadAddress = `mint@127.0.0.1:${dead.port}`
await dead.close()
const k1 = secret('43')
// a note LARGER than the transfer amount, so a premature carve would
// show up here as a burn
const bearer = await makeBearer(source, k1, 50_000)
await expect(
transferBetweenMints([bearer], 21_000, deadAddress)
).rejects.toThrow()
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
// unconfirmedMutation: the melt's response confirms nothing, so the
// melt's outcome is uncertain - the target invoice settling is the
// transfer's ground truth
const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true})
const k1 = secret('44')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
// the melt had landed despite its lost answer - the source note is
// gone, and the target note came out the other end
await expectBurned(source, k1)
expect(result.mintedAtTarget?.note.amount).toBe(21_000)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
// echoWrongK1: the target settles the invoice and reveals the
// preimage, but its informational GET then breaks the claim
const source = await mint()
const target = await mint({testHooks: true, echoWrongK1: true})
const k1 = secret('45')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled-claim-failed')
await expectBurned(source, k1)
// the preimage IS the note secret - surfaced unverified, not lost
const note = result.claimMaterial?.note
expect(note).toBeDefined()
expect(noteK1(note!.url)).toBe(preimage)
expect(note!.verified).toBe(false)
expect(note!.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
})
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint()
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
const k1 = secret('46')
// covers the requested net exactly - but not the grossed-up invoice
const bearer = await makeBearer(source, k1, 100_000)
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`)
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true})
const target = await mint({testHooks: true})
const k1 = secret('47')
const bearer = await makeBearer(source, k1, 21_000)
const result = await transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}}
)
expect(result.outcome).toBe('failed-funds-returned')
expect(result.mintedAtTarget).toBeUndefined()
// the classification rotate re-secured the note (the melt had put its
// k1 on the wire): the old secret is burned, the fresh one in the
// result is outstanding at the full amount
expect(source.state.noteState(k1)).toBe('burned')
const returnedK1 = noteK1(result.carve.note.url)!
expect(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
})
import './ops.carve.cases';
import './ops.carve-crash.cases';
import './ops.signature.cases';
import './ops.mint-named.cases';
import './ops.mint-receive.cases';
import './ops.pay.cases';
import './ops.retry-refusal.cases';
import './ops.transfer.cases';
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, expect } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options);
mints.push(instance);
return instance;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close()));
});
export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)));
export const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path: answers
// every reservation with fresh unique secrets, never reusing one
let allocatedSecretCounter = 0;
export const allocateOutputSecrets = (
_server: string,
count: number,
): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => {
allocatedSecretCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)));
});
return Promise.resolve(secrets);
};
// the receive-rotation staging checkpoint - engine tests keep no wallet,
// so staging is a recorded no-op for them
export const stageRotation = async (): Promise<void> => undefined;
// the trusted signing keys a production caller would serve from the
// trusted-mint registry: the mock mint's current key plus any it retired
export const signatureKeysFor = (instance: Mint) => (): readonly string[] => [
instance.state.pubkey,
...instance.state.previousPubkeys,
];
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
buildNoteUrl(`${instance.url}/w`, k1, amountMsat);
let fixtureCounter = 0;
export const makeBearer = async (
instance: Mint,
k1: string,
amountMsat: number,
): Promise<Bearer> => {
instance.state.creditNote(k1, amountMsat);
const url = noteUrl(instance, k1, amountMsat);
const info = await fetchNoteInfo(url);
fixtureCounter += 1;
return {
id: `fixture-${fixtureCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: instance.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
};
};
export const settleLastInvoice = async (instance: Mint): Promise<string> => {
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1));
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`);
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`);
return requiredValue(instance.state.invoices.get(paymentHash)).preimage;
};
export const settleWhenRequested = async (instance: Mint): Promise<string> => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
return settleLastInvoice(instance);
};
export const expectBurned = async (instance: Mint, k1: string): Promise<void> => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(instance.state.noteState(k1)).toBe('burned');
};
// a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
// is subject to: the mint executes the first attempt and (with the mock's
// default retriedMutation: 'refuse', which is also lnurl-mint's behavior)
// refuses the repeat as an already-spent input, which is all the caller
// ever sees
export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
const first = await fetch(input, init);
await first.arrayBuffer();
return fetch(input, init);
}
return fetch(input, init);
};
return impl;
};
+336
View File
@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
import type { CarveResult, TransferOptions } from './ops';
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
expectBurned,
makeBearer,
mint,
persistOutput,
secret,
settleWhenRequested,
} from './ops.testHarness';
const transferBetweenMints = (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
options: Omit<TransferOptions, 'persistOutput'> = {},
) =>
transferBetweenMintsEngine(bearers, amountMsat, targetMint, {
allocateOutputSecrets,
onMeltReady: () => undefined,
...options,
persistOutput,
});
describe('transferBetweenMints', () => {
const fastPoll = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
it('draws the target and source secrets from the caller allocation path', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('91'), 21_000);
const targetSecret = secret('92');
const sourceRecoverySecret = secret('93');
const allocations: Array<{ server: string; count: number }> = [];
const meltReadiness: string[] = [];
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
return Promise.resolve(secrets.slice(0, count));
},
onMeltReady: (_carve, recovery) => {
meltReadiness.push(recovery);
},
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
// the staged target note first, then the source recovery secret - an
// exact source carve allocates nothing
expect(allocations).toEqual([
{ server: `127.0.0.1:${target.port}`, count: 1 },
{ server: `127.0.0.1:${source.port}`, count: 1 },
]);
expect(meltReadiness).toEqual([sourceRecoverySecret]);
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret);
});
it('refuses the source melt without its durable checkpoint', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('98'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
allocateOutputSecrets,
persistOutput,
}),
).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError);
// the target stage happened (invoice exists) but the source note is
// untouched and nothing melted
expect(source.state.noteState(secret('98'))).toBe('outstanding');
});
it('refuses at target staging without an allocation path', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('99'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => undefined,
persistOutput,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(source.state.noteState(secret('99'))).toBe('outstanding');
});
it('moves value to another mint at a wallet-chosen target secret', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('40');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
});
const preimage = await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/);
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0,
});
expect(result.sourceServer).not.toBe(result.targetServer);
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
expect(target.state.noteState(preimage)).toBeNull();
const newK1 = requiredValue(noteK1(claimed.note.url));
expect(newK1).not.toBe(preimage);
expect(target.state.noteState(newK1)).toBe('outstanding');
});
it('refuses an amount no source mint can cover', async () => {
const source = await mint();
const target = await mint();
const k1 = secret('41');
const bearer = await makeBearer(source, k1, 5_000);
let persisted = false;
await expect(
transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true;
},
}),
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('rejects a transfer onto the mint the notes are already on', async () => {
const instance = await mint();
const k1 = secret('42');
const bearer = await makeBearer(instance, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
).rejects.toThrow(/different target/);
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint();
const dead = await createMockMint();
const deadAddress = `mint@127.0.0.1:${dead.port}`;
await dead.close();
const k1 = secret('43');
const bearer = await makeBearer(source, k1, 50_000);
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow();
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('reports a definitively spent source before waiting on the target', async () => {
const source = await mint();
const target = await mint();
const k1 = secret('54');
const bearer = await makeBearer(source, k1, 21_000);
source.state.settleMelt(k1);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`);
expect(result.outcome).toBe('note-already-spent');
});
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({ unconfirmedMutation: true });
const target = await mint({ testHooks: true });
const k1 = secret('44');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
expect(result.mintedAtTarget?.note.amount).toBe(21_000);
});
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint();
const target = await mint({ baseFeeMsat: 1_000, feePpm: 2_000 });
const k1 = secret('46');
const bearer = await makeBearer(source, k1, 100_000);
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/);
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({ meltAlwaysFails: true });
const target = await mint({ testHooks: true });
const k1 = secret('47');
const bearer = await makeBearer(source, k1, 21_000);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
expect(result.outcome).toBe('failed-funds-returned');
expect(result.mintedAtTarget).toBeUndefined();
expect(source.state.noteState(k1)).toBe('burned');
// the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true);
const returnedK1 = requiredValue(noteK1(rotatedNote.url));
expect(returnedK1).not.toBe(k1);
expect(source.state.noteState(returnedK1)).toBe('outstanding');
expect(rotatedNote.amount).toBe(21_000);
});
it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('48');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
});
const preimage = await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(claimed.note.url));
// the note stands at the wallet's own secret - the quote was bound to
// its hash, and the payment preimage keys nothing at the target
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(preimage)).toBeNull();
});
it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({ meltNeverSettles: true });
const target = await mint({ testHooks: true });
const k1 = secret('49');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
// the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await pending;
expect(result.outcome).toBe('unknown-still-pending');
expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's
// payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note
const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
const material = requiredValue(result.claimMaterial?.note);
expect(material.verified).toBe(false);
expect(noteK1(material.url)).toBe(noteSecret);
});
it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('51'), 21_000);
const seen: CarveResult[] = [];
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
onCarve: (carve) => {
seen.push(carve);
if (carve.consumed.length === 0) {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding');
} else {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding');
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned');
}
},
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
});
it('aborts before the melt when the carve commit hook fails', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('52');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => {
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the source.
expect(source.state.noteState(k1)).toBe('outstanding');
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
});
it('aborts before an exact-note melt when the final checkpoint fails', async () => {
const source = await mint();
const target = await mint();
const k1 = secret('53');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => {
throw new Error('source link commit failed');
},
}),
).rejects.toThrow(/source link commit failed/);
expect(source.state.noteState(k1)).toBe('outstanding');
});
});
+33 -18
View File
@@ -5,10 +5,9 @@
// new/changed notes out - it never mutates wallet state itself.
//
// Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint
// (observer race: anyone who saw the unpaid invoice knows the payment
// hash, and the mint necessarily saw the preimage - the preimage IS the
// note secret)
// - rotate every externally received bearer. Minted notes are staged at a
// wallet-chosen secret before invoice creation and claimed there without
// rotating; the payment preimage is not bearer material
// - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -23,23 +22,39 @@
// ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive)
// ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address)
// ops/transfer.ts - transferBetweenMints (inter-mint move: melt at
// source, mint + claim + rotate at target)
// ops/shared.ts - bounded verify polling, UncertainOutcomeError
// source, mint + claim at target)
// ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared'
export {ensureExactAmount} from './ops/carve'
export type {CarveResult} from './ops/carve'
export {prepareMint, claimMintedNote} from './ops/mint'
export type {PreparedMint, ClaimedNote} from './ops/mint'
export {receiveBearer} from './ops/receiveBearer'
export {payWithBearers} from './ops/pay'
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
export {transferBetweenMints} from './ops/transfer'
export { UncertainOutcomeError } from './ops/shared';
export type { FundOperationOptions, PollOptions } from './ops/shared';
export { OutputSecretAllocationRequiredError } from './ops/allocation';
export type { OutputSecretAllocator } from './ops/allocation';
export {
CarveCheckpointRequiredError,
ensureExactAmount,
UnsupportedMultiBatchMergeError,
} from './ops/carve';
export type { CarveOptions, CarveResult } from './ops/carve';
export {
MintedNoteSpentError,
prepareMint,
claimMintedNote,
recoverStagedMintOutput,
} from './ops/mint';
export type { PreparedMint, PrepareMintOptions, ClaimedNote, StagedMintRecovery } from './ops/mint';
export { receiveBearer, ReceiveRotationStagingRequiredError } from './ops/receiveBearer';
export type { ReceiveBearerOptions, ReceivedNote } from './ops/receiveBearer';
export { payWithBearers, PayReturnCheckpointRequiredError } from './ops/pay';
export type { PayOutcome, PayResult, PayOptions } from './ops/pay';
export {
recoverPendingTransferSource,
transferBetweenMints,
TransferMeltCheckpointRequiredError,
} from './ops/transfer';
export type {
TransferClaimMaterial,
TransferOptions,
TransferOutcome,
TransferQuote,
TransferResult
} from './ops/transfer'
TransferResult,
} from './ops/transfer';

Some files were not shown because too many files have changed in this diff Show More