36 Commits
Author SHA1 Message Date
protom 9bea2d3043 Merge pull request #12 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.6
2026-09-07 14:45:09 +02:00
github-actions[bot] a057bde3ff chore(main): release sattle 0.0.6 2026-09-07 11:39:06 +00:00
protom 785b56de3b Merge pull request #11 from tompro/feat/notes-and-activity
feat: add note management and activity history
2026-09-07 13:38:49 +02:00
protom 644e64ee10 feat: add note management and activity history 2026-09-07 10:43:30 +02:00
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
195 changed files with 16800 additions and 8313 deletions
+1
View File
@@ -28,3 +28,4 @@ yarn-error.log*
# Playwright (e2e/ itself is tracked - only its artifacts are ignored) # Playwright (e2e/ itself is tracked - only its artifacts are ignored)
/playwright-report/ /playwright-report/
/test-results/ /test-results/
/.playwright-mcp/
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.0.4" ".": "0.0.6"
} }
+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). - NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
`sass-embedded` is aliased to pure-JS `sass` via npm overrides. `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 registry packages. Keep their manifest pins, registry tarball URLs, and lock
integrity values intact. integrity values intact.
- Credential and authorization records are bound to the canonical owner ID - Credential and authorization records are bound to the canonical owner ID
+109 -81
View File
@@ -1,109 +1,137 @@
# Changelog # Changelog
## [0.0.6](https://github.com/tompro/sattle/compare/sattle-v0.0.5...sattle-v0.0.6) (2026-09-07)
### Features
* add note management and activity history ([785b56d](https://github.com/tompro/sattle/commit/785b56de3b34fb3752abd7fa637aaa4773380924))
* add note management and activity history ([644e64e](https://github.com/tompro/sattle/commit/644e64ee10bba66609acf5161e176770b5bf5ee1))
## [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) ## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
### Features ### Features
* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) - **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:** 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:** 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)) - **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)) - **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)) - **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)) - support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
### Bug Fixes ### 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 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)) - 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:** 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:** 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)) - **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)) - 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) ## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
### Features ### Features
* activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) - 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)) - 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 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)) - 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 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
* invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) - 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)) - 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)) - 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 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 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)) - 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 trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
* serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) - 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)) - validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
### Bug Fixes ### Bug Fixes
* await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 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 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 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 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
* validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) - 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)) - **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) ## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
### Features ### Features
* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 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)) - 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)) - webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
### Bug Fixes ### Bug Fixes
* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) - 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)) - discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
## Changelog ## 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 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 // 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 // to it is intercepted by Playwright and fulfilled in-process, so the specs
// never touch the network. Protocol shapes mirror lnurlcash-kit's client: // never touch the network. Protocol shapes mirror lnurlcash-kit's client:
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03 // the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the // withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same // mutating GET on the callback (rotate/split/merge/melt) expects
// callback shape also answers a melt (meltNote sends k1 + pr to the same // {status: "OK"} plus the LUD-25 signature(s) over every minted output -
// callback and only requires status "OK"). // 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 // Every method takes an optional origin, so a spec can stand up a SECOND
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers. // 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 { interface MockNoteInfoOptions {
// note value in msat, reported as maxWithdrawable // note value in msat, reported as maxWithdrawable
amountMsat?: number; amountMsat?: number;
// when set, the mint answers the ERROR envelope with this reason instead // when set, the mint answers the ERROR envelope with this reason instead
// (a reason matching /spent/i surfaces the "already been spent" UI) // (a reason matching /spent/i surfaces the "already been spent" UI)
spentReason?: string; 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; mintPubkey?: string;
} }
@@ -43,14 +71,14 @@ interface MockTargetMintOptions {
// the mint's signing pubkey (66 hex chars), advertised on the mint-address // the mint's signing pubkey (66 hex chars), advertised on the mint-address
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest: // (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
// LUD-25 announces it on the withdraw side only, and a too-generous mock // LUD-25 announces it on the withdraw side only, and a too-generous mock
// once masked a real bug // once masked a real bug. Defaults to the origin's real signing key.
mintPubkey: string; mintPubkey?: string;
// the invoice this mint hands out (and reports back as settled) - keep it // the invoice this mint hands out (and reports back as settled) - keep it
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the // amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
// amount cross-check // amount cross-check
invoice: string; invoice: string;
// the payment preimage a settled verify reveals - 64 hex chars (it IS the // the payment preimage a settled verify reveals - a receipt distinct from
// claimed note's secret) // the wallet-chosen secret committed in the invoice comment
preimage: string; preimage: string;
// value of the note a claim mints, in msat // value of the note a claim mints, in msat
noteAmountMsat: number; noteAmountMsat: number;
@@ -62,10 +90,69 @@ interface MockTargetMintOptions {
export class MintMocker { export class MintMocker {
constructor(private page: Page) {} 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 // The note's informational GET: a spec-shaped withdrawRequest, or the
// ERROR envelope for a note the mint considers spent. The kit validates // ERROR envelope for a note the mint considers spent or never issued. The
// that the service echoes back the queried k1, so read it off the request. // 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> { async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
await this.page.route( await this.page.route(
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => { async (route: Route) => {
@@ -74,27 +161,147 @@ export class MintMocker {
return; return;
} }
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; 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, { await fulfillJson(route, {
tag: 'withdrawRequest', tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`, callback: `${origin}${CALLBACK_PATH}`,
k1, k1,
minWithdrawable: options.amountMsat ?? 0, minWithdrawable: amountMsat,
maxWithdrawable: options.amountMsat ?? 0, maxWithdrawable: amountMsat,
defaultDescription: 'mock mint note', defaultDescription: 'mock mint note',
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}), mintPubkey,
}); });
}, },
); );
} }
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr // The settle proof a melt's verify URL serves: settled once the melt
// params) both expect a plain {status: "OK"} confirmation. No sig is // landed, carrying the paid invoice and its preimage receipt.
// returned - a plain LUD-03-style service that does not sign notes. private async mockVerify(origin: string): Promise<void> {
async mockRotateOk(origin = MINT_ORIGIN): 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( await this.page.route(
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`), new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
async (route: Route) => { 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. // nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises.
// The payLink points back at the lnurlp route below, as prepareMint treats // The payLink points back at the lnurlp route below, as prepareMint treats
// it as the authoritative place to read the payRequest from. // 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( await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
async (route: Route) => { async (route: Route) => {
@@ -114,7 +321,7 @@ export class MintMocker {
callback: `${origin}${CALLBACK_PATH}`, callback: `${origin}${CALLBACK_PATH}`,
minWithdrawable: 1000, minWithdrawable: 1000,
maxWithdrawable: 100_000_000_000, maxWithdrawable: 100_000_000_000,
mintPubkey: options.mintPubkey, mintPubkey,
payLink: `${origin}/.well-known/lnurlp/mint`, payLink: `${origin}/.well-known/lnurlp/mint`,
nodeCapacity: 500_000_000, nodeCapacity: 500_000_000,
nodeNumChannels: 4, nodeNumChannels: 4,
@@ -127,15 +334,33 @@ export class MintMocker {
// Everything the target side of a transfer (or a Lightning receive) // Everything the target side of a transfer (or a Lightning receive)
// needs: the mint-address discovery endpoint carrying the mint metadata, // needs: the mint-address discovery endpoint carrying the mint metadata,
// the payRequest at the standard mint@ address, the invoice callback, an // the payRequest at the standard mint@ address, the invoice callback, an
// immediately-settled verify endpoint revealing the preimage, and the // immediately-settled verify endpoint revealing the payment receipt,
// note info + rotate the claim then performs. // and note info at the wallet-chosen committed secret.
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> { async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
await this.mockMintAddress(options, origin); const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
await this.mockNoteInfo( let stagedHash: string | null = null;
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, let settled = false;
origin, 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( await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`), new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
async (route: Route) => { async (route: Route) => {
@@ -144,6 +369,7 @@ export class MintMocker {
callback: `${origin}/pay`, callback: `${origin}/pay`,
minSendable: 1000, minSendable: 1000,
maxSendable: 100_000_000_000, maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: `${origin}${NOTE_PATH}`, withdrawLink: `${origin}${NOTE_PATH}`,
metadata: options.mintFeeMetadata ?? '[]', metadata: options.mintFeeMetadata ?? '[]',
}); });
@@ -152,18 +378,38 @@ export class MintMocker {
await this.page.route( await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`), new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
async (route: Route) => { 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, { await fulfillJson(route, {
pr: options.invoice, pr: options.invoice,
verify: `${origin}/verify`, 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( await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => { async (route: Route) => {
await fulfillJson(route, { await fulfillJson(route, {
settled: true, settled,
preimage: options.preimage, ...(settled ? { preimage: options.preimage } : {}),
pr: options.invoice, pr: options.invoice,
}); });
}, },
+35
View File
@@ -1,4 +1,8 @@
import type { Page } from '@playwright/test'; 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 -> // Drives onboarding end to end on a fresh browser context: /#/welcome ->
// "Create wallet" (empty password = stored unencrypted) -> confirm the // "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.getByRole('button', { name: 'Continue' }).click();
await page.waitForURL(/#\/$/); 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();
};
+135
View File
@@ -0,0 +1,135 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
import type { ActivityKind } from '../../src/lnurlcash/storage/activityLog';
// Settings chrome (version footer, no About entry) and the activity
// history: the home-page preview link and the full log's pagination.
type SeededEvent = { kind: ActivityKind; message: string; createdAt: number };
// Write encrypted activity records straight into localStorage, built with
// the app's own wrap (keys.ts: sha256(linking key + context) -> AES-GCM).
// The activity store only reads the log on unlock, so callers reload after
// seeding.
const seedActivityEvents = async (page: Page, events: SeededEvent[]): Promise<void> => {
const linkingKeyHex = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('enc' in saved) ||
saved.enc !== false ||
!('value' in saved) ||
typeof saved.value !== 'string'
) {
throw new Error('expected a plaintext saved wallet material record');
}
const material: unknown = JSON.parse(saved.value);
if (
typeof material !== 'object' ||
material === null ||
!('linkingKeyHex' in material) ||
typeof material.linkingKeyHex !== 'string'
) {
throw new Error('expected the saved material to carry its linking key');
}
return material.linkingKeyHex;
});
const material = sha256(
new Uint8Array([
...hexToBytes(linkingKeyHex),
...utf8ToBytes('lnurlcash-bearer-encryption-v1'),
]),
);
const aesKey = await crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt']);
const records = [];
for (const event of events) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
utf8ToBytes(JSON.stringify(event)),
),
);
records.push({
id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
});
}
await page.evaluate(
(json) => localStorage.setItem('sattle_activity', json),
JSON.stringify(records),
);
};
test.describe('Settings', () => {
test('shows the app version and no About entry', async ({ page }) => {
await createFreshWallet(page);
await page.goto('/#/settings');
const settings = page.locator('.q-page');
await expect(settings.getByText(/^sattle v\d+\.\d+\.\d+/)).toBeVisible();
await expect(settings.locator('.q-item', { hasText: 'About' })).toHaveCount(0);
// the advanced entries are live routes now
await expect(settings.locator('.q-item', { hasText: 'Notes' })).toBeVisible();
await expect(settings.locator('.q-item', { hasText: 'Activity log' })).toBeVisible();
});
});
test.describe('Activity history', () => {
test('the home history preview links to the full activity log', async ({ page, mint }) => {
await fundMockMintWallet(page, mint, 21_000);
// the preview sits inside the History expansion on the main page
const history = page.locator('.q-expansion-item', { hasText: 'History' });
await history.getByText('History', { exact: true }).click();
await expect(
history.locator('.history-list').getByText('Received 21 sats from mint.test.'),
).toBeVisible();
await history.getByRole('button', { name: 'View all activity' }).click();
await expect(page).toHaveURL(/#\/settings\/activity$/);
await expect(
page.locator('.q-page').getByText('Received 21 sats from mint.test.'),
).toBeVisible();
});
test('paginates the activity log', async ({ page }) => {
await createFreshWallet(page);
const base = Date.now();
const kinds: ActivityKind[] = ['mint', 'receive', 'refresh'];
await seedActivityEvents(
page,
Array.from({ length: 25 }, (_, index) => ({
kind: kinds[index % kinds.length] ?? 'mint',
message: `Seeded activity ${String(index).padStart(2, '0')}`,
createdAt: base - index * 60_000,
})),
);
// the log is decrypted on unlock - reload into the auto-unlock
await page.reload();
await page.goto('/#/settings/activity');
await expect(page.locator('.q-page').getByText('Activity log')).toBeVisible();
// page 1 shows the 20 newest events
const rows = page.locator('.q-page .history-list .q-item');
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('Seeded activity 00');
await expect(rows.last()).toContainText('Seeded activity 19');
const pagination = page.locator('.q-pagination');
await expect(pagination).toBeVisible();
await pagination.getByRole('button', { name: '2', exact: true }).click();
// page 2 shows the remaining five, still newest-first
await expect(rows).toHaveCount(5);
await expect(rows.first()).toContainText('Seeded activity 20');
await expect(rows.last()).toContainText('Seeded activity 24');
});
});
+96 -20
View File
@@ -1,5 +1,9 @@
import { test, expect } from '../fixtures'; import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet'; 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 // M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
// platform authenticator, so the security page is asserted in its honest // 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) => const readSettings = async (page: import('@playwright/test').Page) =>
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}'); 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.describe('Backup page', () => {
test('renders all sections and exports a JSON backup file', async ({ page }) => { test('renders all sections and exports a JSON backup file', async ({ page }) => {
await createFreshWallet(page); await createFreshWallet(page);
@@ -93,19 +163,22 @@ test.describe('Security page', () => {
}); });
await createFreshWallet(page); await createFreshWallet(page);
await page.evaluate(() => { 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 ( if (
typeof saved !== 'object' || typeof saved !== 'object' ||
saved === null || saved === null ||
!('ownerId' in saved) || !('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'); throw new Error('expected saved wallet owner');
} }
const wrap = { const wrap = {
hkdfSalt: '11'.repeat(16), hkdfSalt: '11'.repeat(16),
iv: '22'.repeat(12), iv: '22'.repeat(12),
wrappedKey: '33'.repeat(48), materialHash: saved.materialHash,
wrappedMaterial: '33'.repeat(48),
createdAt: 1, createdAt: 1,
}; };
localStorage.setItem( localStorage.setItem(
@@ -116,14 +189,14 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16), credentialId: '44'.repeat(16),
name: 'Current wallet passkey', name: 'Current wallet passkey',
ownerId: saved.ownerId, ownerId: saved.ownerId,
version: 1, version: 2,
}, },
{ {
...wrap, ...wrap,
credentialId: '55'.repeat(16), credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey', name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 1, version: 2,
}, },
]), ]),
); );
@@ -137,18 +210,15 @@ test.describe('Security page', () => {
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0); await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
}); });
test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => { test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
// Given an encrypted saved key and passkey slot without owner markers page,
await page.addInitScript(() => { }) => {
localStorage.setItem( // Given an encrypted v2 saved wallet whose only passkey slot belongs to
'sattle_linking_key', // a DIFFERENT owner (a foreign wallet's residue on the same device)
JSON.stringify({ const record = await encryptedWalletMaterialRecord(PASSWORD);
enc: true, await page.addInitScript(
salt: '11'.repeat(16), ({ storedRecord }) => {
iv: '22'.repeat(12), localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem( localStorage.setItem(
'sattle_passkey_slots', 'sattle_passkey_slots',
JSON.stringify([ JSON.stringify([
@@ -156,17 +226,23 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16), credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16), hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12), iv: '66'.repeat(12),
wrappedKey: '77'.repeat(48), materialHash: '77'.repeat(32),
wrappedMaterial: '88'.repeat(48),
createdAt: 1, createdAt: 1,
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 2,
}, },
]), ]),
); );
}); },
{ storedRecord: record },
);
// When the locked wallet renders // When the locked wallet renders
await page.goto('/'); 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.getByText('Wallet locked')).toBeVisible();
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0); 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', callback: 'https://mint.600.wtf/pay',
minSendable: 1000, minSendable: 1000,
maxSendable: 100_000_000_000, maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: 'https://mint.600.wtf/note', withdrawLink: 'https://mint.600.wtf/note',
metadata: '[]', metadata: '[]',
}), }),
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
test('a mint with held notes cannot be removed', async ({ page, mint }) => { 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 // hold a 21-sat note from the mock mint - holding funds locks the mint
// against removal // against removal
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY }); await mint.mockNoteInfo({ amountMsat: 21_000 });
await mint.mockRotateOk(); await mint.mockMutationEndpoint();
await createFreshWallet(page); await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click(); await page.getByRole('button', { name: 'Receive' }).click();
+6 -30
View File
@@ -1,13 +1,10 @@
import { test, expect } from '../fixtures'; import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; import { MINT2_ORIGIN } from '../helpers/MintMocker';
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; import { fundMockMintWallet } from '../helpers/wallet';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 50_000; // 50 sats const AMOUNT_MSAT = 50_000; // 50 sats
const MINT_PUBKEY = `02${'ab'.repeat(32)}`; // 64 hex chars - a valid payment receipt, distinct from the claimed note secret
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
const PREIMAGE = 'ef'.repeat(32); const PREIMAGE = 'ef'.repeat(32);
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice // amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
// amount cross-check against the requested value // 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 // give the wallet a verified, spendable 50-sat note at the source mint
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); await fundMockMintWallet(page, mint, AMOUNT_MSAT);
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();
}; };
// pick an option from a Quasar select identified by its label // 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) // the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
await mint.mockTargetMint( await mint.mockTargetMint(
{ {
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE, invoice: INVOICE,
preimage: PREIMAGE, preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT, noteAmountMsat: AMOUNT_MSAT,
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
test('a two-mint transfer moves the balance', async ({ page, mint }) => { test('a two-mint transfer moves the balance', async ({ page, mint }) => {
await fundSourceMint(page, mint); await fundSourceMint(page, mint);
// the target mint: hands out the invoice, reports it settled with the // the target mint commits the wallet-chosen secret, hands out the invoice,
// preimage, and answers the claim's note info + rotation // and exposes that note only after the source-mint melt pays it
await mint.mockTargetMint( await mint.mockTargetMint(
{ {
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE, invoice: INVOICE,
preimage: PREIMAGE, preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT, noteAmountMsat: AMOUNT_MSAT,
+156
View File
@@ -0,0 +1,156 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { fundMockMintWallet } from '../helpers/wallet';
// The Notes page (Settings -> Advanced -> Notes): per-note detail dialog,
// split/combine mutations against the mock mint, check & refresh rotation,
// and the hand-off into a selected-note move.
const AMOUNT_MSAT = 21_000; // 21 sats
// pick an option from a Quasar select identified by its label
const pickOption = async (page: Page, label: string, option: string) => {
await page.locator('.q-field', { hasText: label }).click();
await page.getByRole('option', { name: option }).click();
};
// split the wallet's 21-sat note into 8 + 13 sats through the notes UI
const splitInto8And13 = async (page: Page) => {
await page.goto('/#/settings/notes');
await page.locator('.q-page .q-item', { hasText: '21 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Split' }).click();
const splitDialog = page.locator('.q-dialog .split-dialog');
await splitDialog.getByLabel('New note amount').fill('8');
await splitDialog.getByRole('button', { name: 'Split' }).click();
await expect(page.locator('.q-notification').getByText('Note split.')).toBeVisible();
};
test.describe('Notes page', () => {
test('shows note details and check & refresh rotates to a fresh secret', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await page.goto('/#/settings/notes');
const list = page.locator('.q-page .q-list');
await expect(list.getByText('21 sats')).toBeVisible();
// the detail dialog shows the note's identity, status and actions
await list.getByText('21 sats').click();
const dialog = page.locator('.q-dialog .note-detail');
await expect(dialog.getByText('mint.test')).toBeVisible();
await expect(dialog.getByText('Note ID')).toBeVisible();
await expect(dialog.getByText('Mint signing key')).toBeVisible();
await expect(dialog.locator('.q-badge', { hasText: 'Valid locally' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Split' })).toBeEnabled();
await expect(dialog.getByRole('button', { name: 'Move' })).toBeEnabled();
// A refresh is a secret rotation of the same logical note, so private
// local metadata must follow the active replacement.
await dialog.getByLabel('Private label').fill('Emergency cash');
await dialog.getByRole('button', { name: 'Save label' }).click();
await expect(page.locator('.q-notification').getByText('Label saved.')).toBeVisible();
// the check verifies the note mint-side and rotates it to a fresh
// secret; completion is signalled by the toast
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
await expect(
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
).toBeVisible();
// the checked copy is retired and the rotated note stands valid - the
// wallet still holds exactly 21 spendable sats
await page.goto('/#/settings/notes');
await expect(page.locator('.q-page .q-item', { hasText: '21 sats' })).toHaveCount(2);
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(1);
await expect(
page.locator('.q-page .q-item', { hasText: 'Emergency cash' }).getByText('Valid locally'),
).toBeVisible();
// and the refresh is on the activity log
await page.goto('/#/settings/activity');
await expect(
page.locator('.q-page').getByText('Checked and refreshed a 21 sat note from mint.test.'),
).toBeVisible();
});
test('splits a note and combines the pieces back once the change is refreshed', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await splitInto8And13(page);
// the split retires the original and mints the 8-sat target verified;
// the 13-sat change stays unverified (an upper bound until refreshed),
// so it can neither be selected nor combined yet
const list = page.locator('.q-page .q-list');
await expect(list.locator('.q-item', { hasText: '8 sats' })).toHaveCount(1);
await expect(list.locator('.q-item', { hasText: '13 sats' })).toHaveCount(1);
await expect(list.locator('.q-item', { hasText: '13 sats' })).toContainText('Unverified');
await expect(page.locator('.q-checkbox[aria-label="Select 13 sat note"]')).toBeDisabled();
// refreshing the change note verifies and rotates it
await list.locator('.q-item', { hasText: '13 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
await expect(
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
).toBeVisible();
// now both pieces can be selected and combined back (the detail dialog
// stays open on the retired copy - dismiss it to reach the list)
await page.keyboard.press('Escape');
await expect(page.locator('.note-detail')).toHaveCount(0);
await page.locator('.q-checkbox[aria-label="Select 8 sat note"]').click();
await page.locator('.q-checkbox[aria-label="Select 13 sat note"]:not(.disabled)').click();
const mergeBar = page.locator('.merge-bar');
await expect(mergeBar.getByText('2 selected')).toBeVisible();
await mergeBar.getByRole('button', { name: 'Combine' }).click();
// one valid 21-sat note again; every intermediate copy shows as spent
await expect(page.locator('.q-notification').getByText('Notes combined.')).toBeVisible();
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(4);
// no sats were created or destroyed along the way
await page.goto('/#/');
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
test('moving from a note prefills its amount and locks the source to that note', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await splitInto8And13(page);
// move the 8-sat piece: the move form opens with the note's mint and
// amount prefilled, and the source select is locked
await page.locator('.q-page .q-item', { hasText: '8 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Move' }).click();
await expect(page).toHaveURL(/#\/settings\/move\?noteId=/);
const sourceField = page.locator('.q-field', { hasText: 'From mint' });
await expect(sourceField).toHaveClass(/q-field--disabled/);
await expect(sourceField).toContainText('mint.test - 8 sats available');
await expect(page.getByLabel('Amount')).toHaveValue('8');
// Max tops up to the selected note alone, not the wallet's 21 sats
await page.getByRole('button', { name: 'Max' }).click();
await expect(page.getByLabel('Amount')).toHaveValue('8');
// and asking for more than the locked note is refused inline
await pickOption(page, 'To mint', 'Another mint…');
await page.getByLabel('Target mint address').fill('@mint2.test');
await page.getByLabel('Amount').fill('9');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(
page.locator('.q-banner', { hasText: 'more than the 8 sats spendable at mint.test' }),
).toBeVisible();
});
});
+126 -1
View File
@@ -1,17 +1,34 @@
import { test, expect } from '../fixtures'; 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 // 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 // test that runs the service injects a fake transport through the dev-only
// window hook (stores/nwc.ts) before enabling - every other test stays // window hook (stores/nwc.ts) before enabling - every other test stays
// UI-only with the service off. // 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 }; type FakeSub = { closed: boolean; close: () => void };
declare global { declare global {
interface Window { interface Window {
__sattleNwcTest: { setTransport: (transport: unknown) => void }; __sattleNwcTest: { setTransport: (transport: unknown) => void };
__nwcSubs: FakeSub[]; __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))) .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true); .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'; import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats 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 // a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note // 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.describe('Receive bearer note', () => {
test('redeeming a valid note updates the balance', async ({ page, mint }) => { test('redeeming a valid note updates the balance', async ({ page, mint }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk(); await mint.mockMutationEndpoint();
await createFreshWallet(page); await createFreshWallet(page);
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); 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 // were really consumed (the kit bug this guards against silently fell
// back to the declared amount without any request) // back to the declared amount without any request)
await expect(dialog.locator('.q-banner')).toHaveCount(0); 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 dialog.getByRole('button', { name: 'Done' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21'); await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
}); });
@@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => {
page, page,
mint, mint,
}) => { }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk(); await mint.mockMutationEndpoint();
await createFreshWallet(page); await createFreshWallet(page);
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl()); await redeemNote(page, freshNoteUrl());
@@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => {
page, page,
mint, mint,
}) => { }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk(); await mint.mockMutationEndpoint();
await createFreshWallet(page); await createFreshWallet(page);
await page.evaluate(() => { await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}'); 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 { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet'; 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_A = `02${'aa'.repeat(32)}`;
const MINT_KEY_B = `03${'bb'.repeat(32)}`; const MINT_KEY_B = `03${'bb'.repeat(32)}`;
const MINT_KEY_C = `02${'cc'.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> => const ownerId = async (page: Page): Promise<string | null> =>
page.evaluate(() => { page.evaluate(() => {
const raw = localStorage.getItem('sattle_linking_key'); const raw = localStorage.getItem('sattle_wallet_material_v2');
if (raw === null) return null; if (raw === null) return null;
const saved: unknown = JSON.parse(raw); const saved: unknown = JSON.parse(raw);
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null; 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'; import { test, expect } from '../fixtures';
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet // Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
// with ownerless passkey/NWC/trust residue must migrate to the proven owner // install - the linking-key-only record, the pre-v2 bearer store and their
// during password unlock - before the NWC service may start - and forgetting // ownerless residue - is never migrated (the cash root is unrecoverable
// the wallet must drain/stop that service and remove every wallet-owned key // from a linking key), so boot resets the device to the uninstalled state
// before a successor wallet can be created without any of the old residue. // before any route can expose it, and a successor wallet starts 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.
declare global { // every namespace the alpha wallet could have touched
interface Window { const LEGACY_KEYS = [
__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 = [
'sattle_linking_key', 'sattle_linking_key',
'sattle_bearers', 'sattle_bearers',
'sattle_activity', 'sattle_wallet_material_v2',
'sattle_settings',
'sattle_passkey_slots', 'sattle_passkey_slots',
'sattle_nwc_connections', 'sattle_nwc_connections',
'sattle_nwc_enabled', 'sattle_nwc_enabled',
'sattle_trusted_mints', 'sattle_trusted_mints',
'sattle_biometric_wrap', 'sattle_biometric_wrap',
'sattle_activity',
'sattle_settings',
] as const; ] as const;
test.describe('wallet lifecycle', () => { 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, page,
}) => { }) => {
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue // Given a legacy encrypted linking-key record plus ownerless residue
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD); await page.addInitScript(() => {
await page.addInitScript( localStorage.setItem(
({ storedRecord, mintPubkey }) => { 'sattle_linking_key',
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord })); 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( localStorage.setItem(
'sattle_passkey_slots', 'sattle_passkey_slots',
JSON.stringify([ JSON.stringify([
@@ -112,104 +67,29 @@ test.describe('wallet lifecycle', () => {
localStorage.setItem('sattle_nwc_enabled', 'true'); localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem( localStorage.setItem(
'sattle_trusted_mints', '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('/#/'); await page.goto('/#/');
// the unlock screen proves the app boot finished and the dev hooks exist // Then the alpha reset landed on the uninstalled state - no unlock
await expect(page.getByText('Wallet locked')).toBeVisible(); // surface for a wallet that can never be derived again
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
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await expect await expect(page.getByText('Wallet locked')).toHaveCount(0);
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true); // ...and every namespace the alpha wallet could have touched is gone
const remaining = await page.evaluate( const remaining = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null), (keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[...WALLET_KEYS], [...LEGACY_KEYS],
); );
expect(remaining).toEqual([]); expect(remaining).toEqual([]);
@@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => {
await page.getByRole('button', { name: 'Create wallet' }).click(); await page.getByRole('button', { name: 'Create wallet' }).click();
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
await page.getByRole('button', { name: 'Continue' }).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 // Then it unlocks into a working wallet with zero adopted residue
const successor = await page.evaluate(() => { // (settings/activity are the live app's own namespaces - the residue
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null'); // check covers only the stores a predecessor could have planted)
return { await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
ownerId: await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
typeof saved === 'object' && saved !== null && 'ownerId' in saved const residue = await page.evaluate(
? saved.ownerId (keys) => keys.filter((key) => localStorage.getItem(key) !== null),
: undefined, [
residueKeys: [ 'sattle_linking_key',
'sattle_bearers',
'sattle_passkey_slots', 'sattle_passkey_slots',
'sattle_nwc_connections', 'sattle_nwc_connections',
'sattle_nwc_enabled', 'sattle_nwc_enabled',
'sattle_trusted_mints', 'sattle_trusted_mints',
].filter((key) => localStorage.getItem(key) !== null), 'sattle_biometric_wrap',
}; ],
}); );
expect(successor.ownerId).not.toBe(OWNER_ID); expect(residue).toEqual([]);
expect(successor.residueKeys).toEqual([]);
}); });
}); });
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"name": "sattle", "name": "sattle",
"version": "0.0.4", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sattle", "name": "sattle",
"version": "0.0.4", "version": "0.0.6",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@agicash/qr-scanner": "^0.1.2", "@agicash/qr-scanner": "^0.1.2",
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4", "@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0", "@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0", "@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.5.0", "lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3", "nostr-tools": "2.24.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode.vue": "^3.10.0", "qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"lnurlcash-conformance": "0.4.0", "lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.102.0", "sass": "^1.102.0",
@@ -5961,9 +5961,9 @@
} }
}, },
"node_modules/lnurlcash-conformance": { "node_modules/lnurlcash-conformance": {
"version": "0.4.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz", "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz",
"integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==", "integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5987,9 +5987,9 @@
} }
}, },
"node_modules/lnurlcash-kit": { "node_modules/lnurlcash-kit": {
"version": "0.5.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz", "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz",
"integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==", "integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/curves": "^2.3.0", "@noble/curves": "^2.3.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "sattle", "name": "sattle",
"version": "0.0.4", "version": "0.0.6",
"description": "A Quasar Project", "description": "A Quasar Project",
"productName": "sattle", "productName": "sattle",
"author": "protom <office@protom.eu>", "author": "protom <office@protom.eu>",
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4", "@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0", "@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0", "@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.5.0", "lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3", "nostr-tools": "2.24.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode.vue": "^3.10.0", "qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"lnurlcash-conformance": "0.4.0", "lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.102.0", "sass": "^1.102.0",
+1
View File
@@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => {
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
+1 -3
View File
@@ -4,9 +4,7 @@
"target": { "target": {
"namespace": "android_app", "namespace": "android_app",
"package_name": "eu.protom.sattle", "package_name": "eu.protom.sattle",
"sha256_cert_fingerprints": [ "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"]
"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() // The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments. // events passes a ServiceWorkerRegistration instance in their arguments.
@@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, {
error(/* err */) { error(/* err */) {
// console.error('Error during service worker registration:', 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" * quasar.config file > pwa > workboxMode is set to "InjectManifest"
*/ */
import { clientsClaim } from "workbox-core"; import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from "workbox-routing"; import { NavigationRoute, registerRoute } from 'workbox-routing';
import { import {
cleanupOutdatedCaches, cleanupOutdatedCaches,
createHandlerBoundToURL, createHandlerBoundToURL,
precacheAndRoute precacheAndRoute,
} from "workbox-precaching"; } from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope & typeof globalThis; declare const self: ServiceWorkerGlobalScope & typeof globalThis;
@@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) {
// Non-SSR/SSG fallbacks to index.html // Non-SSR/SSG fallbacks to index.html
// Production SSR/SSG fallbacks to offline.html (except for dev) // Production SSR/SSG fallbacks to offline.html (except for dev)
registerRoute( registerRoute(
new NavigationRoute( new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), {
createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
{ }),
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 lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away, // wallet store at app start - a plaintext-stored material record unlocks
// a password-encrypted one lands on 'locked' for the unlock screen, and no // straight away, a password-encrypted one lands on 'locked' for the unlock
// key at all lands on 'none' for onboarding. // 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 () => { export default defineBoot(async () => {
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.init(); await wallet.init();
+433 -64
View File
@@ -1,101 +1,470 @@
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys'; import {
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; deriveWalletMaterial,
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots'; 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'; import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
const pluginMocks = vi.hoisted(() => ({ const pluginMocks = vi.hoisted(() => ({
authenticate: vi.fn<() => Promise<void>>(), authenticate: vi.fn<() => Promise<void>>(),
checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(),
secureGet: vi.fn<(key: string) => Promise<string | null>>(), 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', () => ({ vi.mock('@aparajita/capacitor-biometric-auth', () => ({
AndroidBiometryStrength: { weak: 'weak' }, AndroidBiometryStrength: { weak: 'weak' },
BiometricAuth: { authenticate: pluginMocks.authenticate }, BiometricAuth: {
authenticate: pluginMocks.authenticate,
checkBiometry: pluginMocks.checkBiometry,
},
BiometryError: class BiometryError extends Error {}, BiometryError: class BiometryError extends Error {},
BiometryErrorType: { userCancel: 'userCancel' }, BiometryErrorType: { userCancel: 'userCancel' },
})); }));
vi.mock('@aparajita/capacitor-secure-storage', () => ({ 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 })); vi.mock('./platform', () => ({ isNative: () => true }));
import { unlockWithBiometrics } from './biometricUnlock'; import {
disableBiometricUnlock,
enableBiometricUnlock,
unlockWalletMaterialWithBiometrics,
} from './biometricUnlock';
const LINKING_KEY = new Uint8Array(32).fill(7); const SEED =
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); '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_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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
stubLocalStorage(); stubLocalStorage();
pluginMocks.secureValues.clear();
pluginMocks.authenticate.mockResolvedValue(); pluginMocks.authenticate.mockResolvedValue();
pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true });
pluginMocks.secureGet.mockImplementation((key) =>
Promise.resolve(pluginMocks.secureValues.get(key) ?? null),
);
pluginMocks.secureSet.mockImplementation((key, value) => {
pluginMocks.secureValues.set(key, value);
return Promise.resolve();
});
pluginMocks.secureRemove.mockImplementation((key) => {
pluginMocks.secureValues.delete(key);
return Promise.resolve();
});
}); });
describe('biometric unlock owner proof', () => { describe('biometric wallet material', () => {
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => { it('rejects foreign enrollment before writing either biometric store', async () => {
// Given an ownerless legacy wallet and credential residue plus a biometric wrap // Given one proven saved owner and complete material for another owner
// whose claimed pubkey does not match the key it unwraps await saveWalletMaterial(MATERIAL);
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) }; const before = persistedState();
const legacySlot = {
credentialId: '11'.repeat(16), // When enrollment is attempted with the foreign material
hkdfSalt: '22'.repeat(16), const attempt = enableBiometricUnlock(OTHER_MATERIAL);
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48), // Then ownership fails before native or local persistence changes
createdAt: 1, await expect(attempt).rejects.toThrow('proven saved wallet owner');
}; expect(persistedState()).toEqual(before);
const legacyNwc = [ expect(pluginMocks.secureSet).not.toHaveBeenCalled();
{ });
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'], it('does not create a local wrap when secure-secret persistence fails', async () => {
budget: { maxMsat: 1000, periodMs: 60_000 }, // Given valid material and a rejecting native secure store
spent: { periodStart: 0, msat: 0 }, await saveWalletMaterial(MATERIAL);
createdAt: 1, const before = persistedState();
}, pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed'));
];
const legacyTrust = [ // When enrollment reaches native secret persistence
{ const attempt = enableBiometricUnlock(MATERIAL);
server: 'legacy.example',
mintPubkey: '02' + 'aa'.repeat(32), // Then failure is reported without a misleading local enrollment record
addedAt: 1, await expect(attempt).rejects.toThrow('secure write failed');
locked: false, expect(persistedState()).toEqual(before);
}, });
];
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey)); it('round-trips both key parts when enrollment belongs to the saved owner', async () => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot])); // Given complete canonical material persisted for its proven owner
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc)); await saveWalletMaterial(MATERIAL);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust)); // When the capability enrolls and unlocks it through the native adapter
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY); await enableBiometricUnlock(MATERIAL);
localStorage.setItem( const beforeUnlock = persistedState();
'sattle_biometric_wrap', 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({ JSON.stringify({
...wrap, ...MATERIAL,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), cashRootHex: '00'.repeat(64),
createdAt: 1, }),
),
],
])('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 = new Map([ const before = persistedState();
['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 // When biometric unlock verifies the AES-GCM tag
const attempt = unlockWithBiometrics(); const attempt = unlockWalletMaterialWithBiometrics();
// Then no key crosses the capability boundary and no legacy namespace is adopted // 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'); await expect(attempt).rejects.toThrow('different wallet');
expect(savedKeyOwnerId()).toBeNull(); expect(persistedState()).toEqual(before);
expect(readPasskeySlots()).toEqual([]); });
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value); 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 // Native biometric unlock: a THIRD wrap of the same canonical wallet material,
// the password wrap (keys.ts) and the passkey slots (passkeys.ts). // alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts).
// //
// Why native: Android WebView has no usable WebAuthn platform authenticator // Why native: Android WebView has no usable WebAuthn platform authenticator
// for this app (passkeys need Play Services / Digital Asset Links wiring we // 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 // cannot rely on), so the biometric path on native is a device-credential
// prompt instead of a passkey ceremony. // prompt instead of a passkey ceremony.
// //
// Design (mirrors the passkey-slot construction, reusing its wrap // Design (mirrors the passkey-slot construction with a biometric-specific
// primitives verbatim): on enrollment a random 32-byte wrap secret is // HKDF domain): on enrollment a random 32-byte wrap secret is generated,
// generated, the linking key is AES-GCM-wrapped under an HKDF of that // canonical v2 material is AES-GCM-wrapped under an HKDF of that secret,
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32 // and only the SECRET goes into biometric-gated secure
// bytes of IKM), and only the SECRET goes into biometric-gated secure
// storage (Android Keystore-backed AES-GCM via // storage (Android Keystore-backed AES-GCM via
// @aparajita/capacitor-secure-storage). The wrapped blob stays in // @aparajita/capacitor-secure-storage). The wrapped blob stays in
// localStorage as a record shaped like a passkey slot, plus the linking // localStorage as a versioned AES-GCM record, plus the linking
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale // pubkey as an identity check AND the SHA-256 commitment of the canonical
// wrap behind, and unlocking with it must fail loudly (never activate the // saved material: restoring a DIFFERENT seed leaves a stale wrap behind,
// old wallet silently), so unlock verifies the unwrapped key against the // and so does replacing only the saved cash root with same-owner material.
// recorded pubkey and tells the holder to re-enroll. // 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 // The biometric gate is app-level (a BiometricPrompt before the secure
// read), not a keystore key invalidated on biometric re-enrollment - 04 // read), not a keystore key invalidated on biometric re-enrollment - 04
@@ -36,39 +38,90 @@ import {
BiometryErrorType, BiometryErrorType,
} from '@aparajita/capacitor-biometric-auth'; } from '@aparajita/capacitor-biometric-auth';
import { SecureStorage } from '@aparajita/capacitor-secure-storage'; 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 {
import type { PasskeyWrap } from '@/lnurlcash/passkeys'; getPlainWalletMaterial,
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; 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'; import { isNative } from './platform';
type BiometricWrapRecord = PasskeyWrap & { type BiometricWrapRecord = {
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps readonly version: 2;
createdAt: number; 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 wrapped blob is useless without the secure-storage secret, so (like
// the passkey slots) this record sits in plain localStorage // the passkey slots) this record sits in plain localStorage
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap'; const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; 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 => { const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
if (typeof record !== 'object' || record === null) return false; if (!isJsonObject(record)) return false;
const r = record as Record<string, unknown>;
return ( return (
typeof r.hkdfSalt === 'string' && Object.keys(record).length === WRAP_RECORD_KEYS.length &&
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) && Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) &&
typeof r.iv === 'string' && record.version === BIOMETRIC_WRAP_VERSION &&
/^[0-9a-f]{24}$/i.test(r.iv) && typeof record.hkdfSalt === 'string' &&
typeof r.wrappedKey === 'string' && /^[0-9a-f]{32}$/.test(record.hkdfSalt) &&
r.wrappedKey.length > 0 && typeof record.iv === 'string' &&
r.wrappedKey.length % 2 === 0 && /^[0-9a-f]{24}$/.test(record.iv) &&
/^[0-9a-f]+$/i.test(r.wrappedKey) && typeof record.wrappedKey === 'string' &&
typeof r.pubkey === 'string' && record.wrappedKey.length > 0 &&
/^[0-9a-f]{66}$/i.test(r.pubkey) && record.wrappedKey.length % 2 === 0 &&
typeof r.createdAt === 'number' /^[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 // sync on purpose (same convention as hasPasskeySlots): the unlock form and
// the security page ask this during render/setup // the security page ask this during render/setup
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null; 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.'); 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(); const biometry = await BiometricAuth.checkBiometry();
if (!biometry.isAvailable) { if (!biometry.isAvailable) {
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
} }
// prove presence before storing anything under the biometric gate // prove presence before storing anything under the biometric gate
await authenticateOrThrow('Set up biometric unlock for your wallet'); 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 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 // secure storage first: if it fails, no record is written and the wallet
// simply stays unenrolled instead of carrying an unwrap-able-nothing // simply stays unenrolled instead of carrying an unwrap-able-nothing
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret)); await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
const record: BiometricWrapRecord = { const record: BiometricWrapRecord = {
...wrap, version: BIOMETRIC_WRAP_VERSION,
pubkey: linkingPubKeyHex(linkingKey), hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
pubkey: ownerId,
materialHash,
createdAt: Date.now(), createdAt: Date.now(),
}; };
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record)); localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
}; };
export const unlockWithBiometrics = async (): Promise<Uint8Array> => { export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMaterialV2> => {
const record = readWrapRecord(); const record = readWrapRecord();
if (!isNative() || !record) { if (!isNative() || !record) {
throw new Error('Biometric unlock is not set up on this device.'); throw new Error('Biometric unlock is not set up on this device.');
} }
await authenticateOrThrow('Unlock your sattle wallet'); const ownerId = savedWalletMaterialOwnerId();
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); if (ownerId === null || ownerId !== record.pubkey) {
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) {
throw new Error( throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', '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 // Native deletion goes first so a failure leaves the complete enrollment
// fire-and-forgets this (forgetWallet), a later unlock attempt can never // available for the locked lifecycle to retry safely.
// reach the secure secret with a stale record
export const disableBiometricUnlock = async (): Promise<void> => { export const disableBiometricUnlock = async (): Promise<void> => {
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
if (isNative()) { if (isNative()) {
await SecureStorage.remove(SECURE_SECRET_KEY); await SecureStorage.remove(SECURE_SECRET_KEY);
} }
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
}; };
+14 -4
View File
@@ -1,19 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
// Unified activity history: renders the activity store's events newest // Unified activity history: renders the activity store's events newest
// first, one row per wallet action. Self-contained - designed to sit inside // first, one row per wallet action. The caller can provide a page-sized slice
// a q-expansion-item on the home page. // or cap the store-backed list for the home-page preview.
import { computed } from 'vue'; import { computed } from 'vue';
import { useActivityStore } from '@/stores/activity'; import { useActivityStore } from '@/stores/activity';
import type { ActivityKind } from '@/lnurlcash/storage'; import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
const props = defineProps<{
events?: ActivityEvent[];
limit?: number;
}>();
const activity = useActivityStore(); const activity = useActivityStore();
// the store already prepends new events, so the array is newest-first // the store already prepends new events, so the array is newest-first
const events = computed(() => activity.events); const events = computed(() => {
const source = props.events ?? activity.events;
return props.limit === undefined ? source : source.slice(0, props.limit);
});
const KIND_ICONS: Record<ActivityKind, string> = { const KIND_ICONS: Record<ActivityKind, string> = {
mint: 'arrow_downward', mint: 'arrow_downward',
receive: 'south_west', receive: 'south_west',
refresh: 'refresh',
melt: 'north_east', melt: 'north_east',
split: 'shuffle', split: 'shuffle',
combine: 'shuffle', combine: 'shuffle',
@@ -26,6 +35,7 @@ const KIND_ICONS: Record<ActivityKind, string> = {
const KIND_COLORS: Record<ActivityKind, string> = { const KIND_COLORS: Record<ActivityKind, string> = {
mint: 'positive', mint: 'positive',
receive: 'positive', receive: 'positive',
refresh: 'info',
melt: 'primary', melt: 'primary',
split: 'info', split: 'info',
combine: 'info', combine: 'info',
+4 -9
View File
@@ -19,19 +19,14 @@ onMounted(async () => {
return; return;
} }
if (!videoEl.value) return; if (!videoEl.value) return;
scanner = new QrScanner( scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), {
videoEl.value, highlightScanRegion: true,
(result) => emit('decode', result.data), });
{ highlightScanRegion: true },
);
await scanner.start(); await scanner.start();
starting.value = false; starting.value = false;
} catch (err) { } catch (err) {
starting.value = false; starting.value = false;
emit( emit('error', err instanceof Error ? err.message : 'Camera access failed.');
'error',
err instanceof Error ? err.message : 'Camera access failed.',
);
} }
}); });
+4 -4
View File
@@ -50,9 +50,7 @@
</div> </div>
<div class="col q-ml-md"> <div class="col q-ml-md">
<div class="text-body1 text-weight-medium">Bearer note</div> <div class="text-body1 text-weight-medium">Bearer note</div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">Paste or scan a note someone sent you</div>
Paste or scan a note someone sent you
</div>
</div> </div>
<q-icon name="chevron_right" color="grey-5" size="24px" /> <q-icon name="chevron_right" color="grey-5" size="24px" />
</div> </div>
@@ -108,7 +106,9 @@ const onReceived = () => {
border-radius: 12px; border-radius: 12px;
padding: 14px 16px; padding: 14px 16px;
cursor: pointer; 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, .action-row:hover,
@@ -113,6 +113,7 @@
{{ claimError }} {{ claimError }}
</q-banner> </q-banner>
<q-btn <q-btn
v-if="claimRetryable"
unelevated unelevated
color="primary" color="primary"
text-color="dark" text-color="dark"
@@ -215,6 +216,7 @@ const {
CUSTOM_MINT, CUSTOM_MINT,
amountSats, amountSats,
claimError, claimError,
claimRetryable,
copyInvoice, copyInvoice,
createInvoice, createInvoice,
customMint, customMint,
+6 -1
View File
@@ -34,7 +34,12 @@ const onSent = () => emit('sent');
</script> </script>
<template> <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 class="sattle-card drawer-card full-width">
<q-card-section class="row items-center q-pb-sm"> <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" /> <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; looked.value = true;
if (parts.notes || parts.mints || parts.settings) { if (parts.notes || parts.mints || parts.settings) {
found.value = { found.value = {
notes: parts.notes?.length ?? 0, notes: parts.notes?.bearers.length ?? 0,
mints: parts.mints?.length ?? 0, mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined, settings: parts.settings !== undefined,
}; };
+67 -27
View File
@@ -1,5 +1,6 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit'; import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit'; import type { MintFee } from 'lnurlcash-kit';
@@ -7,9 +8,10 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops'; import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops'; import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees'; import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { Bearer, 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 { 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 { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity'; import { useActivityStore } from '@/stores/activity';
import { addCommittedBearers, commitCarve } from './walletCarveCommit'; import { addCommittedBearers, commitCarve } from './walletCarveCommit';
@@ -26,6 +28,7 @@ type TransferResult = Readonly<{
export const useMoveFundsPage = () => { export const useMoveFundsPage = () => {
const router = useRouter(); const router = useRouter();
const route = useRoute();
const $q = useQuasar(); const $q = useQuasar();
const wallet = useWalletStore(); const wallet = useWalletStore();
const mints = useMintsStore(); const mints = useMintsStore();
@@ -45,9 +48,20 @@ export const useMoveFundsPage = () => {
); );
const CUSTOM_TARGET = '__custom__'; const CUSTOM_TARGET = '__custom__';
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT; const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const requestedNoteId = computed(() =>
typeof route.query.noteId === 'string' ? route.query.noteId : '',
);
const selectedNote = computed(() =>
requestedNoteId.value
? wallet.bearers.find((bearer) => bearer.id === requestedNoteId.value)
: undefined,
);
const sourceBearers = computed(() =>
selectedNote.value ? [selectedNote.value] : wallet.bearers,
);
const spendableByServerMsat = computed(() => { const spendableByServerMsat = computed(() => {
const byServer = new Map<string, number>(); const byServer = new Map<string, number>();
for (const bearer of wallet.bearers) { for (const bearer of sourceBearers.value) {
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url)) if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
continue; continue;
const server = serverOf(bearer.url); const server = serverOf(bearer.url);
@@ -69,6 +83,15 @@ export const useMoveFundsPage = () => {
const inlineError = ref(''); const inlineError = ref('');
const stage = ref(''); const stage = ref('');
const result = ref<TransferResult | null>(null); const result = ref<TransferResult | null>(null);
watch(
selectedNote,
(note) => {
if (!note) return;
sourceServer.value = serverOf(note.url);
amountSats.value = displaySats(note.amount);
},
{ immediate: true },
);
const targetOptions = computed<Option[]>(() => { const targetOptions = computed<Option[]>(() => {
const options: Option[] = []; const options: Option[] = [];
for (const mint of mints.mints) { for (const mint of mints.mints) {
@@ -132,28 +155,54 @@ export const useMoveFundsPage = () => {
if (!sats) return; if (!sats) return;
step.value = 'working'; step.value = 'working';
stage.value = 'Asking the target mint for an invoice…'; stage.value = 'Asking the target mint for an invoice…';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
const ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence, warn: warnCommitted }; const commitContext = { ownerFence, warn: warnCommitted };
let stagedOutput: Bearer | undefined;
let carvedOutput: Bearer | undefined;
// the carve commits the moment it lands server-side (onCarve), not // the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then // after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs // never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints( const transfer = await transferBetweenMints(
wallet.bearers, sourceBearers.value.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats), satsToMsat(sats),
targetInput.value, 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) => { onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext); 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…'; stage.value = 'Confirming the result…';
// a carve that mutated nothing fires no hook - the note is already // a carve that mutated nothing fires no hook - the note is already
// tracked; look it up // tracked; look it up
const carved = const carved =
carvedOutput ??
carveState.committed ?? carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url); wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.'); if (!carved) throw new Error('The carved note was not tracked.');
@@ -162,13 +211,14 @@ export const useMoveFundsPage = () => {
} }
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat); const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') { if (transfer.outcome === 'settled') {
await wallet.markSpent(carved.id, ownerFence);
const claimed = transfer.mintedAtTarget; const claimed = transfer.mintedAtTarget;
if (claimed) { if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy try {
? [claimed.note, claimed.possibleCopy] await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
: [claimed.note]; } catch (error) {
await addCommittedBearers(wallet, notes, commitContext); if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
} }
await activity.log( await activity.log(
'transfer', 'transfer',
@@ -178,11 +228,7 @@ export const useMoveFundsPage = () => {
toast('positive', `Moved ${sats.toLocaleString()} sats.`); toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') { } else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) { if (transfer.rotatedNote) {
// the source-probe rotate re-secured the returned funds at a await wallet.restoreStagedMintSource(stagedOutput.id, transfer.rotatedNote, ownerFence);
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext);
await wallet.markSpent(carved.id, ownerFence);
} }
await activity.log( await activity.log(
'transfer', 'transfer',
@@ -191,22 +237,13 @@ export const useMoveFundsPage = () => {
); );
} else if (transfer.outcome === 'unknown-still-pending') { } else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence); await wallet.markSpent(carved.id, ownerFence);
// a named target's claim material carries the note at the wallet's
// own secret - claimable the moment the melt settles, so it is
// tracked unverified rather than lost with the result object
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
(error) => warnCommitted(error.message), (error) => warnCommitted(error.message),
); );
} else if (transfer.outcome === 'settled-claim-failed') { } else if (transfer.outcome === 'settled-claim-failed') {
await wallet.markSpent(carved.id, ownerFence); await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`, `${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
@@ -220,7 +257,7 @@ export const useMoveFundsPage = () => {
(error) => warnCommitted(error.message), (error) => warnCommitted(error.message),
); );
} }
const claimNote = transfer.claimMaterial?.note ?? null; const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
result.value = { result.value = {
outcome: transfer.outcome, outcome: transfer.outcome,
requestedSats: sats, requestedSats: sats,
@@ -237,6 +274,8 @@ export const useMoveFundsPage = () => {
: message; : message;
toast('negative', inlineError.value); toast('negative', inlineError.value);
step.value = 'form'; step.value = 'form';
} finally {
fundOperation?.complete();
} }
}; };
return { return {
@@ -252,6 +291,7 @@ export const useMoveFundsPage = () => {
setMax, setMax,
sourceOptions, sourceOptions,
sourceServer, sourceServer,
selectedNote,
stage, stage,
step, step,
targetChoice, targetChoice,
+131
View File
@@ -0,0 +1,131 @@
import { buildNoteUrl, NoteSpentError } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as OpsExports from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
const mocks = vi.hoisted(() => ({
addBearers: vi.fn(),
applyChangeset: vi.fn(),
notify: vi.fn(),
receiveBearer: vi.fn(),
removeNote: vi.fn(),
routerReplace: vi.fn(),
}));
const source = {
id: 'source',
url: buildNoteUrl('https://mint.test/note', '11'.repeat(32), 21_000),
callback: 'https://mint.test/callback',
amount: 21_000,
verified: true,
mintPubkey: `02${'aa'.repeat(32)}`,
label: 'Emergency cash',
createdAt: 1,
updatedAt: 1,
};
const staged = {
id: 'staged',
url: buildNoteUrl('https://mint.test/note', '22'.repeat(32), 21_000),
callback: source.callback,
amount: 0,
verified: false,
label: source.label,
pendingMint: {
refreshSourceBearerId: source.id,
sourceRecoverySecret: '11'.repeat(32),
},
createdAt: 2,
updatedAt: 2,
};
const wallet = {
state: 'unlocked' as const,
pubkey: null,
bearers: [source],
beginFundOperation: () => ({ ownerFence: () => undefined, complete: () => undefined }),
allocateOutputSecrets: vi.fn(),
addBearers: mocks.addBearers,
applyChangeset: mocks.applyChangeset,
commitCarve: vi.fn(),
finalizeStagedMintOutput: vi.fn(),
markSpent: vi.fn(),
removeNote: mocks.removeNote,
updateBearer: vi.fn(),
};
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(), replace: mocks.routerReplace }),
}));
vi.mock('quasar', () => ({
useQuasar: () => ({ notify: mocks.notify }),
}));
vi.mock('@/stores/wallet', () => ({
TrustedMintPostCommitError: class TrustedMintPostCommitError extends Error {},
useWalletStore: () => wallet,
}));
vi.mock('@/stores/activity', () => ({
useActivityStore: () => ({ log: vi.fn() }),
}));
vi.mock('@/lnurlcash/trustedMints', () => ({
getTrustedMintVerificationKeys: () => [],
}));
vi.mock('@/lnurlcash/ops', async (importOriginal) => {
const actual = await importOriginal<typeof OpsExports>();
return { ...actual, receiveBearer: mocks.receiveBearer };
});
import { useNotesPage } from './useNotesPage';
describe('note refresh persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
wallet.bearers = [source];
mocks.addBearers.mockResolvedValue([staged]);
mocks.applyChangeset.mockResolvedValue([]);
mocks.receiveBearer.mockImplementation(
async (
_input: string,
_existing: unknown[],
options: { stageRotation?: (note: NewBearer) => void | Promise<void> },
) => {
await options.stageRotation?.({
url: staged.url,
callback: staged.callback,
amount: source.amount,
verified: false,
pendingMint: { sourceRecoverySecret: '11'.repeat(32) },
});
throw new NoteSpentError('Note is spent');
},
);
});
it('stages at zero with local metadata and atomically retires a definitively dead source', async () => {
const notes = useNotesPage();
await notes.refreshNote(source);
expect(mocks.addBearers).toHaveBeenCalledWith(
[
expect.objectContaining({
amount: 0,
label: 'Emergency cash',
pendingMint: expect.objectContaining({ refreshSourceBearerId: source.id }),
}),
],
expect.any(Function),
);
expect(mocks.applyChangeset).toHaveBeenCalledWith(
{ add: [], markSpent: [source.id], remove: [staged.id] },
expect.any(Function),
);
expect(mocks.removeNote).not.toHaveBeenCalled();
});
});
+312
View File
@@ -0,0 +1,312 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
noteK1,
serverOf,
} from 'lnurlcash-kit';
import { compareBearerOrder } from '@/lnurlcash/storage';
import { ensureExactAmount, receiveBearer, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useActivityStore } from '@/stores/activity';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
export const useNotesPage = () => {
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const selectedIds = ref<string[]>([]);
const detail = ref<Bearer | null>(null);
const splitOpen = ref(false);
const splitAmount = ref<number | null>(null);
const busy = ref(false);
const notify = (type: 'positive' | 'negative' | 'warning', message: string): void => {
$q.notify({ type, message, position: 'top', timeout: 3500 });
};
const warnCommitted = (message: string): void => notify('warning', message);
const notes = computed(() => [...wallet.bearers].sort(compareBearerOrder));
const selectedNotes = computed(() =>
selectedIds.value
.map((id) => wallet.bearers.find((note) => note.id === id))
.filter((note): note is Bearer => note !== undefined),
);
const canMutate = (note: Bearer): boolean =>
note.verified &&
!note.spent &&
!note.pendingMint &&
!note.deviceId &&
note.callback !== '' &&
noteK1(note.url) !== null;
const canMerge = computed(() => {
if (selectedNotes.value.length < 2 || selectedNotes.value.some((note) => !canMutate(note))) {
return false;
}
const first = selectedNotes.value[0];
if (!first) return false;
const server = serverOf(first.url);
return selectedNotes.value.every((note) => serverOf(note.url) === server);
});
const formatSats = (amountMsat: number): string =>
msatToSats(amountMsat).toLocaleString(undefined, { maximumFractionDigits: 3 });
const status = (note: Bearer): string => {
if (note.spent) return 'Spent';
if (note.pendingMint) return 'Pending';
if (note.deviceId) return 'Paired device';
return note.verified ? 'Valid locally' : 'Unverified';
};
const statusColor = (note: Bearer): string => {
if (note.spent) return 'grey-6';
if (note.pendingMint) return 'warning';
return note.verified ? 'positive' : 'warning';
};
watch(
() => wallet.state,
(state) => {
if (state !== 'unlocked') void router.replace('/');
},
{ immediate: true },
);
watch(
() => wallet.bearers,
() => {
selectedIds.value = selectedIds.value.filter((id) => wallet.bearers.some((n) => n.id === id));
if (detail.value)
detail.value = wallet.bearers.find((n) => n.id === detail.value?.id) ?? null;
},
);
const carve = async (inputs: Bearer[], amountMsat: number, kind: 'split' | 'combine') => {
busy.value = true;
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
operation = wallet.beginFundOperation();
const ownerFence = operation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const result = await ensureExactAmount(inputs, amountMsat, {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (checkpoint) => {
await commitCarve(wallet, checkpoint, { ownerFence, warn: warnCommitted });
},
});
await activity.log(
kind,
kind === 'combine'
? `Combined ${inputs.length} notes into ${formatSats(result.note.amount)} sats.`
: `Split a note into ${formatSats(result.note.amount)} and ${formatSats(result.change?.amount ?? 0)} sats.`,
(error) => warnCommitted(error.message),
);
selectedIds.value = [];
splitOpen.value = false;
detail.value = null;
notify('positive', kind === 'combine' ? 'Notes combined.' : 'Note split.');
} catch (error) {
if (error instanceof UncertainOutcomeError && operation) {
await addCommittedBearers(wallet, error.possibleOutputs, {
ownerFence: operation.ownerFence,
warn: warnCommitted,
});
notify('warning', 'The mint result is uncertain. Possible outputs are saved unverified.');
} else {
notify('negative', error instanceof Error ? error.message : 'The note operation failed.');
}
} finally {
operation?.complete();
busy.value = false;
}
};
const mergeSelected = (): Promise<void> => {
if (!canMerge.value) return Promise.resolve();
return carve(
selectedNotes.value,
selectedNotes.value.reduce((total, note) => total + note.amount, 0),
'combine',
);
};
const openSplit = (note: Bearer): void => {
detail.value = note;
splitAmount.value = null;
splitOpen.value = true;
};
const confirmSplit = (): Promise<void> => {
const note = detail.value;
const sats = splitAmount.value;
if (!note || !sats || !Number.isInteger(sats) || satsToMsat(sats) >= note.amount) {
notify('negative', 'Enter a whole-sat amount smaller than the note.');
return Promise.resolve();
}
return carve([note], satsToMsat(sats), 'split');
};
const refreshNote = async (note: Bearer): Promise<void> => {
if (note.spent || note.pendingMint || note.deviceId || !noteK1(note.url)) return;
busy.value = true;
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
let refreshReturned = false;
try {
operation = wallet.beginFundOperation();
const ownerFence = operation.ownerFence;
const refreshed = await receiveBearer(
note.url,
wallet.bearers.filter((candidate) => candidate.id !== note.id),
{
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
stageRotation: async (output) => {
[staged] = await addCommittedBearers(
wallet,
[
{
...output,
amount: 0,
label: note.label,
pendingMint: { ...output.pendingMint, refreshSourceBearerId: note.id },
},
],
{ ownerFence, warn: warnCommitted },
);
},
},
);
refreshReturned = true;
if (refreshed.stage === 'finalize') {
if (!staged) throw new Error('The refreshed note was not staged.');
try {
await wallet.finalizeStagedMintOutput(staged.id, refreshed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
} else if (refreshed.stage === 'discard') {
if (staged) await wallet.removeNote(staged.id, ownerFence);
await wallet.updateBearer(
note.id,
{
callback: refreshed.note.callback,
amount: refreshed.note.amount,
verified: refreshed.note.verified,
mintPubkey: refreshed.note.mintPubkey,
},
ownerFence,
);
} else if (refreshed.stage === 'keep') {
await wallet.updateBearer(note.id, { verified: false }, ownerFence);
} else {
await wallet.updateBearer(
note.id,
{
callback: refreshed.note.callback,
amount: refreshed.note.amount,
verified: refreshed.note.verified,
},
ownerFence,
);
}
await activity.log(
'refresh',
`Checked and refreshed a ${formatSats(refreshed.note.amount)} sat note from ${serverOf(note.url)}.`,
(error) => warnCommitted(error.message),
);
detail.value = wallet.bearers.find((candidate) => candidate.id === note.id) ?? null;
notify(
refreshed.rotated ? 'positive' : 'warning',
refreshed.rotated
? 'Note is valid and has a fresh secret.'
: 'Note checked, but could not be rotated.',
);
} catch (error) {
if ((error instanceof NoteSpentError || error instanceof NoteUnknownError) && operation) {
try {
await wallet.applyChangeset(
{
add: [],
markSpent: [note.id],
...(staged ? { remove: [staged.id] } : {}),
},
operation.ownerFence,
);
notify(
'negative',
error instanceof NoteSpentError
? 'The mint reports this note spent.'
: 'The mint does not know this note.',
);
} catch (commitError) {
notify(
'negative',
commitError instanceof Error ? commitError.message : 'Could not save the note status.',
);
}
} else if (error instanceof PendingNoteError) {
if (staged && operation) {
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
}
notify('warning', 'The note is temporarily locked by a pending payment.');
} else {
if (staged && operation && !refreshReturned) {
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
}
notify('negative', error instanceof Error ? error.message : 'The validity check failed.');
}
} finally {
operation?.complete();
busy.value = false;
}
};
const saveLabel = async (note: Bearer, label: string): Promise<void> => {
const operation = wallet.beginFundOperation();
try {
await wallet.updateBearer(
note.id,
{ label: label.trim() || undefined },
operation.ownerFence,
);
notify('positive', 'Label saved.');
} catch (error) {
notify('negative', error instanceof Error ? error.message : 'Could not save the label.');
} finally {
operation.complete();
}
};
const moveNote = (note: Bearer): void => {
void router.push({ path: '/settings/move', query: { noteId: note.id } });
};
return {
busy,
canMerge,
canMutate,
confirmSplit,
detail,
formatSats,
mergeSelected,
moveNote,
notes,
openSplit,
refreshNote,
router,
saveLabel,
selectedIds,
serverOf,
splitAmount,
splitOpen,
status,
statusColor,
};
};
+59 -18
View File
@@ -1,10 +1,16 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar'; 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 { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types'; import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
@@ -143,31 +149,71 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
step.value = 'working'; step.value = 'working';
stage.value = 'Preparing the exact amount and sending the payment…'; stage.value = 'Preparing the exact amount and sending the payment…';
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
const commitContext = { ownerFence, warn: warnCommitted }; 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 // the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave // after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs // burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
let returnStage: Bearer | undefined;
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => { const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext); carveState.committed = await commitCarve(wallet, carve, commitContext);
}; };
const paid = await payWithBearers( const allocateOutputSecrets = (server: string, count: number) =>
wallet.bearers, wallet.allocateOutputSecrets(server, count, fence);
payment.input, const mintSignatureKeys = (server: string) => getTrustedMintVerificationKeys(server, ownerId);
payment.kind === 'address' // the pay-return recovery output: staged durably (allocated secret,
? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve } // zero value, pendingMint) and linked to the carved source BEFORE the
: { assertOwner: ownerFence, onCarve }, // 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…'; stage.value = 'Confirming the result…';
// a carve that mutated nothing (one note already held the exact // a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up // amount) fires no hook - the note is already tracked; look it up
const committed = const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url); carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.'); if (!committed) throw new Error('The carved note was not tracked.');
if (paid.rescuedNote) { if (returnStage) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); 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)); const sats = formatSats(msatToSats(paid.amountMsat));
if (paid.outcome === 'settled') { if (paid.outcome === 'settled') {
@@ -178,13 +224,6 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('positive', `Paid ${sats} sats.`); toast('positive', `Paid ${sats} sats.`);
emit('sent'); emit('sent');
} else if (paid.outcome === 'failed-funds-returned') { } else if (paid.outcome === 'failed-funds-returned') {
if (paid.rotatedNote) {
// the classification rotate re-secured the returned funds at a
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [paid.rotatedNote], commitContext);
await wallet.markSpent(committed.id, ownerFence);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A ${sats} sat payment failed - funds are back in your wallet.`, `A ${sats} sat payment failed - funds are back in your wallet.`,
@@ -227,6 +266,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('negative', inlineError.value); toast('negative', inlineError.value);
} }
step.value = 'input'; step.value = 'input';
} finally {
fundOperation?.complete();
} }
}; };
const closeResult = (): void => { const closeResult = (): void => {
+39 -18
View File
@@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar'; import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard'; 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 { 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 { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
@@ -35,8 +35,10 @@ export const useReceiveLightningDialog = (
const preparing = ref(false); const preparing = ref(false);
const formError = ref(''); const formError = ref('');
const prepared = ref<PreparedMint | null>(null); const prepared = ref<PreparedMint | null>(null);
const stagedOutput = ref<Bearer | null>(null);
const waiting = ref(false); const waiting = ref(false);
const claimError = ref(''); const claimError = ref('');
const claimRetryable = ref(true);
const receivedSats = ref(0); const receivedSats = ref(0);
const receivedServer = ref(''); const receivedServer = ref('');
const rotationWarning = ref(''); const rotationWarning = ref('');
@@ -85,23 +87,21 @@ export const useReceiveLightningDialog = (
const onClaimed = async ( const onClaimed = async (
claimed: ClaimedNote, claimed: ClaimedNote,
from: PreparedMint, from: PreparedMint,
staged: Bearer,
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
): Promise<void> => { ): Promise<void> => {
const server = from.server; const server = from.server;
const wasTrusted = mints.isTrusted(server); const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = ''; let trustWarning = '';
try { try {
await wallet.addBearers(notes, ownerFence); await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
} catch (error) { } catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error; if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message; trustWarning = error.message;
} }
receivedSats.value = displaySats(claimed.note.amount); receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server; receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? ''; rotationWarning.value = '';
await activity.log( await activity.log(
'mint', 'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`, `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
@@ -130,23 +130,35 @@ export const useReceiveLightningDialog = (
} }
}; };
const beginClaim = (): void => { const beginClaim = (): void => {
if (!prepared.value || claimRun) return; if (!prepared.value || !stagedOutput.value || claimRun) return;
waiting.value = true; waiting.value = true;
claimError.value = ''; claimError.value = '';
claimRetryable.value = true;
const current = prepared.value; const current = prepared.value;
const staged = stagedOutput.value;
claimRun = (async () => { claimRun = (async () => {
const fundOperation = wallet.beginFundOperation();
try { try {
const ownerFence = wallet.captureOwnerFence(); const ownerFence = fundOperation.ownerFence;
await onClaimed( await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }), await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current, current,
staged,
ownerFence, ownerFence,
); );
} catch (error) { } catch (error) {
if (!(error instanceof Error)) throw 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.`; claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) }); Notify.create({ type: 'negative', message: errorMessage(error) });
} finally { } finally {
fundOperation.complete();
waiting.value = false; waiting.value = false;
} }
})(); })();
@@ -156,20 +168,26 @@ export const useReceiveLightningDialog = (
if (!sats || preparing.value) return; if (!sats || preparing.value) return;
preparing.value = true; preparing.value = true;
formError.value = ''; formError.value = '';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats)); fundOperation = wallet.beginFundOperation();
// named mints claim through the wallet's own secret and may const ownerFence = fundOperation.ownerFence;
// legitimately serve no verify URL; unnamed mints need one (the let persisted: Bearer | undefined;
// preimage it reveals IS the claim) const next = await prepareMint(input, satsToMsat(sats), {
if (next.mode === 'unnamed' && !next.verifyUrl) { assertOwner: ownerFence,
formError.value = allocateOutputSecrets: (server, count) =>
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; wallet.allocateOutputSecrets(server, count, ownerFence),
return; 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; prepared.value = next;
claimRun = null; claimRun = null;
claimError.value = ''; claimError.value = '';
claimRetryable.value = true;
step.value = 'invoice'; step.value = 'invoice';
beginClaim(); beginClaim();
} catch (error) { } catch (error) {
@@ -177,6 +195,7 @@ export const useReceiveLightningDialog = (
formError.value = errorMessage(error); formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value }); Notify.create({ type: 'negative', message: formError.value });
} finally { } finally {
fundOperation?.complete();
preparing.value = false; preparing.value = false;
} }
}; };
@@ -211,6 +230,7 @@ export const useReceiveLightningDialog = (
preparing.value = false; preparing.value = false;
formError.value = ''; formError.value = '';
prepared.value = null; prepared.value = null;
stagedOutput.value = null;
waiting.value = false; waiting.value = false;
rotationWarning.value = ''; rotationWarning.value = '';
}, },
@@ -219,6 +239,7 @@ export const useReceiveLightningDialog = (
CUSTOM_MINT, CUSTOM_MINT,
amountSats, amountSats,
claimError, claimError,
claimRetryable,
copyInvoice, copyInvoice,
createInvoice, createInvoice,
customMint, customMint,
+35 -4
View File
@@ -8,7 +8,7 @@ import {
} from 'lnurlcash-kit'; } from 'lnurlcash-kit';
import { receiveBearer } from '@/lnurlcash/ops'; 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 { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints'; import { useMintsStore } from '@/stores/mints';
@@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
if (busy.value || value === '') return; if (busy.value || value === '') return;
busy.value = true; busy.value = true;
clearError(); clearError();
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
try { 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, { const claimed = await receiveBearer(value, wallet.bearers, {
assertOwner: ownerFence, assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
stageRotation: async (note) => {
[staged] = await wallet.addBearers([note], ownerFence);
},
}); });
const note = claimed.note; const note = claimed.note;
const server = new URL(note.url).host; const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server); const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note];
let trustWarning = ''; let trustWarning = '';
try { 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) { } catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error; if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message; trustWarning = error.message;
@@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
trustPrompt.openTrust(server, note.mintPubkey); trustPrompt.openTrust(server, note.mintPubkey);
} }
} catch (error) { } 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))); classifyError(error instanceof Error ? error : new Error(String(error)));
} finally { } finally {
fundOperation?.complete();
busy.value = false; busy.value = false;
} }
}; };
+13 -2
View File
@@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit';
import { writeClipboard } from '@/capabilities/clipboard'; import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share'; import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops'; import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types'; import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
@@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
preparing.value = true; preparing.value = true;
errorMessage.value = null; errorMessage.value = null;
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence; const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
// the carve commits the moment it lands server-side (onCarve), so // the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or // even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable // leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: fence, assertOwner: fence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, fence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (landed) => { onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, { carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence, ownerFence: fence,
@@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
: message; : message;
toast('negative', errorMessage.value); toast('negative', errorMessage.value);
} finally { } finally {
fundOperation?.complete();
preparing.value = false; preparing.value = false;
} }
}; };
@@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
const note = prepared.value; const note = prepared.value;
if (!note) return; if (!note) return;
removing.value = true; removing.value = true;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
await wallet.markSpent(note.id, wallet.captureOwnerFence()); fundOperation = wallet.beginFundOperation();
await wallet.markSpent(note.id, fundOperation.ownerFence);
await activity.log( await activity.log(
'spent', 'spent',
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`, `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
@@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
} catch (error) { } catch (error) {
toast('negative', error instanceof Error ? error.message : 'Something went wrong.'); toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
} finally { } finally {
fundOperation?.complete();
removing.value = false; 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 { deriveBearerAesKey } from '@/lnurlcash/keys';
import type { CarveResult } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops';
import { loadBearers } from '@/lnurlcash/storage'; import { loadBearers } from '@/lnurlcash/storage';
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
import { stubLocalStorage } from '@/lnurlcash/test-utils'; import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types'; import type { NewBearer } from '@/lnurlcash/types';
import { commitCarve } from './walletCarveCommit'; import { commitCarve } from './walletCarveCommit';
@@ -21,7 +22,7 @@ const failOnBearerWrite = (occurrence: number): void => {
const setItem = localStorage.setItem.bind(localStorage); const setItem = localStorage.setItem.bind(localStorage);
let writes = 0; let writes = 0;
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
if (key === 'sattle_bearers') { if (key === FUNDS_STORAGE_KEY) {
writes += 1; writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable'); 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(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
stubLocalStorage(); stubLocalStorage();
setActivePinia(createPinia()); setActivePinia(createPinia());
}); });
@@ -59,7 +68,7 @@ describe('commitCarve', () => {
}); });
// Then the whole rotation landed as ONE durable write // 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(committed.url).toBe(carve.note.url);
expect(wallet.bearers).toHaveLength(3); expect(wallet.bearers).toHaveLength(3);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); 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 () => { 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 // 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) // twice; the single-write commit never reaches a second write)
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
@@ -127,4 +136,37 @@ describe('commitCarve', () => {
expect(persisted).toHaveLength(1); expect(persisted).toHaveLength(1);
expect(persisted[0]?.spent).toBeUndefined(); 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 { CarveResult } from '@/lnurlcash/ops';
import type { BearerChangeset } from '@/lnurlcash/storage';
import type { Bearer, NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet'; import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
@@ -7,10 +6,7 @@ import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = { export type CarveWallet = {
readonly bearers: readonly Bearer[]; readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>; readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly applyChangeset: ( readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
) => Promise<Bearer[]>;
}; };
type CarveCommitContext = Readonly<{ type CarveCommitContext = Readonly<{
@@ -35,33 +31,24 @@ export const addCommittedBearers = async (
// A carve is ONE logical rotation: the fresh notes (target + change) and the // 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 // 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 // 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 // but not spent, or vice versa) would strand or double-show money. Both
// single changeset through the wallet's one-write boundary, never an // checkpoint phases (pre-wire staging, landed retirement) go through the
// add-then-markSpent sequence of separate writes. // 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 ( export const commitCarve = async (
wallet: CarveWallet, wallet: CarveWallet,
carve: CarveResult, carve: CarveResult,
context: CarveCommitContext, context: CarveCommitContext,
): Promise<Bearer> => { ): 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 { try {
added = await wallet.applyChangeset( return await wallet.commitCarve(carve, context.ownerFence);
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
},
context.ownerFence,
);
} catch (error) { } catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error; if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message); context.warn(error.message);
added = error.committedBearers; const committed = error.committedBearers[0];
} if (!committed) throw error;
const committed = existing ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
return committed; return committed;
}
}; };
+8 -12
View File
@@ -6,14 +6,11 @@ lnurlcash-kit directly.
## FUND-CRITICAL INVARIANTS (non-negotiable) ## 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:
from an UNNAMED mint (one that keys the note by the payment preimage). current LUD-25 requires `commentAllowed >= 64`, so the wallet durably
A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint stages its chosen secret before requesting the invoice and claims at
≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the that secret without rotating. `mintToHash` is only an additive receipt
quote; that secret never rode an invoice, so the claim needs no rotate. extension, never a capability fallback.
The claim poll itself is the placement proof: a mint that took the hash
but credited the preimage is caught there and rescued through the
quote's verify URL when one is served.
- A note's declared amount is a claim; the service's `maxWithdrawable` is - A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative. authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or - A melt's "OK" only means the payment is in flight — the verify URL (or
@@ -68,7 +65,6 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS ## TESTS
`npx vitest run` — node environment, `*.test.ts` next to the modules. `npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts` The adversarial mock mint from lnurlcash-conformance is wrapped by
(`conformance-mockmint.d.ts` augments its stale published declarations `ops.testHarness.ts`; its default contract is mandatory comment-bound
with the comment-naming flags). The mock's `commentAllowed` option models minting.
the named-mint generation; default options model the old one.
-30
View File
@@ -1,30 +0,0 @@
// The published lnurlcash-conformance 0.4.0 hand-written declarations
// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the
// comment-naming flags are implemented and documented in the CHANGELOG
// ("The mock mint gains commentAllowed, commentRefusesMalformed and
// verifyOnUnnamedMint") but never made it into the type surface. This
// augmentation restores them so tests can drive the comment-named mint
// mode without `as any`. Report upstream; drop this file once a release
// ships the declarations.
//
// Semantics (from mock-mint/index.mjs):
// - commentAllowed: false (default) means the pay callback ignores the
// `comment` parameter entirely. A number advertises that many
// characters on the payRequest and reads `comment` on the callback: a
// bare 64-hex sha256 binds the minted note to the wallet's secret
// (k1 = secret, preimage opens nothing); anything else falls back to
// preimage keying and verify is withheld for that quote.
// - commentRefusesMalformed: non-compliant; refuses a non-hash comment
// instead of falling back.
// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the
// no-comment fallback, where the preimage it hands out IS the note.
declare module 'lnurlcash-conformance/mock-mint' {
interface MockMintOptions {
commentAllowed?: number | false
commentRefusesMalformed?: boolean
verifyOnUnnamedMint?: boolean
}
}
export {}
+26 -26
View File
@@ -23,29 +23,29 @@ import {
mintAddressUrl, mintAddressUrl,
resolveMintInput, resolveMintInput,
serverOf, serverOf,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit' import type { LnurlcashOptions, MintFee } from 'lnurlcash-kit';
import {ceilMsatToSat, floorMsatToSat} from './units' import { ceilMsatToSat, floorMsatToSat } from './units';
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number => export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
applyMintFee(grossMsat, fee) applyMintFee(grossMsat, fee);
export const grossForMintFee = (netMsat: number, fee: MintFee): number => 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 => 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. // Fee quotes are per-mint and change rarely; cache briefly by server.
// nulls are cached too - an unreachable mint shouldn't be retried on // nulls are cached too - an unreachable mint shouldn't be retried on
// every render either. // every render either.
const QUOTE_TTL_MS = 60_000 const QUOTE_TTL_MS = 60_000;
const QUOTE_TIMEOUT_MS = 5_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 // 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 // A pre-flight read of a mint's advertised receive fee WITHOUT requesting
// an invoice - the same resolution chain as prepareMint (mint-address // an invoice - the same resolution chain as prepareMint (mint-address
@@ -57,29 +57,29 @@ export const quoteMintFee = async (
mintInput: string, mintInput: string,
options: LnurlcashOptions = {}, options: LnurlcashOptions = {},
): Promise<MintFee | null> => { ): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput) const url = resolveMintInput(mintInput);
if (!url) return null if (!url) return null;
const server = serverOf(url) const server = serverOf(url);
const cached = quoteCache.get(server) const cached = quoteCache.get(server);
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee;
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options} const opts: LnurlcashOptions = { timeoutMs: QUOTE_TIMEOUT_MS, ...options };
let payUrl = url let payUrl = url;
const addressUrl = mintAddressUrl(url) const addressUrl = mintAddressUrl(url);
if (addressUrl) { if (addressUrl) {
try { try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
} catch (error) { } catch (error) {
// no mint-address support - the plain payRequest guess still works // 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 { try {
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null;
} catch { } 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> => 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> => { export const parseJsonObject = (source: string): Record<string, unknown> => {
const parsed: unknown = JSON.parse(source) const parsed: unknown = JSON.parse(source);
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.') if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.');
return parsed return parsed;
} };
export const parseJsonArray = (source: string): unknown[] => { export const parseJsonArray = (source: string): unknown[] => {
const parsed: unknown = JSON.parse(source) const parsed: unknown = JSON.parse(source);
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.') if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.');
return Array.from(parsed, (value: unknown) => value) return Array.from(parsed, (value: unknown) => value);
} };
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> => export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
parseJsonArray(source).map((value) => { parseJsonArray(source).map((value) => {
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.') if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.');
return value 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();
});
});
+163 -163
View File
@@ -5,8 +5,8 @@
// field that binds the saved key to its one proven wallet identity. // field that binds the saved key to its one proven wallet identity.
// Node env: in-memory localStorage stub, native WebCrypto. // Node env: in-memory localStorage stub, native WebCrypto.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import { import {
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -20,153 +20,153 @@ import {
savedKeyOwnerMatches, savedKeyOwnerMatches,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
} from './keys' } from './keys';
import {isWalletOwnerId} from './storage/walletOwner' import { isWalletOwnerId } from './storage/walletOwner';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.version.cases' import './keys.version.cases';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const PASSWORD = 'hunter2' const PASSWORD = 'hunter2';
const STORAGE_KEY = 'sattle_linking_key' const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => { const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record') if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
// hand-written records in the pre-owner-marker shape - what every wallet // hand-written records in the pre-owner-marker shape - what every wallet
// created before this change has on disk // created before this change has on disk
const saveLegacyPlaintext = (key: Uint8Array): void => { 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 ( const saveLegacyEncrypted = async (
key: Uint8Array, key: Uint8Array,
password: string, password: string,
): Promise<Record<string, unknown>> => { ): Promise<Record<string, unknown>> => {
const parts = await encryptSecretParts(bytesToHex(key), password) const parts = await encryptSecretParts(bytesToHex(key), password);
const record: Record<string, unknown> = {enc: true, ...parts} const record: Record<string, unknown> = { enc: true, ...parts };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
return record return record;
} };
describe('baseline: saved-key record behavior', () => { describe('baseline: saved-key record behavior', () => {
it('saves a plaintext key and reads it back', async () => { it('saves a plaintext key and reads it back', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
expect(savedKeyExists()).toBe(true) expect(savedKeyExists()).toBe(true);
expect(savedKeyIsEncrypted()).toBe(false) expect(savedKeyIsEncrypted()).toBe(false);
expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)}) expect(readRawRecord()).toMatchObject({ enc: false, value: bytesToHex(LINKING_KEY) });
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
}) });
it('saves a password-encrypted key and decrypts it with the password', async () => { 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) expect(savedKeyIsEncrypted()).toBe(true);
const record = readRawRecord() const record = readRawRecord();
expect(record.enc).toBe(true) expect(record.enc).toBe(true);
expect(record.value).toBeUndefined() expect(record.value).toBeUndefined();
// an encrypted record never reads through the plaintext path // an encrypted record never reads through the plaintext path
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('rejects the wrong password via the GCM auth tag', async () => { it('rejects the wrong password via the GCM auth tag', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow() await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow();
}) });
it('throws when asked to decrypt a plaintext record', async () => { it('throws when asked to decrypt a plaintext record', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow( await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
'No encrypted linking key saved.', 'No encrypted linking key saved.',
) );
}) });
it('restores an ownerless record verbatim and reads it back', async () => { it('restores an ownerless record verbatim and reads it back', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
const record = {enc: true as const, ...parts} const record = { enc: true as const, ...parts };
restoreLinkingKeyStored(record) restoreLinkingKeyStored(record);
expect(readRawRecord()).toEqual(record) expect(readRawRecord()).toEqual(record);
expect(savedKeyIsEncrypted()).toBe(true) expect(savedKeyIsEncrypted()).toBe(true);
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('drops a malformed stored record instead of trusting it', () => { it('drops a malformed stored record instead of trusting it', () => {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }), JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }),
) );
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
localStorage.setItem(STORAGE_KEY, 'not json') localStorage.setItem(STORAGE_KEY, 'not json');
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
}) });
}) });
describe('owner marker on new writes', () => { describe('owner marker on new writes', () => {
it('matches the canonical owner derived from the saved linking key', async () => { it('matches the canonical owner derived from the saved linking key', async () => {
// Given a newly saved owner-bearing key // Given a newly saved owner-bearing key
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
// When its freshly derived linking key is compared // When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY) const matches = savedKeyOwnerMatches(LINKING_KEY);
// Then the saved owner matches // Then the saved owner matches
expect(matches).toBe(true) expect(matches).toBe(true);
}) });
it('does not match a different linking key', async () => { it('does not match a different linking key', async () => {
// Given a key owned by this wallet // Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
// When a foreign freshly derived linking key is compared // 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 // 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 () => { it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const plainOwner = savedKeyOwnerId() const plainOwner = savedKeyOwnerId();
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY)) expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBe(plainOwner) expect(savedKeyOwnerId()).toBe(plainOwner);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
}) });
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => { it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/) expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/);
}) });
}) });
describe('owner marker on legacy records', () => { describe('owner marker on legacy records', () => {
it('reads a legacy record without ownerId as ownerless', async () => { it('reads a legacy record without ownerId as ownerless', async () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
// the record itself stays a fully valid saved key // the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
await saveLegacyEncrypted(LINKING_KEY, PASSWORD) await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('rejects malformed owner-bearing records instead of treating them as legacy', () => { 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[] = [ const junk: unknown[] = [
real.slice(1), // wrong length (65) real.slice(1), // wrong length (65)
real + '00', // wrong length (68) real + '00', // wrong length (68)
@@ -178,100 +178,100 @@ describe('owner marker on legacy records', () => {
null, null,
{ pubkey: real }, { pubkey: real },
'', '',
] ];
for (const ownerId of junk) { for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId})) localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
// the junk marker never reads as an owner... // the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
// ...or downgrades to an adoptable ownerless legacy record // ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
} }
}) });
it('stamps the owner after a password unlock without touching the ciphertext', async () => { it('stamps the owner after a password unlock without touching the ciphertext', async () => {
// Given a legacy encrypted record with no owner marker // 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 // When ownership is proven by a successful unlock and then stamped
const linkingKey = await decryptSavedLinkingKey(PASSWORD) const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
// Then the marker names the proven key and every ciphertext byte is // Then the marker names the proven key and every ciphertext byte is
// preserved // preserved
const after = readRawRecord() const after = readRawRecord();
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
expect(after.ciphertext).toBe(before.ciphertext) expect(after.ciphertext).toBe(before.ciphertext);
expect(after.salt).toBe(before.salt) expect(after.salt).toBe(before.salt);
expect(after.iv).toBe(before.iv) expect(after.iv).toBe(before.iv);
}) });
it('stamps a plaintext legacy record after a plaintext unlock', () => { it('stamps a plaintext legacy record after a plaintext unlock', () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
const linkingKey = getPlainLinkingKey() const linkingKey = getPlainLinkingKey();
if (linkingKey === null) throw new Error('expected a plaintext key') if (linkingKey === null) throw new Error('expected a plaintext key');
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
expect(readRawRecord()).toEqual({ expect(readRawRecord()).toEqual({
enc: false, enc: false,
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
version: 1, version: 1,
ownerId: linkingPubKeyHex(LINKING_KEY), ownerId: linkingPubKeyHex(LINKING_KEY),
}) });
}) });
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => { it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
const linkingKey = await decryptSavedLinkingKey(PASSWORD) const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
const afterFirst = localStorage.getItem(STORAGE_KEY) const afterFirst = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst) expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst);
expect(readRawRecord().ciphertext).toBe(before.ciphertext) expect(readRawRecord().ciphertext).toBe(before.ciphertext);
}) });
it('writes nothing when a new-format record is already correctly stamped', async () => { it('writes nothing when a new-format record is already correctly stamped', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw) expect(localStorage.getItem(STORAGE_KEY)).toBe(raw);
}) });
it('refuses to restamp a record owned by a different wallet', async () => { it('refuses to restamp a record owned by a different wallet', async () => {
await saveLinkingKey(OTHER_KEY, PASSWORD) await saveLinkingKey(OTHER_KEY, PASSWORD);
const before = localStorage.getItem(STORAGE_KEY) 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 // the failed stamp leaves the record - marker included - untouched
expect(localStorage.getItem(STORAGE_KEY)).toBe(before) expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY));
}) });
it('refuses to stamp a key that contradicts a plaintext record', () => { it('refuses to stamp a key that contradicts a plaintext record', () => {
saveLegacyPlaintext(OTHER_KEY) saveLegacyPlaintext(OTHER_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
}) });
it('does not adopt a record carrying a junk owner marker', () => { it('does not adopt a record carrying a junk owner marker', () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }), JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }),
) );
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
}) });
it('is a no-op when no record is saved at all', () => { it('is a no-op when no record is saved at all', () => {
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
}) });
}) });
describe('owner marker on restore', () => { describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => { it('strips the unproven ownerId a restored record arrives with', async () => {
@@ -281,36 +281,36 @@ describe('owner marker on restore', () => {
enc: false, enc: false,
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY), ownerId: linkingPubKeyHex(OTHER_KEY),
}) });
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
// the first proven unlock then establishes the true owner // the first proven unlock then establishes the true owner
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
}) });
it('strips a junk ownerId from a restored encrypted record', async () => { it('strips a junk ownerId from a restored encrypted record', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42}) restoreLinkingKeyStored({ enc: true, ...parts, ownerId: 42 });
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
}) });
describe('isWalletOwnerId', () => { describe('isWalletOwnerId', () => {
it('accepts exactly what linkingPubKeyHex produces', () => { it('accepts exactly what linkingPubKeyHex produces', () => {
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true) expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true);
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true) expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true);
}) });
it('rejects everything else', () => { it('rejects everything else', () => {
const real = linkingPubKeyHex(LINKING_KEY) const real = linkingPubKeyHex(LINKING_KEY);
expect(isWalletOwnerId(real.toUpperCase())).toBe(false) expect(isWalletOwnerId(real.toUpperCase())).toBe(false);
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false) expect(isWalletOwnerId(real.slice(0, 64))).toBe(false);
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false) expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false);
expect(isWalletOwnerId(66)).toBe(false) expect(isWalletOwnerId(66)).toBe(false);
expect(isWalletOwnerId(undefined)).toBe(false) expect(isWalletOwnerId(undefined)).toBe(false);
expect(isWalletOwnerId(null)).toBe(false) expect(isWalletOwnerId(null)).toBe(false);
}) });
}) });
+126 -154
View File
@@ -1,10 +1,11 @@
import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39' import { mnemonicToSeedSync, generateMnemonic, validateMnemonic } from '@scure/bip39';
import {wordlist} from '@scure/bip39/wordlists/english.js' import { wordlist } from '@scure/bip39/wordlists/english.js';
import {HDKey, HARDENED_OFFSET} from '@scure/bip32' import { HDKey, HARDENED_OFFSET } from '@scure/bip32';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {secp256k1} from '@noble/curves/secp256k1.js' import { secp256k1 } from '@noble/curves/secp256k1.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode } from 'lnurlcash-kit';
import { import {
parseStoredSecret, parseStoredSecret,
@@ -14,144 +15,111 @@ import {
stripStoredSecretOwner, stripStoredSecretOwner,
STORED_SECRET_VERSION, STORED_SECRET_VERSION,
type StoredSecret, type StoredSecret,
} from './storage/storedSecret' type WalletMaterialV2,
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' } from './storage/storedSecret';
import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
export {isValidStoredSecret} from './storage/storedSecret' export {
export type {StoredSecret} from './storage/storedSecret' 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 // The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same // window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where // linking key (and thus decrypts the same bearer tokens) no matter where
// this static build happens to be hosted - github.io, a mirror, file://. // 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 => export const isValidSeedPhrase = (phrase: string): boolean =>
validateMnemonic(phrase.trim().toLowerCase(), wordlist) validateMnemonic(phrase.trim().toLowerCase(), wordlist);
const readUint32BE = (bytes: Uint8Array, offset: number): number => const readUint32BE = (bytes: Uint8Array, offset: number): number =>
((bytes[offset] << 24) | ((bytes[offset] << 24) |
(bytes[offset + 1] << 16) | (bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) | (bytes[offset + 2] << 8) |
bytes[offset + 3]) >>> bytes[offset + 3]) >>>
0 0;
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server - // 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 // a seed restored there or here produces the same identity for a given domain
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => { const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) const master = HDKey.fromMasterSeed(seed);
const master = HDKey.fromMasterSeed(seed)
const hashingKeyNode = master.derive("m/138'/0") const hashingKeyNode = master.derive("m/138'/0");
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key') if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key');
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain) const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain);
// path suffix longs are raw BIP32 child indices: whether each level ends up // path suffix longs are raw BIP32 child indices: whether each level ends up
// hardened depends solely on its own magnitude (>= 2^31), never forced // 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) { 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 // 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 // (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
// directly - see keys.test.ts // directly - see keys.test.ts
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => { export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain)) const material = hmac(sha256, hashingKey, utf8ToBytes(domain));
return [0, 4, 8, 12].map((i) => readUint32BE(material, i)) return [0, 4, 8, 12].map((i) => readUint32BE(material, i));
} };
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => 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 => export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true)) 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
const readSecret = (storageKey: string): StoredSecret | null => { const readSecret = (storageKey: string): StoredSecret | null => {
const raw = localStorage.getItem(storageKey) const raw = localStorage.getItem(storageKey);
if (!raw) return null if (!raw) return null;
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw);
return parseStoredSecret(parsed)?.secret ?? null return parseStoredSecret(parsed)?.secret ?? null;
} catch { } catch {
return null return null;
}
} }
};
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> => // The legacy alpha lifecycle below persists only the linking key under its old
crypto.subtle // namespace. Task 5 replaces that lifecycle with the v2 material repository;
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) // these APIs stay byte-compatible until the serialized reset can switch every
.then((baseKey) => // caller atomically rather than creating a split-key wallet.
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 record also carries an ownerId marker: the lowercase compressed // The record also carries an ownerId marker: the lowercase compressed
// pubkey hex of the key itself (storage/walletOwner.ts), binding every // 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 // preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that // already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record. // 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 => 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 // 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) // carries no current version-1 marker (ownerless or compatible unversioned)
export const savedKeyOwnerId = (): string | null => { export const savedKeyOwnerId = (): string | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
return stored === null ? null : storedSecretOwnerId(stored) return stored === null ? null : storedSecretOwnerId(stored);
} };
// Compares only against an owner freshly derived from a linking key. An // Compares only against an owner freshly derived from a linking key. An
// ownerless or unversioned saved marker never matches. // ownerless or unversioned saved marker never matches.
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean => 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 // Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext // just proven against this record (decryptSavedLinkingKey / a plaintext
@@ -193,90 +161,94 @@ export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
// already-correct marker: no write. A DIFFERENT valid owner, or a key that // already-correct marker: no write. A DIFFERENT valid owner, or a key that
// contradicts a plaintext record, throws and leaves storage untouched. // contradicts a plaintext record, throws and leaves storage untouched.
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => { export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null) return if (stored === null) return;
const ownerId = linkingPubKeyHex(linkingKey) const ownerId = linkingPubKeyHex(linkingKey);
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(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) const claimedOwnerId = storedSecretClaimedOwnerId(stored);
if (storedSecretOwnerId(stored) === ownerId) return if (storedSecretOwnerId(stored) === ownerId) return;
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { 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( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify(stampStoredSecretOwner(stored, ownerId)), JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
) );
} };
export const getPlainLinkingKey = (): Uint8Array | null => { export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null || stored.enc === true) return null if (stored === null || stored.enc === true) return null;
return hexToBytes(stored.value) return hexToBytes(stored.value);
} };
export const saveLinkingKey = async ( export const saveLinkingKey = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
password?: string, password?: string,
): Promise<void> => { ): Promise<void> => {
const hex = bytesToHex(linkingPrivKey) const hex = bytesToHex(linkingPrivKey);
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
if (!password) { if (!password) {
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }), JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }),
) );
return return;
} }
const parts = await encryptSecretParts(hex, password) const parts = await encryptSecretParts(hex, password);
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, 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 // 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 // unproven claim by whoever produced that file, so the marker is stripped
// here - the first proven unlock re-establishes it (see the failure-model // here - the first proven unlock re-establishes it (see the failure-model
// comment above) // comment above)
export const restoreLinkingKeyStored = (stored: StoredSecret): void => { 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> => { export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.') if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.');
return hexToBytes(await decryptSecretParts(stored, password)) return hexToBytes(await decryptSecretParts(stored, password));
} };
export const clearSavedLinkingKey = (): void => { 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 // The bearer-encryption key is derived (not random): sha256 over the linking
// key plus a fixed context string. Deterministic derivation is what makes // key plus a fixed context string. Deterministic derivation is what makes
// backup/restore work with nothing but the seed phrase - restore the seed on // backup/restore work with nothing but the seed phrase - restore the seed on
// a fresh device and every previously exported ciphertext decrypts again. // 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> => { export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])) const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]));
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']) 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 ( export const encryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
value: object, value: object,
): Promise<EncryptedRecordParts> => { ): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))), await crypto.subtle.encrypt(
) { name: 'AES-GCM', iv },
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)} aesKey,
} utf8ToBytes(JSON.stringify(value)),
),
);
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
export const decryptRecord = async ( export const decryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
@@ -286,6 +258,6 @@ export const decryptRecord = async (
{ name: 'AES-GCM', iv: hexToBytes(parts.iv) }, { name: 'AES-GCM', iv: hexToBytes(parts.iv) },
aesKey, aesKey,
hexToBytes(parts.ciphertext), hexToBytes(parts.ciphertext),
) );
return JSON.parse(new TextDecoder().decode(plaintext)) return JSON.parse(new TextDecoder().decode(plaintext));
} };
+47 -43
View File
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import { import {
ensureSavedKeyOwner, ensureSavedKeyOwner,
@@ -9,50 +9,54 @@ import {
savedKeyExists, savedKeyExists,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
} from './keys' } from './keys';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) import './keys.material.cases';
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) import './keys.material-commitment.cases';
const STORAGE_KEY = 'sattle_linking_key' 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 readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record') if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
describe('saved-key schema version', () => { describe('saved-key schema version', () => {
it('writes version 1 on current plaintext and encrypted records', async () => { it('writes version 1 on current plaintext and encrypted records', async () => {
// Given a linking key saved through each current persistence path // Given a linking key saved through each current persistence path
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const plaintext = readRawRecord() const plaintext = readRawRecord();
await saveLinkingKey(LINKING_KEY, 'correct horse') await saveLinkingKey(LINKING_KEY, 'correct horse');
const encrypted = readRawRecord() const encrypted = readRawRecord();
// When the persisted schema metadata is inspected // When the persisted schema metadata is inspected
// Then both owner-bearing records carry the recognized discriminator // Then both owner-bearing records carry the recognized discriminator
expect(plaintext.version).toBe(1) expect(plaintext.version).toBe(1);
expect(encrypted.version).toBe(1) expect(encrypted.version).toBe(1);
}) });
it('upgrades an unversioned same-owner record only after key proof', () => { it('upgrades an unversioned same-owner record only after key proof', () => {
// Given the valid owner-bearing shape written before schema versioning // Given the valid owner-bearing shape written before schema versioning
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY);
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, 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 // When it is read before and then stamped after the plaintext key proves ownership
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
const provenKey = getPlainLinkingKey() const provenKey = getPlainLinkingKey();
if (provenKey === null) throw new Error('expected the compatible plaintext key') if (provenKey === null) throw new Error('expected the compatible plaintext key');
ensureSavedKeyOwner(provenKey) ensureSavedKeyOwner(provenKey);
// Then it becomes an explicitly versioned current record // Then it becomes an explicitly versioned current record
expect(readRawRecord()).toEqual({ expect(readRawRecord()).toEqual({
@@ -60,8 +64,8 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId, ownerId,
version: 1, version: 1,
}) });
}) });
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => { it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
// Given an otherwise valid owner-bearing record with unrecognized metadata // Given an otherwise valid owner-bearing record with unrecognized metadata
@@ -70,16 +74,16 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY), ownerId: linkingPubKeyHex(LINKING_KEY),
version, version,
} };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
// When the saved-key boundary parses it // When the saved-key boundary parses it
// Then the record is neither usable nor eligible for legacy adoption // Then the record is neither usable nor eligible for legacy adoption
expect(isValidStoredSecret(record)).toBe(false) expect(isValidStoredSecret(record)).toBe(false);
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
}) });
it('rejects a foreign current owner at the proven-key stamping boundary', () => { it('rejects a foreign current owner at the proven-key stamping boundary', () => {
// Given a versioned record whose owner conflicts with its plaintext key // Given a versioned record whose owner conflicts with its plaintext key
@@ -88,15 +92,15 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1, version: 1,
} };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
const before = localStorage.getItem(STORAGE_KEY) const before = localStorage.getItem(STORAGE_KEY);
// When the actual key is proven // 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 // Then the foreign claim fails closed without rewriting storage
expect(stamp).toThrow('different wallet') expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(STORAGE_KEY)).toBe(before) expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
}) });
}) });
+178 -125
View File
@@ -1,5 +1,5 @@
// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 / // Wire-contract coverage for the pinned lnurlcash-kit 0.8.0 /
// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows // lnurlcash-conformance 0.7.0 artifacts. Contracts the app's mint flows
// rely on: // rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the // 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 // app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
@@ -8,44 +8,56 @@
// mock emits either, selected by its withdrawLinkForm option, so do NOT // mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote // 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`) // request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability // only when the payRequest advertises commentAllowed >= 64. The wallet's
// (commentAllowed >= 64 or mintToHash): a new-generation mint // output secret must be durably staged before that quote request is sent;
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must // mintToHash alone is only an additive receipt extension, not permission
// see the byte-identical request this wallet always sent. // to mint.
import {afterEach, describe, expect, it} from 'vitest' import { afterEach, describe, expect, it } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchMintAddress, hashK1 } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint} from './ops' import { claimMintedNote, prepareMint } from './ops';
import {mintAddressCacheInfo} from './trustedMints' 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 mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options) const m = await createMockMint(options);
mints.push(m) mints.push(m);
return m return m;
} };
afterEach(async () => { 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 // paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step) // exposes that through its test hook (settle + credit in one step)
const settleLastInvoice = async (m: Mint): Promise<string> => { const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1) const paymentHash = [...m.state.invoices.keys()].at(-1);
if (!paymentHash) throw new Error('no invoice requested yet') if (!paymentHash) throw new Error('no invoice requested yet');
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) if (!res.ok) throw new Error(`settle hook failed: ${res.status}`);
const invoice = m.state.invoices.get(paymentHash) const invoice = m.state.invoices.get(paymentHash);
if (!invoice) throw new Error('settled invoice vanished from the mock') if (!invoice) throw new Error('settled invoice vanished from the mock');
return invoice.preimage 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: // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// node stats under their WIRE names - `nodeCapacity` is msat like every // node stats under their WIRE names - `nodeCapacity` is msat like every
@@ -62,26 +74,26 @@ const mintAddressFixture = {
nodeCapacity: 500_000_000, nodeCapacity: 500_000_000,
nodeNumChannels: 4, nodeNumChannels: 4,
nodeNumPeers: 6, nodeNumPeers: 6,
} };
const jsonResponse = (body: unknown): Response => const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), { new Response(JSON.stringify(body), {
status: 200, 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, // 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 // so a test drives the real kit HTTP boundary without any network
const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => { const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => {
const impl: typeof fetch = (input, _init) => { 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) { 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 // fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire // exact query the kit puts on the wire
@@ -90,69 +102,75 @@ const capturingFetch = (
seen: string[], seen: string[],
): typeof fetch => { ): typeof fetch => {
const impl: typeof fetch = (input, init) => { 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;
seen.push(url) seen.push(url);
return fixtureFetch(routes)(input, init) return fixtureFetch(routes)(input, init);
} };
return impl return impl;
} };
describe('mint-address wire contract', () => { describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => { it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
}) });
// renamed fields have to be mapped, not spread: the spread carries the // renamed fields have to be mapped, not spread: the spread carries the
// wire name through and the typed one reads undefined forever // wire name through and the typed one reads undefined forever
expect(info.nodePubkey).toBe(MINT_PUBKEY) expect(info.nodePubkey).toBe(MINT_PUBKEY);
expect(info.nodeCapacityMsat).toBe(500_000_000) expect(info.nodeCapacityMsat).toBe(500_000_000);
expect(info.nodeNumChannels).toBe(4) expect(info.nodeNumChannels).toBe(4);
expect(info.nodeNumPeers).toBe(6) expect(info.nodeNumPeers).toBe(6);
}) });
it('carries node stats into the cached trusted-mint display metadata', async () => { it('carries node stats into the cached trusted-mint display metadata', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
}) });
const cached = mintAddressCacheInfo(info, 'mint') const cached = mintAddressCacheInfo(info, 'mint');
expect(cached?.nodeCapacityMsat).toBe(500_000_000) expect(cached?.nodeCapacityMsat).toBe(500_000_000);
expect(cached?.nodeNumChannels).toBe(4) expect(cached?.nodeNumChannels).toBe(4);
expect(cached?.nodeNumPeers).toBe(6) expect(cached?.nodeNumPeers).toBe(6);
}) });
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => { it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
const m = await mint() const m = await mint();
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) 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 metadata is advertised at the mint-address endpoint itself -
// the payRequest never carried it // the payRequest never carried it
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey);
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000) expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000);
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4) expect(prepared.nodeInfo?.nodeNumChannels).toBe(4);
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6) expect(prepared.nodeInfo?.nodeNumPeers).toBe(6);
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username) const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username);
expect(cached?.nodeCapacityMsat).toBe(500_000_000) expect(cached?.nodeCapacityMsat).toBe(500_000_000);
}) });
}) });
describe('withdraw-link forms', () => { describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
// conformance 0.4.0 defaults to plain https links; lnurlw:// (the form // conformance 0.7.0 defaults to plain https links; lnurlw:// is an
// lnurl-mint emits under LUD-17) is an explicit option // explicit LUD-17 transport form
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) const m = await mint({ testHooks: true, withdrawLinkForm: 'lnurlw' });
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//);
// and the link is fully usable: settle the invoice, claim the note // 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, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.amount).toBe(21_000) expect(m.state.noteState(preimage)).toBeNull();
expect(m.state.noteState(preimage)).toBe('burned') expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding');
}) });
it('accepts an HTTPS withdraw link', async () => { it('accepts an HTTPS withdraw link', async () => {
const fetch = fixtureFetch([ const fetch = fixtureFetch([
@@ -165,25 +183,30 @@ describe('withdraw-link forms', () => {
minSendable: 1_000, minSendable: 1_000,
maxSendable: 100_000_000_000, maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note', withdrawLink: 'https://mint.example/note',
commentAllowed: 64,
metadata: '[]', metadata: '[]',
}, },
], ],
// amount-less invoice: the kit skips its amount cross-check // amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }], ['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
]) ]);
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch}) const prepared = await prepareMint('mint@mint.example', 21_000, {
expect(prepared.withdrawLink).toBe('https://mint.example/note') fetch,
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toBe('https://mint.example/note');
// the mint-address payLink is authoritative - the payRequest came from it // 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', () => { 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( expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe(
buildNoteUrl('https://mint.example/note', k1, 21_000), buildNoteUrl('https://mint.example/note', k1, 21_000),
) );
}) });
}) });
describe('quote output-naming wire contract', () => { describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({ const payRequestFixture = (extra: Record<string, unknown>) => ({
@@ -194,60 +217,90 @@ describe('quote output-naming wire contract', () => {
withdrawLink: 'https://mint.example/note', withdrawLink: 'https://mint.example/note',
metadata: '[]', metadata: '[]',
...extra, ...extra,
}) });
const routesFor = (payRequest: Record<string, unknown>) => const routesFor = (payRequest: Record<string, unknown>) =>
[ [
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture], ['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest], ['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check // amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }], ['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
] as const ] as const;
const callbackUrl = (seen: string[]): URL => { const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?')) const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'));
if (!hit) throw new Error('the quote request never hit the wire') if (!hit) throw new Error('the quote request never hit the wire');
return new URL(hit) return new URL(hit);
} };
it('sends no comment/h to a mint that never advertised output naming', async () => { it('rejects a mint that cannot carry the mandatory output commitment', async () => {
const seen: string[] = [] const seen: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, { await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen), fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
}) persistOutput,
expect(prepared.mode).toBe('unnamed') allocateOutputSecrets,
expect(prepared.noteSecret).toBeUndefined() }),
// an old mint sees the byte-identical request this wallet always sent ).rejects.toThrow(/64-character output commitment/i);
const url = callbackUrl(seen) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
expect(url.searchParams.get('comment')).toBeNull() });
expect(url.searchParams.get('h')).toBeNull()
})
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => { it('persists the wallet output before sending its hash as comment and h', async () => {
const seen: string[] = [] const seen: string[] = [];
const events: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, { const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), allocateOutputSecrets,
}) fetch: (input, init) => {
expect(prepared.mode).toBe('named') const url =
const noteSecret = prepared.noteSecret typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!noteSecret) throw new Error('named mode without a note secret') events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery');
const url = callbackUrl(seen) 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 // both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment') const comment = url.searchParams.get('comment');
const h = url.searchParams.get('h') const h = url.searchParams.get('h');
expect(comment).toMatch(/^[0-9a-f]{64}$/) expect(comment).toMatch(/^[0-9a-f]{64}$/);
expect(h).toBe(comment) expect(h).toBe(comment);
expect(comment).toBe(hashK1(noteSecret)) 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('names the output when only the mintToHash spelling is advertised', async () => { it('does not request an invoice when output persistence fails', async () => {
const seen: string[] = [] const seen: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, { 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), fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
}) persistOutput,
expect(prepared.mode).toBe('named') allocateOutputSecrets,
const noteSecret = prepared.noteSecret }),
if (!noteSecret) throw new Error('named mode without a note secret') ).rejects.toThrow(/64-character output commitment/i);
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) 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');
});
});
+104 -75
View File
@@ -9,33 +9,36 @@
// //
// Three d-tags carry three payloads in separate replaceable slots, so a // Three d-tags carry three payloads in separate replaceable slots, so a
// notes publish never clobbers settings: // notes publish never clobbers settings:
// notes - the encrypted bearer records exactly as they sit in // notes - the funds projection: encrypted bearer records exactly as
// localStorage (each note is already an AES-GCM ciphertext // they sit in the funds document (each note is already an
// under the seed-derived bearer key, so the blob goes up // AES-GCM ciphertext under the seed-derived bearer key, so the
// as-is; the linking key itself is NEVER part of a payload - // blob goes up as-is; the linking key itself is NEVER part of a
// the seed phrase is its recovery path) // 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 // mints - the trusted-mint registry
// settings - plaintext wallet settings // settings - plaintext wallet settings
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {utf8ToBytes} from '@noble/hashes/utils.js' import { utf8ToBytes } from '@noble/hashes/utils.js';
import type {Event as NostrEvent} from 'nostr-tools/core' import type { Event as NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import type {EncryptedBearerRecord} from '../storage/bearers' import type { EncryptedBearerRecord } from '../storage/bearers';
import type {TrustedMint} from '../trustedMints' import type { TrustedMint } from '../trustedMints';
import type {WalletSettings} from '../storage/settings' import type { WalletSettings } from '../storage/settings';
export type {NostrEvent} export type { NostrEvent };
// addressable (parametrized-replaceable) app-data event - relays keep only // addressable (parametrized-replaceable) app-data event - relays keep only
// the newest event per (pubkey, kind, d-tag) // 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 // the backup key is dedicated to this wallet (derived with a
// sattle-specific context, see below), so its pubkey is ours alone and the // 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', notes: 'notes',
mints: 'mints', mints: 'mints',
settings: 'settings', settings: 'settings',
} };
// the notes payload: bearers plus the counter map, from one consistent
// funds-document read
export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number>;
};
// the decrypted payload of each part, without its envelope // the decrypted payload of each part, without its envelope
export type BackupPartPayload = { export type BackupPartPayload = {
notes: EncryptedBearerRecord[] notes: NotesBackupPayload;
mints: TrustedMint[] mints: TrustedMint[];
settings: WalletSettings 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 // Deterministic: sha256(linking key || context), mirroring keys.ts's
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for // deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
// backup - it signs and decrypts backup events, nothing else. // backup - it signs and decrypts backup events, nothing else.
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array => 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 // 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 // NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else // pubkey - decryptable by the seed holder and nobody else
const selfConversationKey = (secretKey: Uint8Array): Uint8Array => 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 // 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 // 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 // kilobytes per part - this is far beyond generous (applyBackup's own
// per-record bounds still apply on top after decrypt) // 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 => { const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) { switch (part) {
case 'notes': case 'notes': {
return JSON.stringify({version: 1, bearers: payload}) const notes = payload as NotesBackupPayload;
return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
}
case 'mints': case 'mints':
return JSON.stringify({version: 1, trustedMints: payload}) return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings': case 'settings':
return JSON.stringify({version: 1, settings: payload}) return JSON.stringify({ version: 1, settings: payload });
}
} }
};
// signs one addressable backup event for a single part // signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>( export const buildBackupEvent = <P extends BackupPart>(
@@ -103,7 +125,7 @@ export const buildBackupEvent = <P extends BackupPart>(
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)), content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
}, },
secretKey, secretKey,
) );
// one event per part present in `parts`, all sharing one timestamp // one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = ( export const buildBackupEvents = (
@@ -111,33 +133,33 @@ export const buildBackupEvents = (
parts: Partial<BackupPartPayload>, parts: Partial<BackupPartPayload>,
createdAt?: number, createdAt?: number,
): NostrEvent[] => { ): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000) const at = createdAt ?? Math.floor(Date.now() / 1000);
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) { for (const part of BACKUP_PARTS) {
const payload = parts[part] const payload = parts[part];
if (payload === undefined) continue if (payload === undefined) continue;
events.push(buildBackupEvent(secretKey, part, payload, at)) events.push(buildBackupEvent(secretKey, part, payload, at));
}
return events
} }
return events;
};
export type ParsedBackupEvent = export type ParsedBackupEvent =
| {part: 'notes'; bearers: EncryptedBearerRecord[]} | { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
| { part: 'mints'; trustedMints: TrustedMint[] } | { part: 'mints'; trustedMints: TrustedMint[] }
| {part: 'settings'; settings: WalletSettings} | { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds // Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, pubkey patterns) are enforced by // (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file // applyBackup / commitFundsRestore on the restore path, same as file
// backups. // backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => { const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null if (typeof data !== 'object' || data === null) return null;
const envelope = data as Record<string, unknown> const envelope = data as Record<string, unknown>;
if (envelope.version !== 1) return null if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) { switch (dTag) {
case 'notes': { case 'notes': {
if (!Array.isArray(envelope.bearers)) return null if (!Array.isArray(envelope.bearers)) return null;
const bearers = envelope.bearers as unknown[] const bearers = envelope.bearers as unknown[];
if ( if (
!bearers.every( !bearers.every(
(r) => (r) =>
@@ -146,13 +168,20 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string', typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
) )
) { ) {
return null return null;
} }
return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]} if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
const counters = envelope.nextByHost as Record<string, unknown>;
if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
return {
part: 'notes',
bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>,
};
} }
case 'mints': { case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null if (!Array.isArray(envelope.trustedMints)) return null;
const mints = envelope.trustedMints as unknown[] const mints = envelope.trustedMints as unknown[];
if ( if (
!mints.every( !mints.every(
(m) => (m) =>
@@ -160,22 +189,22 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (m as TrustedMint)?.mintPubkey === 'string', typeof (m as TrustedMint)?.mintPubkey === 'string',
) )
) { ) {
return null return null;
} }
return {part: 'mints', trustedMints: mints as TrustedMint[]} return { part: 'mints', trustedMints: mints as TrustedMint[] };
} }
case 'settings': { case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) { 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') { 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 // 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, // isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
@@ -185,23 +214,23 @@ export const parseBackupEvent = (
secretKey: Uint8Array, secretKey: Uint8Array,
event: NostrEvent, event: NostrEvent,
): ParsedBackupEvent | null => { ): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null if (event.kind !== BACKUP_EVENT_KIND) return null;
if (event.pubkey !== getPublicKey(secretKey)) return null if (event.pubkey !== getPublicKey(secretKey)) return null;
const dTag = dTagOf(event) const dTag = dTagOf(event);
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
if (!verifyEvent(event)) return null if (!verifyEvent(event)) return null;
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
let plaintext: string let plaintext: string;
try { try {
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey)) plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch { } catch {
return null return null;
} }
let data: unknown let data: unknown;
try { try {
data = JSON.parse(plaintext) data = JSON.parse(plaintext);
} catch { } 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 // edits (a mint plus a receive plus a reorder) collapse into one publish
// of the final state. Trailing-edge debounce with in-flight coalescing. // of the final state. Trailing-edge debounce with in-flight coalescing.
import type {BackupPartPayload} from './events' import type { BackupPartPayload } from './events';
export type BackupPublisher = { export type BackupPublisher = {
// record that local state changed - the latest snapshot wins, and only // record that local state changed - the latest snapshot wins, and only
// one publish fires per quiet window no matter how many changes landed // 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) // publish any pending snapshot immediately (app backgrounding, logout)
flush: () => Promise<void> flush: () => Promise<void>;
// drop any pending snapshot without publishing // drop any pending snapshot without publishing
cancel: () => void cancel: () => void;
} };
export type BackupPublisherOptions = { export type BackupPublisherOptions = {
publish: (parts: Partial<BackupPartPayload>) => Promise<void> publish: (parts: Partial<BackupPartPayload>) => Promise<void>;
delayMs: number delayMs: number;
// a debounced publish has no caller to throw to - errors surface here; // a debounced publish has no caller to throw to - errors surface here;
// the next scheduled change retries // the next scheduled change retries
onError?: (error: unknown) => void onError?: (error: unknown) => void;
} };
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => { export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Partial<BackupPartPayload> | null = null let pending: Partial<BackupPartPayload> | null = null;
let running: Promise<void> | null = null let running: Promise<void> | null = null;
const clearTimer = (): void => { const clearTimer = (): void => {
if (timer !== null) { if (timer !== null) {
clearTimeout(timer) clearTimeout(timer);
timer = null timer = null;
}
} }
};
const drain = async (): Promise<void> => { const drain = async (): Promise<void> => {
// a change can land mid-publish - keep looping until the newest // a change can land mid-publish - keep looping until the newest
// snapshot is the one that's out // snapshot is the one that's out
while (pending !== null) { while (pending !== null) {
const snapshot = pending const snapshot = pending;
pending = null pending = null;
try { try {
await options.publish(snapshot) await options.publish(snapshot);
} catch (error) { } catch (error) {
options.onError?.( 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> => { const fire = (): Promise<void> => {
clearTimer() clearTimer();
// an in-flight drain picks up anything pending itself - a second drain // an in-flight drain picks up anything pending itself - a second drain
// would double-publish the same snapshot // would double-publish the same snapshot
running ??= drain().finally(() => { running ??= drain().finally(() => {
running = null running = null;
}) });
return running return running;
} };
return { return {
schedule: (parts) => { schedule: (parts) => {
pending = parts pending = parts;
clearTimer() clearTimer();
timer = setTimeout(() => void fire(), options.delayMs) timer = setTimeout(() => void fire(), options.delayMs);
}, },
flush: fire, flush: fire,
cancel: () => { cancel: () => {
clearTimer() clearTimer();
pending = null pending = null;
}, },
} };
} };
+62 -61
View File
@@ -2,17 +2,17 @@
// //
// v1 is single-device last-writer-wins at the relay (addressable events // v1 is single-device last-writer-wins at the relay (addressable events
// replace). Restore merges locally through storage/backup.ts's applyBackup // replace). Restore merges locally through storage/backup.ts's applyBackup
// - the SAME entry point as file restore (union by record id, mints merged // - the SAME entry point as file restore (union by record id, counters
// unconfirmed, settings fill-only) - so the two restore paths can't drift // merged upward-only, mints merged unconfirmed, settings fill-only) - so the
// apart. The note-level dedupe (same note under different record ids, // two restore paths can't drift apart. The note-level dedupe (same note
// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly // under different record ids, spent-wins) happens after decrypt in
// as with a file backup. // bearers.ts's mergeBearers, exactly as with a file backup.
import type {RestoreResult} from '../storage/backup' import type { RestoreResult } from '../storage/backup';
import {applyBackup} from '../storage/backup' import { applyBackup } from '../storage/backup';
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {BackupPart, BackupPartPayload, NostrEvent} from './events' import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_PARTS, BACKUP_PARTS,
@@ -21,18 +21,18 @@ import {
deriveBackupKey, deriveBackupKey,
dTagOf, dTagOf,
parseBackupEvent, parseBackupEvent,
} from './events' } from './events';
import type {BackupTransport} from './transport' import type { BackupTransport } from './transport';
import {defaultTransport} from './transport' import { defaultTransport } from './transport';
export type PublishBackupOptions = { export type PublishBackupOptions = {
transport?: BackupTransport transport?: BackupTransport;
createdAt?: number createdAt?: number;
} };
export type PublishBackupResult = { export type PublishBackupResult = {
published: BackupPart[] published: BackupPart[];
} };
// builds and publishes one event per part present in `parts` // builds and publishes one event per part present in `parts`
export const publishBackup = async ( export const publishBackup = async (
@@ -41,26 +41,26 @@ export const publishBackup = async (
relays: string[], relays: string[],
options: PublishBackupOptions = {}, options: PublishBackupOptions = {},
): Promise<PublishBackupResult> => { ): Promise<PublishBackupResult> => {
const at = options.createdAt ?? Math.floor(Date.now() / 1000) const at = options.createdAt ?? Math.floor(Date.now() / 1000);
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined) const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
if (present.length === 0) return {published: []} if (present.length === 0) return { published: [] };
const transport = options.transport ?? (await defaultTransport()) const transport = options.transport ?? (await defaultTransport());
const published: BackupPart[] = [] const published: BackupPart[] = [];
for (const part of present) { for (const part of present) {
const payload = parts[part] const payload = parts[part];
if (payload === undefined) continue if (payload === undefined) continue;
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at)) await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
published.push(part) published.push(part);
}
return {published}
} }
return { published };
};
export type FetchBackupOptions = { export type FetchBackupOptions = {
// the pubkey alone can fetch the ciphertext, but only the key holder // the pubkey alone can fetch the ciphertext, but only the key holder
// reads it - decryption is part of fetching // reads it - decryption is part of fetching
secretKey: Uint8Array secretKey: Uint8Array;
transport?: BackupTransport transport?: BackupTransport;
} };
// fetches the newest valid event per d-tag and decrypts it. Honest relays // fetches the newest valid event per d-tag and decrypts it. Honest relays
// already replace addressable events, but a stale or misbehaving relay may // already replace addressable events, but a stale or misbehaving relay may
@@ -71,49 +71,49 @@ export const fetchBackup = async (
relays: string[], relays: string[],
options: FetchBackupOptions, options: FetchBackupOptions,
): Promise<Partial<BackupPartPayload>> => { ): Promise<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport()) const transport = options.transport ?? (await defaultTransport());
const events = await transport.fetch(relays, { const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND], kinds: [BACKUP_EVENT_KIND],
authors: [pubkey], authors: [pubkey],
}) });
const byTag = new Map<string, NostrEvent[]>() const byTag = new Map<string, NostrEvent[]>();
for (const event of events) { for (const event of events) {
const d = dTagOf(event) const d = dTagOf(event);
if (!d) continue if (!d) continue;
const candidates = byTag.get(d) const candidates = byTag.get(d);
if (candidates) candidates.push(event) if (candidates) candidates.push(event);
else byTag.set(d, [event]) else byTag.set(d, [event]);
} }
const parts: Partial<BackupPartPayload> = {} const parts: Partial<BackupPartPayload> = {};
for (const candidates of byTag.values()) { for (const candidates of byTag.values()) {
// a hostile relay may serve a tampered "newest" copy - walk // a hostile relay may serve a tampered "newest" copy - walk
// newest-first and take the first that validates and decrypts // 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) { for (const event of candidates) {
const parsed = parseBackupEvent(options.secretKey, event) const parsed = parseBackupEvent(options.secretKey, event);
if (!parsed) continue if (!parsed) continue;
switch (parsed.part) { switch (parsed.part) {
case 'notes': case 'notes':
parts.notes = parsed.bearers parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
break break;
case 'mints': case 'mints':
parts.mints = parsed.trustedMints parts.mints = parsed.trustedMints;
break break;
case 'settings': case 'settings':
parts.settings = parsed.settings parts.settings = parsed.settings;
break break;
} }
break break;
} }
} }
return parts return parts;
} };
export type NostrRestoreResult = RestoreResult & { export type NostrRestoreResult = RestoreResult & {
// which d-tags carried a valid payload at all - lets the caller // which d-tags carried a valid payload at all - lets the caller
// distinguish "nothing backed up yet" from "restored an empty wallet" // 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 // 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), // key (the holder already re-entered the seed phrase to get that far),
@@ -126,24 +126,25 @@ export const restoreFromNostr = async (
relays: string[], relays: string[],
options: { transport?: BackupTransport } = {}, options: { transport?: BackupTransport } = {},
): Promise<NostrRestoreResult> => { ): Promise<NostrRestoreResult> => {
const secretKey = deriveBackupKey(linkingPrivKey) const secretKey = deriveBackupKey(linkingPrivKey);
const parts = await fetchBackup(backupPubkey(secretKey), relays, { const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey, secretKey,
transport: options.transport, transport: options.transport,
}) });
const result = await applyBackup( const result = await applyBackup(
{ {
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: Date.now(), createdAt: Date.now(),
bearers: parts.notes ?? [], bearers: parts.notes?.bearers ?? [],
nextByHost: parts.notes?.nextByHost ?? {},
trustedMints: parts.mints, trustedMints: parts.mints,
settings: parts.settings, settings: parts.settings,
}, },
linkingPubKeyHex(linkingPrivKey), linkingPubKeyHex(linkingPrivKey),
) );
return { return {
...result, ...result,
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined), 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 // lazily: merely importing the backup module must never open (or even
// reference) a WebSocket. // 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 // publish throws when the event was accepted NOWHERE
export type BackupTransport = { export type BackupTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void> publish: (relays: string[], event: NostrEvent) => Promise<void>;
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]> fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>;
} };
export const defaultTransport = async (): Promise<BackupTransport> => { export const defaultTransport = async (): Promise<BackupTransport> => {
const {SimplePool} = await import('nostr-tools/pool') const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool() const pool = new SimplePool();
return { return {
publish: async (relays, event) => { 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 // one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway // are re-publishable, and the next debounced publish retries anyway
if (!results.some((r) => r.status === 'fulfilled')) { 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), fetch: (relays, filter) => pool.querySync(relays, filter),
} };
} };
+99 -76
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // 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 => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,105 +78,107 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!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 // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('buildBackupEvent / parseBackupEvent', () => { describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const records = [ const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in // long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext // base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below // assertion below
{ id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }, { 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 notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
const settings = {defaultMint: 'mint.example'} 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', () => { it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
expect(events).toHaveLength(3) expect(events).toHaveLength(3);
expect(events.map((e) => e.kind)).toEqual([ expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
]) ]);
expect(events.map((e) => e.tags)).toEqual([ expect(events.map((e) => e.tags)).toEqual([
[['d', 'notes']], [['d', 'notes']],
[['d', 'mints']], [['d', 'mints']],
[['d', 'settings']], [['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({ expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes', part: 'notes',
bearers: records, bearers: records,
}) nextByHost: { 'mint.example': 3 },
});
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints', part: 'mints',
trustedMints: mints, trustedMints: mints,
}) });
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings', part: 'settings',
settings, settings,
}) });
}) });
it('leaves no plaintext in the payload', () => { it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', records) const event = buildBackupEvent(secretKey, 'notes', notes);
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
expect(event.content).not.toContain('ciphertext') expect(event.content).not.toContain('ciphertext');
}) });
it('builds events only for the parts present', () => { it('builds events only for the parts present', () => {
const events = buildBackupEvents(secretKey, {settings}, 1000) const events = buildBackupEvents(secretKey, { settings }, 1000);
expect(events).toHaveLength(1) expect(events).toHaveLength(1);
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]) expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
}) });
it('rejects a payload encrypted for a different key', () => { it('rejects a payload encrypted for a different key', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull() expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
}) });
it('rejects the wrong kind', () => { it('rejects the wrong kind', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull() expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
}) });
it('rejects an unknown d-tag', () => { it('rejects an unknown d-tag', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull() expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
}) });
it('rejects a modified ciphertext - the signature no longer matches', () => { it('rejects a modified ciphertext - the signature no longer matches', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
const tampered = {...event, content: tamperContent(event.content)} const tampered = { ...event, content: tamperContent(event.content) };
expect(parseBackupEvent(secretKey, tampered)).toBeNull() expect(parseBackupEvent(secretKey, tampered)).toBeNull();
}) });
it('rejects an event signed by a different key', () => { it('rejects an event signed by a different key', () => {
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings) const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
expect(parseBackupEvent(secretKey, foreign)).toBeNull() expect(parseBackupEvent(secretKey, foreign)).toBeNull();
}) });
it('rejects a validly signed event whose payload is not a backup envelope', () => { 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 // a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope // 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( const event = finalizeEvent(
{ {
kind: BACKUP_EVENT_KIND, kind: BACKUP_EVENT_KIND,
@@ -185,7 +187,28 @@ describe('buildBackupEvent / parseBackupEvent', () => {
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey), content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
}, },
secretKey, 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 // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // 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 => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,21 +78,21 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!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 // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('deriveBackupKey', () => { describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => { it('derives a stable key from the linking key', () => {
@@ -101,12 +101,12 @@ describe('deriveBackupKey', () => {
// derive a different pubkey and find nothing to restore // derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe( expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9', 'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
) );
}) });
it('derives a different key from a different linking key', () => { it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe( expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY)), bytesToHex(deriveBackupKey(LINKING_KEY)),
) );
}) });
}) });
+105 -102
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // 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 => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,134 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!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 // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('createBackupPublisher', () => { describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => { it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.schedule({settings: {defaultMint: 'c'}}) publisher.schedule({ settings: { defaultMint: 'c' } });
await vi.advanceTimersByTimeAsync(999) await vi.advanceTimersByTimeAsync(999);
expect(published).toEqual([]) expect(published).toEqual([]);
await vi.advanceTimersByTimeAsync(1) await vi.advanceTimersByTimeAsync(1);
expect(published).toEqual([{settings: {defaultMint: 'c'}}]) expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
}) });
it('publishes again when a change lands after the quiet window', async () => { it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}]) expect(published).toEqual([
}) { settings: { defaultMint: 'a' } },
{ settings: { defaultMint: 'b' } },
]);
});
it('publishes a snapshot that lands mid-publish instead of losing it', async () => { it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
// the publish callback re-schedules on the publisher being created - // the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const // a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {} const holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
// a local change lands while the first publish is in flight // a local change lands while the first publish is in flight
if (published.length === 1) { 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, delayMs: 1000,
}) });
holder.publisher = publisher holder.publisher = publisher;
publisher.schedule({settings: {defaultMint: 'first'}}) publisher.schedule({ settings: { defaultMint: 'first' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([ expect(published).toEqual([
{ settings: { defaultMint: 'first' } }, { settings: { defaultMint: 'first' } },
{ settings: { defaultMint: 'mid-flight' } }, { settings: { defaultMint: 'mid-flight' } },
]) ]);
}) });
it('flush publishes immediately; cancel drops the pending snapshot', async () => { it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 60_000, delayMs: 60_000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await publisher.flush() await publisher.flush();
expect(published).toEqual([{settings: {defaultMint: 'a'}}]) expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.cancel() publisher.cancel();
await vi.advanceTimersByTimeAsync(60_000) await vi.advanceTimersByTimeAsync(60_000);
expect(published).toHaveLength(1) expect(published).toHaveLength(1);
}) });
it('reports a failed publish via onError and retries on the next change', async () => { it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const errors: unknown[] = [] const errors: unknown[] = [];
let failing = true let failing = true;
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
if (failing) return Promise.reject(new Error('relay down')) if (failing) return Promise.reject(new Error('relay down'));
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
onError: (e) => { onError: (e) => {
errors.push(e) errors.push(e);
}, },
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([]) expect(published).toEqual([]);
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1);
failing = false failing = false;
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{settings: {defaultMint: 'b'}}]) expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
}) });
}) });
+176 -97
View File
@@ -2,14 +2,19 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import {
deriveBearerAesKey,
deriveWalletMaterial,
linkingPubKeyHex,
saveWalletMaterial,
} from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +26,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +35,30 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' readNextByHost,
import type {Bearer} from './types' } from './storage';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { readFundsDocument, writeFundsDocument } from './storage/bearers';
import {requiredValue, stubLocalStorage} from './test-utils' 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 LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) 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_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // 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 => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +69,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,146 +92,211 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!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 // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('restoreFromNostr', () => { describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => { it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
// device A: one note, one trusted mint, one setting - all published // device A: one note, one trusted mint, one setting - all published
await saveLinkingKey(LINKING_KEY) await saveWalletMaterial(MATERIAL);
await persistBearer(aesKey, bearerFixture({id: 'note-a'})) await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID}) await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
persistSettings({defaultMint: 'mint.example'}) persistSettings({ defaultMint: 'mint.example' });
await publishBackup( await publishBackup(
secretKey, secretKey,
{ {
notes: readEncryptedBearers(), notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
mints: readTrustedMints(OWNER_ID), mints: readTrustedMints(OWNER_ID),
settings: loadSettings(), settings: loadSettings(),
}, },
RELAYS, RELAYS,
{ transport }, { transport },
) );
// device B: the same seed on empty storage // device B: the same seed on empty storage
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveWalletMaterial(MATERIAL);
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes', 'mints', 'settings']) expect(result.found).toEqual(['notes', 'mints', 'settings']);
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect(result.trustedMintsAdded).toBe(1) expect(result.trustedMintsAdded).toBe(1);
expect(result.settingsRestored).toBe(true) expect(result.settingsRestored).toBe(true);
// the linking key is never part of a nostr backup - the seed phrase // the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path // 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 // the note decrypts under this device's bearer key - same seed
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
expect(loadSettings()).toEqual({defaultMint: 'mint.example'}) expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
// a file/backup-sourced mint pin stays unconfirmed until a live // a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from // response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged // 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 () => { it('unions records by id and lets a spent copy win after decrypt', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
// device A publishes its store holding the spendable note // device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport, transport,
createdAt: 1000, createdAt: 1000,
}) },
);
// device B restores, then marks the same note spent under its OWN // device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store // record id, and republishes its full store
stubLocalStorage() stubLocalStorage();
await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport, transport,
createdAt: 2000, createdAt: 2000,
}) },
);
// device C restores from the final published state // device C restores from the final published state
stubLocalStorage() stubLocalStorage();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed // union by record id: both copies landed
expect(result.added).toBe(2) expect(result.added).toBe(2);
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['rec-a', 'rec-b']) ).toEqual(['rec-a', 'rec-b']);
// after decrypt, the note-level merge (same server + k1) collapses // after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer // them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money // arrival - a restored backup must never resurrect spendable money
const merged = mergeBearers([], await loadBearers(aesKey)) const merged = mergeBearers([], await loadBearers(aesKey));
expect(merged).toHaveLength(1) expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('rec-b') expect(requiredValue(merged[0]).id).toBe('rec-b');
expect(requiredValue(merged[0]).spent).toBe(true) expect(requiredValue(merged[0]).spent).toBe(true);
}) });
it('never overwrites local state: records union, settings keep local values', async () => { it('never overwrites local state: records union, settings keep local values', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
await persistBearer(aesKey, bearerFixture({id: 'remote'})) await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
persistSettings({defaultMint: 'remote.example'}) persistSettings({ defaultMint: 'remote.example' });
await publishBackup( await publishBackup(
secretKey, secretKey,
{notes: readEncryptedBearers(), settings: loadSettings()}, {
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
settings: loadSettings(),
},
RELAYS, RELAYS,
{ transport, createdAt: 1000 }, { transport, createdAt: 1000 },
) );
// this device already has its own wallet state // this device already has its own wallet state
stubLocalStorage() stubLocalStorage();
await persistBearer( await persistBearer(
aesKey, aesKey,
bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }), bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
) );
persistSettings({defaultMint: 'local.example'}) persistSettings({ defaultMint: 'local.example' });
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['local', 'remote']) ).toEqual(['local', 'remote']);
expect(result.settingsRestored).toBe(false) expect(result.settingsRestored).toBe(false);
expect(loadSettings()).toEqual({defaultMint: 'local.example'}) expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
}) });
it('reports nothing found when the relays hold no backup', async () => { it('reports nothing found when the relays hold no backup', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual([]) expect(result.found).toEqual([]);
expect(result.added).toBe(0) 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.keys.cases';
import './nostrBackup.codec.cases' import './nostrBackup.codec.cases';
import './nostrBackup.transport.cases' import './nostrBackup.transport.cases';
import './nostrBackup.restore.cases' import './nostrBackup.restore.cases';
import './nostrBackup.publisher.cases' import './nostrBackup.publisher.cases';
+65 -62
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // 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 => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,83 +78,86 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!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 // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('publishBackup / fetchBackup', () => { describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => { it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
const parts = { const parts = {
notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], 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 }], mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
settings: { defaultMint: 'mint.example' }, settings: { defaultMint: 'mint.example' },
} };
const published = await publishBackup(secretKey, parts, RELAYS, {transport}) const published = await publishBackup(secretKey, parts, RELAYS, { transport });
expect(published.published).toEqual(['notes', 'mints', 'settings']) expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, transport,
}) });
expect(fetched).toEqual(parts) expect(fetched).toEqual(parts);
}) });
it('publishes nothing when no parts are given', async () => { it('publishes nothing when no parts are given', async () => {
const {transport, events} = createRecordingTransport() const { transport, events } = createRecordingTransport();
const result = await publishBackup(secretKey, {}, RELAYS, {transport}) const result = await publishBackup(secretKey, {}, RELAYS, { transport });
expect(result.published).toEqual([]) expect(result.published).toEqual([]);
expect(events).toEqual([]) expect(events).toEqual([]);
}) });
it('picks the newest event per d-tag when a relay serves stale copies', async () => { it('picks the newest event per d-tag when a relay serves stale copies', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport, transport,
createdAt: 1000, createdAt: 1000,
}) });
await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
transport, transport,
createdAt: 2000, createdAt: 2000,
}) });
// the recording transport serves BOTH - the newer must win // the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, 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 () => { it('falls back to an older valid copy when the newest event is tampered', async () => {
const {transport, events} = createRecordingTransport() const { transport, events } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport, transport,
createdAt: 1000, createdAt: 1000,
}) });
const latest = requiredValue(events[0]) const latest = requiredValue(events[0]);
events.push({ events.push({
...latest, ...latest,
content: tamperContent(latest.content), content: tamperContent(latest.content),
created_at: 3000, created_at: 3000,
}) });
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, 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, buildBackupEvent,
buildBackupEvents, buildBackupEvents,
parseBackupEvent, parseBackupEvent,
} from './nostr/events' } from './nostr/events';
export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} 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 { export type {
PublishBackupOptions, PublishBackupOptions,
PublishBackupResult, PublishBackupResult,
FetchBackupOptions, FetchBackupOptions,
NostrRestoreResult, NostrRestoreResult,
} from './nostr/sync' } from './nostr/sync';
export {createBackupPublisher} from './nostr/publisher' export { createBackupPublisher } from './nostr/publisher';
export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher' export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
+46 -46
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,90 +55,90 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('connection strings', () => { describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => { it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [ const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
'wss://relay-a.example', 'wss://relay-a.example',
'wss://relay-b.example/path?q=1', 'wss://relay-b.example/path?q=1',
]) ]);
expect(uri).toBe( 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)}`, `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({ expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32), walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32), clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'], relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
}) });
}) });
it('rejects strings that are not connection strings', () => { it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull() expect(parseConnectionString('not a uri')).toBeNull();
expect(parseConnectionString('https://example.com')).toBeNull() expect(parseConnectionString('https://example.com')).toBeNull();
// missing secret // missing secret
expect( expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`), parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
).toBeNull() ).toBeNull();
// missing relay // missing relay
expect( expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`), parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
).toBeNull() ).toBeNull();
// a non-hex pubkey // a non-hex pubkey
expect( expect(
parseConnectionString( parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32), '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', () => { it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, { const connection = createConnection(LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 }, budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
}) });
const parsed = parseConnectionString(connection.connectionString) const parsed = parseConnectionString(connection.connectionString);
expect(parsed).toEqual({ expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey, walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32), clientSecret: '11'.repeat(32),
relays: RELAYS, relays: RELAYS,
}) });
// the record persisted WITHOUT the client secret - it is handed out // the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored // once, in the connection string, and never stored
const records = readNwcConnections(OWNER_ID) const records = readNwcConnections(OWNER_ID);
expect(records).toHaveLength(1) expect(records).toHaveLength(1);
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY) expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)) expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
}) });
}) });
describe('deriveNwcWalletKey', () => { describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => { it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d', '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
) );
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
) );
}) });
it('derives a distinct key per client and per linking key', () => { 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( expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
base, 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', () => { it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, { const first = createConnection(LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 }, budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
}) });
expect(first.walletServicePubkey).toBe( expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
) );
}) });
}) });
+24 -24
View File
@@ -4,29 +4,29 @@
// no longer possible. What must not happen is any stale-owner WRITE: // no longer possible. What must not happen is any stale-owner WRITE:
// no bearer changeset, no budget debit, no success response. // no bearer changeset, no budget debit, no success response.
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {readNwcConnections} from './nwc' import { readNwcConnections } from './nwc';
import { import {
methodRequest, methodRequest,
OTHER_LINKING_KEY, OTHER_LINKING_KEY,
OTHER_OWNER_ID, OTHER_OWNER_ID,
OWNER_ID, OWNER_ID,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: post-boundary stale-owner fencing', () => { describe('service: post-boundary stale-owner fencing', () => {
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => { 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 // Given a running service whose next mint call coincides with a second
// tab installing its own wallet (the first fetch is the melt itself: // tab installing its own wallet (the first fetch is the melt itself:
// the exact-match carve performs no mint call of its own) // the exact-match carve performs no mint call of its own)
const m = await mint() const m = await mint();
let swapped = false let swapped = false;
const swappingFetch: typeof fetch = (input, init) => { const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) { if (!swapped) {
swapped = true swapped = true;
localStorage.setItem( localStorage.setItem(
'sattle_linking_key', 'sattle_linking_key',
JSON.stringify({ JSON.stringify({
@@ -35,32 +35,32 @@ describe('service: post-boundary stale-owner fencing', () => {
ownerId: OTHER_OWNER_ID, ownerId: OTHER_OWNER_ID,
version: 1, version: 1,
}), }),
) );
}
return fetch(input, init)
} }
return fetch(input, init);
};
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
kit: { fetch: swappingFetch }, kit: { fetch: swappingFetch },
}) });
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
// When the pay flows past the irreversible boundary and reaches the // When the pay flows past the irreversible boundary and reaches the
// first owner-bound persistence (the conservative budget debit) // first owner-bound persistence (the conservative budget debit)
relay.emit(request) relay.emit(request);
await waitFor(() => state.errors.length > 0) await waitFor(() => state.errors.length > 0);
// Then the melt genuinely happened (we are past the boundary) ... // Then the melt genuinely happened (we are past the boundary) ...
expect(swapped).toBe(true) expect(swapped).toBe(true);
expect(m.state.noteState('d7'.repeat(32))).toBe('burned') expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
// ... but nothing under the stale owner moved: no budget debit, no // ... but nothing under the stale owner moved: no budget debit, no
// bearer changeset, no success response (the failure surfaced through // bearer changeset, no success response (the failure surfaced through
// onError instead) // onError instead)
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
expect(state.changesets).toEqual([]) expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
await stop() await stop();
}) });
}) });
+119 -226
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredString, requiredValue, stubLocalStorage} from './test-utils' import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,21 +55,36 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' 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', () => { describe('service: make_invoice / lookup_invoice', () => {
it('issues an invoice, settles it in the background, and reports the preimage', async () => { it('derives a safe retirement time from a signed BOLT11 invoice', () => {
const m = await mint({testHooks: true}) 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({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
description: 'nwc test', description: 'nwc test',
expiry: 3600, expiry: 3600,
}) });
expect(made.error).toBeNull() expect(made.error).toBeNull();
expect(made.result).toMatchObject({ expect(made.result).toMatchObject({
type: 'incoming', type: 'incoming',
state: 'pending', state: 'pending',
@@ -77,253 +92,131 @@ describe('service: make_invoice / lookup_invoice', () => {
description: 'nwc test', description: 'nwc test',
created_at: nowSeconds(), created_at: nowSeconds(),
expires_at: nowSeconds() + 3600, expires_at: nowSeconds() + 3600,
}) });
const invoice = requiredValue(made.result).invoice const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') { 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') { 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(invoice).toMatch(/^lnbc/);
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) 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 // before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(pending.error).toBeNull() expect(pending.error).toBeNull();
expect(pending.result?.state).toBe('pending') expect(pending.result?.state).toBe('pending');
expect(pending.result?.preimage).toBeUndefined() expect(pending.result?.preimage).toBeUndefined();
// the "payer" pays the invoice; the background claim settles and // the "payer" pays the invoice; the background claim settles and
// mints the note // mints the note
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleRes.ok).toBe(true) expect(settleRes.ok).toBe(true);
await waitFor(() => state.changesets.some((c) => c.add.length > 0)) await waitFor(() => state.bearers.some((bearer) => bearer.verified));
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(settled.error).toBeNull() expect(settled.error).toBeNull();
expect(settled.result?.state).toBe('settled') expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds()) expect(settled.result?.settled_at).toBe(nowSeconds());
const preimage = requiredString(settled.result?.preimage) const preimage = requiredString(settled.result?.preimage);
expect(preimage).toMatch(/^[0-9a-f]{64}$/) expect(preimage).toMatch(/^[0-9a-f]{64}$/);
// the minted note was claimed AND rotated before settlement was // The staged record was updated in place before settlement became
// recorded: the preimage the client just learned is a burned secret, // visible. The payment preimage is only a receipt and keys no note.
// and the wallet's fresh note is the only live one expect(m.state.noteState(preimage)).toBeNull();
expect(m.state.noteState(preimage)).toBe('burned') const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) expect(minted.amount).toBe(21_000);
expect(minted.amount).toBe(21_000) expect(minted.verified).toBe(true);
expect(minted.verified).toBe(true) expect(noteK1(minted.url)).not.toBe(preimage);
expect(noteK1(minted.url)).not.toBe(preimage) expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding') await stop();
await stop() });
})
it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => {
const m = await mint({commentAllowed: 64, 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,
})
expect(made.error).toBeNull()
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
// a named mint's verify preimage is an ordinary payment receipt -
// it keys nothing at the mint - so it is always safe to hand out
const preimage = requiredString(settled.result?.preimage)
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
expect(m.state.noteState(preimage)).toBeNull()
// the note was claimed at the wallet's own secret - unrotated,
// because nothing exposed it
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
const mintedK1 = requiredValue(noteK1(minted.url))
expect(m.state.noteState(mintedK1)).toBe('outstanding')
// the quote was bound to that secret's hash
expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1))
await stop()
})
it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => {
// the mintToHashIgnoresH adversary: advertises output naming, binds
// nothing, credits the payment hash - and (non-compliantly) still
// serves verify, so the preimage path can recover the note
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// the rescue claimed through the preimage path and rotated off it, so
// the receipt handed to the client is a burned secret
const preimage = requiredString(settled.result?.preimage)
expect(m.state.noteState(preimage)).toBe('burned')
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
const mintedK1 = requiredValue(noteK1(minted.url))
expect(mintedK1).not.toBe(preimage)
expect(m.state.noteState(mintedK1)).toBe('outstanding')
await stop()
})
it('withholds the preimage when the rescue claim could not rotate', async () => {
// same lying mint, but the rotate's request never lands (the mint
// dies between the claim and the rotate): the preimage may still be
// the note's secret, so it stays a secret even though the invoice
// reads settled
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const rotateFailingFetch: 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 {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
kit: {fetch: rotateFailingFetch},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// settled, but NO receipt: an unrotated preimage may still be the note
expect(settled.result?.preimage).toBeUndefined()
// the note is tracked either way (it IS money)
expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true)
await stop()
})
it('keeps a paid invoice pending while its bearer commit is deferred', async () => { it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { 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}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(pending.result?.state).toBe('pending') expect(pending.result?.state).toBe('pending');
expect(pending.result?.settled_at).toBeUndefined() expect(pending.result?.settled_at).toBeUndefined();
expect(pending.result?.preimage).toBeUndefined() expect(pending.result?.preimage).toBeUndefined();
commit.resolve() commit.resolve();
await waitFor(() => state.changesets.length === 1) await waitFor(() => state.changesets.length === 1);
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(settled.result?.state).toBe('settled') expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds()) expect(settled.result?.settled_at).toBe(nowSeconds());
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/) expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
await stop() await stop();
}) });
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => { it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { 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}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
let stopped = false let stopped = false;
const firstStop = stop().then(() => { const firstStop = stop().then(() => {
stopped = true stopped = true;
return state.changesets.length return state.changesets.length;
}) });
const repeatedStop = stop() const repeatedStop = stop();
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
commit.resolve() commit.resolve();
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]) const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
expect(changesetsAtStop).toBe(1) expect(changesetsAtStop).toBe(1);
expect(state.changesets).toHaveLength(1) expect(state.changesets).toHaveLength(1);
}) });
}) });
+203 -117
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,187 +55,273 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' 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)', () => { describe('service: make_invoice / lookup_invoice (continued)', () => {
it('drains a rejected invoice settlement and reports it before stop resolves', async () => { it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
const commitError = new Error('invoice commit rejected during stop') const commitError = new Error('invoice commit rejected during stop');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { 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}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
let stopped = false let stopped = false;
const stopping = stop().then(() => { const stopping = stop().then(() => {
stopped = true stopped = true;
return state.errors.length return state.errors.length;
}) });
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
commit.reject(commitError) commit.reject(commitError);
expect(await stopping).toBe(1) expect(await stopping).toBe(1);
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
await stop() await stop();
}) });
it('does not block stop on an invoice whose claim is still polling', async () => { 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 // an unpaid invoice's claim poll can legally run for minutes (the
// client pays whenever it pays) - stop must interrupt the wait, not // client pays whenever it pays) - stop must interrupt the wait, not
// sit on it; a settlement that already REACHED the commit phase is // sit on it; a settlement that already REACHED the commit phase is
// still awaited (see the drain tests above) // still awaited (see the drain tests above)
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, 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', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000, amount: 5_000,
}) });
expect(made.error).toBeNull() expect(made.error).toBeNull();
// nobody pays the invoice; the claim keeps polling. stop must resolve // nobody pays the invoice; the claim keeps polling. stop must resolve
// promptly regardless (an un-interrupted stop would wait out the // promptly regardless (an un-interrupted stop would wait out the
// whole 60s claim budget) // whole 60s claim budget)
await stop() await stop();
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
expect(state.errors).toHaveLength(0) expect(state.errors).toHaveLength(0);
}) });
it('does not start invoice settlement after stop begins during preparation', async () => { it('does not start invoice settlement after stop begins during preparation', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const prepare = deferred() const prepare = deferred();
let prepareStarted = false let prepareStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
kit: { kit: {
fetch: async (input, init) => { fetch: async (input, init) => {
prepareStarted = true prepareStarted = true;
await prepare.promise await prepare.promise;
return fetch(input, init) return fetch(input, init);
}, },
}, },
}) });
const request = methodRequest(walletServicePubkey, 'make_invoice', { const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => prepareStarted) await waitFor(() => prepareStarted);
let stopped = false let stopped = false;
const stopping = stop().then(() => { const stopping = stop().then(() => {
stopped = true stopped = true;
}) });
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
prepare.resolve() prepare.resolve();
await stopping await stopping;
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
}) });
it('marks a paid invoice failed when its bearer commit rejects', async () => { it('marks a paid invoice failed when its bearer commit rejects', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
const commitError = new Error('invoice bearer commit failed') const commitError = new Error('invoice bearer commit failed');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { 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}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
commit.reject(commitError) commit.reject(commitError);
await waitFor(() => state.errors.length === 1) await waitFor(() => state.errors.length === 1);
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', { const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(failed.result?.state).toBe('failed') expect(failed.result?.state).toBe('failed');
expect(failed.result?.settled_at).toBeUndefined() expect(failed.result?.settled_at).toBeUndefined();
expect(failed.result?.preimage).toBeUndefined() expect(failed.result?.preimage).toBeUndefined();
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
await stop() await stop();
}) });
it('finds an invoice by its invoice string too', async () => { it('finds an invoice by its invoice string too', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, stop } = await startTestService({ const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, 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', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000, amount: 5_000,
}) });
const invoice = made.result?.invoice const invoice = made.result?.invoice;
if (typeof invoice !== 'string') { 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', { const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: invoice.toUpperCase(), invoice: invoice.toUpperCase(),
}) });
expect(found.error).toBeNull() expect(found.error).toBeNull();
expect(found.result?.payment_hash).toBe(made.result?.payment_hash) expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
await stop() await stop();
}) });
it('answers an unknown invoice with NOT_FOUND', async () => { 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', { const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32), payment_hash: 'ab'.repeat(32),
}) });
expect(response.error?.code).toBe('NOT_FOUND') expect(response.error?.code).toBe('NOT_FOUND');
await stop() await stop();
}) });
it('answers make_invoice without a default mint with INTERNAL', async () => { 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, defaultMint: null,
}) });
const response = await call(relay, walletServicePubkey, 'make_invoice', { const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
expect(response.error?.code).toBe('INTERNAL') expect(response.error?.code).toBe('INTERNAL');
await stop() 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 () => { 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', { const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5, amount: -5,
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
await stop() await stop();
}) });
}) });
+83 -61
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,8 +55,8 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service ownership', () => { describe('service ownership', () => {
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => { it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
const current = createConnection(LINKING_KEY, { const current = createConnection(LINKING_KEY, {
@@ -64,26 +64,37 @@ describe('service ownership', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 0, now: 0,
}) });
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
storeForeignConnection(foreign.record) storeForeignConnection(foreign.record);
const relay = createFakeRelay() const relay = createFakeRelay();
const service = await startService(LINKING_KEY, { const service = await startService(LINKING_KEY, {
assertCurrentOwner: () => undefined, assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [], getBearers: () => [],
getDefaultMint: () => null, 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(), applyChangeset: () => Promise.resolve(),
transport: relay.transport, transport: relay.transport,
nowSeconds, nowSeconds,
}) });
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1) expect(relay.subscriptionCount()).toBe(1);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
expect(service.connections).not.toContainEqual(foreign) expect(service.connections).not.toContainEqual(foreign);
await service.stop() await service.stop();
}) });
it('ignores foreign records handed in through the records snapshot', async () => { it('ignores foreign records handed in through the records snapshot', async () => {
// the injected-records path bypasses storage, so the service's own // the injected-records path bypasses storage, so the service's own
@@ -94,66 +105,77 @@ describe('service ownership', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 0, now: 0,
}) });
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
const relay = createFakeRelay() const relay = createFakeRelay();
const service = await startService( const service = await startService(
LINKING_KEY, LINKING_KEY,
{ {
assertCurrentOwner: () => undefined, assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [], getBearers: () => [],
getDefaultMint: () => null, 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(), applyChangeset: () => Promise.resolve(),
transport: relay.transport, transport: relay.transport,
nowSeconds, nowSeconds,
}, },
[current.record, foreign.record], [current.record, foreign.record],
) );
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1) expect(relay.subscriptionCount()).toBe(1);
// the foreign snapshot record must not be persisted for the new owner // the foreign snapshot record must not be persisted for the new owner
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]) expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
await service.stop() await service.stop();
}) });
}) });
describe('service: info and balance', () => { describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => { it('publishes a kind-13194 info event on startup', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND)) const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
expect(info.pubkey).toBe(walletServicePubkey) expect(info.pubkey).toBe(walletServicePubkey);
expect(info.content).toContain('pay_invoice') expect(info.content).toContain('pay_invoice');
expect(info.content).toContain('make_invoice') expect(info.content).toContain('make_invoice');
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']) expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
await stop() await stop();
}) });
it('answers get_info with the connection identity and method list', async () => { it('answers get_info with the connection identity and method list', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_info', {}) const response = await call(relay, walletServicePubkey, 'get_info', {});
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result_type).toBe('get_info') expect(response.result_type).toBe('get_info');
expect(response.result).toMatchObject({ expect(response.result).toMatchObject({
alias: 'sattle', alias: 'sattle',
pubkey: walletServicePubkey, pubkey: walletServicePubkey,
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'], 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 () => { it('answers get_balance with the spendable total only', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [ state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000), await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000), await makeBearer(m, 'bb'.repeat(32), 5_000),
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true }, { ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
] ];
const response = await call(relay, walletServicePubkey, 'get_balance', {}) const response = await call(relay, walletServicePubkey, 'get_balance', {});
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result).toEqual({balance: 26_000}) expect(response.result).toEqual({ balance: 26_000 });
await stop() await stop();
}) });
}) });
+114 -114
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredString, requiredValue, stubLocalStorage} from './test-utils' import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,159 +55,159 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice', () => { describe('service: pay_invoice', () => {
it('applies the settled changeset before publishing success', async () => { it('applies the settled changeset before publishing success', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(state.changesets).toHaveLength(1) expect(state.changesets).toHaveLength(1);
expect(state.bearers[0]?.spent).toBe(true) expect(state.bearers[0]?.spent).toBe(true);
await stop() await stop();
}) });
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000, budgetMsat: 50_000,
}) });
const foreign = foreignConnectionFixture( const foreign = foreignConnectionFixture(
{ maxMsat: 99_000, periodMs: 86_400_000 }, { maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000, nowSeconds() * 1000,
) );
storeForeignConnection(foreign.record) storeForeignConnection(foreign.record);
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(requiredString(response.result?.preimage)).toHaveLength(64) expect(requiredString(response.result?.preimage)).toHaveLength(64);
// the note is gone (melted) and locked spent via the changeset // the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned') expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
expect(requiredValue(state.bearers[0]).spent).toBe(true) expect(requiredValue(state.bearers[0]).spent).toBe(true);
// the spend was recorded against the budget, persisted // the spend was recorded against the budget, persisted
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000) expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
await stop() await stop();
}) });
it('waits for a deferred bearer commit before publishing payment success', async () => { it('waits for a deferred bearer commit before publishing payment success', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise 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', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
// a non-awaiting engine publishes within milliseconds of the commit // a non-awaiting engine publishes within milliseconds of the commit
// call (one local verify re-read + encrypt); this window is far wider // 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 // than that, so an early response here can only mean a missing barrier
await new Promise((resolve) => setTimeout(resolve, 250)) await new Promise((resolve) => setTimeout(resolve, 250));
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
commit.resolve() commit.resolve();
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
await stop() await stop();
}) });
it('reports a rejected bearer commit without publishing payment success', async () => { it('reports a rejected bearer commit without publishing payment success', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
const commitError = new Error('bearer commit failed') const commitError = new Error('bearer commit failed');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise 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', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
commit.reject(commitError) commit.reject(commitError);
await waitFor( await waitFor(
() => () =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
) );
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
// the conservative budget debit is persisted separately from bearer // the conservative budget debit is persisted separately from bearer
// storage and deliberately NOT rolled back: the budget was debited, // storage and deliberately NOT rolled back: the budget was debited,
// the bearer was never locked spent // the bearer was never locked spent
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
expect(state.bearers[0]?.spent).toBeUndefined() expect(state.bearers[0]?.spent).toBeUndefined();
await stop() await stop();
}) });
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => { it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise 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', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
// stop closes subscriptions; in-flight handlers still finish (their // stop closes subscriptions; in-flight handlers still finish (their
// changesets hold money) - a repeated stop interrupts nothing twice // changesets hold money) - a repeated stop interrupts nothing twice
let stopped = false let stopped = false;
const firstStop = stop().then(() => { const firstStop = stop().then(() => {
stopped = true stopped = true;
}) });
const repeatedStop = stop() const repeatedStop = stop();
await Promise.resolve() await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes // subscriptions close IMMEDIATELY, before the drain completes
expect(relay.subscriptionCount()).toBe(0) expect(relay.subscriptionCount()).toBe(0);
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {}) const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(afterStop) relay.emitAfterClose(afterStop);
await new Promise((resolve) => setTimeout(resolve, 25)) await new Promise((resolve) => setTimeout(resolve, 25));
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
commit.resolve() commit.resolve();
await Promise.all([firstStop, repeatedStop]) await Promise.all([firstStop, repeatedStop]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
expect(state.bearers[0]?.spent).toBe(true) expect(state.bearers[0]?.spent).toBe(true);
}) });
it('rejects a stale saved owner before touching the mint', async () => { it('rejects a stale saved owner before touching the mint', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
localStorage.setItem( localStorage.setItem(
'sattle_linking_key', 'sattle_linking_key',
JSON.stringify({ JSON.stringify({
@@ -216,20 +216,20 @@ describe('service: pay_invoice', () => {
ownerId: OTHER_OWNER_ID, ownerId: OTHER_OWNER_ID,
version: 1, version: 1,
}), }),
) );
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor( await waitFor(
() => () =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
) );
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding') expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
expect(state.changesets).toEqual([]) expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
await stop() await stop();
}) });
}) });
+99 -72
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,104 +55,131 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice (continued)', () => { describe('service: pay_invoice (continued)', () => {
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000, 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', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', 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 // nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
expect(requiredValue(state.bearers[0]).spent).toBeUndefined() expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop() await stop();
}) });
it('resets the allowance once the budget period has rolled over', async () => { it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 21_000, budgetMsat: 21_000,
periodMs: 60_000, periodMs: 60_000,
}) });
// simulate a fully spent budget from a period that ended long ago // 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, [ writeNwcConnections(OWNER_ID, [
{ ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } }, { ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
]) ]);
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
await stop() await stop();
}) });
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
await stop() await stop();
}) });
it('rejects a request amount that mismatches the invoice amount', async () => { 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', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000, amount: 5_000,
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/match/i) expect(response.error?.message).toMatch(/match/i);
await stop() await stop();
}) });
it('rejects an amount-less invoice instead of guessing', async () => { 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', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz', invoice: 'lnbc1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/amount/i) expect(response.error?.message).toMatch(/amount/i);
await stop() await stop();
}) });
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({meltAlwaysFails: true}) const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
// a short verify budget: the failed melt is classified by the poll // a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock // running out, and that wait is the test's own clock
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)] const original = await makeBearer(m, '01'.repeat(32), 21_000);
state.bearers = [original];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', 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 expect(m.state.noteState('01'.repeat(32))).toBe('burned');
// tracked unspent via the changeset - and no budget spend recorded expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
expect(m.state.noteState('01'.repeat(32))).toBe('burned') expect(
const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) state.bearers.find(
expect(returned.spent).toBeUndefined() (bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
expect(returned.amount).toBe(21_000) ),
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding') ).toBeDefined();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) expect(
await stop() 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();
});
});
+56 -56
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,8 +55,8 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('storage validation', () => { describe('storage validation', () => {
it('drops malformed records instead of throwing', () => { it('drops malformed records instead of throwing', () => {
localStorage.setItem( localStorage.setItem(
@@ -73,15 +73,15 @@ describe('storage validation', () => {
createdAt: 0, createdAt: 0,
}, },
]), ]),
) );
expect(readNwcConnections(OWNER_ID)).toHaveLength(1) expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
}) });
it('returns nothing for garbage json', () => { it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{') localStorage.setItem('sattle_nwc_connections', '{{{');
expect(readNwcConnections(OWNER_ID)).toEqual([]) expect(readNwcConnections(OWNER_ID)).toEqual([]);
}) });
it('returns only strictly parsed records belonging to the requested owner', () => { it('returns only strictly parsed records belonging to the requested owner', () => {
const current = createConnection(LINKING_KEY, { const current = createConnection(LINKING_KEY, {
@@ -89,10 +89,10 @@ describe('storage validation', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 10, now: 10,
}).record }).record;
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem( localStorage.setItem(
'sattle_nwc_connections', 'sattle_nwc_connections',
JSON.stringify([ JSON.stringify([
@@ -107,21 +107,21 @@ describe('storage validation', () => {
createdAt: 30, createdAt: 30,
}, },
]), ]),
) );
expect(readNwcConnections(OWNER_ID)).toEqual([current]) expect(readNwcConnections(OWNER_ID)).toEqual([current]);
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]) expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
}) });
it('adopts ownerless connections and enabled state only after owner proof', async () => { it('adopts ownerless connections and enabled state only after owner proof', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}') const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
if (typeof saved !== 'object' || saved === null) { 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, 'ownerId');
Reflect.deleteProperty(saved, 'version') Reflect.deleteProperty(saved, 'version');
localStorage.setItem('sattle_linking_key', JSON.stringify(saved)) localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
localStorage.setItem( localStorage.setItem(
'sattle_nwc_connections', 'sattle_nwc_connections',
JSON.stringify([ JSON.stringify([
@@ -133,32 +133,32 @@ describe('storage validation', () => {
createdAt: 0, createdAt: 0,
}, },
]), ]),
) );
localStorage.setItem('sattle_nwc_enabled', 'true') localStorage.setItem('sattle_nwc_enabled', 'true');
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow() expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
expect(readNwcConnections(OWNER_ID)).toEqual([]) expect(readNwcConnections(OWNER_ID)).toEqual([]);
expect(readNwcEnabled(OWNER_ID)).toBe(false) expect(readNwcEnabled(OWNER_ID)).toBe(false);
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 1, connections: 1,
enabled: true, enabled: true,
}) });
expect(readNwcConnections(OWNER_ID)).toHaveLength(1) expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readNwcEnabled(OWNER_ID)).toBe(true) expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 0, connections: 0,
enabled: false, enabled: false,
}) });
}) });
it('does not expose one wallet enabled state to another owner', () => { 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(OWNER_ID)).toBe(true);
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false) expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
}) });
it('treats malformed owner-bearing enabled records as disabled', () => { it('treats malformed owner-bearing enabled records as disabled', () => {
for (const value of [ for (const value of [
@@ -166,8 +166,8 @@ describe('storage validation', () => {
{ version: 2, ownerId: OWNER_ID, enabled: true }, { version: 2, ownerId: OWNER_ID, enabled: true },
{ version: 1, ownerId: OWNER_ID, enabled: 'true' }, { version: 1, ownerId: OWNER_ID, enabled: 'true' },
]) { ]) {
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value)) localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
expect(readNwcEnabled(OWNER_ID)).toBe(false) expect(readNwcEnabled(OWNER_ID)).toBe(false);
} }
}) });
}) });
+9 -9
View File
@@ -1,9 +1,9 @@
import './nwc.connection.cases' import './nwc.connection.cases';
import './nwc.storage.cases' import './nwc.storage.cases';
import './nwc.ownership.cases' import './nwc.ownership.cases';
import './nwc.validation.cases' import './nwc.validation.cases';
import './nwc.pay-a.cases' import './nwc.pay-a.cases';
import './nwc.pay-b.cases' import './nwc.pay-b.cases';
import './nwc.fencing.cases' import './nwc.fencing.cases';
import './nwc.invoice-a.cases' import './nwc.invoice-a.cases';
import './nwc.invoice-b.cases' import './nwc.invoice-b.cases';
+78 -78
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,29 +29,29 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
export const LINKING_KEY = new Uint8Array(32).fill(7) export const LINKING_KEY = new Uint8Array(32).fill(7);
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
export const CLIENT_SECRET = hexToBytes('11'.repeat(32)) export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET) export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
export const STRANGER_SECRET = hexToBytes('22'.repeat(32)) export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY) export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY) export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
// never connected - the in-memory relay below stands in // 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 let NOW = 1_800_000_000;
export const nowSeconds = (): number => NOW 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) => export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, { connectionInfoOf(OTHER_LINKING_KEY, {
@@ -62,67 +62,67 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
budget, budget,
spent: { periodStart: now, msat: 0 }, spent: { periodStart: now, msat: 0 },
createdAt: now, createdAt: now,
}) });
export const storeForeignConnection = (record: NwcConnectionRecord): void => { export const storeForeignConnection = (record: NwcConnectionRecord): void => {
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record])) localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
} };
// an in-memory relay set: subscriptions register, emit delivers to every // an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records // matching one, publish records
export const createFakeRelay = (): { export const createFakeRelay = (): {
transport: NwcTransport transport: NwcTransport;
published: NostrEvent[] published: NostrEvent[];
emit: (event: NostrEvent) => void emit: (event: NostrEvent) => void;
emitAfterClose: (event: NostrEvent) => void emitAfterClose: (event: NostrEvent) => void;
subscriptionCount: () => number subscriptionCount: () => number;
} => { } => {
const published: NostrEvent[] = [] const published: NostrEvent[] = [];
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const transport: NwcTransport = { const transport: NwcTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
published.push(event) published.push(event);
return Promise.resolve() return Promise.resolve();
}, },
subscribe: (_relays, filter, onEvent) => { subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent} const sub = { filter, onEvent };
subs.push(sub) subs.push(sub);
allSubs.push(sub) allSubs.push(sub);
return { return {
close: () => { close: () => {
const index = subs.indexOf(sub) const index = subs.indexOf(sub);
if (index >= 0) subs.splice(index, 1) if (index >= 0) subs.splice(index, 1);
}, },
} };
}, },
} };
const deliver = ( const deliver = (
targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[], targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
event: NostrEvent, event: NostrEvent,
): void => { ): void => {
for (const sub of [...targets]) { for (const sub of [...targets]) {
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind) const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
const wanted = sub.filter['#p'] const wanted = sub.filter['#p'];
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? '')) 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 const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event) if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
} }
} };
const emit = (event: NostrEvent): void => deliver(subs, event) const emit = (event: NostrEvent): void => deliver(subs, event);
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event) const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
return { return {
transport, transport,
published, published,
emit, emit,
emitAfterClose, emitAfterClose,
subscriptionCount: () => subs.length, 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 // a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret // connection's client secret
@@ -132,8 +132,8 @@ export const clientRequest = (
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
createdAt: number = NOW, createdAt: number = NOW,
): NostrEvent => { ): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]] const tags: string[][] = [['p', walletServicePubkey]];
if (scheme !== 'none') tags.push(['encryption', scheme]) if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent( return finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -148,8 +148,8 @@ export const clientRequest = (
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content), : nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
}, },
CLIENT_SECRET, CLIENT_SECRET,
) );
} };
export const methodRequest = ( export const methodRequest = (
walletServicePubkey: string, walletServicePubkey: string,
@@ -158,31 +158,31 @@ export const methodRequest = (
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
createdAt?: number, createdAt?: number,
): NostrEvent => ): 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 // generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out // verify-poll budget (seconds) runs out
export const waitFor = async (cond: () => boolean): Promise<void> => { export const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) { 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 = (): { export const deferred = (): {
promise: Promise<void> promise: Promise<void>;
resolve: () => void resolve: () => void;
reject: (reason: Error) => void reject: (reason: Error) => void;
} => { } => {
let resolve = (): void => undefined let resolve = (): void => undefined;
let reject = (_reason: Error): void => undefined let reject = (_reason: Error): void => undefined;
const promise = new Promise<void>((resolvePromise, rejectPromise) => { const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise resolve = resolvePromise;
reject = rejectPromise reject = rejectPromise;
}) });
return {promise, resolve, reject} return { promise, resolve, reject };
} };
export const resetNow = (): void => { export const resetNow = (): void => {
NOW = 1_800_000_000 NOW = 1_800_000_000;
} };
+199 -79
View File
@@ -6,13 +6,14 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { sha256 } from '@noble/hashes/sha2.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,14 +30,14 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import {isJsonObject} from './jsonParsing' import { isJsonObject } from './jsonParsing';
import { import {
CLIENT_SECRET, CLIENT_SECRET,
@@ -49,13 +50,13 @@ import {
nowSeconds, nowSeconds,
resetNow, resetNow,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import type {Encryption} from './nwc.testProtocol' import type { Encryption } from './nwc.testProtocol';
export type NwcResponsePayload = { export type NwcResponsePayload = {
result_type: string result_type: string;
error: {code: string; message: string} | null error: { code: string; message: string } | null;
result: Record<string, unknown> | null result: Record<string, unknown> | null;
} };
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload => const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) && isJsonObject(value) &&
@@ -64,7 +65,7 @@ const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
(isJsonObject(value.error) && (isJsonObject(value.error) &&
typeof value.error.code === 'string' && typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) && typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result)) (value.result === null || isJsonObject(value.result));
export const readResponse = ( export const readResponse = (
published: NostrEvent[], published: NostrEvent[],
@@ -73,19 +74,19 @@ export const readResponse = (
): NwcResponsePayload | null => { ): NwcResponsePayload | null => {
const event = published.find( const event = published.find(
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId), (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 = const plaintext =
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.decrypt( ? nip44v2.decrypt(
event.content, event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey), nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
) )
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content) : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
const parsed: unknown = JSON.parse(plaintext) const parsed: unknown = JSON.parse(plaintext);
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.') if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
return parsed return parsed;
} };
// drives one full request/response round trip over the fake relay // drives one full request/response round trip over the fake relay
export const call = async ( export const call = async (
@@ -95,30 +96,31 @@ export const call = async (
params: Record<string, unknown>, params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
): Promise<NwcResponsePayload> => { ): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme) const request = methodRequest(walletServicePubkey, method, params, scheme);
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null) await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
return requiredValue(readResponse(relay.published, request.id, scheme)) return requiredValue(readResponse(relay.published, request.id, scheme));
} };
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> => { export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options) const m = await createMockMint(options);
mints.push(m) mints.push(m);
return m return m;
} };
afterEach(async () => { 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> => { export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat) m.state.creditNote(k1, amountMsat);
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
const info = await fetchNoteInfo(url) const info = await fetchNoteInfo(url);
bearerCounter += 1 bearerCounter += 1;
return { return {
id: `bearer-${bearerCounter}`, id: `bearer-${bearerCounter}`,
url, url,
@@ -128,80 +130,198 @@ export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promi
mintPubkey: m.state.pubkey, mintPubkey: m.state.pubkey,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} };
} };
// the harness around startService: a fake relay, an in-memory "store" // the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created // applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret // connection with a pinned client secret
export const startTestService = async (options: { export const startTestService = async (options: {
budgetMsat?: number budgetMsat?: number;
periodMs?: number periodMs?: number;
defaultMint?: string | null defaultMint?: string | null;
linkingKey?: Uint8Array linkingKey?: Uint8Array;
poll?: typeof FAST_POLL poll?: typeof FAST_POLL;
claimPoll?: typeof FAST_POLL claimPoll?: typeof FAST_POLL;
kit?: NwcServiceDeps['kit'] kit?: NwcServiceDeps['kit'];
commitChangeset?: (changeset: NwcChangeset) => Promise<void> commitChangeset?: (changeset: NwcChangeset) => Promise<void>;
setMintOutputRetirement?: () => Promise<void>;
}): Promise<{ }): Promise<{
relay: ReturnType<typeof createFakeRelay> relay: ReturnType<typeof createFakeRelay>;
walletServicePubkey: string walletServicePubkey: string;
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
stop: () => Promise<void> 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, { const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 }, budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: nowSeconds() * 1000, now: nowSeconds() * 1000,
}) });
const relay = createFakeRelay() const relay = createFakeRelay();
const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = { const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
bearers: [], bearers: [],
changesets: [], changesets: [],
errors: [], errors: [],
} };
const service = await startService(options.linkingKey ?? LINKING_KEY, { const service = await startService(options.linkingKey ?? LINKING_KEY, {
assertCurrentOwner: () => undefined, 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, getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null, 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) => { applyChangeset: async (changeset: NwcChangeset) => {
await options.commitChangeset?.(changeset) await options.commitChangeset?.(changeset);
state.changesets.push(changeset) state.changesets.push(changeset);
for (const id of changeset.markSpent) { for (const id of changeset.markSpent) {
const bearer = state.bearers.find((b) => b.id === id) const bearer = state.bearers.find((b) => b.id === id);
if (bearer) bearer.spent = true if (bearer) bearer.spent = true;
} }
for (const note of changeset.add) { for (const note of changeset.add) {
bearerCounter += 1 bearerCounter += 1;
state.bearers.push({ state.bearers.push({
...note, ...note,
id: `added-${bearerCounter}`, id: `added-${bearerCounter}`,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}) });
} }
}, },
onError: (err) => { onError: (err) => {
state.errors.push(err) state.errors.push(err);
}, },
transport: relay.transport, transport: relay.transport,
kit: options.kit, kit: options.kit,
poll: options.poll ?? FAST_POLL, poll: options.poll ?? FAST_POLL,
claimPoll: options.claimPoll ?? FAST_POLL, claimPoll: options.claimPoll ?? FAST_POLL,
nowSeconds, nowSeconds,
}) });
return { return {
relay, relay,
walletServicePubkey: connection.walletServicePubkey, walletServicePubkey: connection.walletServicePubkey,
state, state,
stop: service.stop, stop: service.stop,
} };
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
resetNow() resetNow();
}) });
+16 -16
View File
@@ -37,7 +37,7 @@
// per-connection request queues // per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts. // 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 { export {
clearNwcStorageForOwner, clearNwcStorageForOwner,
clearUnownedNwcStorage, clearUnownedNwcStorage,
@@ -46,9 +46,9 @@ export {
readNwcConnections, readNwcConnections,
removeNwcConnection, removeNwcConnection,
writeNwcConnections, writeNwcConnections,
} from './storage/nwcConnections' } from './storage/nwcConnections';
export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled' export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
export type {NwcLegacyMigrationResult} from './storage/nwcConnections' export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export { export {
buildConnectionString, buildConnectionString,
@@ -57,13 +57,13 @@ export {
deriveNwcWalletKey, deriveNwcWalletKey,
nwcWalletPubkey, nwcWalletPubkey,
parseConnectionString, parseConnectionString,
} from './nwc/connection' } from './nwc/connection';
export type { export type {
CreateConnectionOptions, CreateConnectionOptions,
CreatedConnection, CreatedConnection,
NwcConnectionInfo, NwcConnectionInfo,
ParsedConnectionString, ParsedConnectionString,
} from './nwc/connection' } from './nwc/connection';
export { export {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -75,7 +75,7 @@ export {
decryptRequest, decryptRequest,
errResult, errResult,
okResult, okResult,
} from './nwc/protocol' } from './nwc/protocol';
export type { export type {
DecryptedNwcRequest, DecryptedNwcRequest,
NostrEvent, NostrEvent,
@@ -84,15 +84,15 @@ export type {
NwcMethod, NwcMethod,
NwcRequest, NwcRequest,
NwcResponse, NwcResponse,
} from './nwc/protocol' } from './nwc/protocol';
export {defaultNwcTransport} from './nwc/transport' export { defaultNwcTransport } from './nwc/transport';
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport' export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
export {budgetRemainingMsat, recordSpend} from './nwc/budget' export { budgetRemainingMsat, recordSpend } from './nwc/budget';
export type {PendingInvoice, RequestContext} from './nwc/context' export type { PendingInvoice, RequestContext } from './nwc/context';
export {invoiceResult, resolvePaymentHash} from './nwc/invoices' export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
export {payChangeset} from './nwc/pay' export { payChangeset } from './nwc/pay';
export {startService} from './nwc/service' export { startService } from './nwc/service';
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service' export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
+80 -80
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,45 +55,45 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: request validation', () => { describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => { it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_payments', {}) const response = await call(relay, walletServicePubkey, 'get_payments', {});
expect(response.result_type).toBe('get_payments') expect(response.result_type).toBe('get_payments');
expect(response.error?.code).toBe('NOT_IMPLEMENTED') expect(response.error?.code).toBe('NOT_IMPLEMENTED');
expect(response.result).toBeNull() expect(response.result).toBeNull();
await stop() await stop();
}) });
it('answers a malformed (non-JSON) request with an error, not a crash', async () => { it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, 'this is not json') const request = clientRequest(walletServicePubkey, 'this is not json');
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2')) const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
await stop() await stop();
}) });
it('answers a JSON request without a method with an error', async () => { it('answers a JSON request without a method with an error', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe( expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
'OTHER', 'OTHER',
) );
await stop() await stop();
}) });
it('ignores a request signed by a stranger key - silently', async () => { 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( const content = nip44v2.encrypt(
JSON.stringify({ method: 'get_balance', params: {} }), JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey), nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
) );
const forged = finalizeEvent( const forged = finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -105,20 +105,20 @@ describe('service: request validation', () => {
content, content,
}, },
STRANGER_SECRET, STRANGER_SECRET,
) );
relay.emit(forged) relay.emit(forged);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop() await stop();
}) });
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { 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( const content = nip04Encrypt(
CLIENT_SECRET, CLIENT_SECRET,
walletServicePubkey, walletServicePubkey,
JSON.stringify({ method: 'get_balance', params: {} }), JSON.stringify({ method: 'get_balance', params: {} }),
) );
const request = finalizeEvent( const request = finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -130,58 +130,58 @@ describe('service: request validation', () => {
content, content,
}, },
CLIENT_SECRET, CLIENT_SECRET,
) );
relay.emit(request) relay.emit(request);
// the error answer goes out in the legacy scheme every client reads // 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( expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION', 'UNSUPPORTED_ENCRYPTION',
) );
await stop() await stop();
}) });
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { 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) { for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip04')) const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result).toEqual({balance: 0}) expect(response.result).toEqual({ balance: 0 });
// the response mirrors the request's scheme // the response mirrors the request's scheme
const event = requiredValue( const event = requiredValue(
relay.published.find( relay.published.find(
(e) => (e) =>
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id), 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 () => { 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( const stale = methodRequest(
walletServicePubkey, walletServicePubkey,
'get_balance', 'get_balance',
{}, {},
'nip44_v2', 'nip44_v2',
nowSeconds() - 1200, nowSeconds() - 1200,
) );
relay.emit(stale) relay.emit(stale);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop() await stop();
}) });
it('picks up no new requests after stop', async () => { it('picks up no new requests after stop', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
await stop() await stop();
const request = methodRequest(walletServicePubkey, 'get_balance', {}) const request = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(request) relay.emitAfterClose(request);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
}) });
}) });
+9 -9
View File
@@ -5,14 +5,14 @@
// check + record below atomic enough: no two pays of one connection ever // check + record below atomic enough: no two pays of one connection ever
// run this concurrently. // run this concurrently.
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import {persistNwcConnection} from '../storage/nwcConnections' import { persistNwcConnection } from '../storage/nwcConnections';
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => { export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
const {maxMsat, periodMs} = record.budget const { maxMsat, periodMs } = record.budget;
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
return Math.max(0, maxMsat - record.spent.msat) return Math.max(0, maxMsat - record.spent.msat);
} };
// rolls the period when it expired, then adds the spend; persists (the // rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write) // caller's queue serialized this read-modify-write)
@@ -22,7 +22,7 @@ export const recordSpend = (
amountMsat: number, amountMsat: number,
nowMs: number, nowMs: number,
): NwcConnectionRecord => { ): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
return persistNwcConnection(ownerId, { return persistNwcConnection(ownerId, {
...record, ...record,
spent: expired spent: expired
@@ -31,5 +31,5 @@ export const recordSpend = (
periodStart: record.spent.periodStart, periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat, msat: record.spent.msat + amountMsat,
}, },
}) });
} };
+48 -48
View File
@@ -20,17 +20,17 @@
// The connection string follows NIP-47 exactly: // The connection string follows NIP-47 exactly:
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex> // nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {getPublicKey} from 'nostr-tools/pure' import { getPublicKey } from 'nostr-tools/pure';
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
import {persistNwcConnection} 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 // Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's // 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), ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey), ...hexToBytes(clientPubkey),
]), ]),
) );
// the x-only nostr pubkey the client addresses its requests to // the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string => export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
getPublicKey(walletSecretKey) getPublicKey(walletSecretKey);
export type NwcConnectionInfo = { export type NwcConnectionInfo = {
record: NwcConnectionRecord record: NwcConnectionRecord;
walletServicePubkey: string walletServicePubkey: string;
} };
// the runtime view of a persisted record: the record plus its re-derived // the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity // wallet-service identity
@@ -62,21 +62,21 @@ export const connectionInfoOf = (
): NwcConnectionInfo => ({ ): NwcConnectionInfo => ({
record, record,
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)), walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
}) });
export type CreatedConnection = NwcConnectionInfo & { export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret, // shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store // which the wallet deliberately does NOT store
connectionString: string connectionString: string;
} };
export type CreateConnectionOptions = { export type CreateConnectionOptions = {
relays: string[] relays: string[];
budget: NwcBudget budget: NwcBudget;
// test hook: a fixed client secret (32 bytes) instead of a random one // test hook: a fixed client secret (32 bytes) instead of a random one
clientSecret?: Uint8Array clientSecret?: Uint8Array;
now?: number now?: number;
} };
// Creates and persists a connection. The client secret is random by // Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above. // default; the wallet-service key falls out of the derivation above.
@@ -85,11 +85,11 @@ export const createConnection = (
options: CreateConnectionOptions, options: CreateConnectionOptions,
): CreatedConnection => { ): CreatedConnection => {
if (options.relays.length === 0) { 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 clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
const clientPubkey = getPublicKey(clientSecret) const clientPubkey = getPublicKey(clientSecret);
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
const record = persistNwcConnection(ownerId, { const record = persistNwcConnection(ownerId, {
version: 1, version: 1,
ownerId, ownerId,
@@ -98,8 +98,8 @@ export const createConnection = (
budget: options.budget, budget: options.budget,
spent: { periodStart: options.now ?? Date.now(), msat: 0 }, spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(), createdAt: options.now ?? Date.now(),
}) });
const info = connectionInfoOf(linkingPrivKey, record) const info = connectionInfoOf(linkingPrivKey, record);
return { return {
...info, ...info,
connectionString: buildConnectionString( connectionString: buildConnectionString(
@@ -107,43 +107,43 @@ export const createConnection = (
bytesToHex(clientSecret), bytesToHex(clientSecret),
record.relays, record.relays,
), ),
} };
} };
export const buildConnectionString = ( export const buildConnectionString = (
walletServicePubkey: string, walletServicePubkey: string,
clientSecretHex: string, clientSecretHex: string,
relays: string[], relays: string[],
): string => { ): string => {
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&') const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
} };
export type ParsedConnectionString = { export type ParsedConnectionString = {
walletServicePubkey: string walletServicePubkey: string;
clientSecret: string clientSecret: string;
relays: string[] relays: string[];
} };
// parses a NIP-47 connection string; returns null for anything that isn't // parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so // exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse) // the format has a tested inverse)
export const parseConnectionString = (uri: string): ParsedConnectionString | null => { export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
let url: URL let url: URL;
try { try {
url = new URL(uri.trim()) url = new URL(uri.trim());
} catch { } 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 // the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are // hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex) // conventionally lowercase hex)
const walletServicePubkey = url.host.toLowerCase() const walletServicePubkey = url.host.toLowerCase();
if (!HEX_64.test(walletServicePubkey)) return null if (!HEX_64.test(walletServicePubkey)) return null;
const secret = url.searchParams.get('secret') const secret = url.searchParams.get('secret');
if (!secret || !HEX_64.test(secret)) return null if (!secret || !HEX_64.test(secret)) return null;
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay)) const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
if (relays.length === 0) return null if (relays.length === 0) return null;
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} 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 // no logic, so every other nwc/ module can import from here without
// cycles. // cycles.
import type {LnurlcashOptions} from 'lnurlcash-kit' import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import type {PreparedMint} from '../ops' import type { PreparedMint } from '../ops';
import type {PollOptions} from '../ops/shared' 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 { NwcConnectionInfo } from './connection';
import type {NwcMethod} from './protocol' import type { NwcMethod } from './protocol';
import type {NwcTransport} from './transport' import type { NwcTransport } from './transport';
// the state delta the caller's store applies after an op ran: new notes // 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 // to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of // freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state // it - they were never the wallet's money in a trackable state
export type NwcChangeset = { export type NwcChangeset = {
add: NewBearer[] add: NewBearer[];
markSpent: string[] markSpent: string[];
} };
export type NwcServiceDeps = { export type NwcServiceDeps = {
getBearers: () => Bearer[] getBearers: () => Bearer[];
// the mint make_invoice issues invoices against (the wallet's default // the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice) // mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null getDefaultMint: () => string | null;
assertCurrentOwner: () => void 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: ( applyChangeset: (
changeset: NwcChangeset, changeset: NwcChangeset,
connection: NwcConnectionInfo, connection: NwcConnectionInfo,
method: NwcMethod, method: NwcMethod,
assertOwner: () => void, assertOwner: () => void,
) => Promise<void> ) => Promise<void>;
transport?: NwcTransport transport?: NwcTransport;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
// verify-poll budget for pay_invoice outcome classification // verify-poll budget for pay_invoice outcome classification
poll?: PollOptions poll?: PollOptions;
// verify-poll budget for the background claim after make_invoice - // verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it // 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 // background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here // 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 // test hook: pinned clock for event timestamps and expirations
nowSeconds?: () => number nowSeconds?: () => number;
} };
export type PendingInvoice = { export type PendingInvoice = {
invoice: string invoice: string;
paymentHash: string paymentHash: string;
// the gross invoiced amount (net + mint fee) - what the payer pays // the gross invoiced amount (net + mint fee) - what the payer pays
amountMsat: number amountMsat: number;
description?: string description?: string;
createdAt: number // unix seconds createdAt: number; // unix seconds
expiresAt?: number expiresAt?: number;
prepared: PreparedMint prepared: PreparedMint;
state: 'pending' | 'settled' | 'failed' stagedOutput: Bearer;
preimage?: string state: 'pending' | 'settled' | 'failed';
settledAt?: number preimage?: string;
} settledAt?: number;
};
// what a method handler sees of its connection's runtime: the deps, the // 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 // (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock // getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = { export type RequestContext = {
deps: NwcServiceDeps deps: NwcServiceDeps;
connection: () => NwcConnectionInfo connection: () => NwcConnectionInfo;
updateRecord: (record: NwcConnectionRecord) => void updateRecord: (record: NwcConnectionRecord) => void;
invoices: Map<string, PendingInvoice> invoices: Map<string, PendingInvoice>;
nowSeconds: () => number nowSeconds: () => number;
assertOwner: () => void assertOwner: () => void;
// Starts service-owned work only while the service accepts new work. // Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup. // 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 // fires when the service stops: long OBSERVATION waits (the invoice
// claim poll) must interrupt themselves on it. Work that has already // claim poll) must interrupt themselves on it. Work that has already
// reached a fund-critical commit must NOT consult it - stop awaits // reached a fund-critical commit must NOT consult it - stop awaits
// those tasks through its drain // 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 // results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself. // wallet state itself.
import {noteK1, sameInvoice} from 'lnurlcash-kit' import { noteK1, sameInvoice } from 'lnurlcash-kit';
import type {Bearer} from '../types' import type { Bearer } from '../types';
import type {PreparedMint} from '../ops' import type { PreparedMint } from '../ops';
import {prepareMint} from '../ops' import { prepareMint } from '../ops';
import {PollAbortedError} from '../ops/shared' import { PollAbortedError } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context' import type { PendingInvoice, RequestContext } from './context';
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
import {handlePayInvoice} from './pay' import { handlePayInvoice } from './pay';
import type {NwcRequest, NwcResponse} from './protocol' import type { NwcRequest, NwcResponse } from './protocol';
import {NWC_METHODS, errResult, okResult} 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 // the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now" // this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean => 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 => const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', { okResult('get_info', {
@@ -33,7 +35,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
network: 'mainnet', network: 'mainnet',
methods: [...NWC_METHODS], methods: [...NWC_METHODS],
notifications: [], notifications: [],
}) });
const handleGetBalance = (ctx: RequestContext): NwcResponse => const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', { okResult('get_balance', {
@@ -41,25 +43,88 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
.getBearers() .getBearers()
.filter(spendable) .filter(spendable)
.reduce((sum, b) => sum + b.amount, 0), .reduce((sum, b) => sum + b.amount, 0),
}) });
const handleMakeInvoice = async ( const handleMakeInvoice = async (
ctx: RequestContext, ctx: RequestContext,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<NwcResponse> => { ): Promise<NwcResponse> => {
const amountMsat = Number(params.amount) const amountMsat = Number(params.amount);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { 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) { 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 { 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) { } 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 = { const entry: PendingInvoice = {
invoice: prepared.invoice, invoice: prepared.invoice,
@@ -67,69 +132,70 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat, amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(), createdAt: ctx.nowSeconds(),
prepared, prepared,
stagedOutput,
state: 'pending', state: 'pending',
} };
if (typeof params.description === 'string' && params.description) { 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) { 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 // phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the service-owned claim observes // lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() => const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => { settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed' entry.state = 'failed';
// an interrupted claim poll is normal service teardown (stop // an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing // aborted it), not a background failure worth surfacing
if (err instanceof PollAbortedError) return if (err instanceof PollAbortedError) return;
ctx.deps.onError?.(err, ctx.connection()) ctx.deps.onError?.(err, ctx.connection());
}), }),
) );
if (!started) { if (!started) {
entry.state = 'failed' entry.state = 'failed';
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.') 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 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 = 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) { 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) { if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) { for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) { if (sameInvoice(candidate.invoice, invoiceParam)) {
entry = candidate entry = candidate;
break break;
} }
} }
} }
if (!entry) { if (!entry) {
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.') return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
}
return okResult('lookup_invoice', invoiceResult(entry))
} }
return okResult('lookup_invoice', invoiceResult(entry));
};
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => { export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
switch (request.method) { switch (request.method) {
case 'get_info': case 'get_info':
return handleGetInfo(ctx) return handleGetInfo(ctx);
case 'get_balance': case 'get_balance':
return handleGetBalance(ctx) return handleGetBalance(ctx);
case 'make_invoice': case 'make_invoice':
return await handleMakeInvoice(ctx, request.params) return await handleMakeInvoice(ctx, request.params);
case 'pay_invoice': case 'pay_invoice':
return await handlePayInvoice(ctx, request.params) return await handlePayInvoice(ctx, request.params);
case 'lookup_invoice': case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params) return handleLookupInvoice(ctx, request.params);
default: default:
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`) return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
}
} }
};
+91 -141
View File
@@ -1,49 +1,77 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair // The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this // shares. A minted note always lives at the wallet-chosen secret that was
// module's settleAndClaim watches settlement in the background, claims // durably staged before quote creation; the payment preimage is only a
// the minted note, and only THEN records the settlement the lookup // receipt. Settlement becomes visible only after the staged bearer has
// reports. // been replaced with the confirmed note details.
//
// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint 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. On a
// NAMED mint the note lives at a wallet-chosen secret from the start and
// the preimage opens nothing at all, so it is an ordinary payment receipt
// and always safe to hand out.
import {isPreimage, sameInvoice} from 'lnurlcash-kit' import { isPreimage, sameInvoice } from 'lnurlcash-kit';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import type {NewBearer} from '../types' import type { PreparedMint } from '../ops';
import type {PreparedMint} from '../ops' import { MintedNoteSpentError } from '../ops';
import {claimFromPreimage, claimFromSecret} from '../ops/mint' import { claimFromSecret } from '../ops/mint';
import {pollVerifyUntilSettled} from '../ops/shared' import { pollVerifyUntilSettled } from '../ops/shared';
import type {PollOptions} 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'>> = { export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000, intervalMs: 2000,
intervalCapMs: 10_000, intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000, maxWaitMs: 15 * 60_000,
} };
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
// convention) - that suffix is the invoice's real payment hash. A service const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
// that shapes its verify URL differently gets a wallet-local correlation const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
// id instead (sha256 of the invoice): still unique and stable for const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
// 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 => { export const resolvePaymentHash = (prepared: PreparedMint): string => {
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1] const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
if (fromVerify) return fromVerify.toLowerCase() if (fromVerify) return fromVerify.toLowerCase();
return bytesToHex(sha256(utf8ToBytes(prepared.invoice))) return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
} };
// the NIP-47 transaction object make_invoice and lookup_invoice share
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => { export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
type: 'incoming', type: 'incoming',
@@ -53,126 +81,48 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
amount: entry.amountMsat, amount: entry.amountMsat,
created_at: entry.createdAt, created_at: entry.createdAt,
metadata: {}, metadata: {},
} };
if (entry.description) result.description = entry.description if (entry.description) result.description = entry.description;
if (entry.expiresAt) result.expires_at = entry.expiresAt if (entry.expiresAt) result.expires_at = entry.expiresAt;
if (entry.state === 'settled' && entry.preimage) { if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
result.preimage = entry.preimage if (entry.settledAt) result.settled_at = entry.settledAt;
} return result;
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 on an unnamed mint) and
// hand the fresh bearer to the caller. Settlement is recorded LAST - after
// the claim and bearer commit - so lookup can only reveal durably tracked
// funds and, on an unnamed mint, 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> => { export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry) const prepared = entry.prepared;
if (!entry.prepared.verifyUrl) { const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
throw new Error( let preimage: string | null = null;
'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 result = await pollVerifyUntilSettled(
entry.prepared.verifyUrl,
{...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), 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.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error('The payment settled but the service did not reveal the preimage.')
}
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
// other half) - invoked in two steps here because NWC needs the
// preimage, which claimMintedNote deliberately discards
const claimed = await claimFromPreimage(entry.prepared, preimage, {
...(ctx.deps.kit ?? {}),
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
}
}
// the named-mint half of settleAndClaim: the note lands at the wallet's
// own secret, so its APPEARANCE there is the settlement observation and no
// rotate follows. When the mint also serves the quote's verify, that
// answer's preimage is kept as the NIP-47 receipt (an ordinary payment
// proof on a named mint); a named mint without verify yields a settled
// lookup with no preimage - correct, just receipt-less.
const settleAndClaimNamed = async (
ctx: RequestContext,
entry: PendingInvoice,
): Promise<void> => {
const noteSecret = entry.prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
const prepared = entry.prepared
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
let preimage: string | null = null
if (prepared.verifyUrl) { if (prepared.verifyUrl) {
// the interruptible observation half: watch settlement on verify, // Observation remains interruptible. Once settlement is observed, the
// then claim by secret below (fund-critical, uninterruptible) // claim and durable update ignore shutdown while stop drains the task.
const result = await pollVerifyUntilSettled( const result = await pollVerifyUntilSettled(
prepared.verifyUrl, prepared.verifyUrl,
{ ...claimPoll, signal: ctx.stopSignal }, { ...claimPoll, signal: ctx.stopSignal },
ctx.deps.kit ?? {}, ctx.deps.kit ?? {},
) );
if (!sameInvoice(result.pr, prepared.invoice)) { if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.") throw new Error("The service's verify response is for a different invoice than requested.");
} }
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage;
} }
// without a verify URL the claim poll IS the observation half, so it let claimed;
// stays interruptible; after a verify-observed settlement the note is try {
// already there and this returns on the first check claimed = await claimFromSecret(
const claimed = await claimFromSecret( prepared,
{...prepared, noteSecret},
prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal }, prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
{ {
...(ctx.deps.kit ?? {}), ...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner, assertOwner: ctx.assertOwner,
}, },
) );
const add: NewBearer[] = [claimed.note] } catch (error) {
if (claimed.possibleCopy) add.push(claimed.possibleCopy) if (!(error instanceof MintedNoteSpentError)) throw error;
await ctx.deps.applyChangeset( await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
{add, markSpent: []}, throw error;
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
// the rotate guard still matters here: the rescue path (a mint that
// took the hash but credited the PREIMAGE) claims through
// claimFromPreimage, and if its rotate failed the preimage is still a
// LIVE note secret - withheld exactly like the unnamed path
if (preimage && claimed.rotated) entry.preimage = preimage
} }
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
entry.settledAt = ctx.nowSeconds();
entry.state = 'settled';
if (preimage) entry.preimage = preimage;
};
+101 -53
View File
@@ -10,16 +10,17 @@ import {
fetchInvoiceVerification, fetchInvoiceVerification,
isBolt11Invoice, isBolt11Invoice,
noteK1, noteK1,
} from 'lnurlcash-kit' withNewK1,
} from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {PayResult} from '../ops' import type { PayResult } from '../ops';
import {UncertainOutcomeError, payWithBearers} from '../ops' import { UncertainOutcomeError, payWithBearers } from '../ops';
import {budgetRemainingMsat, recordSpend} from './budget' import { budgetRemainingMsat, recordSpend } from './budget';
import type {NwcChangeset, RequestContext} from './context' import type { NwcChangeset, RequestContext } from './context';
import type {NwcResponse} from './protocol' import type { NwcResponse } from './protocol';
import {errResult, okResult} from './protocol' import { errResult, okResult } from './protocol';
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's // maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds // semantics: carve inputs lock spent, change is tracked, returned funds
@@ -31,60 +32,99 @@ import {errResult, okResult} from './protocol'
// the next refresh to reconcile, exactly as the UI leaves it - the money // the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked. // itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = [] const add: NewBearer[] = [];
const markSpent: string[] = result.carve.consumed.map((b) => b.id) const markSpent: string[] = result.carve.consumed.map((b) => b.id);
if (result.carve.change) add.push(result.carve.change) // 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') { if (result.outcome === 'failed-funds-returned') {
add.push(result.rotatedNote ?? result.carve.note) if (existing) {
const index = markSpent.indexOf(existing.id);
if (index >= 0) markSpent.splice(index, 1);
}
} else { } else {
// settled / still-pending / already-spent: the carved note is gone or // 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 // 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 - // carve), lock that bearer; a freshly carved note is never added -
// it was born spent // it was born spent
const carvedK1 = noteK1(result.carve.note.url) if (existing) markSpent.push(existing.id);
const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
if (existing) markSpent.push(existing.id)
}
if (result.rescuedNote) add.push(result.rescuedNote)
return {add, markSpent}
} }
return { add, markSpent };
};
export const handlePayInvoice = async ( export const handlePayInvoice = async (
ctx: RequestContext, ctx: RequestContext,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<NwcResponse> => { ): Promise<NwcResponse> => {
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '' const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
if (!isBolt11Invoice(invoice)) { 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 // the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request // read from the invoice itself - never trusted from the request
const amountMsat = decodeBolt11AmountMsat(invoice) const amountMsat = decodeBolt11AmountMsat(invoice);
if (amountMsat === null || amountMsat <= 0) { if (amountMsat === null || amountMsat <= 0) {
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'OTHER', 'OTHER',
"Could not read this invoice's amount - amount-less invoices are not supported.", "Could not read this invoice's amount - amount-less invoices are not supported.",
) );
} }
if (params.amount !== undefined && params.amount !== amountMsat) { if (params.amount !== undefined && params.amount !== amountMsat) {
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.") return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
} }
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) { if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'QUOTA_EXCEEDED', 'QUOTA_EXCEEDED',
"This payment exceeds the connection's budget.", "This payment exceeds the connection's budget.",
) );
} }
const bearers = ctx.deps.getBearers() const bearers = ctx.deps.getBearers();
let result: PayResult let result: PayResult;
let committedCarve: Bearer | undefined;
let returnStage: Bearer | undefined;
try { try {
result = await payWithBearers(bearers, invoice, { result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {}, poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {}, kit: ctx.deps.kit ?? {},
assertOwner: ctx.assertOwner, 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) { } catch (err) {
if (err instanceof UncertainOutcomeError) { if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the // the carve's answer was lost and the probe couldn't tell: the
@@ -95,87 +135,95 @@ export const handlePayInvoice = async (
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'INTERNAL', 'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.', 'The payment preparation could not be confirmed; possible new notes were stored unverified.',
) );
} }
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err);
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL', /enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message, message,
) );
}
if (returnStage) {
const returned = result.rotatedNote ?? result.rescuedNote;
if (returned) {
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
} else if (result.outcome !== 'unknown-still-pending') {
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
}
} }
const spendRecorded = (): void => { const spendRecorded = (): void => {
// This conservative budget debit is persisted separately from bearer // This conservative budget debit is persisted separately from bearer
// storage. A later bearer commit failure does not roll it back. // storage. A later bearer commit failure does not roll it back.
const connection = ctx.connection().record const connection = ctx.connection().record;
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now())) ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
} };
switch (result.outcome) { switch (result.outcome) {
case 'settled': { case 'settled': {
spendRecorded() spendRecorded();
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(bearers, result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
// the receipt NIP-47 clients expect: the melt's own payment // the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals // preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one. // none yields an empty preimage rather than a fabricated one.
let preimage = '' let preimage = '';
if (result.verifyUrl) { if (result.verifyUrl) {
try { try {
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {}) const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
preimage = proof.preimage ?? '' preimage = proof.preimage ?? '';
} catch (error) { } catch (error) {
// the settle proof was already polled inside payWithBearers; // the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome // 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': case 'failed-funds-returned':
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(bearers, result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'PAYMENT_FAILED', 'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.', 'The payment failed; the funds are back in the wallet.',
) );
case 'note-already-spent': case 'note-already-spent':
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(bearers, result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'PAYMENT_FAILED', 'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.', 'The note backing this payment was already spent; nothing was paid.',
) );
case 'unknown-still-pending': case 'unknown-still-pending':
spendRecorded() spendRecorded();
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(bearers, result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'OTHER', 'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.', 'The payment is still in flight; the note stays locked until it reconciles.',
) );
}
} }
};
+51 -50
View File
@@ -13,16 +13,16 @@
// - a kind 13194 replaceable info event advertises the supported methods // - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes // and encryption schemes
import type {Event as NostrEvent} from 'nostr-tools/core' import type { Event as NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure' import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
export type {NostrEvent} export type { NostrEvent };
export const NWC_REQUEST_KIND = 23194 export const NWC_REQUEST_KIND = 23194;
export const NWC_RESPONSE_KIND = 23195 export const NWC_RESPONSE_KIND = 23195;
export const NWC_INFO_KIND = 13194 export const NWC_INFO_KIND = 13194;
export const NWC_METHODS = [ export const NWC_METHODS = [
'get_info', 'get_info',
@@ -30,9 +30,9 @@ export const NWC_METHODS = [
'make_invoice', 'make_invoice',
'pay_invoice', 'pay_invoice',
'lookup_invoice', 'lookup_invoice',
] as const ] as const;
export type NwcMethod = (typeof NWC_METHODS)[number] export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode = export type NwcErrorCode =
| 'RATE_LIMITED' | 'RATE_LIMITED'
@@ -45,38 +45,38 @@ export type NwcErrorCode =
| 'UNSUPPORTED_ENCRYPTION' | 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED' | 'PAYMENT_FAILED'
| 'NOT_FOUND' | 'NOT_FOUND'
| 'OTHER' | 'OTHER';
export type NwcRequest = { export type NwcRequest = {
method: string method: string;
params: Record<string, unknown> params: Record<string, unknown>;
} };
export type NwcResponse = { export type NwcResponse = {
result_type: string result_type: string;
error: {code: NwcErrorCode; message: string} | null error: { code: NwcErrorCode; message: string } | null;
result: unknown result: unknown;
} };
export const okResult = (method: string, result: unknown): NwcResponse => ({ export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method, result_type: method,
error: null, error: null,
result, result,
}) });
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({ export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method, result_type: method,
error: { code, message }, error: { code, message },
result: null, result: null,
}) });
// the two encryption schemes this service speaks; the scheme of a request // the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme // decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client") // requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04' export type NwcEncryption = 'nip44_v2' | 'nip04';
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array => const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = ( export const encryptFor = (
scheme: NwcEncryption, scheme: NwcEncryption,
@@ -86,7 +86,7 @@ export const encryptFor = (
): string => ): string =>
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey)) ? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext) : nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = ( const decryptFrom = (
scheme: NwcEncryption, scheme: NwcEncryption,
@@ -96,10 +96,10 @@ const decryptFrom = (
): string => ): string =>
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey)) ? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content) : nip04Decrypt(walletSecretKey, clientPubkey, content);
const tagValue = (event: NostrEvent, name: string): string | undefined => 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: // The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can // - a request to dispatch (encryption scheme carried so the response can
@@ -113,7 +113,7 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// demands // demands
export type DecryptedNwcRequest = export type DecryptedNwcRequest =
| { respond: false; request: NwcRequest; encryption: NwcEncryption } | { respond: false; request: NwcRequest; encryption: NwcEncryption }
| {respond: true; response: NwcResponse; encryption: NwcEncryption} | { respond: true; response: NwcResponse; encryption: NwcEncryption };
export const decryptRequest = ( export const decryptRequest = (
walletSecretKey: Uint8Array, walletSecretKey: Uint8Array,
@@ -122,24 +122,24 @@ export const decryptRequest = (
event: NostrEvent, event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000), nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => { ): 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 // only the authorized client may talk to this connection, and the
// request must actually be addressed to it // request must actually be addressed to it
if (event.pubkey !== clientPubkey) return null if (event.pubkey !== clientPubkey) return null;
if (tagValue(event, 'p') !== walletServicePubkey) return null if (tagValue(event, 'p') !== walletServicePubkey) return null;
if (!verifyEvent(event)) return null if (!verifyEvent(event)) return null;
// an expired request is ignored, never answered (NIP-47) // 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) { if (expiration !== undefined && Number(expiration) < nowSeconds) {
return null return null;
} }
// encryption negotiation: no tag means legacy NIP-04 (NIP-47) // encryption negotiation: no tag means legacy NIP-04 (NIP-47)
const advertised = tagValue(event, 'encryption') const advertised = tagValue(event, 'encryption');
let encryption: NwcEncryption let encryption: NwcEncryption;
if (advertised === undefined || advertised === 'nip04') { if (advertised === undefined || advertised === 'nip04') {
encryption = 'nip04' encryption = 'nip04';
} else if (advertised === 'nip44_v2') { } else if (advertised === 'nip44_v2') {
encryption = 'nip44_v2' encryption = 'nip44_v2';
} else { } else {
// the client asked for a scheme we don't speak - answer in the // the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read // legacy default, the one scheme every NIP-47 client must read
@@ -151,24 +151,24 @@ export const decryptRequest = (
'UNSUPPORTED_ENCRYPTION', 'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`, `Unsupported encryption scheme: ${advertised}.`,
), ),
};
} }
} let plaintext: string;
let plaintext: string
try { try {
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content) plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
} catch { } catch {
// undecryptable - indistinguishable from relay noise; stay silent // undecryptable - indistinguishable from relay noise; stay silent
return null return null;
} }
let data: unknown let data: unknown;
try { try {
data = JSON.parse(plaintext) data = JSON.parse(plaintext);
} catch { } catch {
return { return {
respond: true, respond: true,
encryption, encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.'), response: errResult('', 'OTHER', 'The request is not valid JSON.'),
} };
} }
if ( if (
typeof data !== 'object' || typeof data !== 'object' ||
@@ -180,12 +180,13 @@ export const decryptRequest = (
respond: true, respond: true,
encryption, encryption,
response: errResult('', 'OTHER', 'The request has no method.'), response: errResult('', 'OTHER', 'The request has no method.'),
};
} }
} const request = data as NwcRequest;
const request = data as NwcRequest const params =
const params = typeof request.params === 'object' && request.params !== null ? request.params : {} typeof request.params === 'object' && request.params !== null ? request.params : {};
return {respond: false, request: {method: request.method, params}, encryption} return { respond: false, request: { method: request.method, params }, encryption };
} };
// signs the kind-23195 answer to a request, mirroring its encryption // signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag // scheme and referencing it via the 'e' tag
@@ -209,7 +210,7 @@ export const buildResponseEvent = (
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)), content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
}, },
walletSecretKey, walletSecretKey,
) );
// the replaceable info event advertising this service's capabilities // the replaceable info event advertising this service's capabilities
export const buildInfoEvent = ( export const buildInfoEvent = (
@@ -224,4 +225,4 @@ export const buildInfoEvent = (
content: NWC_METHODS.join(' '), content: NWC_METHODS.join(' '),
}, },
walletSecretKey, walletSecretKey,
) );
+110 -93
View File
@@ -14,60 +14,60 @@
// foreground-only design itself is documented in the nwc.ts façade // foreground-only design itself is documented in the nwc.ts façade
// header. // header.
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import {readNwcConnections} from '../storage/nwcConnections' import { readNwcConnections } from '../storage/nwcConnections';
import {assertSavedKeyOwner} from '../storage/currentOwner' import { assertSavedKeyOwner } from '../storage/currentOwner';
import type {NwcConnectionInfo} from './connection' import type { NwcConnectionInfo } from './connection';
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection' import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context' import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
import {dispatch} from './dispatch' import { dispatch } from './dispatch';
import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol' import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol' import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
import type {NwcSubscription} from './transport' import type { NwcSubscription } from './transport';
import {defaultNwcTransport} from './transport' import { defaultNwcTransport } from './transport';
export type {NwcConnectionInfo} export type { NwcConnectionInfo };
export type {NwcChangeset, NwcServiceDeps} from './context' export type { NwcChangeset, NwcServiceDeps } from './context';
// requests older than this are dropped unanswered (see the header) // requests older than this are dropped unanswered (see the header)
const MAX_REQUEST_AGE_SECONDS = 600 const MAX_REQUEST_AGE_SECONDS = 600;
type ConnectionRuntime = { type ConnectionRuntime = {
info: NwcConnectionInfo info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains // 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: // invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers // pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet // 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 // serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave // the spend record can't interleave
queue: Promise<unknown> queue: Promise<unknown>;
sub: NwcSubscription sub: NwcSubscription;
} };
export type NwcService = { export type NwcService = {
// a snapshot of the served connections at startup; the persisted // a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state // records (storage/nwcConnections) carry the live budget state
connections: NwcConnectionInfo[] connections: NwcConnectionInfo[];
// closes every relay subscription. In-flight handlers still finish - // closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up // their changesets hold money - but no new requests are picked up
stop: () => Promise<void> stop: () => Promise<void>;
} };
export const startService = async ( export const startService = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
deps: NwcServiceDeps, deps: NwcServiceDeps,
records?: NwcConnectionRecord[], records?: NwcConnectionRecord[],
): Promise<NwcService> => { ): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport()) const transport = deps.transport ?? (await defaultNwcTransport());
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter( const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId, (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 ( const publishResponse = async (
runtime: ConnectionRuntime, runtime: ConnectionRuntime,
@@ -86,43 +86,54 @@ export const startService = async (
response, response,
nowSeconds(), nowSeconds(),
), ),
) );
} };
// pay_invoice goes through the connection's queue (budget atomicity); let invoiceQueue: Promise<unknown> = Promise.resolve();
// everything else dispatches directly
// 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 = ( const dispatchSerialized = (
runtime: ConnectionRuntime, runtime: ConnectionRuntime,
ctx: RequestContext, ctx: RequestContext,
request: NwcRequest, request: NwcRequest,
): Promise<NwcResponse> => { ): 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( const run = runtime.queue.then(
() => dispatch(ctx, request), () => dispatch(ctx, request),
() => dispatch(ctx, request), () => dispatch(ctx, request),
) );
runtime.queue = run.catch(() => undefined) runtime.queue = run.catch(() => undefined);
return run return run;
} };
const handleEvent = async ( const handleEvent = async (
runtime: ConnectionRuntime, runtime: ConnectionRuntime,
ctx: RequestContext, ctx: RequestContext,
event: NostrEvent, event: NostrEvent,
): Promise<void> => { ): Promise<void> => {
const walletSecret = runtime.walletSecret const walletSecret = runtime.walletSecret;
if (walletSecret === null) return if (walletSecret === null) return;
const at = nowSeconds() const at = nowSeconds();
// replay safety (see the header): too-old requests are dropped // 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( const decrypted = decryptRequest(
walletSecret, walletSecret,
runtime.info.walletServicePubkey, runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey, runtime.info.record.clientPubkey,
event, event,
at, at,
) );
if (decrypted === null) return if (decrypted === null) return;
if (decrypted.respond) { if (decrypted.respond) {
await publishResponse( await publishResponse(
runtime, runtime,
@@ -130,32 +141,32 @@ export const startService = async (
event.id, event.id,
decrypted.encryption, decrypted.encryption,
decrypted.response, decrypted.response,
) );
return return;
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
} }
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; // interrupts long observation waits (the invoice claim poll) at stop;
// the drain below still awaits tasks that reached a fund-critical commit // the drain below still awaits tasks that reached a fund-critical commit
const stopController = new AbortController() const stopController = new AbortController();
const inFlight = new Set<Promise<void>>() const inFlight = new Set<Promise<void>>();
const track = (task: Promise<void>): void => { const track = (task: Promise<void>): void => {
inFlight.add(task) inFlight.add(task);
void task.then( void task.then(
() => inFlight.delete(task), () => inFlight.delete(task),
() => inFlight.delete(task), () => inFlight.delete(task),
) );
} };
const startBackground = (work: () => Promise<void>): boolean => { const startBackground = (work: () => Promise<void>): boolean => {
if (!accepting) return false if (!accepting) return false;
track(work()) track(work());
return true return true;
} };
let runtimes = ownedRecords.map((record) => { let runtimes = ownedRecords.map((record) => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
const runtime: ConnectionRuntime = { const runtime: ConnectionRuntime = {
info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) }, info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
walletSecret, walletSecret,
@@ -164,22 +175,22 @@ export const startService = async (
// replaced below, immediately - the field exists because the // replaced below, immediately - the field exists because the
// subscription callback closes over the runtime // subscription callback closes over the runtime
sub: { close: () => undefined }, sub: { close: () => undefined },
} };
const ctx: RequestContext = { const ctx: RequestContext = {
deps, deps,
connection: () => runtime.info, connection: () => runtime.info,
updateRecord: (updated) => { updateRecord: (updated) => {
runtime.info = {...runtime.info, record: updated} runtime.info = { ...runtime.info, record: updated };
}, },
invoices: runtime.invoices, invoices: runtime.invoices,
nowSeconds, nowSeconds,
assertOwner: () => { assertOwner: () => {
deps.assertCurrentOwner() deps.assertCurrentOwner();
assertSavedKeyOwner(ownerId) assertSavedKeyOwner(ownerId);
}, },
startBackground, startBackground,
stopSignal: stopController.signal, stopSignal: stopController.signal,
} };
runtime.sub = transport.subscribe( runtime.sub = transport.subscribe(
record.relays, record.relays,
{ {
@@ -188,56 +199,62 @@ export const startService = async (
since: nowSeconds(), since: nowSeconds(),
}, },
(event) => { (event) => {
if (!accepting) return if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => { const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info) deps.onError?.(err, runtime.info);
}) });
track(handler) track(handler);
}, },
) );
return runtime return runtime;
}) });
// info events: best-effort - a rejected publish must not sink startup; // info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too // the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) { for (const runtime of runtimes) {
const walletSecret = runtime.walletSecret const walletSecret = runtime.walletSecret;
if (walletSecret === null) continue if (walletSecret === null) continue;
try { try {
await transport.publish( await transport.publish(
runtime.info.record.relays, runtime.info.record.relays,
buildInfoEvent(walletSecret, nowSeconds()), buildInfoEvent(walletSecret, nowSeconds()),
) );
} catch (error) { } catch (error) {
deps.onError?.( 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, runtime.info,
) );
} }
} }
const connections = runtimes.map((runtime) => runtime.info) const connections = runtimes.map((runtime) => runtime.info);
let stopPromise: Promise<void> | null = null let stopPromise: Promise<void> | null = null;
const stop = (): Promise<void> => { const stop = (): Promise<void> => {
if (stopPromise !== null) return stopPromise if (stopPromise !== null) return stopPromise;
accepting = false accepting = false;
for (const runtime of runtimes) runtime.sub.close() for (const runtime of runtimes) runtime.sub.close();
stopController.abort() stopController.abort();
stopPromise = Promise.all([...inFlight]) stopPromise = Promise.allSettled([...inFlight])
.then(() => undefined) .then((results) => {
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
})
.finally(() => { .finally(() => {
for (const runtime of runtimes) { for (const runtime of runtimes) {
runtime.walletSecret?.fill(0) runtime.walletSecret?.fill(0);
runtime.walletSecret = null runtime.walletSecret = null;
runtime.invoices.clear() runtime.invoices.clear();
}
runtimes = []
})
return stopPromise
} }
runtimes = [];
});
return stopPromise;
};
return { return {
connections, connections,
stop, stop,
} };
} };
+16 -15
View File
@@ -4,36 +4,37 @@
// importing the service module never opens (or even references) a // importing the service module never opens (or even references) a
// WebSocket. // 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 = { export type NwcSubscription = {
close: () => void close: () => void;
} };
export type NwcTransport = { export type NwcTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void> publish: (relays: string[], event: NostrEvent) => Promise<void>;
subscribe: ( subscribe: (
relays: string[], relays: string[],
filter: NostrFilter, filter: NostrFilter,
onEvent: (event: NostrEvent) => void, onEvent: (event: NostrEvent) => void,
) => NwcSubscription ) => NwcSubscription;
} };
export const defaultNwcTransport = async (): Promise<NwcTransport> => { export const defaultNwcTransport = async (): Promise<NwcTransport> => {
const {SimplePool} = await import('nostr-tools/pool') const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool() const pool = new SimplePool();
return { return {
publish: async (relays, event) => { 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 // one honest relay accepting is enough - same rule as the backup
if (!results.some((r) => r.status === 'fulfilled')) { 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');
});
});
+328 -130
View File
@@ -1,163 +1,361 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
import type {Bearer} from './types' import type { Bearer } from './types';
import type {CarveResult} from './ops' import type { CarveResult } from './ops';
import {UncertainOutcomeError, ensureExactAmount} from './ops' import {
import {requiredValue} from './test-utils' CarveCheckpointRequiredError,
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' OutputSecretAllocationRequiredError,
UncertainOutcomeError,
ensureExactAmount,
} from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
secret,
signatureKeysFor,
} from './ops.testHarness';
describe('ensureExactAmount', () => { describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => { it('returns an already-exact note untouched, burning nothing', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('01') const k1 = secret('01');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 21_000) const result = await ensureExactAmount([bearer], 21_000);
expect(noteK1(result.note.url)).toBe(k1) expect(noteK1(result.note.url)).toBe(k1);
expect(result.consumed).toEqual([]) expect(result.consumed).toEqual([]);
expect(result.change).toBeUndefined() expect(result.change).toBeUndefined();
expect(instance.state.noteState(k1)).toBe('outstanding') expect(instance.state.noteState(k1)).toBe('outstanding');
}) });
it('split path: carves an exact note off a larger one, with change', async () => { it('split path: carves an exact note off a larger one, with change', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('02') const k1 = secret('02');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000) const result = await ensureExactAmount([bearer], 5_000, {
expect(result.note.amount).toBe(5_000) onCarve: () => undefined,
expect(result.note.verified).toBe(true) allocateOutputSecrets,
expect(result.change?.amount).toBe(16_000) mintSignatureKeys: signatureKeysFor(instance),
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) });
expect(instance.state.noteState(k1)).toBe('burned') expect(result.note.amount).toBe(5_000);
const partK1 = requiredValue(noteK1(result.note.url)) expect(result.note.verified).toBe(true);
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(result.change?.amount).toBe(16_000);
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) 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 () => { it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('03'), 3_000) const first = await makeBearer(instance, secret('03'), 3_000);
const second = await makeBearer(instance, secret('04'), 4_000) const second = await makeBearer(instance, secret('04'), 4_000);
const result = await ensureExactAmount([first, second], 7_000) const result = await ensureExactAmount([first, second], 7_000, {
expect(result.note.amount).toBe(7_000) onCarve: () => undefined,
expect(result.change).toBeUndefined() allocateOutputSecrets,
expect(result.consumed).toHaveLength(2) });
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned') expect(result.note.amount).toBe(7_000);
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned') expect(result.change).toBeUndefined();
const mergedK1 = requiredValue(noteK1(result.note.url)) expect(result.consumed).toHaveLength(2);
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000) 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 () => { it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('05'), 3_000) const first = await makeBearer(instance, secret('05'), 3_000);
const second = await makeBearer(instance, secret('06'), 4_000) const second = await makeBearer(instance, secret('06'), 4_000);
const result = await ensureExactAmount([first, second], 5_000) const result = await ensureExactAmount([first, second], 5_000, {
expect(result.note.amount).toBe(5_000) onCarve: () => undefined,
expect(result.change?.amount).toBe(2_000) allocateOutputSecrets,
expect(result.consumed).toHaveLength(2) });
const partK1 = requiredValue(noteK1(result.note.url)) expect(result.note.amount).toBe(5_000);
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(result.change?.amount).toBe(2_000);
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) expect(result.consumed).toHaveLength(2);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000) 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 () => { it('excludes spent and unverified notes from selection', async () => {
const instance = await mint() const instance = await mint();
const spentBearer = await makeBearer(instance, secret('07'), 50_000) const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = { const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)), ...(await makeBearer(instance, secret('08'), 50_000)),
callback: '', verified: false,
} };
await expect( await expect(
ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000), ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/);
}) });
it('refuses an amount no mint can cover', async () => { it('refuses an amount no mint can cover', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('09'), 5_000) const bearer = await makeBearer(instance, secret('09'), 5_000);
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/) await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/);
}) });
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => { it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
const instance = await mint({dropAfterMutation: true}) const instance = await mint({ dropAfterMutation: true });
const k1 = secret('10') const k1 = secret('10');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000) const result = await ensureExactAmount([bearer], 5_000, {
const partK1 = requiredValue(noteK1(result.note.url)) onCarve: () => undefined,
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) allocateOutputSecrets,
expect(partK1).not.toBe(k1) });
expect(instance.state.noteState(k1)).toBe('burned') const partK1 = requiredValue(noteK1(result.note.url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) 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 () => { it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint({dropAfterMutation: true}) const instance = await mint();
const k1 = secret('11') const k1 = secret('11');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const probeKillingFetch: typeof fetch = (input, init) => { // every mutation answer is lost - the first attempt's AND the forced
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url // byte-identical replay's (the mint executed both; the replay is the
if (url.includes('/w/cb')) return fetch(input, init) // recorded success served again, then dropped by the wrapper) - and
return Promise.reject(new Error('probe unreachable')) // 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, { const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch, fetch: everythingDyingFetch,
}).catch((error: unknown) => error) allocateOutputSecrets,
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)
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)
})
it('reports a mutation-bearing carve through onCarve before returning', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = []
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) checkpoints.push(carve);
}, },
}) }).catch((error: unknown) => error);
// the hook saw exactly the returned result, once expect(failure).toBeInstanceOf(UncertainOutcomeError);
expect(seen).toHaveLength(1) if (!(failure instanceof UncertainOutcomeError)) throw failure;
expect(seen[0]).toBe(result) expect(failure.possibleOutputs).toEqual([]);
expect(result.consumed).toHaveLength(1) 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('never fires onCarve for an already-exact note - nothing was burned', async () => { it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('13'), 21_000) const bearer = await makeBearer(instance, secret('12'), 21_000);
let called = false const seen: CarveResult[] = [];
const result = await ensureExactAmount([bearer], 21_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => { allocateOutputSecrets,
called = true onCarve: (carve) => {
seen.push(carve);
}, },
}) mintSignatureKeys: signatureKeysFor(instance),
expect(called).toBe(false) });
expect(result.consumed).toEqual([]) 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[] = [];
it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect( await expect(
ensureExactAmount([bearer], 5_000, { ensureExactAmount([bearer], 5_000, {
onCarve: () => { allocateOutputSecrets,
throw new Error('commit failed') onCarve: (carve) => {
seen.push(carve);
if (carve.consumed.length > 0) throw new Error('final commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/final commit failed/);
})
}) expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined();
expect(instance.state.noteState(sourceSecret)).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
'outstanding',
);
expect(
instance.state.noteState(
requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
),
).toBe('outstanding');
});
it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('13'), 21_000);
let called = false;
const result = await ensureExactAmount([bearer], 21_000, {
onCarve: () => {
called = true;
},
});
expect(called).toBe(false);
expect(result.consumed).toEqual([]);
});
it('requires a typed durable checkpoint before a mutating carve', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('16'), 21_000);
await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
CarveCheckpointRequiredError,
);
expect(instance.state.noteState(secret('16'))).toBe('outstanding');
});
it('propagates an onCarve failure before the carve reaches the mint', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('14'), 21_000);
await expect(
ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: () => {
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/);
expect(instance.state.noteState(secret('14'))).toBe('outstanding');
});
it('draws the split outputs from the caller allocation path, exactly two', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('80'), 21_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('81'), secret('82')];
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 2 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]);
expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]);
});
it('draws the merge output from the caller allocation path, exactly one', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('83'), 3_000);
const second = await makeBearer(instance, secret('84'), 4_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('85')];
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]);
});
it('allocates nothing when one note already holds the exact amount', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('86'), 21_000);
let allocations = 0;
await ensureExactAmount([bearer], 21_000, {
allocateOutputSecrets: () => {
allocations += 1;
return Promise.resolve([]);
},
});
expect(allocations).toBe(0);
});
it('refuses a mutating carve without an allocation path, before any staging', async () => {
const instance = await mint();
const split = await makeBearer(instance, secret('87'), 21_000);
const mergeA = await makeBearer(instance, secret('88'), 3_000);
const mergeB = await makeBearer(instance, secret('89'), 4_000);
let mutations = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutations += 1;
return fetch(input, init);
};
await expect(
ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
await expect(
ensureExactAmount([mergeA, mergeB], 7_000, {
fetch: countingFetch,
onCarve: () => undefined,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(mutations).toBe(0);
expect(instance.state.noteState(secret('87'))).toBe('outstanding');
expect(instance.state.noteState(secret('88'))).toBe('outstanding');
expect(instance.state.noteState(secret('89'))).toBe('outstanding');
});
});
+93 -102
View File
@@ -1,121 +1,112 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {hashK1, noteK1} from 'lnurlcash-kit' import { hashK1, noteK1 } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint} from './ops' import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import type {Mint} from './ops.testHarness' import type { Mint } from './ops.testHarness';
import {mint, settleLastInvoice} from './ops.testHarness' import {
allocateOutputSecrets,
mint,
persistOutput,
secret,
settleLastInvoice,
} from './ops.testHarness';
const HEX32 = /^[0-9a-f]{64}$/ const HEX32 = /^[0-9a-f]{64}$/;
// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) => const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo) [...instance.state.invoices.values()].find((invoice) => invoice.boundTo);
describe('mint -> claim, named output (comment/mintToHash)', () => { describe('mint -> claim, comment-bound output', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => { it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
expect(prepared.mode).toBe('named') persistOutput,
const noteSecret = requiredValue(prepared.noteSecret) allocateOutputSecrets,
expect(noteSecret).toMatch(HEX32) });
// the mint bound the quote to sha256 of the wallet's secret - the kit expect(prepared.noteSecret).toMatch(HEX32);
// sends it as both comment and h, the mock read one of them expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret)) });
})
it('claims a named output at the wallet secret, without a rotate', async () => { it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
const noteSecret = requiredValue(prepared.noteSecret) persistOutput,
const preimage = await settleLastInvoice(instance) allocateOutputSecrets,
});
const preimage = await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(claimed.rotated).toBe(true) expect(claimed.note.verified).toBe(true);
expect(claimed.note.verified).toBe(true) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(noteK1(claimed.note.url)).toBe(noteSecret) expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
// no rotate happened: the note still stands at the wallet's own expect(instance.state.noteState(preimage)).toBeNull();
// secret, and the payment preimage keys nothing at all });
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(instance.state.noteState(preimage)).toBeNull()
})
it('names the output through the mintToHash spelling too', async () => { it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({mintToHash: true, testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
expect(prepared.mode).toBe('named') persistOutput,
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret))) allocateOutputSecrets,
const preimage = await settleLastInvoice(instance) });
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull()
})
it('keeps an unnamed mint on the preimage path', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output
expect(boundInvoice(instance)).toBeUndefined()
})
it('times out cleanly when a named mint invoice is never paid', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect( await expect(
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }), claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
).rejects.toThrow(/not confirmed/i) ).rejects.toThrow(/not confirmed/i);
}) });
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => { it('uses the required comment even when the optional h extension is advertised', async () => {
// the mintToHashIgnoresH adversary: claims the capability, binds const instance = await mint({ mintToHash: true, testHooks: true });
// nothing, credits the payment hash - the preimage is still the money const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
const instance = await mint({ persistOutput,
commentAllowed: 64, allocateOutputSecrets,
mintToHashIgnoresH: true, });
verifyOnUnnamedMint: true, expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
testHooks: true, await settleLastInvoice(instance);
})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
expect(instance.state.noteState(preimage)).toBe('outstanding')
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 20, intervalCapMs: 50,
maxWaitMs: 300, maxWaitMs: 5_000,
}) });
expect(claimed.rotated).toBe(true) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(claimed.note.verified).toBe(true) expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
// claimed through the preimage path and rotated off it, because that });
// preimage rode the invoice
expect(instance.state.noteState(preimage)).toBe('burned') it('draws the staged note secret from the caller allocation path', async () => {
const k1 = requiredValue(noteK1(claimed.note.url)) const instance = await mint({ testHooks: true });
expect(k1).not.toBe(preimage) const allocations: Array<{ server: string; count: number }> = [];
expect(instance.state.noteState(k1)).toBe('outstanding') 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;
it('fails loudly when the mint credits the preimage and serves no verify', async () => {
const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
// verify is withheld on the unnamed fallback - there the preimage IS
// the note, so a compliant mint never serves it
expect(prepared.verifyUrl).toBeNull()
const preimage = await settleLastInvoice(instance)
await expect( await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}), prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
).rejects.toThrow(/not confirmed/i) persistOutput: () => {
// the money exists at the preimage but is unreachable without verify - persisted += 1;
// the wallet must NOT report a success here },
expect(instance.state.noteState(preimage)).toBe('outstanding') }),
}) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
})
expect(persisted).toBe(0);
expect(instance.state.invoices.size).toBe(0);
});
});
+157 -94
View File
@@ -1,4 +1,4 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import { import {
NoteSpentError, NoteSpentError,
PendingNoteError, PendingNoteError,
@@ -6,118 +6,181 @@ import {
meltNote, meltNote,
noteK1, noteK1,
rotateNote, rotateNote,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {
import {requiredValue} from './test-utils' claimMintedNote,
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness' OutputSecretAllocationRequiredError,
prepareMint,
describe('mint -> claim -> rotate', () => { receiveBearer,
it('mints a note from a paid invoice and rotates it immediately', async () => { ReceiveRotationStagingRequiredError,
const instance = await mint({testHooks: true}) } from './ops';
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) import { requiredValue } from './test-utils';
expect(prepared.invoice).toMatch(/^lnbc/) import type { NewBearer } from './types';
expect(prepared.verifyUrl).toBeTruthy() import {
expect(prepared.expectedNoteValueMsat).toBe(21_000) allocateOutputSecrets,
const preimage = await settleLastInvoice(instance) makeBearer,
const claimed = await claimMintedNote(prepared, { mint,
intervalMs: 10, noteUrl,
intervalCapMs: 50, persistOutput,
maxWaitMs: 5_000, secret,
}) settleLastInvoice,
expect(claimed.rotated).toBe(true) stageRotation,
expect(claimed.note.amount).toBe(21_000) } from './ops.testHarness';
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')
})
describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => { it('grosses the invoice up for an advertised mint fee', async () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) 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) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
expect(prepared.grossMsat).toBeGreaterThan(100_000) persistOutput,
await settleLastInvoice(instance) allocateOutputSecrets,
});
expect(prepared.grossMsat).toBeGreaterThan(100_000);
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 20, intervalCapMs: 20,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
// the service's maxWithdrawable is authoritative: the fee is withheld // the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross // 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).toBeGreaterThanOrEqual(99_000);
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
expect(claimed.rotated).toBe(true) });
}) });
it('refuses a verify response naming a different invoice', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await settleLastInvoice(instance)
// a verify answer that names any invoice but the requested one must
// stop the claim - the preimage it carries belongs to another payment
const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}`
await expect(
claimMintedNote({...prepared, invoice: other}, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
}),
).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)
})
})
describe('receiveBearer', () => { 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 () => { it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('20') const senderK1 = secret('20');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), []) const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
expect(received.rotated).toBe(true) allocateOutputSecrets,
expect(received.note.amount).toBe(21_000) stageRotation,
expect(received.note.verified).toBe(true) });
const newK1 = requiredValue(noteK1(received.note.url)) expect(received.rotated).toBe(true);
expect(newK1).not.toBe(senderK1) expect(received.stage).toBe('finalize');
expect(instance.state.noteState(senderK1)).toBe('burned') expect(received.note.amount).toBe(21_000);
expect(instance.state.noteState(newK1)).toBe('outstanding') 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 () => { it('refuses a note the wallet already holds', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('21') const senderK1 = secret('21');
const existing = await makeBearer(instance, senderK1, 21_000) const existing = await makeBearer(instance, senderK1, 21_000);
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow( await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
/already/, /already/,
) );
}) });
it('surfaces a spent note as definitively spent', async () => { it('surfaces a spent note as definitively spent', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('22') const k1 = secret('22');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const info = await fetchNoteInfo(bearer.url) const info = await fetchNoteInfo(bearer.url);
await rotateNote(info.callback, k1) await rotateNote(info.callback, k1);
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError, NoteSpentError,
) );
}) });
it('surfaces a note locked mid-melt as pending, not as unverified', async () => { it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const instance = await mint({meltNeverSettles: true}) const instance = await mint({ meltNeverSettles: true });
const k1 = secret('23') const k1 = secret('23');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( await expect(
PendingNoteError, 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');
});
});
+184 -65
View File
@@ -1,98 +1,217 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1} from 'lnurlcash-kit' import { noteK1 } from 'lnurlcash-kit';
import {payWithBearers} from './ops' import {
import {requiredValue} from './test-utils' OutputSecretAllocationRequiredError,
import {makeBearer, mint, secret} from './ops.testHarness' PayReturnCheckpointRequiredError,
payWithBearers,
} from './ops';
import { requiredValue } from './test-utils';
import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
describe('payWithBearers', () => { describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => { it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('30'), 21_000) const bearer = await makeBearer(instance, secret('30'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('30'))).toBe('burned') expect(instance.state.noteState(secret('30'))).toBe('burned');
}) });
it('pays a Lightning Address by requesting an invoice first', async () => { it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint() const payer = await mint();
const payee = await mint() const bearer = await makeBearer(payer, secret('31'), 21_000);
const bearer = await makeBearer(payer, secret('31'), 21_000) const payeeFetch: typeof fetch = (input, init) => {
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, { 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, amountMsat: 21_000,
kit: { fetch: payeeFetch },
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/) expect(result.invoice).toMatch(/^lnbc/);
expect(payer.state.noteState(secret('31'))).toBe('burned') expect(payer.state.noteState(secret('31'))).toBe('burned');
}) });
it('carves the exact amount out of a larger note before melting', async () => { it('carves the exact amount out of a larger note before melting', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('32'), 50_000) const bearer = await makeBearer(instance, secret('32'), 50_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
}) allocateOutputSecrets,
expect(result.outcome).toBe('settled') onCarve: () => undefined,
expect(instance.state.noteState(secret('32'))).toBe('burned') });
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.outcome).toBe('settled');
expect(result.carve.change?.amount).toBe(29_000) expect(instance.state.noteState(secret('32'))).toBe('burned');
const change = requiredValue(result.carve.change) expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding') 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 () => { it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('33'), 21_000) const bearer = await makeBearer(instance, secret('33'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) allocateOutputSecrets,
expect(result.outcome).toBe('failed-funds-returned') onReturnReady: () => undefined,
expect(instance.state.noteState(secret('33'))).toBe('burned') });
// the returned funds come back re-secured at the classification expect(result.outcome).toBe('failed-funds-returned');
// rotate's fresh secret - carried as rotatedNote, NOT folded into the expect(instance.state.noteState(secret('33'))).toBe('burned');
// carve (which an onCarve checkpoint may already have committed) const rotatedNote = requiredValue(result.rotatedNote);
const rotatedNote = requiredValue(result.rotatedNote) expect(rotatedNote.verified).toBe(true);
expect(rotatedNote.verified).toBe(true) expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
const returnedK1 = requiredValue(noteK1(rotatedNote.url)) });
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(rotatedNote.amount).toBe(21_000) 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 () => { it('classifies a never-settling melt as unknown-still-pending', async () => {
const instance = await mint({meltNeverSettles: true}) const instance = await mint({ meltNeverSettles: true });
const bearer = await makeBearer(instance, secret('34'), 21_000) const bearer = await makeBearer(instance, secret('34'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) allocateOutputSecrets,
expect(result.outcome).toBe('unknown-still-pending') onReturnReady: () => undefined,
expect(instance.state.noteState(secret('34'))).toBe('pending') });
}) 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 () => { it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('35'), 21_000) const bearer = await makeBearer(instance, secret('35'), 21_000);
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/);
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) 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 () => { it('aborts before the melt when the carve commit hook fails', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('36'), 21_000) const bearer = await makeBearer(instance, secret('36'), 21_000);
// paying 10_500 off a 21_000 note forces a split, which must commit // paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start // through the hook before the melt may start
await expect( await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
allocateOutputSecrets,
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/);
// the split landed (the input is burned) but the melt never happened: // The checkpoint failed before either split or melt reached the mint.
// nothing sits pending at the mint expect(instance.state.noteState(secret('36'))).toBe('outstanding');
expect(instance.state.noteState(secret('36'))).toBe('burned') expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
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');
});
});
+70 -63
View File
@@ -1,17 +1,18 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1} from 'lnurlcash-kit' import { noteK1 } from 'lnurlcash-kit';
import {ensureExactAmount, receiveBearer} from './ops' import type { CarveResult } from './ops';
import {claimFromPreimage, prepareMint} from './ops/mint' import { ensureExactAmount, receiveBearer } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import { import {
allocateOutputSecrets,
makeBearer, makeBearer,
mint, mint,
noteUrl, noteUrl,
retryingCbFetch, retryingCbFetch,
secret, secret,
settleLastInvoice, stageRotation,
} from './ops.testHarness' } from './ops.testHarness';
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint // 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 // can execute the first attempt of a mutation and refuse the byte-identical
@@ -21,70 +22,76 @@ import {
// carried secrets are the only money left. // carried secrets are the only money left.
describe('mutation retry refusals', () => { describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => { it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('60') const k1 = secret('60');
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000);
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}) const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined,
});
// the split landed on the first (unseen) attempt: the input is burned // the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried // and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned');
expect(result.consumed).toHaveLength(1) expect(result.consumed).toHaveLength(1);
const partK1 = requiredValue(noteK1(result.note.url)) const partK1 = requiredValue(noteK1(result.note.url));
expect(partK1).not.toBe(k1) expect(partK1).not.toBe(k1);
expect(result.note.amount).toBe(25_000) expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(partK1)).toBe('outstanding') expect(instance.state.noteState(partK1)).toBe('outstanding');
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(changeK1)).toBe('outstanding') expect(instance.state.noteState(changeK1)).toBe('outstanding');
}) });
it('rescues the combined note when the merge answer is a retry refusal', async () => { it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const a = await makeBearer(instance, secret('61'), 20_000) const a = await makeBearer(instance, secret('61'), 20_000);
const b = await makeBearer(instance, secret('62'), 5_000) const b = await makeBearer(instance, secret('62'), 5_000);
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()}) const result = await ensureExactAmount([a, b], 25_000, {
expect(result.consumed).toHaveLength(2) fetch: retryingCbFetch(),
expect(instance.state.noteState(secret('61'))).toBe('burned') allocateOutputSecrets,
expect(instance.state.noteState(secret('62'))).toBe('burned') onCarve: () => undefined,
const mergedK1 = requiredValue(noteK1(result.note.url)) });
expect(result.note.amount).toBe(25_000) expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(mergedK1)).toBe('outstanding') expect(instance.state.noteState(secret('61'))).toBe('burned');
}) expect(instance.state.noteState(secret('62'))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => { expect(result.note.amount).toBe(25_000);
const instance = await mint({testHooks: true}) expect(instance.state.noteState(mergedK1)).toBe('outstanding');
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) });
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
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('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('63') const senderK1 = secret('63');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
}) allocateOutputSecrets,
expect(received.rotated).toBe(true) stageRotation,
expect(received.note.amount).toBe(21_000) });
expect(instance.state.noteState(senderK1)).toBe('burned') expect(received.rotated).toBe(true);
const k1 = requiredValue(noteK1(received.note.url)) expect(received.note.amount).toBe(21_000);
expect(k1).not.toBe(senderK1) expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(k1)).toBe('outstanding') 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 () => { it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('64') const k1 = secret('64');
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000);
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = [];
await expect( await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}), ensureExactAmount([bearer], 25_000, {
).rejects.toThrow(/spent/i) 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 -6
View File
@@ -1,6 +1,8 @@
import './ops.carve.cases' import './ops.carve.cases';
import './ops.mint-named.cases' import './ops.carve-crash.cases';
import './ops.mint-receive.cases' import './ops.signature.cases';
import './ops.pay.cases' import './ops.mint-named.cases';
import './ops.retry-refusal.cases' import './ops.mint-receive.cases';
import './ops.transfer.cases' import './ops.pay.cases';
import './ops.retry-refusal.cases';
import './ops.transfer.cases';
+70 -43
View File
@@ -1,42 +1,69 @@
import {afterEach, expect} from 'vitest' import { afterEach, expect } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
import type {Bearer} from './types' import type { Bearer } from './types';
import {requiredValue} from './test-utils' 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> => { export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options) const instance = await createMockMint(options);
mints.push(instance) mints.push(instance);
return instance return instance;
} };
afterEach(async () => { 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 => 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 => 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 ( export const makeBearer = async (
instance: Mint, instance: Mint,
k1: string, k1: string,
amountMsat: number, amountMsat: number,
): Promise<Bearer> => { ): Promise<Bearer> => {
instance.state.creditNote(k1, amountMsat) instance.state.creditNote(k1, amountMsat);
const url = noteUrl(instance, k1, amountMsat) const url = noteUrl(instance, k1, amountMsat);
const info = await fetchNoteInfo(url) const info = await fetchNoteInfo(url);
fixtureCounter += 1 fixtureCounter += 1;
return { return {
id: `fixture-${fixtureCounter}`, id: `fixture-${fixtureCounter}`,
url, url,
@@ -46,29 +73,29 @@ export const makeBearer = async (
mintPubkey: instance.state.pubkey, mintPubkey: instance.state.pubkey,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} };
} };
export const settleLastInvoice = async (instance: Mint): Promise<string> => { export const settleLastInvoice = async (instance: Mint): Promise<string> => {
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1)) const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1));
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`) const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`);
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`) if (!response.ok) throw new Error(`settle hook failed: ${response.status}`);
return requiredValue(instance.state.invoices.get(paymentHash)).preimage return requiredValue(instance.state.invoices.get(paymentHash)).preimage;
} };
export const settleWhenRequested = async (instance: Mint): Promise<string> => { export const settleWhenRequested = async (instance: Mint): Promise<string> => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) { 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> => { export const expectBurned = async (instance: Mint, k1: string): Promise<void> => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) { 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 // 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 // its SECOND, byte-identical send - the HTTP-stack GET retry a real client
@@ -78,13 +105,13 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
// ever sees // ever sees
export const retryingCbFetch = (): typeof fetch => { export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => { const impl: typeof fetch = async (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;
if (url.includes('/w/cb')) { if (url.includes('/w/cb')) {
const first = await fetch(input, init) const first = await fetch(input, init);
await first.arrayBuffer() await first.arrayBuffer();
return fetch(input, init) return fetch(input, init);
}
return fetch(input, init)
}
return impl
} }
return fetch(input, init);
};
return impl;
};
+296 -197
View File
@@ -1,260 +1,359 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {hashK1, noteK1} from 'lnurlcash-kit' import { hashK1, noteK1 } from 'lnurlcash-kit';
import {transferBetweenMints} from './ops' import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
import type {CarveResult} from './ops' import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
import {requiredValue} from './test-utils' import type { CarveResult, TransferOptions } from './ops';
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' 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', () => { 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}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) allocateOutputSecrets: (server, count) => {
const preimage = await settleWhenRequested(target) allocations.push({ server, count });
const result = await pending const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
expect(result.outcome).toBe('settled') return Promise.resolve(secrets.slice(0, count));
expect(result.invoice).toMatch(/^lnbc/) },
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({ expect(result.quote).toEqual({
requestedMsat: 21_000, requestedMsat: 21_000,
grossMsat: 21_000, grossMsat: 21_000,
targetMintFeeMsat: 0, targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0, sourceMeltFeeReserveMsat: 0,
}) });
expect(result.sourceServer).not.toBe(result.targetServer) expect(result.sourceServer).not.toBe(result.targetServer);
await expectBurned(source, k1) await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.verified).toBe(true);
expect(claimed.note.verified).toBe(true) expect(target.state.noteState(preimage)).toBeNull();
expect(target.state.noteState(preimage)).toBe('burned') const newK1 = requiredValue(noteK1(claimed.note.url));
const newK1 = requiredValue(noteK1(claimed.note.url)) expect(newK1).not.toBe(preimage);
expect(newK1).not.toBe(preimage) expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(newK1)).toBe('outstanding') });
})
it('refuses an amount no source mint can cover', async () => { it('refuses an amount no source mint can cover', async () => {
const source = await mint() const source = await mint();
const target = await mint() const target = await mint();
const k1 = secret('41') const k1 = secret('41');
const bearer = await makeBearer(source, k1, 5_000) const bearer = await makeBearer(source, k1, 5_000);
let persisted = false;
await expect( await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
).rejects.toThrow(/enough/) persistOutput: () => {
expect(source.state.noteState(k1)).toBe('outstanding') persisted = true;
}) },
}),
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('refuses an invoice the single selected note cannot cover, even when its mint holds more', async () => {
const source = await mint();
const target = await mint();
const selectedK1 = secret('55');
const untappedK1 = secret('56');
// the move page's selected-note constraint: the engine's source list is
// ONLY the picked note, so the sibling note at the same mint must not
// top the amount up
const selected = await makeBearer(source, selectedK1, 5_000);
await makeBearer(source, untappedK1, 50_000);
let persisted = false;
await expect(
transferBetweenMintsEngine([selected], 21_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true;
},
}),
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
expect(source.state.noteState(selectedK1)).toBe('outstanding');
expect(source.state.noteState(untappedK1)).toBe('outstanding');
});
it('rejects a transfer onto the mint the notes are already on', async () => { it('rejects a transfer onto the mint the notes are already on', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('42') const k1 = secret('42');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
await expect( await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`), transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
).rejects.toThrow(/different target/) ).rejects.toThrow(/different target/);
expect(instance.state.noteState(k1)).toBe('outstanding') expect(instance.state.noteState(k1)).toBe('outstanding');
}) });
it('moves nothing when the target mint is unreachable', async () => { it('moves nothing when the target mint is unreachable', async () => {
const source = await mint() const source = await mint();
const dead = await createMockMint() const dead = await createMockMint();
const deadAddress = `mint@127.0.0.1:${dead.port}` const deadAddress = `mint@127.0.0.1:${dead.port}`;
await dead.close() await dead.close();
const k1 = secret('43') const k1 = secret('43');
const bearer = await makeBearer(source, k1, 50_000) const bearer = await makeBearer(source, k1, 50_000);
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow() await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow();
expect(source.state.noteState(k1)).toBe('outstanding') 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 () => { it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({unconfirmedMutation: true}) const source = await mint({ unconfirmedMutation: true });
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('44') const k1 = secret('44');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) });
await settleWhenRequested(target) await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
await expectBurned(source, k1) await expectBurned(source, k1);
expect(result.mintedAtTarget?.note.amount).toBe(21_000) 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}`)
})
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint() const source = await mint();
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000}) const target = await mint({ baseFeeMsat: 1_000, feePpm: 2_000 });
const k1 = secret('46') const k1 = secret('46');
const bearer = await makeBearer(source, k1, 100_000) const bearer = await makeBearer(source, k1, 100_000);
await expect( await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`), transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/);
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
}) });
it('restores the source note, re-secured, when the melt fails', async () => { it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true}) const source = await mint({ meltAlwaysFails: true });
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('47') const k1 = secret('47');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned');
expect(result.mintedAtTarget).toBeUndefined() expect(result.mintedAtTarget).toBeUndefined();
expect(source.state.noteState(k1)).toBe('burned') expect(source.state.noteState(k1)).toBe('burned');
// the returned funds come back re-secured at the source-probe rotate's // the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve // fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed) // (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote) const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true) expect(rotatedNote.verified).toBe(true);
const returnedK1 = requiredValue(noteK1(rotatedNote.url)) const returnedK1 = requiredValue(noteK1(rotatedNote.url));
expect(returnedK1).not.toBe(k1) expect(returnedK1).not.toBe(k1);
expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(source.state.noteState(returnedK1)).toBe('outstanding');
expect(rotatedNote.amount).toBe(21_000) expect(rotatedNote.amount).toBe(21_000);
}) });
it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint() const source = await mint();
const target = await mint({commentAllowed: 64, testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('48') const k1 = secret('48');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) });
const preimage = await settleWhenRequested(target) const preimage = await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
await expectBurned(source, k1) await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.verified).toBe(true);
expect(claimed.note.verified).toBe(true) const newK1 = requiredValue(noteK1(claimed.note.url));
const newK1 = requiredValue(noteK1(claimed.note.url))
// the note stands at the wallet's own secret - the quote was bound to // 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 // 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.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
expect(target.state.noteState(newK1)).toBe('outstanding') expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(preimage)).toBeNull() expect(target.state.noteState(preimage)).toBeNull();
}) });
it('keeps the named claim material when the target never credits the note', async () => { it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true}) const source = await mint({ meltNeverSettles: true });
const target = await mint({commentAllowed: 64, testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('49') const k1 = secret('49');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
// the target invoice exists (the quote is bound) but the source melt // the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret // never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) { while (target.state.invoices.size === 0) {
await new Promise((resolve) => setTimeout(resolve, 10)) await new Promise((resolve) => setTimeout(resolve, 10));
} }
const result = await pending const result = await pending;
expect(result.outcome).toBe('unknown-still-pending') expect(result.outcome).toBe('unknown-still-pending');
expect(result.mintedAtTarget).toBeUndefined() expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's // 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 // payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note // unverified, so a late settlement is never a lost note
const noteSecret = requiredValue(result.claimMaterial?.noteSecret) const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/) expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
const material = requiredValue(result.claimMaterial?.note) const material = requiredValue(result.claimMaterial?.note);
expect(material.verified).toBe(false) expect(material.verified).toBe(false);
expect(noteK1(material.url)).toBe(noteSecret) expect(noteK1(material.url)).toBe(noteSecret);
}) });
it('rescues a named transfer through the preimage when the target credited it anyway', async () => { it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint() const source = await mint();
// the mintToHashIgnoresH adversary: advertises naming, credits the const target = await mint({ testHooks: true });
// payment hash - and (non-compliantly) still serves verify const bearer = await makeBearer(source, secret('51'), 21_000);
const target = await mint({ const seen: CarveResult[] = [];
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const k1 = secret('50')
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: 400},
})
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.rotated).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
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')
})
it('fires onCarve after the carve landed and before the melt starts', 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}`, { const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve);
// at hook time the melt must NOT have started: the carved note is if (carve.consumed.length === 0) {
// still outstanding (a melt would leave it pending) expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') 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) await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(seen).toHaveLength(1) expect(seen).toHaveLength(2);
expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) 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 () => { it('aborts before the melt when the carve commit hook fails', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('52') const k1 = secret('52');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
await expect( await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/);
// the split landed (the input is burned) but the melt never happened: // The checkpoint failed before either split or melt reached the source.
// nothing sits pending at the source, nothing was ever paid expect(source.state.noteState(k1)).toBe('outstanding');
expect(source.state.noteState(k1)).toBe('burned') expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
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');
});
});
+30 -17
View File
@@ -5,12 +5,9 @@
// new/changed notes out - it never mutates wallet state itself. // new/changed notes out - it never mutates wallet state itself.
// //
// Fund-critical invariants enforced across the flows (see the project plan): // Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint // - rotate every externally received bearer. Minted notes are staged at a
// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice // wallet-chosen secret before invoice creation and claimed there without
// knows the payment hash, and the mint necessarily saw the preimage - // rotating; the payment preimage is not bearer material
// the preimage IS the note secret). A NAMED mint (LUD-25 comment /
// mintToHash) credits the note to a wallet-chosen secret instead; that
// secret never rode an invoice, so no rotate follows its claim
// - a note's declared amount is a claim; the service's maxWithdrawable is // - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative // authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or // - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -28,20 +25,36 @@
// source, mint + claim at target) // source, mint + claim at target)
// ops/shared.ts - bounded polling, UncertainOutcomeError // ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared' export { UncertainOutcomeError } from './ops/shared';
export type {PollOptions} from './ops/shared' export type { FundOperationOptions, PollOptions } from './ops/shared';
export {ensureExactAmount} from './ops/carve' export { OutputSecretAllocationRequiredError } from './ops/allocation';
export type {CarveResult} from './ops/carve' export type { OutputSecretAllocator } from './ops/allocation';
export {prepareMint, claimMintedNote} from './ops/mint' export {
export type {PreparedMint, ClaimedNote} from './ops/mint' CarveCheckpointRequiredError,
export {receiveBearer} from './ops/receiveBearer' ensureExactAmount,
export {payWithBearers} from './ops/pay' UnsupportedMultiBatchMergeError,
export type {PayOutcome, PayResult, PayOptions} from './ops/pay' } from './ops/carve';
export {transferBetweenMints} from './ops/transfer' 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 { export type {
TransferClaimMaterial, TransferClaimMaterial,
TransferOptions, TransferOptions,
TransferOutcome, TransferOutcome,
TransferQuote, TransferQuote,
TransferResult, 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;
};
+207 -241
View File
@@ -2,40 +2,71 @@
// and/or splitting as needed, into a single fresh note worth exactly the // and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from. // target - the operation every send and every melt starts from.
// //
// A carve whose mutation landed reports itself through options.onCarve // Every output secret is chosen and reported through options.onCarve BEFORE
// BEFORE returning, so a caller about to do something slow (a settlement // its hash reaches the mint. That first checkpoint stages possible outputs
// wait) can durably commit first - a flow aborted mid-wait must never // without retiring inputs; a second checkpoint retires the inputs only after
// leave burned inputs looking spendable or strand the fresh outputs. // the mutation is known to have landed.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
mergeNotes, mergeBatches,
newSecretsOf, newSecretsOf,
noteK1, noteK1,
probeBurnedNote, probeBurnedNote,
requireNoteK1, requireNoteK1,
serverOf, serverOf,
settleNote,
splitNote, splitNote,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {FundOperationOptions} from './shared' import type { OutputSecretAllocator } from './allocation';
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared' 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 // The same shape serves both checkpoint phases: the pre-wire phase carries
// `consumed` - the mint call already burned every consumed input // note/change with an empty consumed list, and the landed phase carries the
// server-side, so the outputs are the only money left and must be tracked // already-staged note plus the inputs the caller can now retire.
// first; a crash between the two must strand a duplicate, never a secret
export type CarveResult = { export type CarveResult = {
// the exact-amount note, ready to hand over or melt // the exact-amount note, ready to hand over or melt
note: NewBearer note: NewBearer;
// the remainder note, when the carve split a larger input // 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 // the input notes burned server-side by the carve (empty when a single
// note already held exactly the target amount) // 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 // Selection: only notes that can actually take part - verified (callback
// known), not locally spent, holding a real k1 (device-backed mirrors are // known), not locally spent, holding a real k1 (device-backed mirrors are
@@ -46,53 +77,59 @@ export type CarveResult = {
// //
// Execution, mirroring lnurl-wallet's SendDialog: // Execution, mirroring lnurl-wallet's SendDialog:
// - one note already exact: returned as-is, nothing burned // - one note already exact: returned as-is, nothing burned
// - several notes summing exactly: one merge, then settle (reads the true // - several notes summing exactly: one merge; folds spanning multiple
// post-fee value back and rotates, since the read put k1 on the wire) // requests are rejected because partial progress needs a larger journal
// - total above target (one or many notes): a single split request (LUD-25 // - 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 // split takes many k1s - no merge round trip first); change stays
// settled for its true value; the target part carries the mint's // unverified until refresh because a second settle mutation would create
// signature and needs no settle // another crash window
export const ensureExactAmount = async ( export const ensureExactAmount = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
options: FundOperationOptions = {}, options: CarveOptions = {},
): Promise<CarveResult> => { ): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { 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( 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 // per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at // covered (an exact single-note match short-circuits - no mutation at
// all is always better than carving) // all is always better than carving)
const byServer = new Map<string, Bearer[]>() const byServer = new Map<string, Bearer[]>();
for (const b of eligible) { for (const b of eligible) {
const server = serverOf(b.url) const server = serverOf(b.url);
byServer.set(server, [...(byServer.get(server) ?? []), b]) byServer.set(server, [...(byServer.get(server) ?? []), b]);
} }
let pick: Bearer[] | null = null let pick: Bearer[] | null = null;
for (const group of byServer.values()) { for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount) const sorted = [...group].sort((a, b) => a.amount - b.amount);
const exact = sorted.find((b) => b.amount === amountMsat) const exact = sorted.find((b) => b.amount === amountMsat);
const candidate = exact ? [exact] : accumulate(sorted, amountMsat) const candidate = exact ? [exact] : accumulate(sorted, amountMsat);
if (!candidate) continue if (!candidate) continue;
if (!pick || better(candidate, pick, amountMsat)) pick = candidate if (!pick || better(candidate, pick, amountMsat)) pick = candidate;
} }
if (!pick) { 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 base = pick[0];
const total = pick.reduce((sum, b) => sum + b.amount, 0) const total = pick.reduce((sum, b) => sum + b.amount, 0);
const k1s = pick.map((b) => requireNoteK1(b.url)) const k1s = pick.map((b) => requireNoteK1(b.url));
// a carve that burned inputs reports itself the moment its outcome is const checkpoint = async (result: CarveResult): Promise<void> => {
// known, before the caller's flow moves on to anything slow or if (!options.onCarve) {
// uncertain - see FundOperationOptions.onCarve throw new CarveCheckpointRequiredError();
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
} }
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) { if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched // already exact - hand over the note itself, untouched
@@ -105,256 +142,185 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
}, },
consumed: [], consumed: [],
} };
} }
if (total === amountMsat) { if (total === amountMsat) {
// merge path: many notes, exact sum - merge into one, then settle it if (mergeBatches(base.callback, k1s).length !== 1) {
// (true value + fresh secret; a failed settle leaves an unverified throw new UnsupportedMultiBatchMergeError();
// note a refresh can repair, not a lost secret) }
assertFundOwner(options) if (!options.onCarve) {
const merged = await mergeAmbiguitySafe(base, k1s, total, options) throw new CarveCheckpointRequiredError();
const unverified: NewBearer = { }
url: withNewK1(base.url, merged.k1, total, merged.signature), const [mergeSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
1,
);
const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total),
callback: base.callback, callback: base.callback,
amount: total, amount: total,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
// a merge whose answer was lost leaves the service in an unknown await stage({ note: staged, consumed: pick });
// state from here - settling fires another mutation (the rotate assertFundOwner(options);
// inside settleNote) at it, whose own ambiguous failure would strand const merged = await mergeAmbiguitySafe(base, k1s, {
// the rescued secret. Don't compound: return unverified and let a ...mutationOptions,
// refresh repair. randomSecret: () => mergeSecret,
if (merged.rescued) return checkpoint({note: unverified, consumed: pick}) });
try { // a landed merge reports its signature (carried in the URL's sig param)
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) // and earns verified only when that signature checks against a trusted
return checkpoint({ // 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: { note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), ...staged,
callback: settled.callback, url: landedUrl,
amount: settled.amountMsat, verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
verified: true,
mintPubkey: base.mintPubkey,
}, },
consumed: pick, consumed: pick,
}) };
} catch { await retire(result.note);
return checkpoint({note: unverified, consumed: pick}) return result;
}
} }
// split path: total above target - one split request across all picked // split path: total above target - one split request across all picked
// k1s, carving the target off and leaving the change as a fresh note // k1s, carving the target off and leaving the change as a fresh note
let partK1: string if (!options.onCarve) {
let partSignature: string | undefined throw new CarveCheckpointRequiredError();
let changeK1: string }
let changeSignature: string | undefined const [partK1, changeK1] = await requireOutputSecrets(
let partVerified = false options.allocateOutputSecrets,
// true when the split's answer was lost and the probe proved the burn - serverOf(base.url),
// the carried secrets were rescued, but the service is in an unknown 2,
// state, so the change is NOT settled (that would fire another mutation );
// at it, whose own ambiguous failure would strand the rescued secret) let partSignature: string | undefined;
let rescued = false let changeSignature: string | undefined;
assertFundOwner(options) const stagedNote: NewBearer = {
try { url: withNewK1(base.url, partK1, amountMsat),
const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1
partSignature = parts.signature
changeK1 = parts.change
changeSignature = parts.changeSignature
partVerified = true
} catch (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
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, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
}, };
{ const stagedChange: NewBearer = {
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, 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, {
...mutationOptions,
randomSecret: preparedSecret,
});
partSignature = parts.signature;
changeSignature = parts.changeSignature;
} catch (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, 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 - its possible outputs were already staged unverified alongside the originals.',
[],
);
} }
// 'gone': the burn landed - the carried secrets are the only money // 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else { } else {
// A classified refusal can still be a LANDED split: the callback is // 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 // 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 first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe // The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless. // one output before deciding they are worthless.
const carried = newSecretsOf(err) const carried = newSecretsOf(err);
if (carried.length !== 2) throw err if (carried.length !== 2) throw err;
const outcome = await probeMutationOutput(base.url, carried[0], options) const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions);
if (outcome === 'absent') throw err // never landed - a plain refusal if (outcome === 'absent') throw err; // never landed - a plain refusal
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as // can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume // the ambiguous case above: track the possible outputs, consume
// nothing, stop here // nothing, stop here
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.', '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.',
[ [],
{ );
url: withNewK1(base.url, carried[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, carried[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
} }
// 'live': the split landed and this answer was its retried twin - // 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left // the carried secrets are the only money left
partK1 = carried[0]
changeK1 = carried[1]
rescued = true
} }
} }
// 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 = { const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature), url: noteUrl,
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: partVerified, verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
// settleNote: the change may be worth less than total - amount if this // The change may be worth less than total - amount when the mint charges
// mint charges split fees (LUD-25 deducts them from change, never the // split fees. It remains unverified at that upper bound until refresh;
// split-off amount) - it comes back at its true value, or stays // settling it here would rotate to an unstaged secret in a second request.
// unverified at the naive pre-fee one for a refresh to repair const changeUrl =
let change: NewBearer = { changeSignature === undefined
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature), ? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature);
const change: NewBearer = {
url: changeUrl,
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
if (!rescued) { const result = { note, change, consumed: pick };
try { await retire(note);
const settled = await settleNote( return result;
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 checkpoint({note, change, consumed: pick})
}
// smallest-first accumulation until the target is covered; null when the // smallest-first accumulation until the target is covered; null when the
// whole group can't reach it // whole group can't reach it
const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => { const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => {
const picked: Bearer[] = [] const picked: Bearer[] = [];
let total = 0 let total = 0;
for (const b of sorted) { for (const b of sorted) {
picked.push(b) picked.push(b);
total += b.amount total += b.amount;
if (total >= amountMsat) return picked if (total >= amountMsat) return picked;
}
return null
} }
return null;
};
// the better carve plan: less waste first, then fewer notes burned // the better carve plan: less waste first, then fewer notes burned
const better = (a: Bearer[], b: Bearer[], target: number): boolean => { const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0) const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0);
const wasteA = sum(a) - target const wasteA = sum(a) - target;
const wasteB = sum(b) - target const wasteB = sum(b) - target;
if (wasteA !== wasteB) return wasteA < wasteB if (wasteA !== wasteB) return wasteA < wasteB;
return a.length < b.length 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) {
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}
}
// same retry-refusal rescue as the split path: the refusal may name a
// merge that already landed - probe the would-be combined note
const carried = newSecretsOf(err)
if (carried.length !== 1) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
return {k1: carried[0], rescued: true}
}
}
+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 };
}
};

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