50 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
183 changed files with 15902 additions and 7373 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.3"
".": "0.0.5"
}
+1 -1
View File
@@ -79,7 +79,7 @@ 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@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm
- `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
+109 -66
View File
@@ -1,86 +1,129 @@
# Changelog
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
### Features
* 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
* **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))
- 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))
- 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))
- 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))
- 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;
+275 -29
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,13 +42,28 @@ 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;
}
@@ -43,14 +71,14 @@ interface MockTargetMintOptions {
// 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
mintPubkey: string;
// 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;
@@ -62,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) => {
@@ -74,27 +161,147 @@ 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}/verify`)}`),
async (route: Route) => {
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,
});
},
);
}
// 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) => {
await fulfillJson(route, { status: 'OK' });
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' });
},
);
}
@@ -105,7 +312,7 @@ export class MintMocker {
// 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(options: MockTargetMintOptions, origin: string): Promise<void> {
private async mockMintAddress(origin: string, mintPubkey: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
async (route: Route) => {
@@ -114,7 +321,7 @@ export class MintMocker {
callback: `${origin}${CALLBACK_PATH}`,
minWithdrawable: 1000,
maxWithdrawable: 100_000_000_000,
mintPubkey: options.mintPubkey,
mintPubkey,
payLink: `${origin}/.well-known/lnurlp/mint`,
nodeCapacity: 500_000_000,
nodeNumChannels: 4,
@@ -127,15 +334,33 @@ export class MintMocker {
// Everything the target side of a transfer (or a Lightning receive)
// 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 preimage, and the
// note info + rotate the claim then performs.
// 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.mockMintAddress(options, 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) => {
@@ -144,6 +369,7 @@ export class MintMocker {
callback: `${origin}/pay`,
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: `${origin}${NOTE_PATH}`,
metadata: options.mintFeeMetadata ?? '[]',
});
@@ -152,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();
};
+96 -20
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);
@@ -93,19 +163,22 @@ test.describe('Security page', () => {
});
await createFreshWallet(page);
await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('ownerId' in saved) ||
typeof saved.ownerId !== 'string'
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),
wrappedKey: '33'.repeat(48),
materialHash: saved.materialHash,
wrappedMaterial: '33'.repeat(48),
createdAt: 1,
};
localStorage.setItem(
@@ -116,14 +189,14 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16),
name: 'Current wallet passkey',
ownerId: saved.ownerId,
version: 1,
version: 2,
},
{
...wrap,
credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 1,
version: 2,
},
]),
);
@@ -137,18 +210,15 @@ test.describe('Security page', () => {
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
});
test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => {
// Given an encrypted saved key and passkey slot without owner markers
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
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([
@@ -156,17 +226,23 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
wrappedKey: '77'.repeat(48),
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 until another owner proof
// 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);
});
+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,
+126 -1
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;
}
}
@@ -146,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');
});
});
+17 -6
View File
@@ -5,7 +5,6 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
// a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note
@@ -26,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' });
@@ -38,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');
});
@@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
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());
@@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
+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');
});
});
+9 -1
View File
@@ -3,6 +3,14 @@ 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)}`;
@@ -23,7 +31,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise<
const ownerId = async (page: Page): Promise<string | null> =>
page.evaluate(() => {
const raw = localStorage.getItem('sattle_linking_key');
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;
+56 -177
View File
@@ -1,90 +1,45 @@
import { bytesToHex } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { test, expect } from '../fixtures';
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet
// with ownerless passkey/NWC/trust residue must migrate to the proven owner
// during password unlock - before the NWC service may start - and forgetting
// the wallet must drain/stop that service and remove every wallet-owned key
// before a successor wallet can be created without any of the old residue.
//
// The legacy encrypted record is produced here with the exact same KDF/wrap
// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the
// owner marker a current build would stamp.
// 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.
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const PASSWORD = 'correct horse battery staple';
const LINKING_KEY_HEX = '07'.repeat(32);
const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true));
const MINT_PUBKEY = '02' + 'aa'.repeat(32);
function 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;
}
const PBKDF2_ITERATIONS = 210_000;
const legacyEncryptedRecord = async (
valueHex: string,
password: string,
): Promise<{ salt: string; iv: string; ciphertext: string }> => {
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(valueHex),
),
);
return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
const WALLET_KEYS = [
// every namespace the alpha wallet could have touched
const LEGACY_KEYS = [
'sattle_linking_key',
'sattle_bearers',
'sattle_activity',
'sattle_settings',
'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('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
page,
}) => {
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD);
await page.addInitScript(
({ storedRecord, mintPubkey }) => {
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord }));
// 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([
@@ -112,104 +67,29 @@ test.describe('wallet lifecycle', () => {
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]),
);
JSON.stringify([
{
server: 'legacy.example',
mintPubkey: `02${'aa'.repeat(32)}`,
addedAt: 1,
locked: false,
},
{ storedRecord: record, mintPubkey: MINT_PUBKEY },
]),
);
});
// When the app boots
await page.goto('/#/');
// the unlock screen proves the app boot finished and the dev hooks exist
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect
.poll(async () => page.evaluate(() => typeof window.__sattleNwcTest))
.toBe('object');
// no real relay traffic once the migrated enabled state starts the service
await page.evaluate(() => {
window.__nwcSubs = [];
window.__sattleNwcTest.setTransport({
publish: () => Promise.resolve(),
subscribe: () => {
const sub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
// When the holder proves the wallet by password
await page.locator('.unlock-card input').fill(PASSWORD);
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
// Then the wallet unlocks...
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// ...and every legacy namespace was migrated to the proven owner BEFORE
// the NWC service could start (the subscriptions below only exist because
// the migrated owner-scoped enabled record read true)
const migrated = await page.evaluate(() => {
const field = (storageKey: string, name: string): unknown => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
return typeof value === 'object' && value !== null
? Object.entries(value).find(([key]) => key === name)?.[1]
: undefined;
};
const fields = (storageKey: string, name: string): unknown[] => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
if (!Array.isArray(value)) return [];
return value.map((entry: unknown) =>
typeof entry === 'object' && entry !== null
? Object.entries(entry).find(([key]) => key === name)?.[1]
: undefined,
);
};
const mints: unknown = field('sattle_trusted_mints', 'mints');
return {
savedKeyOwner: field('sattle_linking_key', 'ownerId'),
passkeyOwners: fields('sattle_passkey_slots', 'ownerId'),
nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'),
nwcEnabled: field('sattle_nwc_enabled', 'enabled'),
nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'),
trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'),
trustedMintServers: Array.isArray(mints)
? mints.map((mint: unknown) =>
typeof mint === 'object' && mint !== null && 'server' in mint
? mint.server
: undefined,
)
: [],
};
});
expect(migrated.savedKeyOwner).toBe(OWNER_ID);
expect(migrated.passkeyOwners).toEqual([OWNER_ID]);
expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({
ownerId: OWNER_ID,
enabled: true,
});
expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]);
expect(migrated.trustedMintsOwner).toBe(OWNER_ID);
expect(migrated.trustedMintServers).toEqual(['legacy.example']);
await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0);
// When the wallet is forgotten
await page.evaluate(() => window.__sattleWalletTest.forget());
// Then the app lands on the no-wallet screen, the service is drained
// (every subscription closed), and no wallet-owned key remains
// 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
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
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),
[...WALLET_KEYS],
[...LEGACY_KEYS],
);
expect(remaining).toEqual([]);
@@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => {
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();
// Then it is a different owner with zero adopted residue
const successor = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null');
return {
ownerId:
typeof saved === 'object' && saved !== null && 'ownerId' in saved
? saved.ownerId
: undefined,
residueKeys: [
// 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',
].filter((key) => localStorage.getItem(key) !== null),
};
});
expect(successor.ownerId).not.toBe(OWNER_ID);
expect(successor.residueKeys).toEqual([]);
'sattle_biometric_wrap',
],
);
expect(residue).toEqual([]);
});
});
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "sattle",
"version": "0.0.3",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sattle",
"version": "0.0.3",
"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": "0.1.1",
"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": "0.1.1",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
@@ -5961,9 +5961,9 @@
}
},
"node_modules/lnurlcash-conformance": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz",
"integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==",
"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": {
@@ -5987,9 +5987,9 @@
}
},
"node_modules/lnurlcash-kit": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz",
"integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==",
"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.3",
"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": "0.1.1",
"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": "0.1.1",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
+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,
+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$/],
}),
);
}
+6 -3
View File
@@ -13,9 +13,12 @@ declare global {
}
// 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();
+439 -70
View File
@@ -1,101 +1,470 @@
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys';
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots';
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 },
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 },
SecureStorage: {
get: pluginMocks.secureGet,
remove: pluginMocks.secureRemove,
set: pluginMocks.secureSet,
},
}));
vi.mock('./platform', () => ({ isNative: () => true }));
import { unlockWithBiometrics } from './biometricUnlock';
import {
disableBiometricUnlock,
enableBiometricUnlock,
unlockWalletMaterialWithBiometrics,
} from './biometricUnlock';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
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.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET));
});
describe('biometric unlock owner proof', () => {
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => {
// Given an ownerless legacy wallet and credential residue plus a biometric wrap
// whose claimed pubkey does not match the key it unwraps
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) };
const legacySlot = {
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
};
const legacyNwc = [
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
];
const legacyTrust = [
{
server: 'legacy.example',
mintPubkey: '02' + 'aa'.repeat(32),
addedAt: 1,
locked: false,
},
];
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey));
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot]));
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc));
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust));
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY);
localStorage.setItem(
'sattle_biometric_wrap',
JSON.stringify({
...wrap,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY),
createdAt: 1,
}),
pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true });
pluginMocks.secureGet.mockImplementation((key) =>
Promise.resolve(pluginMocks.secureValues.get(key) ?? null),
);
const before = new Map([
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')],
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')],
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')],
]);
// When biometric unwrap reaches the pubkey proof check
const attempt = unlockWithBiometrics();
// Then no key crosses the capability boundary and no legacy namespace is adopted
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(savedKeyOwnerId()).toBeNull();
expect(readPasskeySlots()).toEqual([]);
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value);
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,
@@ -113,6 +113,7 @@
{{ claimError }}
</q-banner>
<q-btn
v-if="claimRetryable"
unelevated
color="primary"
text-color="dark"
@@ -215,6 +216,7 @@ const {
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
+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" />
+1 -1
View File
@@ -131,7 +131,7 @@ const lookForBackup = async (): Promise<void> => {
looked.value = true;
if (parts.notes || parts.mints || parts.settings) {
found.value = {
notes: parts.notes?.length ?? 0,
notes: parts.notes?.bearers.length ?? 0,
mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined,
};
+61 -16
View File
@@ -1,3 +1,4 @@
// 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';
@@ -7,9 +8,10 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { NewBearer } from '@/lnurlcash/types';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
@@ -132,29 +134,70 @@ export const useMoveFundsPage = () => {
if (!sats) return;
step.value = 'working';
stage.value = 'Asking the target mint for an invoice…';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
const ownerFence = wallet.captureOwnerFence();
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,
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats),
targetInput.value,
{ assertOwner: ownerFence },
{
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…';
const carved = await commitCarve(wallet, transfer.carve, commitContext);
// 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') {
await wallet.markSpent(carved.id, ownerFence);
const claimed = transfer.mintedAtTarget;
if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await addCommittedBearers(wallet, notes, commitContext);
try {
await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
}
await activity.log(
'transfer',
@@ -163,6 +206,9 @@ export const useMoveFundsPage = () => {
);
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.`,
@@ -176,10 +222,7 @@ export const useMoveFundsPage = () => {
(error) => warnCommitted(error.message),
);
} else if (transfer.outcome === 'settled-claim-failed') {
await wallet.markSpent(carved.id, ownerFence);
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
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.`,
@@ -193,7 +236,7 @@ export const useMoveFundsPage = () => {
(error) => warnCommitted(error.message),
);
}
const claimNote = transfer.claimMaterial?.note ?? null;
const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
result.value = {
outcome: transfer.outcome,
requestedSats: sats,
@@ -210,6 +253,8 @@ export const useMoveFundsPage = () => {
: message;
toast('negative', inlineError.value);
step.value = 'form';
} finally {
fundOperation?.complete();
}
};
return {
+72 -12
View File
@@ -1,10 +1,17 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
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';
@@ -142,20 +149,71 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
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 {
ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
const paid = await payWithBearers(
wallet.bearers,
payment.input,
payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence }
: { assertOwner: ownerFence },
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…';
const committed = await commitCarve(wallet, paid.carve, commitContext);
if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
// 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') {
@@ -208,6 +266,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('negative', inlineError.value);
}
step.value = 'input';
} finally {
fundOperation?.complete();
}
};
const closeResult = (): void => {
+39 -15
View File
@@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
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';
@@ -35,8 +35,10 @@ export const useReceiveLightningDialog = (
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('');
@@ -85,23 +87,21 @@ export const useReceiveLightningDialog = (
const onClaimed = async (
claimed: ClaimedNote,
from: PreparedMint,
staged: Bearer,
ownerFence: WalletOwnerFence,
): Promise<void> => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
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 = claimed.rotationError ?? '';
rotationWarning.value = '';
await activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
@@ -130,23 +130,35 @@ export const useReceiveLightningDialog = (
}
};
const beginClaim = (): void => {
if (!prepared.value || claimRun) return;
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 = wallet.captureOwnerFence();
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;
}
})();
@@ -156,17 +168,26 @@ export const useReceiveLightningDialog = (
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;
const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
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) {
@@ -174,6 +195,7 @@ export const useReceiveLightningDialog = (
formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value });
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
@@ -208,6 +230,7 @@ export const useReceiveLightningDialog = (
preparing.value = false;
formError.value = '';
prepared.value = null;
stagedOutput.value = null;
waiting.value = false;
rotationWarning.value = '';
},
@@ -216,6 +239,7 @@ export const useReceiveLightningDialog = (
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
+35 -4
View File
@@ -8,7 +8,7 @@ import {
} from 'lnurlcash-kit';
import { receiveBearer } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
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';
@@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
if (busy.value || value === '') return;
busy.value = true;
clearError();
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
try {
const ownerFence = wallet.captureOwnerFence();
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);
const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
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;
@@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
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;
}
};
+31 -4
View File
@@ -5,6 +5,7 @@ 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';
@@ -80,12 +81,34 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
preparing.value = true;
errorMessage.value = null;
let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
ownerFence = wallet.captureOwnerFence();
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: ownerFence,
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,
});
const note = await commitCarve(wallet, carve, { ownerFence, 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',
@@ -125,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
: message;
toast('negative', errorMessage.value);
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
@@ -149,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
const note = prepared.value;
if (!note) return;
removing.value = true;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
await wallet.markSpent(note.id, wallet.captureOwnerFence());
fundOperation = wallet.beginFundOperation();
await wallet.markSpent(note.id, fundOperation.ownerFence);
await activity.log(
'spent',
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
@@ -162,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
} catch (error) {
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
} finally {
fundOperation?.complete();
removing.value = false;
}
};
+46 -4
View File
@@ -5,6 +5,7 @@ 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';
@@ -21,7 +22,7 @@ const failOnBearerWrite = (occurrence: number): void => {
const setItem = localStorage.setItem.bind(localStorage);
let writes = 0;
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
if (key === 'sattle_bearers') {
if (key === FUNDS_STORAGE_KEY) {
writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable');
}
@@ -29,10 +30,18 @@ const failOnBearerWrite = (occurrence: number): void => {
});
};
// 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();
vi.stubGlobal('navigator', {});
stubPassthroughLocks();
stubLocalStorage();
setActivePinia(createPinia());
});
@@ -59,7 +68,7 @@ describe('commitCarve', () => {
});
// Then the whole rotation landed as ONE durable write
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
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);
@@ -71,7 +80,7 @@ describe('commitCarve', () => {
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
// sattle_bearers write poisoned (the old add-then-markSpent split wrote
// 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();
@@ -127,4 +136,37 @@ describe('commitCarve', () => {
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);
});
});
+11 -24
View File
@@ -1,5 +1,4 @@
import type { CarveResult } from '@/lnurlcash/ops';
import type { BearerChangeset } from '@/lnurlcash/storage';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
@@ -7,10 +6,7 @@ import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = {
readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly applyChangeset: (
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
) => Promise<Bearer[]>;
readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
};
type CarveCommitContext = Readonly<{
@@ -35,33 +31,24 @@ export const addCommittedBearers = async (
// 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. Hence a
// single changeset through the wallet's one-write boundary, never an
// add-then-markSpent sequence of separate writes.
// 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> => {
const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
const additions: NewBearer[] = [];
if (!existing) additions.push(carve.note);
if (carve.change) additions.push(carve.change);
let added: Bearer[];
try {
added = await wallet.applyChangeset(
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
},
context.ownerFence,
);
return await wallet.commitCarve(carve, context.ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message);
added = error.committedBearers;
}
const committed = existing ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
const committed = error.committedBearers[0];
if (!committed) throw error;
return committed;
}
};
+8 -2
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
@@ -61,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.
+26 -26
View File
@@ -23,29 +23,29 @@ import {
mintAddressUrl,
resolveMintInput,
serverOf,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
import {ceilMsatToSat, floorMsatToSat} from './units'
} 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
@@ -57,29 +57,29 @@ export const quoteMintFee = async (
mintInput: string,
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
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
} catch (error) {
// no mint-address support - the plain payRequest guess still works
if (!(error instanceof Error)) throw error
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;
};
+12 -12
View File
@@ -1,20 +1,20 @@
export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
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
}
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)
}
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
})
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();
});
});
+166 -166
View File
@@ -5,8 +5,8 @@
// 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 { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
decryptSavedLinkingKey,
@@ -20,153 +20,153 @@ import {
savedKeyOwnerMatches,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {isWalletOwnerId} from './storage/walletOwner'
import {parseJsonObject, stubLocalStorage} from './test-utils'
} from './keys';
import { isWalletOwnerId } from './storage/walletOwner';
import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.version.cases'
import './keys.version.cases';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const PASSWORD = 'hunter2'
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 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)
}
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage()
})
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)}))
}
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
}
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)
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)
})
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)
await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyIsEncrypted()).toBe(true)
const record = readRawRecord()
expect(record.enc).toBe(true)
expect(record.value).toBeUndefined()
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)
})
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()
})
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 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)
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)
})
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()
})
})
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)
await saveLinkingKey(LINKING_KEY);
// When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY)
const matches = savedKeyOwnerMatches(LINKING_KEY);
// Then the saved owner matches
expect(matches).toBe(true)
})
expect(matches).toBe(true);
});
it('does not match a different linking key', async () => {
// Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY)
await saveLinkingKey(LINKING_KEY);
// When a foreign freshly derived linking key is compared
const matches = savedKeyOwnerMatches(OTHER_KEY)
const matches = savedKeyOwnerMatches(OTHER_KEY);
// Then the foreign key is rejected
expect(matches).toBe(false)
})
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);
const plainOwner = savedKeyOwnerId();
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
await saveLinkingKey(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBe(plainOwner)
expect(savedKeyOwnerId()).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}$/)
})
})
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()
saveLegacyPlaintext(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull();
// the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).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 real = linkingPubKeyHex(LINKING_KEY);
const junk: unknown[] = [
real.slice(1), // wrong length (65)
real + '00', // wrong length (68)
@@ -176,102 +176,102 @@ describe('owner marker on legacy records', () => {
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point
42, // wrong type
null,
{pubkey: real},
{ pubkey: real },
'',
]
];
for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY)
localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId}))
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
// the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull()
expect(savedKeyOwnerId()).toBeNull();
// ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false)
expect(getPlainLinkingKey()).toBeNull()
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)
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)
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)
})
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)
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)
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey)
const afterFirst = localStorage.getItem(STORAGE_KEY)
ensureSavedKeyOwner(linkingKey)
ensureSavedKeyOwner(linkingKey);
const afterFirst = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(linkingKey);
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst)
expect(readRawRecord().ciphertext).toBe(before.ciphertext)
})
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)
})
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)
await saveLinkingKey(OTHER_KEY, PASSWORD);
const before = localStorage.getItem(STORAGE_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow()
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))
})
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()
})
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)
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({...readRawRecord(), ownerId: 'obviously junk'}),
)
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyOwnerId()).toBeNull()
expect(savedKeyExists()).toBe(false)
})
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)
})
})
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyExists()).toBe(false);
});
});
describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => {
@@ -281,36 +281,36 @@ describe('owner marker on restore', () => {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY),
})
expect(savedKeyOwnerId()).toBeNull()
expect(getPlainLinkingKey()).toEqual(LINKING_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))
})
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)
})
})
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)
})
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)
})
})
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);
});
});
+129 -157
View File
@@ -1,10 +1,11 @@
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 { 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 {
parseStoredSecret,
@@ -14,144 +15,111 @@ import {
stripStoredSecretOwner,
STORED_SECRET_VERSION,
type StoredSecret,
} from './storage/storedSecret'
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
type WalletMaterialV2,
} from './storage/storedSecret';
import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
export {isValidStoredSecret} from './storage/storedSecret'
export type {StoredSecret} from './storage/storedSecret'
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))
}
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
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 parseStoredSecret(parsed)?.secret ?? 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'],
),
)
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.
// 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
@@ -167,24 +135,24 @@ export const decryptSecretParts = async (
// 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 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)
}
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)
savedKeyOwnerId() === linkingPubKeyHex(linkingKey);
// Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext
@@ -193,99 +161,103 @@ export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
// 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)
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.')
throw new Error('Proven key does not match the saved wallet key.');
}
const claimedOwnerId = storedSecretClaimedOwnerId(stored)
if (storedSecretOwnerId(stored) === ownerId) return
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.')
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,
): Promise<void> => {
const hex = bytesToHex(linkingPrivKey)
const ownerId = linkingPubKeyHex(linkingPrivKey)
const hex = bytesToHex(linkingPrivKey);
const ownerId = linkingPubKeyHex(linkingPrivKey);
if (!password) {
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}),
)
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, ownerId, version: STORED_SECRET_VERSION}),
)
}
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(stripStoredSecretOwner(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))
}
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'])
}
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,
): 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}, aesKey, utf8ToBytes(JSON.stringify(value))),
)
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
}
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
utf8ToBytes(JSON.stringify(value)),
),
);
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
export const decryptRecord = async (
aesKey: CryptoKey,
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))
}
);
return JSON.parse(new TextDecoder().decode(plaintext));
};
+48 -44
View File
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
ensureSavedKeyOwner,
@@ -9,50 +9,54 @@ import {
savedKeyExists,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, stubLocalStorage} from './test-utils'
} from './keys';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const STORAGE_KEY = 'sattle_linking_key'
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)
}
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage()
})
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()
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)
})
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)
const ownerId = linkingPubKeyHex(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
)
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)
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({
@@ -60,8 +64,8 @@ describe('saved-key schema version', () => {
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
@@ -70,16 +74,16 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY),
version,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
};
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()
})
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
@@ -88,15 +92,15 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
const before = localStorage.getItem(STORAGE_KEY)
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
const before = localStorage.getItem(STORAGE_KEY);
// When the actual key is proven
const stamp = () => ensureSavedKeyOwner(LINKING_KEY)
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)
})
})
expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
});
});
+221 -80
View File
@@ -1,45 +1,63 @@
// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on:
// 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), and a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an
// HTTPS default anywhere in the receive path.
// 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} from 'lnurlcash-kit'
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 { claimMintedNote, prepareMint } from './ops';
import { mintAddressCacheInfo } from './trustedMints';
import './mintContract.v08.cases';
type Mint = Awaited<ReturnType<typeof createMockMint>>
type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = []
const mints: Mint[] = [];
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()))
})
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 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 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
@@ -56,82 +74,103 @@ const mintAddressFixture = {
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'},
})
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
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))
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body));
}
return Promise.resolve(new Response('not found', {status: 404}))
}
return impl
}
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)
})
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)
})
});
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)
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)
})
})
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 () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
// published conformance 0.1.1 emits lnurlw:// by default - NOT https
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// 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 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(m.state.noteState(preimage)).toBe('burned')
})
});
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([
@@ -144,22 +183,124 @@ describe('withdraw-link forms', () => {
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})
expect(prepared.withdrawLink).toBe('https://mint.example/note')
['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')
})
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)
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');
});
});
+107 -78
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
@@ -44,49 +47,68 @@ export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes',
mints: 'mints',
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] ?? ''
export const dTagOf = (event: NostrEvent): string =>
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
// 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 'mints':
return JSON.stringify({version: 1, trustedMints: payload})
case 'settings':
return JSON.stringify({version: 1, settings: 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 });
case 'settings':
return JSON.stringify({ version: 1, settings: payload });
}
};
// signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>(
@@ -103,7 +125,7 @@ export const buildBackupEvent = <P extends BackupPart>(
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey,
)
);
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
@@ -111,33 +133,33 @@ export const buildBackupEvents = (
parts: Partial<BackupPartPayload>,
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
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) =>
@@ -146,13 +168,20 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
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) =>
@@ -160,22 +189,22 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
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>
const settings = envelope.settings as Record<string, unknown>;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null
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,
@@ -185,23 +214,23 @@ export const parseBackupEvent = (
secretKey: Uint8Array,
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);
};
+35 -33
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
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 instanceof Error ? error : new Error('Backup publication failed.', {cause: error}),
)
}
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)
pending = parts;
clearTimer();
timer = setTimeout(() => void fire(), options.delayMs);
},
flush: fire,
cancel: () => {
clearTimer()
pending = null
clearTimer();
pending = null;
},
}
}
};
};
+63 -62
View File
@@ -2,17 +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 {linkingPubKeyHex} from '../keys'
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,
@@ -21,18 +21,18 @@ import {
deriveBackupKey,
dTagOf,
parseBackupEvent,
} from './events'
import type {BackupTransport} from './transport'
import {defaultTransport} from './transport'
} 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 (
@@ -41,26 +41,26 @@ export const publishBackup = async (
relays: string[],
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
@@ -71,49 +71,49 @@ export const fetchBackup = async (
relays: string[],
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[]>()
});
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),
@@ -124,26 +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 = await applyBackup(
{
type: 'sattle-backup',
version: 1,
version: 2,
createdAt: Date.now(),
bearers: parts.notes ?? [],
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),
}
}
};
};
+12 -12
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.')
throw new Error('No relay accepted the backup event.');
}
},
fetch: (relays, filter) => pool.querySync(relays, filter),
}
}
};
};
+101 -78
View File
@@ -2,14 +2,14 @@
// 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 { 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 { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} 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 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)
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 RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
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[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,114 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
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')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
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'}
{ 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: records, mints, settings}, 1000)
expect(events).toHaveLength(3)
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(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', records)
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
expect(event.content).not.toContain('ciphertext')
})
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']])
})
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()
})
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()
})
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()
})
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()
})
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()
})
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 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),
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
},
secretKey,
)
expect(parseBackupEvent(secretKey, event)).toBeNull()
})
})
);
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();
});
});
+39 -39
View File
@@ -2,14 +2,14 @@
// 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 { 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 { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} 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 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)
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 RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
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[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,21 +78,21 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
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')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => {
@@ -101,12 +101,12 @@ describe('deriveBackupKey', () => {
// 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)),
)
})
})
);
});
});
+107 -104
View File
@@ -2,14 +2,14 @@
// 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 { 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 { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} 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 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)
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 RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
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[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,134 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
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')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
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'}}])
})
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>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
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'}}])
})
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>[] = []
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 holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
const publisher = createBackupPublisher({
publish: (p) => {
published.push(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'}})
holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
}
return Promise.resolve()
return Promise.resolve();
},
delayMs: 1000,
})
holder.publisher = publisher
});
holder.publisher = publisher;
publisher.schedule({settings: {defaultMint: 'first'}})
await vi.advanceTimersByTimeAsync(1000)
publisher.schedule({ settings: { defaultMint: 'first' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
{settings: {defaultMint: 'first'}},
{settings: {defaultMint: 'mid-flight'}},
])
})
{ settings: { defaultMint: 'first' } },
{ settings: { defaultMint: 'mid-flight' } },
]);
});
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
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: '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)
})
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
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()
if (failing) return Promise.reject(new Error('relay down'));
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
onError: (e) => {
errors.push(e)
errors.push(e);
},
})
});
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([])
expect(errors).toHaveLength(1)
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'}}])
})
})
failing = false;
publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
});
});
+179 -100
View File
@@ -2,14 +2,19 @@
// 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 { 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 {
deriveBearerAesKey,
deriveWalletMaterial,
linkingPubKeyHex,
saveWalletMaterial,
} from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +26,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +35,30 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
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)
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)
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 RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +69,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
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[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,146 +92,211 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
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')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
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()
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 saveLinkingKey(LINKING_KEY)
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID})
persistSettings({defaultMint: 'mint.example'})
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: readEncryptedBearers(),
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
mints: readTrustedMints(OWNER_ID),
settings: loadSettings(),
},
RELAYS,
{transport},
)
{ transport },
);
// device B: the same seed on empty storage
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
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)
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)
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'})
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)
})
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()
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, {
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: readEncryptedBearers()}, RELAYS, {
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})
stubLocalStorage();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed
expect(result.added).toBe(2)
expect(result.added).toBe(2);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['rec-a', 'rec-b'])
).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)
})
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()
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 persistBearer(aesKey, bearerFixture({ id: 'remote' }));
persistSettings({ defaultMint: 'remote.example' });
await publishBackup(
secretKey,
{notes: readEncryptedBearers(), settings: loadSettings()},
{
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
settings: loadSettings(),
},
RELAYS,
{transport, createdAt: 1000},
)
{ transport, createdAt: 1000 },
);
// this device already has its own wallet state
stubLocalStorage()
stubLocalStorage();
await persistBearer(
aesKey,
bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
)
persistSettings({defaultMint: 'local.example'})
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)
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'})
})
).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)
})
})
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 -5
View File
@@ -1,5 +1,5 @@
import './nostrBackup.keys.cases'
import './nostrBackup.codec.cases'
import './nostrBackup.transport.cases'
import './nostrBackup.restore.cases'
import './nostrBackup.publisher.cases'
import './nostrBackup.keys.cases';
import './nostrBackup.codec.cases';
import './nostrBackup.transport.cases';
import './nostrBackup.restore.cases';
import './nostrBackup.publisher.cases';
+70 -67
View File
@@ -2,14 +2,14 @@
// 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 { 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 { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} 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 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)
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 RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
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[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,83 +78,86 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
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')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport()
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'])
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)
})
});
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([])
})
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, {
const { transport } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport,
createdAt: 1000,
})
await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, {
});
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'})
})
});
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, {
const { transport, events } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport,
createdAt: 1000,
})
const latest = requiredValue(events[0])
});
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'})
})
})
});
expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
});
});
+7 -7
View File
@@ -25,18 +25,18 @@ export {
buildBackupEvent,
buildBackupEvents,
parseBackupEvent,
} from './nostr/events'
export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events'
} 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'
} 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';
+48 -48
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,90 +55,90 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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()
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()
).toBeNull();
// missing relay
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
).toBeNull()
).toBeNull();
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
),
).toBeNull()
})
).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},
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
})
const parsed = parseConnectionString(connection.connectionString)
});
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))
})
})
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))
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)
})
);
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},
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
})
});
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
)
})
})
);
});
});
+26 -26
View File
@@ -4,29 +4,29 @@
// 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 { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {readNwcConnections} from './nwc'
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'
} 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 m = await mint();
let swapped = false;
const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) {
swapped = true
swapped = true;
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
@@ -35,32 +35,32 @@ describe('service: post-boundary stale-owner fencing', () => {
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)]
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)
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')
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()
})
})
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
await stop();
});
});
+122 -106
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,21 +55,36 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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('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({
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.error).toBeNull();
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
@@ -77,130 +92,131 @@ describe('service: make_invoice / lookup_invoice', () => {
description: 'nwc test',
created_at: nowSeconds(),
expires_at: nowSeconds() + 3600,
})
const invoice = requiredValue(made.result).invoice
});
const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice')
throw new TypeError('make_invoice did not return an invoice');
}
const paymentHash = made.result?.payment_hash
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
expect(invoice).toMatch(/^lnbc/)
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
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()
});
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 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}$/)
});
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 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 = requiredValue(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(requiredValue(noteK1(minted.url)))).toBe('outstanding')
await stop()
})
// 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({
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
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
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 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()
});
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)
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()
})
});
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({
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
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false
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()
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)
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)
})
})
commit.resolve();
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
expect(changesetsAtStop).toBe(1);
expect(state.changesets).toHaveLength(1);
});
});
+211 -125
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,187 +55,273 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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({
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
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false
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)
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()
})
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({
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},
})
claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
})
expect(made.error).toBeNull()
});
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)
})
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({
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)
prepareStarted = true;
await prepare.promise;
return fetch(input, init);
},
},
})
});
const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
relay.emit(request)
await waitFor(() => prepareStarted)
});
relay.emit(request);
await waitFor(() => prepareStarted);
let stopped = false
let stopped = false;
const stopping = stop().then(() => {
stopped = true
})
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
expect(stopped).toBe(false)
stopped = true;
});
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
prepare.resolve()
await stopping
prepare.resolve();
await stopping;
expect(state.changesets).toHaveLength(0)
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
})
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({
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
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
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 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()
})
});
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({
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},
})
claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
})
const invoice = made.result?.invoice
});
const invoice = made.result?.invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice')
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()
})
});
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 { 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()
})
});
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({
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()
})
});
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 { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5,
})
expect(response.error?.code).toBe('OTHER')
await stop()
})
})
});
expect(response.error?.code).toBe('OTHER');
await stop();
});
});
+86 -64
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,35 +55,46 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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},
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 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()
})
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
@@ -91,69 +102,80 @@ describe('service ownership', () => {
// snapshot from a previous wallet must not be served)
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
})
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
const relay = createFakeRelay()
});
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)
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()
})
})
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()
})
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')
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()
})
});
await stop();
});
it('answers get_balance with the spendable total only', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
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()
})
})
{ ...(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();
});
});
+119 -119
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,159 +55,159 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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 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()
})
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({
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000,
})
});
const foreign = foreignConnectionFixture(
{maxMsat: 99_000, periodMs: 86_400_000},
{ maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000,
)
storeForeignConnection(foreign.record)
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
);
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)
});
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)
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()
})
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({
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
await waitFor(() => commitStarted)
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()
})
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({
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
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)]
});
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)
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])
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()
})
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({
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
await waitFor(() => commitStarted)
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
let stopped = false;
const firstStop = stop().then(() => {
stopped = true
})
const repeatedStop = stop()
await Promise.resolve()
expect(stopped).toBe(false)
stopped = true;
});
const repeatedStop = stop();
await Promise.resolve();
expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes
expect(relay.subscriptionCount()).toBe(0)
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()
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)
})
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)]
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({
@@ -216,20 +216,20 @@ describe('service: pay_invoice', () => {
ownerId: OTHER_OWNER_ID,
version: 1,
}),
)
);
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
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()
})
})
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();
});
});
+104 -77
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,104 +55,131 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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({
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000,
})
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_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')
});
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()
})
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({
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])
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)]
{ ...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()
})
});
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 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()
})
});
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 { 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()
})
});
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 { 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()
})
});
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({
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)]
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(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 = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(returned.spent).toBeUndefined()
expect(returned.amount).toBe(21_000)
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
await stop()
})
})
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();
});
});
+68 -68
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,119 +55,119 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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'},
{ clientPubkey: 'nope' },
{
version: 1,
ownerId: OWNER_ID,
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
spent: {periodStart: 0, msat: 0},
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)
})
);
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([])
})
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},
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')
}).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'},
{ ...current, ownerId: 'malformed' },
{
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
relays: RELAYS,
budget: {maxMsat: 3000, periodMs: 3000},
spent: {periodStart: 0, msat: 0},
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])
})
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') ?? '{}')
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')
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))
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},
budget: { maxMsat: 1000, periodMs: 1000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
)
localStorage.setItem('sattle_nwc_enabled', 'true')
);
localStorage.setItem('sattle_nwc_enabled', 'true');
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow()
expect(readNwcConnections(OWNER_ID)).toEqual([])
expect(readNwcEnabled(OWNER_ID)).toBe(false)
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
expect(readNwcConnections(OWNER_ID)).toEqual([]);
expect(readNwcEnabled(OWNER_ID)).toBe(false);
ensureSavedKeyOwner(LINKING_KEY)
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(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)
writeNwcEnabled(OWNER_ID, true);
expect(readNwcEnabled(OWNER_ID)).toBe(true)
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false)
})
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'},
{ 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)
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
expect(readNwcEnabled(OWNER_ID)).toBe(false);
}
})
})
});
});
+9 -9
View File
@@ -1,9 +1,9 @@
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'
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';
+80 -80
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,29 +29,29 @@ import {
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'
} 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)
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']
export const RELAYS = ['wss://relay-a.example'];
let NOW = 1_800_000_000
export const nowSeconds = (): number => NOW
let NOW = 1_800_000_000;
export const nowSeconds = (): number => NOW;
export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, {
@@ -60,69 +60,69 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
clientPubkey: getPublicKey(STRANGER_SECRET),
relays: RELAYS,
budget,
spent: {periodStart: now, msat: 0},
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]))
}
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
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 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()
published.push(event);
return Promise.resolve();
},
subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent}
subs.push(sub)
allSubs.push(sub)
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 index = subs.indexOf(sub);
if (index >= 0) subs.splice(index, 1);
},
}
};
},
}
};
const deliver = (
targets: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[],
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 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)
};
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'
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
@@ -132,8 +132,8 @@ export const clientRequest = (
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW,
): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]]
if (scheme !== 'none') tags.push(['encryption', scheme])
const tags: string[][] = [['p', walletServicePubkey]];
if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -148,8 +148,8 @@ export const clientRequest = (
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
},
CLIENT_SECRET,
)
}
);
};
export const methodRequest = (
walletServicePubkey: string,
@@ -158,31 +158,31 @@ export const methodRequest = (
scheme: Encryption = 'nip44_v2',
createdAt?: number,
): NostrEvent =>
clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt)
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))
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(cond()).toBe(true)
}
expect(cond()).toBe(true);
};
export const deferred = (): {
promise: Promise<void>
resolve: () => void
reject: (reason: Error) => void
promise: Promise<void>;
resolve: () => void;
reject: (reason: Error) => void;
} => {
let resolve = (): void => undefined
let reject = (_reason: Error): void => undefined
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}
}
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
export const resetNow = (): void => {
NOW = 1_800_000_000
}
NOW = 1_800_000_000;
};
+201 -81
View File
@@ -6,13 +6,14 @@
// 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 { 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,
@@ -29,14 +30,14 @@ import {
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'
} 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,
@@ -49,13 +50,13 @@ import {
nowSeconds,
resetNow,
waitFor,
} from './nwc.testProtocol'
import type {Encryption} from './nwc.testProtocol'
} 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
}
result_type: string;
error: { code: string; message: string } | null;
result: Record<string, unknown> | null;
};
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) &&
@@ -64,7 +65,7 @@ const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
(isJsonObject(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result))
(value.result === null || isJsonObject(value.result));
export const readResponse = (
published: NostrEvent[],
@@ -73,19 +74,19 @@ export const readResponse = (
): 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
);
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
}
: 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 (
@@ -95,30 +96,31 @@ export const call = async (
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))
}
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 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
}
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()))
})
await Promise.all(mints.splice(0).map((m) => m.close()));
});
let bearerCounter = 0
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
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,
@@ -128,80 +130,198 @@ export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promi
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>
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>
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 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},
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[]} = {
});
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)
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
const bearer = state.bearers.find((b) => b.id === id);
if (bearer) bearer.spent = true;
}
for (const note of changeset.add) {
bearerCounter += 1
bearerCounter += 1;
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
})
});
}
},
onError: (err) => {
state.errors.push(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()
})
stubLocalStorage();
await saveLinkingKey(LINKING_KEY);
resetNow();
});
+16 -16
View File
@@ -37,7 +37,7 @@
// 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,
@@ -46,9 +46,9 @@ export {
readNwcConnections,
removeNwcConnection,
writeNwcConnections,
} from './storage/nwcConnections'
export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled'
export type {NwcLegacyMigrationResult} from './storage/nwcConnections'
} from './storage/nwcConnections';
export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export {
buildConnectionString,
@@ -57,13 +57,13 @@ export {
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString,
} from './nwc/connection'
} from './nwc/connection';
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString,
} from './nwc/connection'
} from './nwc/connection';
export {
NWC_INFO_KIND,
@@ -75,7 +75,7 @@ export {
decryptRequest,
errResult,
okResult,
} from './nwc/protocol'
} from './nwc/protocol';
export type {
DecryptedNwcRequest,
NostrEvent,
@@ -84,15 +84,15 @@ export type {
NwcMethod,
NwcRequest,
NwcResponse,
} from './nwc/protocol'
} 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';
+82 -82
View File
@@ -6,13 +6,13 @@
// 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 { 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,
@@ -29,13 +29,13 @@ import {
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'
} 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,
@@ -55,45 +55,45 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} 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()
})
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()
})
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)
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()
})
);
await stop();
});
it('ignores a request signed by a stranger key - silently', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip44v2.encrypt(
JSON.stringify({method: 'get_balance', params: {}}),
JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
)
);
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -105,20 +105,20 @@ describe('service: request validation', () => {
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()
})
);
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 { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
JSON.stringify({method: 'get_balance', params: {}}),
)
JSON.stringify({ method: 'get_balance', params: {} }),
);
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -130,58 +130,58 @@ describe('service: request validation', () => {
content,
},
CLIENT_SECRET,
)
relay.emit(request)
);
relay.emit(request);
// the error answer goes out in the legacy scheme every client reads
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null)
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION',
)
await stop()
})
);
await stop();
});
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
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})
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'])
);
expect(event.tags).toContainEqual(['encryption', 'nip04']);
}
await stop()
})
await stop();
});
it('drops requests older than the replay window unanswered', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
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()
})
);
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)
})
})
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);
});
});
+10 -10
View File
@@ -5,14 +5,14 @@
// 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)
}
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)
@@ -22,14 +22,14 @@ export const recordSpend = (
amountMsat: number,
nowMs: number,
): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
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,
},
})
}
});
};
+49 -49
View File
@@ -20,17 +20,17 @@
// 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 {linkingPubKeyHex} from '../keys'
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
@@ -43,16 +43,16 @@ export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: str
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...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
@@ -62,21 +62,21 @@ export const connectionInfoOf = (
): NwcConnectionInfo => ({
record,
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.
@@ -85,21 +85,21 @@ export const createConnection = (
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 ownerId = linkingPubKeyHex(linkingPrivKey)
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},
spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(),
})
const info = connectionInfoOf(linkingPrivKey, record)
});
const info = connectionInfoOf(linkingPrivKey, record);
return {
...info,
connectionString: buildConnectionString(
@@ -107,43 +107,43 @@ export const createConnection = (
bytesToHex(clientSecret),
record.relays,
),
}
}
};
};
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: 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
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 };
};
+78 -42
View File
@@ -5,83 +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
assertCurrentOwner: () => void
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,
assertOwner: () => void,
) => Promise<void>
transport?: NwcTransport
) => 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
assertOwner: () => void
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
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
}
stopSignal: AbortSignal;
};
+115 -49
View File
@@ -4,23 +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 {PollAbortedError} from '../ops/shared'
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', {
@@ -33,7 +35,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: [],
})
});
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', {
@@ -41,25 +43,88 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0),
})
});
const handleMakeInvoice = async (
ctx: RequestContext,
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,
@@ -67,69 +132,70 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
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 service-owned claim observes
const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed'
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 (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.')
entry.state = 'failed';
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
}
return okResult('make_invoice', invoiceResult(entry))
}
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 invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
const hashParam =
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined
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.')
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
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> => {
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}.`);
}
}
};
+98 -86
View File
@@ -1,46 +1,77 @@
// 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<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000,
intervalCapMs: 10_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> => {
const result: Record<string, unknown> = {
type: 'incoming',
@@ -50,67 +81,48 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
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
}
};
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 and
// bearer commit - so lookup can only reveal durably tracked funds and 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.',
)
}
// the observation half is interruptible (the client may never pay, so
// the poll can legally outlive the service); once settlement is seen,
// everything below - claim, rotate, bearer commit - is fund-critical and
// deliberately ignores the stop signal: stop's drain awaits it
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(
entry.prepared.verifyUrl,
{...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal},
prepared.verifyUrl,
{ ...claimPoll, signal: ctx.stopSignal },
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.")
);
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.");
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error('The payment settled but the service did not reveal the preimage.')
if (result.preimage && isPreimage(result.preimage)) preimage = result.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, {
let claimed;
try {
claimed = await claimFromSecret(
prepared,
prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
})
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
await ctx.deps.applyChangeset(
{add, markSpent: []},
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
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
},
);
} catch (error) {
if (!(error instanceof MintedNoteSpentError)) throw error;
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
throw error;
}
}
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
entry.settledAt = ctx.nowSeconds();
entry.state = 'settled';
if (preimage) entry.preimage = preimage;
};
+108 -59
View File
@@ -10,171 +10,220 @@ import {
fetchInvoiceVerification,
isBolt11Invoice,
noteK1,
} from 'lnurlcash-kit'
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.
// 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)
if (result.carve.change) add.push(result.carve.change)
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>,
): 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.",
)
);
}
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())) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
"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 ?? {},
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
await ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []},
{ add: err.possibleOutputs, markSpent: [] },
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
return errResult(
'pay_invoice',
'INTERNAL',
'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,
)
);
}
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 => {
// 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()))
}
const connection = ctx.connection().record;
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
};
switch (result.outcome) {
case 'settled': {
spendRecorded()
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
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 ?? ''
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
if (!(error instanceof Error)) throw error;
}
}
return okResult('pay_invoice', {preimage})
return okResult('pay_invoice', { preimage });
}
case 'failed-funds-returned':
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
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.',
)
);
case 'note-already-spent':
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
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.',
)
);
case 'unknown-still-pending':
spendRecorded()
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
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.',
)
);
}
}
};
+53 -52
View File
@@ -13,16 +13,16 @@
// - 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',
@@ -30,9 +30,9 @@ export const NWC_METHODS = [
'make_invoice',
'pay_invoice',
'lookup_invoice',
] as const
] as const;
export type NwcMethod = (typeof NWC_METHODS)[number]
export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode =
| 'RATE_LIMITED'
@@ -45,38 +45,38 @@ 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,
})
});
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method,
error: {code, message},
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)
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = (
scheme: NwcEncryption,
@@ -86,7 +86,7 @@ export const encryptFor = (
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = (
scheme: NwcEncryption,
@@ -96,10 +96,10 @@ const decryptFrom = (
): 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
@@ -112,8 +112,8 @@ 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,
@@ -122,24 +122,24 @@ export const decryptRequest = (
event: NostrEvent,
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
@@ -151,24 +151,24 @@ export const decryptRequest = (
'UNSUPPORTED_ENCRYPTION',
`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.'),
}
};
}
if (
typeof data !== 'object' ||
@@ -180,12 +180,13 @@ export const decryptRequest = (
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.'),
};
}
}
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}
}
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 };
};
// signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag
@@ -209,7 +210,7 @@ export const buildResponseEvent = (
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
},
walletSecretKey,
)
);
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
@@ -224,4 +225,4 @@ export const buildInfoEvent = (
content: NWC_METHODS.join(' '),
},
walletSecretKey,
)
);
+112 -95
View File
@@ -14,60 +14,60 @@
// foreground-only design itself is documented in the nwc.ts façade
// header.
import {linkingPubKeyHex} from '../keys'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {readNwcConnections} from '../storage/nwcConnections'
import {assertSavedKeyOwner} from '../storage/currentOwner'
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} 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
info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains
walletSecret: Uint8Array | null
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: () => Promise<void>
}
stop: () => Promise<void>;
};
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records?: NwcConnectionRecord[],
): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport())
const ownerId = linkingPubKeyHex(linkingPrivKey)
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 nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
const publishResponse = async (
runtime: ConnectionRuntime,
@@ -86,43 +86,54 @@ export const startService = async (
response,
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,
): 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
}
);
runtime.queue = run.catch(() => undefined);
return run;
};
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent,
): Promise<void> => {
const walletSecret = runtime.walletSecret
if (walletSecret === null) return
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(
walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at,
)
if (decrypted === null) return
);
if (decrypted === null) return;
if (decrypted.respond) {
await publishResponse(
runtime,
@@ -130,56 +141,56 @@ export const startService = async (
event.id,
decrypted.encryption,
decrypted.response,
)
return
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
);
return;
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request);
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
};
let accepting = true
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 stopController = new AbortController();
const inFlight = new Set<Promise<void>>();
const track = (task: Promise<void>): void => {
inFlight.add(task)
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
}
if (!accepting) return false;
track(work());
return true;
};
let runtimes = ownedRecords.map((record) => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
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}
runtime.info = { ...runtime.info, record: updated };
},
invoices: runtime.invoices,
nowSeconds,
assertOwner: () => {
deps.assertCurrentOwner()
assertSavedKeyOwner(ownerId)
deps.assertCurrentOwner();
assertSavedKeyOwner(ownerId);
},
startBackground,
stopSignal: stopController.signal,
}
};
runtime.sub = transport.subscribe(
record.relays,
{
@@ -188,56 +199,62 @@ export const startService = async (
since: nowSeconds(),
},
(event) => {
if (!accepting) return
if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info)
})
track(handler)
deps.onError?.(err, runtime.info);
});
track(handler);
},
)
return runtime
})
);
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
const walletSecret = runtime.walletSecret;
if (walletSecret === null) continue;
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(walletSecret, nowSeconds()),
)
);
} catch (error) {
deps.onError?.(
error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}),
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 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.all([...inFlight])
.then(() => undefined)
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
runtime.walletSecret?.fill(0);
runtime.walletSecret = null;
runtime.invoices.clear();
}
runtimes = [];
});
return stopPromise;
};
return {
connections,
stop,
}
}
};
};
+16 -15
View File
@@ -4,36 +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
}
) => 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.')
throw new Error('No relay accepted the event.');
}
},
subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}),
}
}
subscribe: (relays, filter, 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');
});
});
+337 -98
View File
@@ -1,122 +1,361 @@
import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
import type {Bearer} from './types'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
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')
})
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)
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)
})
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)
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)
})
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)
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)
})
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 instance = await mint();
const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
callback: '',
}
verified: false,
};
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
).rejects.toThrow(/enough/)
})
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/)
})
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)
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)
})
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({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(instance, k1, 21_000)
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 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: probeKillingFetch,
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2)
const first = requiredValue(failure.possibleOutputs[0])
const second = requiredValue(failure.possibleOutputs[1])
expect(first.amount).toBe(5_000)
expect(second.amount).toBe(16_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(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
).toBe(5_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000)
})
})
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);
});
});
+161 -81
View File
@@ -1,106 +1,186 @@
import {describe, expect, it} from 'vitest'
import { describe, expect, it } from 'vitest';
import {
NoteSpentError,
PendingNoteError,
buildNoteUrl,
fetchNoteInfo,
meltNote,
noteK1,
rotateNote,
} from 'lnurlcash-kit'
} from 'lnurlcash-kit';
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness'
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 -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(instance)
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: 50,
intervalCapMs: 20,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
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)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(instance)
const info = await fetchNoteInfo(
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat),
)
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000)
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat)
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 instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
})
});
// 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), [])
expect(received.rotated).toBe(true)
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')
})
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)
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)
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), [])).rejects.toBeInstanceOf(
PendingNoteError,
)
})
})
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');
});
});
+198 -56
View File
@@ -1,75 +1,217 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import {payWithBearers} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness'
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 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')
})
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 payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, {
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,
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')
})
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 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},
})
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')
})
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 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},
})
expect(result.outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('33'))).toBe('burned')
const returnedK1 = requiredValue(noteK1(result.carve.note.url))
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
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 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},
})
expect(result.outcome).toBe('unknown-still-pending')
expect(instance.state.noteState(secret('34'))).toBe('pending')
})
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)
})
})
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 -4
View File
@@ -1,4 +1,8 @@
import './ops.carve.cases'
import './ops.mint-receive.cases'
import './ops.pay.cases'
import './ops.transfer.cases'
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';
+81 -35
View File
@@ -1,42 +1,69 @@
import {afterEach, expect} 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 {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit'
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'
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
export type Mint = Awaited<ReturnType<typeof createMockMint>>
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = []
const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options)
mints.push(instance)
return instance
}
const instance = await createMockMint(options);
mints.push(instance);
return instance;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close()))
})
await Promise.all(mints.splice(0).map((instance) => instance.close()));
});
export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
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)
buildNoteUrl(`${instance.url}/w`, k1, amountMsat);
let fixtureCounter = 0
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
instance.state.creditNote(k1, amountMsat);
const url = noteUrl(instance, k1, amountMsat);
const info = await fetchNoteInfo(url);
fixtureCounter += 1;
return {
id: `fixture-${fixtureCounter}`,
url,
@@ -46,26 +73,45 @@ export const makeBearer = async (
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
}
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))
await new Promise((resolve) => setTimeout(resolve, 10));
}
return settleLastInvoice(instance)
}
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))
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(instance.state.noteState(k1)).toBe('burned')
}
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;
};
+305 -105
View File
@@ -1,136 +1,336 @@
import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import {transferBetweenMints} from './ops'
import {requiredValue} from './test-utils'
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
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}
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[] = [];
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,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
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.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
});
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)
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(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
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)
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')
})
).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')
})
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 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)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
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)
const note = requiredValue(result.claimMaterial?.note)
expect(noteK1(note.url)).toBe(preimage)
expect(note.verified).toBe(false)
expect(note.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
})
});
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)
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')
})
).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 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')
const returnedK1 = requiredValue(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)
})
})
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');
});
});
+32 -17
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'
} from './ops/transfer';
+43
View File
@@ -0,0 +1,43 @@
// Output-secret allocation: a mint-touching operation never invents the
// secrets its outputs will live at. It asks the caller's durable allocation
// path (the wallet's BIP-32 counter reservation, see
// stores/walletFunds.ts) for exactly the secrets it is about to stage, at
// the moment the target server is known - before any hash of them can
// reach a mint. The reservation commit (counter bump plus encrypted
// journal record) is what makes a crash between staging and wire safe:
// indices may be burned, never reused, and the staged secrets are
// recoverable from the funds document.
//
// The ops layer stays framework-free: this is just the option type and the
// point-of-use requirement. Tests inject deterministic allocators;
// production callers serve reserveCashIndices-backed secrets.
export type OutputSecretAllocator = (server: string, count: number) => Promise<readonly string[]>;
export class OutputSecretAllocationRequiredError extends Error {
override readonly name = 'OutputSecretAllocationRequiredError';
constructor() {
super('This operation requires durably allocated output secrets.');
}
}
// The one place secrets enter an operation: exactly `count` fresh secrets
// for `server`, or a rejection before anything is staged or sent. A
// short/long/wrong-shaped answer is a caller bug - fail the operation
// rather than stage a note at an unmappable secret.
export const requireOutputSecrets = async (
allocate: OutputSecretAllocator | undefined,
server: string,
count: number,
): Promise<readonly string[]> => {
if (!allocate) throw new OutputSecretAllocationRequiredError();
const secrets = await allocate(server, count);
if (
secrets.length !== count ||
secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret))
) {
throw new Error('The output secret allocation did not match the request.');
}
return secrets;
};
+226 -184
View File
@@ -1,36 +1,73 @@
// ensureExactAmount: carve an exact amount out of the held notes, merging
// and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from.
//
// Every output secret is chosen and reported through options.onCarve BEFORE
// its hash reaches the mint. That first checkpoint stages possible outputs
// without retiring inputs; a second checkpoint retires the inputs only after
// the mutation is known to have landed.
import {
AmbiguousMutationError,
mergeNotes,
mergeBatches,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
serverOf,
settleNote,
splitNote,
withNewK1,
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared'
} from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import { mergeAmbiguitySafe } from './carveRecovery';
import type { FundOperationOptions } from './shared';
import {
assertFundOwner,
landedNoteVerifies,
probeMutationOutput,
UncertainOutcomeError,
withMutationSafety,
} from './shared';
// the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input
// server-side, so the outputs are the only money left and must be tracked
// first; a crash between the two must strand a duplicate, never a secret
// The same shape serves both checkpoint phases: the pre-wire phase carries
// note/change with an empty consumed list, and the landed phase carries the
// already-staged note plus the inputs the caller can now retire.
export type CarveResult = {
// the exact-amount note, ready to hand over or melt
note: NewBearer
note: NewBearer;
// the remainder note, when the carve split a larger input
change?: NewBearer
change?: NewBearer;
// the input notes burned server-side by the carve (empty when a single
// note already held exactly the target amount)
consumed: Bearer[]
consumed: Bearer[];
};
export class UnsupportedMultiBatchMergeError extends Error {
override readonly name = 'UnsupportedMultiBatchMergeError';
constructor() {
super('This carve would require multiple merge requests and cannot be performed safely.');
}
}
export class CarveCheckpointRequiredError extends Error {
override readonly name = 'CarveCheckpointRequiredError';
constructor() {
super('A mutating carve requires a durable onCarve checkpoint.');
}
}
export type CarveOptions = FundOperationOptions & {
// the caller's durable allocation path for the carve's output secrets
// (split: target + change; merge: the combined note). Required for any
// mutating carve: the secrets come from the wallet's reserved BIP-32
// indices so a restart can never reuse or lose them.
readonly allocateOutputSecrets?: OutputSecretAllocator;
};
// Selection: only notes that can actually take part - verified (callback
// known), not locally spent, holding a real k1 (device-backed mirrors are
// excluded; the ops engine cannot mutate a secret it doesn't hold). Notes
@@ -40,45 +77,59 @@ export type CarveResult = {
//
// Execution, mirroring lnurl-wallet's SendDialog:
// - one note already exact: returned as-is, nothing burned
// - several notes summing exactly: one merge, then settle (reads the true
// post-fee value back and rotates, since the read put k1 on the wire)
// - several notes summing exactly: one merge; folds spanning multiple
// requests are rejected because partial progress needs a larger journal
// - total above target (one or many notes): a single split request (LUD-25
// split takes many k1s - no merge round trip first), then the change is
// settled for its true value; the target part carries the mint's
// signature and needs no settle
// split takes many k1s - no merge round trip first); change stays
// unverified until refresh because a second settle mutation would create
// another crash window
export const ensureExactAmount = async (
bearers: Bearer[],
amountMsat: number,
options: FundOperationOptions = {},
options: CarveOptions = {},
): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
throw new Error('Amount must be a positive whole number of msat.');
}
// the forced mutation policy (signatures required, one byte-identical
// replay) applies to every mint call below regardless of caller options
const mutationOptions = withMutationSafety(options);
const eligible = bearers.filter(
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
(b) => b.verified && !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
);
// per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at
// all is always better than carving)
const byServer = new Map<string, Bearer[]>()
const byServer = new Map<string, Bearer[]>();
for (const b of eligible) {
const server = serverOf(b.url)
byServer.set(server, [...(byServer.get(server) ?? []), b])
const server = serverOf(b.url);
byServer.set(server, [...(byServer.get(server) ?? []), b]);
}
let pick: Bearer[] | null = null
let pick: Bearer[] | null = null;
for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount)
const exact = sorted.find((b) => b.amount === amountMsat)
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
if (!candidate) continue
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
const sorted = [...group].sort((a, b) => a.amount - b.amount);
const exact = sorted.find((b) => b.amount === amountMsat);
const candidate = exact ? [exact] : accumulate(sorted, amountMsat);
if (!candidate) continue;
if (!pick || better(candidate, pick, amountMsat)) pick = candidate;
}
if (!pick) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
}
const base = pick[0]
const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map((b) => requireNoteK1(b.url))
const base = pick[0];
const total = pick.reduce((sum, b) => sum + b.amount, 0);
const k1s = pick.map((b) => requireNoteK1(b.url));
const checkpoint = async (result: CarveResult): Promise<void> => {
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
assertFundOwner(options);
await options.onCarve(result);
assertFundOwner(options);
};
const stage = (result: CarveResult): Promise<void> => checkpoint({ ...result, consumed: [] });
const retire = (note: NewBearer): Promise<void> => checkpoint({ note, consumed: pick });
if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched
@@ -91,194 +142,185 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey,
},
consumed: [],
}
};
}
if (total === amountMsat) {
// merge path: many notes, exact sum - merge into one, then settle it
// (true value + fresh secret; a failed settle leaves an unverified
// note a refresh can repair, not a lost secret)
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
const unverified: NewBearer = {
url: withNewK1(base.url, merged.k1, total, merged.signature),
if (mergeBatches(base.callback, k1s).length !== 1) {
throw new UnsupportedMultiBatchMergeError();
}
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
const [mergeSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
1,
);
const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
}
// a merge whose answer was lost leaves the service in an unknown
// state from here - settling fires another mutation (the rotate
// inside settleNote) at it, whose own ambiguous failure would strand
// the rescued secret. Don't compound: return unverified and let a
// refresh repair.
if (merged.rescued) return {note: unverified, consumed: pick}
try {
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return {
};
await stage({ note: staged, consumed: pick });
assertFundOwner(options);
const merged = await mergeAmbiguitySafe(base, k1s, {
...mutationOptions,
randomSecret: () => mergeSecret,
});
// a landed merge reports its signature (carried in the URL's sig param)
// and earns verified only when that signature checks against a trusted
// current or previous key; a rescued merge saw no answer, so its note
// stays exactly as staged - unverified, same URL
const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature);
const result: CarveResult = merged.rescued
? { note: staged, consumed: pick }
: {
note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
...staged,
url: landedUrl,
verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
},
consumed: pick,
}
} catch {
return {note: unverified, consumed: pick}
}
};
await retire(result.note);
return result;
}
// split path: total above target - one split request across all picked
// k1s, carving the target off and leaving the change as a fresh note
let partK1: string
let partSignature: string | undefined
let changeK1: string
let changeSignature: string | undefined
let partVerified = false
// true when the split's answer was lost and the probe proved the burn -
// the carried secrets were rescued, but the service is in an unknown
// state, so the change is NOT settled (that would fire another mutation
// at it, whose own ambiguous failure would strand the rescued secret)
let rescued = false
assertFundOwner(options)
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
const [partK1, changeK1] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
2,
);
let partSignature: string | undefined;
let changeSignature: string | undefined;
const stagedNote: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
const stagedChange: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
await stage({ note: stagedNote, change: stagedChange, consumed: pick });
let secretIndex = 0;
const preparedSecret = (): string => {
secretIndex += 1;
if (secretIndex === 1) return partK1;
if (secretIndex === 2) return changeK1;
throw new Error('The split requested more output secrets than were staged.');
};
assertFundOwner(options);
try {
const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1
partSignature = parts.signature
changeK1 = parts.change
changeSignature = parts.changeSignature
partVerified = true
const parts = await splitNote(base.callback, k1s, amountMsat, {
...mutationOptions,
randomSecret: preparedSecret,
});
partSignature = parts.signature;
changeSignature = parts.changeSignature;
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
const outcome = await probeBurnedNote(base.url, mutationOptions);
if (outcome === 'live') throw err; // nothing burned - a plain failure
if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[],
);
}
// 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else {
// A classified refusal can still be a LANDED split: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless.
const carried = newSecretsOf(err);
if (carried.length !== 2) throw err;
const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions);
if (outcome === 'absent') throw err; // never landed - a plain refusal
if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.',
[],
);
}
// 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left
}
}
// the landed answer's signatures ride in the URLs (sig param); a rescued
// split saw no answer, so its notes keep the staged sig-less URLs. Only a
// signature that verifies against a trusted current/previous key earns
// verified:true - anything else stays staged unverified for a refresh.
const noteUrl =
partSignature === undefined
? stagedNote.url
: withNewK1(base.url, partK1, amountMsat, partSignature);
const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature),
url: noteUrl,
callback: base.callback,
amount: amountMsat,
verified: partVerified,
verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey,
}
// settleNote: the change may be worth less than total - amount if this
// mint charges split fees (LUD-25 deducts them from change, never the
// split-off amount) - it comes back at its true value, or stays
// unverified at the naive pre-fee one for a refresh to repair
let change: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature),
};
// The change may be worth less than total - amount when the mint charges
// split fees. It remains unverified at that upper bound until refresh;
// settling it here would rotate to an unstaged secret in a second request.
const changeUrl =
changeSignature === undefined
? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature);
const change: NewBearer = {
url: changeUrl,
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
}
if (!rescued) {
try {
const settled = await settleNote(
base.url,
changeK1,
total - amountMsat,
changeSignature,
options,
)
change = {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
}
} catch (error) {
// settle is best-effort - the unverified change above is still tracked
if (!(error instanceof Error)) throw error
}
}
return {note, change, consumed: pick}
}
};
const result = { note, change, consumed: pick };
await retire(note);
return result;
};
// smallest-first accumulation until the target is covered; null when the
// whole group can't reach it
const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => {
const picked: Bearer[] = []
let total = 0
const picked: Bearer[] = [];
let total = 0;
for (const b of sorted) {
picked.push(b)
total += b.amount
if (total >= amountMsat) return picked
picked.push(b);
total += b.amount;
if (total >= amountMsat) return picked;
}
return null
}
return null;
};
// the better carve plan: less waste first, then fewer notes burned
const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0)
const wasteA = sum(a) - target
const wasteB = sum(b) - target
if (wasteA !== wasteB) return wasteA < wasteB
return a.length < b.length
}
// merge with the full ambiguity protocol: probe one input, rescue the
// carried secret only once the burn is confirmed, surface it unverified
// when the probe can't tell either
const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
total: number,
options: FundOperationOptions,
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
try {
const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true}
}
}
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0);
const wasteA = sum(a) - target;
const wasteB = sum(b) - target;
if (wasteA !== wasteB) return wasteA < wasteB;
return a.length < b.length;
};
+50
View File
@@ -0,0 +1,50 @@
// A merge failure can hide a landed mutation because callback GETs may be
// retried below the engine. This module owns the probes that distinguish a
// plain refusal from a live or still-uncertain wallet-chosen output.
import { AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote } from 'lnurlcash-kit';
import type { Bearer } from '../types';
import type { FundOperationOptions } from './shared';
import { probeMutationOutput, UncertainOutcomeError } from './shared';
export type MergeOutcome = {
readonly rescued: boolean;
// the mint's signature over the combined note - present only when the
// merge's own answer was seen; a rescued merge (lost answer, retried
// twin) has none to offer
readonly signature?: string;
};
export const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
options: FundOperationOptions,
): Promise<MergeOutcome> => {
try {
const merged = await mergeNotes(base.callback, k1s, options);
return { rescued: false, signature: merged.signature };
} catch (error) {
if (error instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options);
if (outcome === 'live') throw error;
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.',
[],
);
}
return { rescued: true };
}
const carried = newSecretsOf(error);
if (carried.length !== 1) throw error;
const outcome = await probeMutationOutput(base.url, carried[0], options);
if (outcome === 'absent') throw error;
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.',
[],
);
}
return { rescued: true };
}
};
+200 -160
View File
@@ -1,49 +1,65 @@
// Minting: receiving over Lightning. prepareMint resolves a mint and
// requests the invoice; paying it brings the note into existence;
// claimMintedNote watches the payment and converts the revealed preimage
// into a rotated, wallet-owned bearer note.
// Minting: receiving over Lightning. Current LUD-25 minting is always
// comment-bound: the wallet chooses the future note secret, durably stages
// an unverified bearer for it, and only then sends sha256(secret) with the
// invoice request. Paying the invoice brings that already-tracked note into
// existence; claiming confirms its authoritative value and callback.
import {
AmbiguousMutationError,
buildNoteUrl,
claimMintedNote as fetchMintClaim,
fetchMintAddress,
fetchNoteInfo,
fetchPayRequest,
grossUpForMintFee,
isPreimage,
hashK1,
lightningAddressUsername,
mintAddressUrl,
probeBurnedNote,
requestInvoice,
noteDeclaredAmount,
noteK1,
resolveMintInput,
rotateNote,
sameInvoice,
serverOf,
withNewK1,
} from 'lnurlcash-kit'
import type {MintAddressInfo} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
} from 'lnurlcash-kit';
import type { MintAddressInfo, MintClaim } from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import { ceilMsatToSat } from '../units';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import type { PollOptions } from './shared';
import type { FundOperationOptions } from './shared';
import { assertFundOwner, pollUntil, withMutationSafety } from './shared';
export type PreparedMint = {
invoice: string
verifyUrl: string | null
invoice: string;
verifyUrl: string | null;
// The wallet-chosen secret the minted note lands at. Its unverified note
// is persisted before the invoice request can put this secret's hash on
// the wire.
noteSecret: string;
// the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it
expectedNoteValueMsat: number
expectedNoteValueMsat: number;
// the gross amount actually invoiced (net + mint fee, rounded up to a
// whole sat - sub-sat invoices aren't reliably payable)
grossMsat: number
mintUrl: string
withdrawLink: string
mintPubkey?: string
server: string
username: string | null
nodeInfo: MintAddressInfo | null
}
grossMsat: number;
mintUrl: string;
withdrawLink: string;
mintPubkey?: string;
server: string;
username: string | null;
nodeInfo: MintAddressInfo | null;
};
export type PrepareMintOptions = FundOperationOptions & {
readonly persistOutput: (note: NewBearer) => void | Promise<void>;
readonly beforePersist?: (quote: {
readonly grossMsat: number;
readonly server: string;
}) => void | Promise<void>;
// the caller's durable allocation path for the staged note's secret -
// required: the wallet's reserved BIP-32 indices are the only source a
// restart can never reuse or lose
readonly allocateOutputSecrets?: OutputSecretAllocator;
};
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
// its mint address (best-effort LUD-25 experimental endpoint), read its
@@ -53,173 +69,197 @@ export type PreparedMint = {
export const prepareMint = async (
mintInput: string,
amountMsat: number,
options: FundOperationOptions = {},
options: PrepareMintOptions,
): Promise<PreparedMint> => {
const { beforePersist, persistOutput, ...rest } = options;
// the forced mutation policy (see shared.ts) covers the invoice request
const kitOptions = withMutationSafety(rest);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
throw new Error('Amount must be a positive whole number of msat.');
}
const url = resolveMintInput(mintInput)
if (!url) throw new Error('Enter a mint LNURL or Lightning Address.')
const url = resolveMintInput(mintInput);
if (!url) throw new Error('Enter a mint LNURL or Lightning Address.');
// best-effort mint-address discovery - derived from `url`'s own
// .well-known/lnurlp/{name} path; when it succeeds, its payLink is the
// authoritative place to fetch the payRequest from
const addressUrl = mintAddressUrl(url)
let nodeInfo: MintAddressInfo | null = null
let payUrl = url
const addressUrl = mintAddressUrl(url);
let nodeInfo: MintAddressInfo | null = null;
let payUrl = url;
if (addressUrl) {
try {
nodeInfo = await fetchMintAddress(addressUrl, options)
payUrl = nodeInfo.payLink
nodeInfo = await fetchMintAddress(addressUrl, kitOptions);
payUrl = nodeInfo.payLink;
} catch (error) {
// no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error
if (!(error instanceof Error)) throw error;
}
}
const info = await fetchPayRequest(payUrl, options)
const info = await fetchPayRequest(payUrl, kitOptions);
if (!info.withdrawLink) {
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).')
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).');
}
const grossMsat = ceilMsatToSat(
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat,
)
);
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.")
throw new Error("Amount is outside this mint's sendable range.");
}
const invoice = await requestInvoice(info.callback, grossMsat, options)
const server = serverOf(payUrl);
await beforePersist?.({ grossMsat, server });
const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1);
const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey;
const staged: NewBearer = {
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
callback: '',
// The URL retains the claimed amount for recovery, but the wallet's
// balance stays at zero until the mint confirms the authoritative value.
amount: 0,
verified: false,
pendingMint: mintPubkey ? { mintPubkey } : {},
};
assertFundOwner(options);
await persistOutput(staged);
assertFundOwner(options);
const invoice = await requestInvoice(info.callback, grossMsat, {
...kitOptions,
h: hashK1(noteSecret),
});
const prepared: PreparedMint = {
invoice: invoice.pr,
verifyUrl: invoice.verify ?? null,
noteSecret,
expectedNoteValueMsat: amountMsat,
grossMsat,
mintUrl: payUrl,
withdrawLink: info.withdrawLink,
server: serverOf(payUrl),
server,
username: lightningAddressUsername(payUrl),
nodeInfo,
}
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
return prepared
}
};
if (mintPubkey) prepared.mintPubkey = mintPubkey;
return prepared;
};
export type ClaimedNote = {
note: NewBearer
// false when the rotate after claim failed - the note is tracked either
// way (it IS money), but the preimage was transmitted and the mint
// necessarily knows it, so an unrotated note must be treated as exposed
rotated: boolean
// set when the rotate's answer was lost and the probe couldn't tell: the
// possible rotated copy, to track unverified alongside `note`
possibleCopy?: NewBearer
rotationError?: string
note: NewBearer;
rotated: boolean;
possibleCopy?: NewBearer;
rotationError?: string;
};
export class MintedNoteSpentError extends Error {
override readonly name = 'MintedNoteSpentError';
constructor() {
super('The mint reports the freshly minted note as already spent.');
}
}
// Polls the mint invoice's LUD-21 verify URL until the payment settles,
// then claims the note: the payment preimage IS the note secret. The claim
// itself (an informational GET) puts that secret on the wire, and the mint
// has known it since it generated the invoice - so the fresh note is
// rotated immediately and unconditionally (observer race: anyone who saw
// the unpaid invoice knows the payment hash), before anything else happens
// with it.
// Polls a named mint output's informational GET until the note is there:
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the
// caller to reject, never retried); 'unminted' and 'pending' keep it
// going. The poll IS the placement proof for a named output: the service
// only answers 'minted' for the secret it actually credited.
export const pollMintClaim = async (
withdrawLink: string,
noteSecret: string,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<MintClaim> =>
pollUntil(
async (fetchOptions) => {
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions);
return claim.state === 'minted' || claim.state === 'spent' ? claim : null;
},
'Minted note not confirmed',
poll,
options,
);
export type ClaimTarget = {
withdrawLink: string;
noteSecret: string;
// the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable
expectedNoteValueMsat: number;
mintPubkey?: string;
};
// The note was credited to the wallet's own secret, which rode to the mint
// only as a hash inside the invoice request. The payment preimage opens
// nothing, so the claim needs no preimage and no rotate follows it.
export const claimFromSecret = async (
target: ClaimTarget,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
assertFundOwner(options);
const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options);
if (claim.state === 'spent') {
throw new MintedNoteSpentError();
}
if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried
// secret by construction and fetchNoteInfo rejects a mismatched echo,
// so this cannot trip through the kit - if it ever does, building a
// note from the answer would track the wrong secret
throw new Error('The mint answered the claim for a different note secret.');
}
if (claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the minted note details.');
}
const note: NewBearer = {
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
};
if (target.mintPubkey) note.mintPubkey = target.mintPubkey;
return { note, rotated: true };
};
// Polls until the minted note is claimable at the wallet's staged secret.
export const claimMintedNote = async (
prepared: PreparedMint,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
if (!prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
const verifyUrl = prepared.verifyUrl
const result = await pollVerifyUntilSettled(verifyUrl, poll, options)
// 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, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error('The payment settled but the service did not reveal the preimage.')
}
return claimFromPreimage(prepared, preimage, options)
}
): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options);
// what a claim needs once the preimage is known - PreparedMint satisfies
// this, and so does the target side of an inter-mint transfer (see
// transfer.ts)
export type ClaimTarget = {
withdrawLink: string
// the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable
expectedNoteValueMsat: number
mintPubkey?: string
}
// One bounded observation for durable recovery. An unpaid invoice is normal:
// keep the staged output and try again at the next wallet/service activation.
export type StagedMintRecovery =
| { readonly state: 'unminted' }
| { readonly state: 'pending' }
| { readonly state: 'spent' }
| { readonly state: 'minted'; readonly note: NewBearer };
// The claim itself, once the payment's preimage is known: the preimage IS
// the note secret. The claim (an informational GET) puts that secret on
// the wire, and the mint has known it since it generated the invoice - so
// the fresh note is rotated immediately and unconditionally (observer
// race: anyone who saw the unpaid invoice knows the payment hash), before
// anything else happens with it.
export const claimFromPreimage = async (
claim: ClaimTarget,
preimage: string,
export const recoverStagedMintOutput = async (
staged: Bearer,
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
// declare the invoiced amount (a claim - not yet confirmed) so the note
// is self-describing even before the verifying GET below
const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat)
assertFundOwner(options)
// the service's maxWithdrawable is authoritative - SERVICE's own fee
// math might not match this wallet's estimate, and the note is worth
// exactly maxWithdrawable regardless
const noteInfo = await fetchNoteInfo(declaredUrl, options)
const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey
const base: NewBearer = {
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
): Promise<StagedMintRecovery> => {
const noteSecret = noteK1(staged.url);
const expectedNoteValueMsat = noteDeclaredAmount(staged.url);
if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) {
throw new Error('The pending mint output is malformed.');
}
const withdrawLink = new URL(staged.url);
withdrawLink.searchParams.delete('k1');
withdrawLink.searchParams.delete('amount');
withdrawLink.searchParams.delete('sig');
assertFundOwner(options);
const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options));
if (claim.state === 'unminted') return { state: 'unminted' };
if (claim.state === 'pending') return { state: 'pending' };
if (claim.state === 'spent') return { state: 'spent' };
if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the pending output details.');
}
const note: NewBearer = {
url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (mintPubkey) base.mintPubkey = mintPubkey
let url = base.url
let rotated = true
let possibleCopy: NewBearer | undefined
let rotationError: string | undefined
try {
const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options)
url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature)
} catch (err) {
rotated = false
if (err instanceof AmbiguousMutationError) {
// the rotate request may have landed despite the failure - the fresh
// secret it carried is then the only copy of this note
const outcome = await probeBurnedNote(declaredUrl, options)
if (outcome === 'gone') {
// the burn landed - adopt the fresh secret as the note
url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
// can't tell: the preimage note is returned either way - the
// possible rotated copy goes alongside it, both refreshable
possibleCopy = {
url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
} else {
rotationError = err.message
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy
if (rotationError) claimed.rotationError = rotationError
return claimed
}
};
if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey;
return { state: 'minted', note };
};
+132 -81
View File
@@ -16,15 +16,18 @@ import {
resolveLnurlInput,
rotateNote,
sameInvoice,
serverOf,
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
} from 'lnurlcash-kit';
import type { LnurlcashOptions, MeltResult } from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import type { CarveResult } from './carve';
import { ensureExactAmount } from './carve';
import type { PollOptions } from './shared';
import type { FundOperationOptions, MintSignatureKeys } from './shared';
import { assertFundOwner, pollVerifyUntilSettled, withMutationSafety } from './shared';
export type PayOutcome =
| 'settled'
@@ -32,31 +35,51 @@ export type PayOutcome =
| 'unknown-still-pending'
// the service reports the carved note as already spent before the melt
// even started - nothing was paid, and the note is definitively gone
| 'note-already-spent'
| 'note-already-spent';
export type PayResult = {
outcome: PayOutcome
carve: CarveResult
outcome: PayOutcome;
carve: CarveResult;
// the invoice that was (attempted to be) paid
invoice: string
amountMsat: number
verifyUrl: string | null
// a fresh secret rescued from an ambiguous rotate during outcome
// classification - the caller must track it unverified; if the rotate
// landed, this is the only copy of the (returned) funds
rescuedNote?: NewBearer
invoice: string;
amountMsat: number;
verifyUrl: string | null;
rotatedNote?: NewBearer;
rescuedNote?: NewBearer;
};
export class PayReturnCheckpointRequiredError extends Error {
override readonly name = 'PayReturnCheckpointRequiredError';
constructor() {
super('A pay-return classification requires a durable onReturnReady checkpoint.');
}
}
export type PayOptions = {
// required when `input` is a Lightning Address / LNURL-pay (a bolt11
// carries its own amount)
amountMsat?: number
amountMsat?: number;
// verify-poll budget - tests shrink this
poll?: PollOptions
poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
}
kit?: LnurlcashOptions;
assertOwner?: () => void;
// carve checkpoint - committed before the melt and the settlement wait,
// so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'];
// the caller's durable allocation path: the carve's output secrets and -
// only when the return classification actually needs one - the recovery
// secret. Required for any mutating carve or return classification.
allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the carve's landed outputs verify against (see
// FundOperationOptions.mintSignatureKeys)
mintSignatureKeys?: MintSignatureKeys;
// Called before the classification rotate puts its chosen output secret
// on the wire. Production callers durably link it to the carved source.
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>;
};
// A melt's resolved promise only means the payment is in flight; the
// outcome is classified by polling the melt's LUD-25 verify URL, then - if
@@ -71,65 +94,90 @@ export type PayOptions = {
export const payWithBearers = async (
bearers: Bearer[],
input: string,
{amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {},
{
amountMsat,
poll = {},
kit = {},
assertOwner,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
}: PayOptions = {},
): Promise<PayResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
let invoice: string
let amount: number
const trimmed = input.trim()
// the forced mutation policy (see shared.ts) covers the carve, the melt,
// and the classification rotate regardless of caller kit options
const options: FundOperationOptions = withMutationSafety({
...kit,
...(assertOwner ? { assertOwner } : {}),
...(onCarve ? { onCarve } : {}),
...(mintSignatureKeys ? { mintSignatureKeys } : {}),
});
let invoice: string;
let amount: number;
const trimmed = input.trim();
if (isBolt11Invoice(trimmed)) {
const decoded = decodeBolt11AmountMsat(trimmed)
const decoded = decodeBolt11AmountMsat(trimmed);
if (decoded === null || decoded <= 0) {
throw new Error(
"Could not read this invoice's amount - amount-less invoices are not supported.",
)
);
}
invoice = trimmed
amount = decoded
invoice = trimmed;
amount = decoded;
} else {
// a Lightning Address (or LNURL-pay) has no invoice of its own yet -
// resolving it gets a payRequest, and an amount is needed before an
// actual invoice exists
const url = resolveLnurlInput(trimmed)
const url = resolveLnurlInput(trimmed);
if (!url) {
throw new Error('Not a valid bolt11 invoice or Lightning Address.')
throw new Error('Not a valid bolt11 invoice or Lightning Address.');
}
if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Enter an amount to pay to this address.')
throw new Error('Enter an amount to pay to this address.');
}
const info = await fetchPayRequest(url, options)
const info = await fetchPayRequest(url, options);
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
throw new Error("Amount is outside the payee's sendable range.")
throw new Error("Amount is outside the payee's sendable range.");
}
const result = await requestInvoice(info.callback, amountMsat, options)
invoice = result.pr
amount = amountMsat
const result = await requestInvoice(info.callback, amountMsat, options);
invoice = result.pr;
amount = amountMsat;
}
const carve = await ensureExactAmount(bearers, amount, options)
const k1 = requireNoteK1(carve.note.url)
if (carve.consumed.length === 0) assertFundOwner(options)
let melt: MeltResult
const carve = await ensureExactAmount(bearers, amount, {
...options,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
});
const k1 = requireNoteK1(carve.note.url);
if (carve.consumed.length === 0) assertFundOwner(options);
let melt: MeltResult;
try {
melt = await meltNote(carve.note.callback, k1, invoice, options)
melt = await meltNote(carve.note.callback, k1, invoice, options);
} catch (err) {
// this melt names a single note, so a NoteSpentError here is
// unambiguous - it's already gone, and gets locked spent the same way
// a successful melt would have locked it
if (err instanceof NoteSpentError) {
return {outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null}
return { outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null };
}
throw err
throw err;
}
if (!melt.verify) {
// no melt proof to poll - the note locking as spent locally is all the
// confirmation there is
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl: null}
return {
outcome: 'unknown-still-pending',
carve,
invoice,
amountMsat: amount,
verifyUrl: null,
};
}
const verifyUrl = melt.verify
const verifyUrl = melt.verify;
try {
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options);
// a settled report is only this payment's proof when it's for the
// invoice this melt actually paid - a mint that mixes up proofs must
// not confirm the wrong payment. The verify URL is already scoped to
@@ -138,53 +186,56 @@ export const payWithBearers = async (
// belongs to another payment, while an undecodable one says nothing
// either way (a service regenerating synthetic prs in proofs) and is
// tolerated.
const proofAmount = decodeBolt11AmountMsat(proof.pr)
const proofAmount = decodeBolt11AmountMsat(proof.pr);
if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) {
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
}
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
} catch {
// the verify budget ran out - probe the note itself with a rotate: a
// failed melt is only observable as the note becoming spendable again
// The verify budget ran out. Journal the exact output secret before the
// classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement.
if (!onReturnReady) throw new PayReturnCheckpointRequiredError();
const [recoverySecret] = await requireOutputSecrets(
allocateOutputSecrets,
serverOf(carve.note.url),
1,
);
await onReturnReady(carve, recoverySecret);
try {
const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded, so the mint restored the note - and k1 had
// been on the wire since the melt, so the rotation doubles as the
// required re-securing of the returned funds
const rotated = await rotateNote(carve.note.callback, k1, {
...options,
randomSecret: () => recoverySecret,
});
return {
outcome: 'failed-funds-returned',
carve: {
...carve,
note: {
...carve.note,
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
},
carve,
rotatedNote: {
url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature),
callback: carve.note.callback,
amount,
verified: true,
...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}),
},
invoice,
amountMsat: amount,
verifyUrl,
}
};
} catch (err) {
if (err instanceof PendingNoteError) {
// still locked mid-melt - no outcome either way
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
}
if (err instanceof NoteSpentError) {
// burned without a settled proof - the money is gone either way
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
}
if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost. Had the note still been pending,
// the service would have said so cleanly - so the funds ARE back,
// but whether the rotation landed is unknown: the original k1 may
// be live, or the fresh secret may be the only copy. Surface both.
const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
url: withNewK1(carve.note.url, recoverySecret, amount),
callback: carve.note.callback,
amount,
verified: false,
}
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
};
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return {
outcome: 'failed-funds-returned',
carve,
@@ -192,9 +243,9 @@ export const payWithBearers = async (
amountMsat: amount,
verifyUrl,
rescuedNote,
};
}
return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
}
}
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
}
}
}
};
+128 -28
View File
@@ -3,20 +3,67 @@
// bech32, lnurlw://), verify it with the issuing service, then rotate
// immediately, since the previous holder (and anything that logged the URL
// in transit) still knows the old secret.
//
// Durability contract: the rotate's fresh secret comes from the caller's
// reservation path (options.allocateOutputSecrets) and the future rotated
// note is persisted through options.stageRotation BEFORE the rotate can
// land - a crash after the mint burned the sender's copy never loses the
// only secret the money answers to. The staged record carries the original
// k1 as its pendingMint.sourceRecoverySecret so wallet recovery can restore
// the received note when the rotate provably never landed.
import {
AmbiguousMutationError,
NoteSpentError,
NoteUnknownError,
PendingNoteError,
newSecretsOf,
probeBurnedNote,
requireNoteK1,
serverOf,
withNewK1,
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared'
} from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import { receiveNote, secureReceivedNote } from '../receive';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import type { FundOperationOptions } from './shared';
import { assertFundOwner, probeMutationOutput, withMutationSafety } from './shared';
export class ReceiveRotationStagingRequiredError extends Error {
override readonly name = 'ReceiveRotationStagingRequiredError';
constructor() {
super('A receive rotation requires a durable stageRotation checkpoint.');
}
}
export type ReceiveBearerOptions = FundOperationOptions & {
// the caller's durable allocation path for the rotation's fresh secret -
// required when stageRotation is present
readonly allocateOutputSecrets?: OutputSecretAllocator;
// durably persist the future rotated note (unverified, pendingMint
// carrying the original k1 as sourceRecoverySecret) before the rotate
// reaches the mint. The record's fate is decided by the result's
// `stage` disposition.
readonly stageRotation?: (staged: NewBearer) => void | Promise<void>;
};
export type ReceivedNote = {
readonly note: NewBearer;
readonly rotated: boolean;
readonly rotationError?: string;
// how the caller disposes the staged rotation output:
// - 'finalize': the rotate landed - replace the staged record with `note`
// - 'keep': the outcome is genuinely ambiguous - the staged record IS
// the possible rotated copy, kept unverified alongside `note` (the
// original, demoted to unverified since its copy may be burned)
// - 'discard': the rotate provably never landed - drop the staged
// record; `note` is the original, still verified as received
// - 'none': no rotate was attempted (the note could not be verified) -
// nothing was staged
readonly stage: 'finalize' | 'keep' | 'discard' | 'none';
};
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note,
@@ -24,17 +71,74 @@ import {assertFundOwner} from './shared'
export const receiveBearer = async (
input: string,
existing: Bearer[],
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
const note = await receiveNote(input, existing)
options: ReceiveBearerOptions = {},
): Promise<ReceivedNote> => {
// the forced mutation policy (see shared.ts) covers the rotate below
const mutationOptions = withMutationSafety(options);
const note = await receiveNote(input, existing);
if (!note.verified || !note.callback) {
return {note, rotated: false}
return { note, rotated: false, stage: 'none' };
}
// the rotate burns the sender's secret at a fresh one: stage that future
// note durably before the mutation can land, or a crash strands the money
const staging = options.stageRotation;
if (!staging) throw new ReceiveRotationStagingRequiredError();
const [rotationSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(note.url),
1,
);
const staged: NewBearer = {
url: withNewK1(note.url, rotationSecret, note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
pendingMint: {
sourceRecoverySecret: requireNoteK1(note.url),
...(note.mintPubkey ? { mintPubkey: note.mintPubkey } : {}),
},
};
await staging(staged);
const rotateOptions = { ...mutationOptions, randomSecret: () => rotationSecret };
// the disposition the caller applies to its staged record
const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => {
if (rotated) return 'finalize';
return ambiguous ? 'keep' : 'discard';
};
try {
assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note)
return {note: {...note, url: rotatedUrl}, rotated: true}
assertFundOwner(options);
const rotatedUrl = await secureReceivedNote(note, rotateOptions);
return { note: { ...note, url: rotatedUrl }, rotated: true, stage: disposition(true, false) };
} catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the
// callback is a GET and HTTP stacks retry GETs, so the service may
// have executed the first attempt and refused this one as an
// already-spent input. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe
// it before believing the refusal.
const carried = newSecretsOf(err);
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions);
if (outcome === 'live') {
return {
note: { ...note, url: withNewK1(note.url, carried[0], note.amount) },
rotated: true,
stage: disposition(true, false),
};
}
if (outcome === 'unknown') {
return {
// the sender's copy may be burned - it cannot stay verified; the
// staged record is the possible rotated copy
note: { ...note, verified: false },
rotated: false,
stage: disposition(false, true),
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
};
}
// 'absent': the refusal is genuine - fall through to the distinct
// handling below
}
// a definitive service state (dead/unknown/locked mid-melt) is not a
// rotation failure to warn about - it tells the holder what this note
// actually is, so it propagates distinctly
@@ -43,10 +147,10 @@ export const receiveBearer = async (
err instanceof PendingNoteError ||
err instanceof NoteUnknownError
) {
throw err
throw err;
}
if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(note.url, options)
const outcome = await probeBurnedNote(note.url, mutationOptions);
if (outcome === 'gone') {
return {
note: {
@@ -54,30 +158,26 @@ export const receiveBearer = async (
url: withNewK1(note.url, err.newSecrets[0], note.amount),
},
rotated: true,
}
stage: disposition(true, false),
};
}
if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, err.newSecrets[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return {
note,
rotated: false,
possibleCopy,
stage: disposition(false, true),
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`,
}
};
}
}
// a failed rotate never fails the receive - the note is money as it
// is; the caller warns that it must be treated as exposed
// is; the caller warns that it must be treated as exposed. The rotate
// provably never landed, so the staged record is worthless.
return {
note,
rotated: false,
rotationError: err instanceof Error ? err.message : String(err),
stage: disposition(false, false),
};
}
}
}
};
+178 -70
View File
@@ -2,20 +2,31 @@
// every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as.
import {fetchInvoiceVerification} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
import {
fetchInvoiceVerification,
fetchNoteInfo,
noteK1,
noteSignature,
serverOf,
verifyNoteSignature,
withNewK1,
} from 'lnurlcash-kit';
import type { LnurlcashOptions, VerifyResult } from 'lnurlcash-kit';
import { NoteSpentError, NoteUnknownError, noteDeclaredAmount } from 'lnurlcash-kit';
import type { NewBearer } from '../types';
import type { CarveResult } from './carve';
// a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the
// caller to track unverified alongside the (kept) inputs. Never dropped:
// if the mutation did land, these are the only money left.
// caller to track unverified alongside the kept inputs. A pre-wire carve
// checkpoint may already have persisted them, in which case this list is
// empty so callers do not add duplicate records.
export class UncertainOutcomeError extends Error {
readonly possibleOutputs: NewBearer[]
readonly possibleOutputs: NewBearer[];
constructor(message: string, possibleOutputs: NewBearer[]) {
super(message)
this.name = 'UncertainOutcomeError'
this.possibleOutputs = possibleOutputs
super(message);
this.name = 'UncertainOutcomeError';
this.possibleOutputs = possibleOutputs;
}
}
@@ -23,102 +34,199 @@ export class UncertainOutcomeError extends Error {
// from budget exhaustion so the caller can treat it as normal teardown
export class PollAbortedError extends Error {
constructor() {
super('The wait was interrupted by shutdown.')
this.name = 'PollAbortedError'
super('The wait was interrupted by shutdown.');
this.name = 'PollAbortedError';
}
}
export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void
}
readonly assertOwner?: () => void;
// Two durable phases for a mutating carve. Before wire use, unverified
// outputs are reported with `consumed: []`. After the mint mutation is
// known to have landed, the already-staged note is reported again with
// the inputs that can now be retired. A rejection in either phase stops
// the flow; exact-note carves call neither phase.
readonly onCarve?: (carve: CarveResult) => void | Promise<void>;
// the trusted signing keys a landed mutation output's signature is
// checked against before it may report verified: the mint's pinned
// current key AND its previous one (a just-rotated mint's last notes
// stay verifiable). Production supplies the trusted-mint registry's
// keys; an absent source (or empty list) keeps every landed output
// staged unverified rather than trusting it blindly.
readonly mintSignatureKeys?: MintSignatureKeys;
};
// the trusted signing keys available for one mint server
export type MintSignatureKeys = (server: string) => readonly string[];
// The mutation safety policy, forced where the engine talks to a mint:
// every mutation must come back signed (the signature is the only offline
// proof the mint issued the output) and a lost answer is retried exactly
// once, byte-identically (the replay the pre-wire staging exists to make
// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the
// caller spread means no caller's options object can quietly strip either
// guarantee.
export const withMutationSafety = <Options extends LnurlcashOptions>(
options: Options,
): Options => ({
...options,
requireSignatures: true,
mutationRetries: 1,
});
// A landed mutation output earns verified:true only when its signature
// (carried as the URL's sig param) verifies against a trusted key for its
// server. Missing, malformed, or wrong-key signatures - or no trusted keys
// on file at all - leave the note staged unverified; a refresh can repair
// it later. The money is never at stake here (the note exists at the mint
// either way), only the offline-verifiable badge is.
export const landedNoteVerifies = (
noteUrl: string,
keysFor: MintSignatureKeys | undefined,
): boolean => {
if (!keysFor) return false;
const k1 = noteK1(noteUrl);
const amountMsat = noteDeclaredAmount(noteUrl);
const signature = noteSignature(noteUrl);
if (!k1 || amountMsat === null || !signature) return false;
const keys = keysFor(serverOf(noteUrl));
return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]);
};
export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.()
}
options.assertOwner?.();
};
// what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown';
// A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first
// attempt and refused this one as an already-spent input (LUD-25 says a
// byte-identical retry SHOULD get the original success replayed; real
// mints refuse instead). The kit attaches the fresh output secrets to
// every service refusal (newSecretsOf) - so before believing a refusal,
// probe one would-be output: 'live' proves the mutation landed and the
// carried secrets are the only money left, 'absent' proves the refusal is
// genuine, 'unknown' keeps it genuinely ambiguous.
export const probeMutationOutput = async (
noteUrl: string,
secret: string,
options: LnurlcashOptions = {},
): Promise<OutputProbe> => {
try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options);
return 'live';
} catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent';
return 'unknown';
}
};
export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number
intervalCapMs?: number
intervalMs?: number;
intervalCapMs?: number;
// total budget before giving up
maxWaitMs?: number
maxWaitMs?: number;
// aborts the wait promptly (shutdown). Only the WAIT is interruptible:
// callers pass this for work whose observation phase may outlive the
// caller - once pollVerifyUntilSettled has returned, the signal no
// longer reaches anything
signal?: AbortSignal
}
signal?: AbortSignal;
};
const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 1000,
intervalCapMs: 5000,
maxWaitMs: 120_000,
}
};
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
// a sleep that ends immediately on abort instead of riding out its timer
const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new PollAbortedError())
}
clearTimeout(timer);
reject(new PollAbortedError());
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
if (signal.aborted) {
clearTimeout(timer)
reject(new PollAbortedError())
return
clearTimeout(timer);
reject(new PollAbortedError());
return;
}
signal.addEventListener('abort', onAbort, {once: true})
})
signal.addEventListener('abort', onAbort, { once: true });
});
// polls `check` until it yields a value, with backoff, inside a total
// time budget. A single failed check isn't fatal - the next round tries
// again. Returns the first value; throws on budget exhaustion, or
// PollAbortedError when the caller's signal fires (a hung fetch is
// interrupted too: the signal is bound into the request).
export const pollUntil = async <T>(
check: (options: LnurlcashOptions) => Promise<T | null>,
exhausted: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<T> => {
const { intervalMs, intervalCapMs, maxWaitMs, signal } = {
...DEFAULT_POLL,
...poll,
};
const fetchOptions: LnurlcashOptions = signal
? {
...options,
fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch;
return base(input, { ...init, signal });
},
}
: options;
const deadline = Date.now() + maxWaitMs;
let delay = intervalMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError();
try {
const result = await check(fetchOptions);
if (result !== null) return result;
lastError = null;
} catch (err) {
// the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError();
lastError = err;
}
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal);
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())));
delay = Math.min(delay * 2, intervalCapMs);
}
if (lastError instanceof Error) {
throw new Error(`${exhausted}: ${lastError.message}`);
}
throw new Error(`${exhausted} within the time budget.`);
};
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = async (
export const pollVerifyUntilSettled = (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<VerifyResult> => {
const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL,
...poll,
}
const fetchOptions: LnurlcashOptions = signal
? {
...options,
fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch
return base(input, {...init, signal})
): Promise<VerifyResult> =>
pollUntil(
async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions);
return result.settled ? result : null;
},
}
: options
const deadline = Date.now() + maxWaitMs
let delay = intervalMs
let lastError: unknown = null
while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError()
try {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
if (result.settled) return result
lastError = null
} catch (err) {
// the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError()
lastError = err
}
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal)
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
delay = Math.min(delay * 2, intervalCapMs)
}
if (lastError instanceof Error) {
throw new Error(`Payment not confirmed: ${lastError.message}`)
}
throw new Error('Payment not confirmed within the time budget.')
}
'Payment not confirmed',
poll,
options,
);
+258 -133
View File
@@ -3,38 +3,40 @@
// that exist: this wallet requests an invoice FROM the target mint
// (grossed up for its advertised mint fee, so the note that comes out
// nets the requested amount), melts source notes to pay it, then claims
// the target note from the revealed preimage exactly like any other
// minted receive. The target invoice settling is the transfer's ground
// truth: it can only settle if the source melt's payment arrived, and its
// verify response is what reveals the preimage to claim with. When it
// never settles, the source note itself is the oracle - a successful
// rotate proves the melt never burned it (funds returned), anything else
// stays uncertain.
// the target note exactly like any other minted receive. The target note
// appearing at the wallet's pre-staged secret is the transfer's ground
// truth: it can only appear if the source melt's payment arrived.
//
// When settlement never confirms, the source note itself is the oracle -
// a successful rotate proves the melt never burned it (funds returned),
// anything else stays uncertain.
import {
AmbiguousMutationError,
NoteSpentError,
NoteUnknownError,
PendingNoteError,
buildNoteUrl,
decodeBolt11AmountMsat,
isPreimage,
fetchNoteInfo,
meltNote,
newSecretsOf,
noteK1,
requireNoteK1,
rotateNote,
sameInvoice,
serverOf,
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve'
import type {ClaimedNote} from './mint'
import {claimFromPreimage, prepareMint} from './mint'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
} from 'lnurlcash-kit';
import type { LnurlcashOptions } from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import type { CarveResult } from './carve';
import { ensureExactAmount } from './carve';
import type { ClaimedNote, PreparedMint, PrepareMintOptions } from './mint';
import { MintedNoteSpentError, claimFromSecret, prepareMint } from './mint';
import type { PollOptions } from './shared';
import type { FundOperationOptions, MintSignatureKeys } from './shared';
import { assertFundOwner, withMutationSafety } from './shared';
export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated)
@@ -49,166 +51,283 @@ export type TransferOutcome =
| 'note-already-spent'
// the target invoice settled (the money arrived) but the claim could
// not complete - claimMaterial carries everything needed to retry it
| 'settled-claim-failed'
| 'settled-claim-failed';
export type TransferQuote = {
// the net value the user wants to land on the target mint
requestedMsat: number
requestedMsat: number;
// what the source side must cover - the target invoice, grossed up for
// the target's advertised mint fee and rounded to a whole sat
grossMsat: number
grossMsat: number;
// the target mint's receive fee as estimated by the gross-up (the
// service's own fee math is authoritative - the claimed note's amount
// is what it actually withheld)
targetMintFeeMsat: number
targetMintFeeMsat: number;
// LUD-25 melt has no fee field - the melted note must equal the invoice
// exactly, so no source-side reserve is even expressible
sourceMeltFeeReserveMsat: number
}
sourceMeltFeeReserveMsat: number;
};
// everything a caller needs to retry (or log) the target claim when the
// transfer could not complete it - once the melt has settled this
// material IS the money, so it is never dropped
export type TransferClaimMaterial = {
invoice: string
withdrawLink: string
expectedNoteValueMsat: number
// the preimage note, unverified, once the preimage is known - the
// preimage IS the note secret; the caller must track it and retry
note?: NewBearer
}
invoice: string;
withdrawLink: string;
expectedNoteValueMsat: number;
note: NewBearer;
// the wallet-chosen secret the target note lands at.
// The note exists at this secret from the moment the invoice settles -
// the caller can rebuild the note URL from it and retry the claim
noteSecret: string;
};
export type TransferResult = {
outcome: TransferOutcome
outcome: TransferOutcome;
// the source-side changeset: consumed inputs and any change note
carve: CarveResult
quote: TransferQuote
carve: CarveResult;
quote: TransferQuote;
// the invoice the source note was melted to pay
invoice: string
// the target invoice's verify URL - the transfer's ground truth
verifyUrl: string
sourceServer: string
targetServer: string
invoice: string;
// the target invoice's optional verify URL (used for a payment receipt;
// the staged note itself is the settlement observation)
verifyUrl: string | null;
sourceServer: string;
targetServer: string;
// the fresh target note, on 'settled'
mintedAtTarget?: ClaimedNote
mintedAtTarget?: ClaimedNote;
// on failed-funds-returned, when the source-probe rotate succeeded: the
// returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer;
// present whenever the claim could still complete later
claimMaterial?: TransferClaimMaterial
claimMaterial?: TransferClaimMaterial;
// a fresh secret rescued from an ambiguous rotate while classifying the
// melt - the caller must track it unverified (same semantics as pay.ts)
rescuedNote?: NewBearer
rescuedNote?: NewBearer;
};
export class TransferMeltCheckpointRequiredError extends Error {
override readonly name = 'TransferMeltCheckpointRequiredError';
constructor() {
super('A source melt requires a durable onMeltReady checkpoint.');
}
}
export type TransferOptions = {
// verify-poll budget - tests shrink this
poll?: PollOptions
poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
}
kit?: LnurlcashOptions;
assertOwner?: () => void;
// carve checkpoint - committed before the melt and the target
// settlement wait, so an abort mid-wait can never strand the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'];
// persists the target note before its hash reaches the invoice callback
persistOutput: PrepareMintOptions['persistOutput'];
// the caller's durable allocation path: the staged target note's secret,
// the source carve's outputs, and the source recovery secret. Required:
// the wallet's reserved BIP-32 indices are the only source a restart can
// never reuse or lose.
allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the source carve's landed outputs verify against
mintSignatureKeys?: MintSignatureKeys;
// Last durable checkpoint before the source melt can become irreversible.
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>;
};
// the claim retry material for a named target: the note at the wallet's
// own secret, unverified until a claim succeeds
const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer => {
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, noteSecret, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
};
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey;
return note;
};
const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => {
const eligible = bearers.filter(
(bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url),
);
const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer);
if (eligible.length > 0 && candidates.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.");
}
const totals = new Map<string, number>();
for (const bearer of candidates) {
const server = serverOf(bearer.url);
totals.set(server, (totals.get(server) ?? 0) + bearer.amount);
}
if (![...totals.values()].some((total) => total >= grossMsat)) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
}
return candidates;
};
export type PendingTransferSourceRecovery =
| { readonly state: 'pending' }
| { readonly state: 'spent' }
| { readonly state: 'returned'; readonly note: NewBearer };
export const recoverPendingTransferSource = async (
staged: Bearer,
source: Bearer,
options: FundOperationOptions = {},
): Promise<PendingTransferSourceRecovery> => {
// a recovery only ever PROBES the melt outcome and rotates - it never
// replays the melt itself; the forced policy pins signature/retry safety
const mutationOptions = withMutationSafety(options);
const recoverySecret = staged.pendingMint?.sourceRecoverySecret;
if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.');
const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount);
assertFundOwner(options);
try {
const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions);
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable),
callback: recovered.callback,
amount: recovered.maxWithdrawable,
verified: true,
};
if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return { state: 'returned', note };
} catch (error) {
if (error instanceof NoteSpentError) return { state: 'spent' };
if (!(error instanceof NoteUnknownError)) throw error;
}
assertFundOwner(options);
try {
const rotated = await rotateNote(source.callback, requireNoteK1(source.url), {
...mutationOptions,
randomSecret: () => recoverySecret,
});
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature),
callback: source.callback,
amount: source.amount,
verified: true,
};
if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return { state: 'returned', note };
} catch (error) {
if (
error instanceof PendingNoteError ||
error instanceof NoteSpentError ||
error instanceof AmbiguousMutationError ||
newSecretsOf(error).includes(recoverySecret)
) {
return { state: 'pending' };
}
throw error;
}
};
export const transferBetweenMints = async (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
{poll = {}, kit = {}, assertOwner}: TransferOptions = {},
{
poll = {},
kit = {},
assertOwner,
onCarve,
persistOutput,
allocateOutputSecrets,
mintSignatureKeys,
onMeltReady,
}: TransferOptions,
): Promise<TransferResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
const options: FundOperationOptions = withMutationSafety({
...kit,
...(assertOwner ? { assertOwner } : {}),
...(onCarve ? { onCarve } : {}),
...(mintSignatureKeys ? { mintSignatureKeys } : {}),
});
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
throw new Error('Amount must be a positive whole number of msat.');
}
// resolving the target and requesting its invoice touches only the
// TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options)
if (!prepared.verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const verifyUrl = prepared.verifyUrl
const targetServer = prepared.server
const prepared = await prepareMint(targetMint, amountMsat, {
...options,
persistOutput,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
beforePersist: ({ grossMsat, server }) => {
transferSources(bearers, server, grossMsat);
},
});
const verifyUrl = prepared.verifyUrl;
const targetServer = prepared.server;
// the source must be a DIFFERENT mint - value "moved" within one mint
// goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing)
const eligible = bearers.filter(
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer)
if (eligible.length > 0 && offTarget.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.")
}
const offTarget = transferSources(bearers, targetServer, prepared.grossMsat);
const quote: TransferQuote = {
requestedMsat: amountMsat,
grossMsat: prepared.grossMsat,
targetMintFeeMsat: prepared.grossMsat - amountMsat,
sourceMeltFeeReserveMsat: 0,
}
};
// carving burns its inputs server-side, so it happens only once the
// target is known good and the invoice exists
const carve = await ensureExactAmount(offTarget, prepared.grossMsat, options)
const sourceServer = serverOf(carve.note.url)
const invoice = prepared.invoice
const carve = await ensureExactAmount(offTarget, prepared.grossMsat, {
...options,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
});
const sourceServer = serverOf(carve.note.url);
const invoice = prepared.invoice;
const claimMaterial: TransferClaimMaterial = {
invoice,
withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat,
}
noteSecret: prepared.noteSecret,
note: namedClaimNote(prepared, prepared.noteSecret),
};
// from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
const k1 = requireNoteK1(carve.note.url)
if (carve.consumed.length === 0) assertFundOwner(options)
const base = { carve, quote, invoice, verifyUrl, sourceServer, targetServer };
const k1 = requireNoteK1(carve.note.url);
if (!onMeltReady) throw new TransferMeltCheckpointRequiredError();
const [sourceRecoverySecret] = await requireOutputSecrets(
allocateOutputSecrets,
serverOf(carve.note.url),
1,
);
if (carve.consumed.length === 0) assertFundOwner(options);
await onMeltReady(carve, sourceRecoverySecret);
assertFundOwner(options);
try {
await meltNote(carve.note.callback, k1, invoice, options)
} catch (err) {
if (err instanceof NoteSpentError) {
// this melt names a single note, so this is unambiguous - it was
// already gone before the melt even started
return {...base, outcome: 'note-already-spent'}
await meltNote(carve.note.callback, k1, invoice, options);
} catch (error) {
if (!(error instanceof Error)) throw error;
if (error instanceof NoteSpentError) {
return { ...base, outcome: 'note-already-spent' };
}
// anything else - a clean refusal, a dropped response, a lost answer -
// is resolved below: the target invoice settles only if this melt's
// payment arrived, and the source probe tells the rest
}
try {
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
// the proof-binding rule from pay.ts, extended for the gross-up: the
// verify URL is scoped to this invoice's payment hash, so an exact pr
// match binds it; short of that, a proof amount that is neither the
// invoiced gross nor the expected net belongs to another payment,
// while an undecodable one says nothing either way and is tolerated
const proofAmount = decodeBolt11AmountMsat(proof.pr)
if (
!sameInvoice(proof.pr, invoice) &&
proofAmount !== null &&
proofAmount !== prepared.grossMsat &&
proofAmount !== prepared.expectedNoteValueMsat
) {
return {...base, outcome: 'unknown-still-pending', claimMaterial}
}
if (!proof.preimage || !isPreimage(proof.preimage)) {
// settled, but the service won't reveal the preimage - the claim
// cannot complete automatically
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch {
// the melt settled - the money is now the preimage note at the
// target and nowhere else; surface it rather than lose it
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
const claimed = await claimFromSecret(prepared, poll, options);
return { ...base, outcome: 'settled', mintedAtTarget: claimed };
} catch (error) {
if (error instanceof MintedNoteSpentError) {
return { ...base, outcome: 'settled-claim-failed', claimMaterial };
}
throw new Error('The target note could not be confirmed at its staged secret.', {
cause: error,
});
}
} catch {
// the target invoice never settled within budget - the source note is
@@ -218,37 +337,43 @@ export const transferBetweenMints = async (
// left but never arrived within the budget - the claim material stays
// as the way back to the money if the invoice settles later
try {
const rotated = await rotateNote(carve.note.callback, k1, options)
const rotated = await rotateNote(carve.note.callback, k1, {
...options,
randomSecret: () => sourceRecoverySecret,
});
// the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the
// carve may already be committed, and its note is what this rotate
// burned
return {
...base,
outcome: 'failed-funds-returned',
carve: {
...carve,
note: {
...carve.note,
rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature),
callback: carve.note.callback,
amount: carve.note.amount,
verified: true,
...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}),
},
},
}
};
} catch (err) {
if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
return {...base, outcome: 'unknown-still-pending', claimMaterial}
return { ...base, outcome: 'unknown-still-pending', claimMaterial };
}
if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost - pay.ts's reasoning: had the note
// still been pending the service would have said so, so the funds
// ARE back, but whether the rotation landed is unknown. Surface
// the possible fresh copy alongside the unchanged note.
// The rotate's answer was lost. Keep the transfer journal until
// recovery proves whether the original or recovery secret is live.
const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
callback: carve.note.callback,
amount: carve.note.amount,
verified: false,
};
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return { ...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote };
}
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
return {...base, outcome: 'failed-funds-returned', rescuedNote}
}
return {...base, outcome: 'unknown-still-pending', claimMaterial}
return { ...base, outcome: 'unknown-still-pending', claimMaterial };
}
}
}
};
+10 -7
View File
@@ -1,15 +1,18 @@
// Legacy passkey slots may be adopted only after another unlock path has
// proven and stamped the saved wallet owner. The linking key is checked
// against that marker before markerless slots are changed under the lock.
// The proven marker lives on the v2 wallet-material record; adoption itself
// additionally requires the slot to commit to the exact saved material.
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
import {withStorageLock} from './storageLock'
import { linkingPubKeyHex } from './keys';
import { adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY } from './storage/passkeySlots';
import { withStorageLock } from './storageLock';
import { savedWalletMaterialOwnerId } from './walletMaterialStorage';
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
const ownerId = savedKeyOwnerId()
const ownerId = savedWalletMaterialOwnerId();
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.')
throw new Error('Legacy passkey migration requires a proven owner.');
}
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId))
}
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId));
};

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