32 Commits
Author SHA1 Message Date
protom f30f98fe49 Merge pull request #10 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.5
2026-09-05 19:07:58 +02:00
github-actions[bot] 9f1c9a7976 chore(main): release sattle 0.0.5 2026-09-05 17:04:37 +00:00
protom d026533456 Merge pull request #9 from tompro/feat/lnurlcash-protocol-0.6
feat: adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets
2026-09-05 19:04:10 +02:00
protom e6fef1e9cb test: stub Web Locks where the runtime lacks them 2026-09-05 18:58:04 +02:00
protom 4caa075052 Formatting 2026-09-05 18:30:35 +02:00
protom 454a9db1d5 test(e2e): clean the e2e program's diagnostics 2026-09-05 15:56:23 +02:00
protom 3a7aa682c9 test(e2e): warm the dev server before the suite runs 2026-09-05 15:46:47 +02:00
protom 1beb474012 test(e2e): cover signed BIP-32 wallet flows 2026-09-05 15:22:02 +02:00
protom 5d6e092ae4 docs(lnurlcash): update the minting contract and test harness description 2026-09-05 15:21:55 +02:00
protom b2b5974dae chore: ignore Playwright MCP artifacts 2026-09-05 15:21:55 +02:00
protom f62e5d7ad4 build(deps): pin lnurlcash-kit 0.8.0 and conformance 0.7.0 2026-09-05 15:21:47 +02:00
protom a91ef30498 refactor(ops): require durable allocation and checkpoints 2026-09-05 11:59:34 +02:00
protom 8e9c9dcbbc feat(composables): route fund dialogs through the durable seam 2026-09-05 11:44:29 +02:00
protom 04e62110b6 fix(composables): commit carve checkpoints by output secret 2026-09-05 11:44:29 +02:00
protom 29830dc9e3 fix(nwc): await durable commits before responses 2026-09-05 11:38:29 +02:00
protom 22b1c5595c feat(ops): draw mint-touching output secrets from caller allocation 2026-09-05 11:34:01 +02:00
protom e6d0712258 feat(wallet): allocate operation secrets from the cash root 2026-09-05 11:33:42 +02:00
protom 3876791a0b feat(funds): recover staged mutations 2026-09-05 10:22:51 +02:00
protom a30c4bb2cc feat(trust): keep previous mint signing key verifiable 2026-09-05 10:22:24 +02:00
protom 7b7d4edaf3 test(stores): stub mutation locks in trust and nwc-drain harnesses 2026-09-05 09:42:51 +02:00
protom ff9e8a073e test(funds): fence harness on v2 owner install 2026-09-05 09:29:05 +02:00
protom e94549a16e fix(wallet): activate complete wallet material 2026-09-05 09:17:12 +02:00
protom 641608eff0 feat(backup): preserve BIP-32 counters 2026-09-05 08:22:21 +02:00
protom 0cec423d6f feat(funds): persist BIP-32 counters atomically 2026-09-05 08:22:02 +02:00
protom 7e4e67e1cd fix(unlock): bind factors to wallet material 2026-09-05 07:28:03 +02:00
protom aaee045916 test(passkeys): reverify exact-material slot binding 2026-09-05 07:27:56 +02:00
protom 9374a87d0d fix(passkeys): bind slots to wallet material 2026-09-04 21:27:14 +02:00
protom b7c0a33a69 feat(biometrics): unlock complete wallet material 2026-09-04 20:53:45 +02:00
protom 6099bb4b6b feat(passkeys): wrap complete wallet material 2026-09-04 20:19:48 +02:00
protom d48deeb6e0 feat(keys): persist BIP-32 wallet material 2026-09-04 19:46:15 +02:00
protom 0677b65016 docs: record lnurlcash 0.8 dependency pins 2026-09-04 18:48:41 +02:00
protom cbe49a83af build(deps): adopt lnurlcash-kit 0.8 contracts 2026-09-04 18:48:11 +02:00
184 changed files with 15482 additions and 8303 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.5"
} }
+1 -1
View File
@@ -79,7 +79,7 @@ npm run cap:sync # build + cap sync android
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e). - 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
+101 -81
View File
@@ -1,109 +1,129 @@
# Changelog # Changelog
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
### Features
* adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets ([d026533](https://github.com/tompro/sattle/commit/d02653345639539c9c9268c1b6f1fd2a0359bb30))
* **backup:** preserve BIP-32 counters ([641608e](https://github.com/tompro/sattle/commit/641608eff07464613570818cf1463ca28702d7c7))
* **biometrics:** unlock complete wallet material ([b7c0a33](https://github.com/tompro/sattle/commit/b7c0a33a6968756e372a8b99156d70b766c2691d))
* **composables:** route fund dialogs through the durable seam ([8e9c9dc](https://github.com/tompro/sattle/commit/8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071))
* **funds:** persist BIP-32 counters atomically ([0cec423](https://github.com/tompro/sattle/commit/0cec423d6fe6edafa1d4d3a6520d2bc659906a1c))
* **funds:** recover staged mutations ([3876791](https://github.com/tompro/sattle/commit/3876791a0bc12851d74f7dd98424c4b30b788207))
* **keys:** persist BIP-32 wallet material ([d48deeb](https://github.com/tompro/sattle/commit/d48deeb6e0b32ffd3f5abc5fbc2080198e4ecb5b))
* **ops:** draw mint-touching output secrets from caller allocation ([22b1c55](https://github.com/tompro/sattle/commit/22b1c5595cea73c8e796e22b381d8c6a5d7fd564))
* **passkeys:** wrap complete wallet material ([6099bb4](https://github.com/tompro/sattle/commit/6099bb4b6b994b682662a33ef18da388e675b07e))
* **trust:** keep previous mint signing key verifiable ([a30c4bb](https://github.com/tompro/sattle/commit/a30c4bb2cc60250a22c3c3f2c044393bd4d2d778))
* **wallet:** allocate operation secrets from the cash root ([e6d0712](https://github.com/tompro/sattle/commit/e6d0712258dd31d85690687824d25b48240ca16e))
### Bug Fixes
* **composables:** commit carve checkpoints by output secret ([04e6211](https://github.com/tompro/sattle/commit/04e62110b66c4be50f07a97aec48d3f68f6c9989))
* **nwc:** await durable commits before responses ([29830dc](https://github.com/tompro/sattle/commit/29830dc9e3d34d2e626d0d6df52c86beca02d4b8))
* **passkeys:** bind slots to wallet material ([9374a87](https://github.com/tompro/sattle/commit/9374a87d0d64cfd70f5bf938cdf4582331cea87a))
* **unlock:** bind factors to wallet material ([7e4e67e](https://github.com/tompro/sattle/commit/7e4e67e1cddc836830add3baf787a0c5368102f7))
* **wallet:** activate complete wallet material ([e94549a](https://github.com/tompro/sattle/commit/e94549a16e983875eaf445287108b62212255d61))
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31) ## [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();
};
+107 -31
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,36 +210,39 @@ 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(
}), 'sattle_passkey_slots',
); JSON.stringify([
localStorage.setItem( {
'sattle_passkey_slots', credentialId: '44'.repeat(16),
JSON.stringify([ hkdfSalt: '55'.repeat(16),
{ iv: '66'.repeat(12),
credentialId: '44'.repeat(16), materialHash: '77'.repeat(32),
hkdfSalt: '55'.repeat(16), wrappedMaterial: '88'.repeat(48),
iv: '66'.repeat(12), createdAt: 1,
wrappedKey: '77'.repeat(48), ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
createdAt: 1, 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,
+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;
+89 -210
View File
@@ -1,215 +1,95 @@
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({
localStorage.setItem( enc: true,
'sattle_passkey_slots', salt: '11'.repeat(16),
JSON.stringify([ iv: '22'.repeat(12),
{ ciphertext: '33'.repeat(48),
credentialId: '11'.repeat(16), }),
hkdfSalt: '22'.repeat(16), );
iv: '33'.repeat(12), localStorage.setItem(
wrappedKey: '44'.repeat(48), 'sattle_bearers',
createdAt: 1, JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
}, );
]), localStorage.setItem(
); 'sattle_passkey_slots',
localStorage.setItem( JSON.stringify([
'sattle_nwc_connections', {
JSON.stringify([ credentialId: '11'.repeat(16),
{ hkdfSalt: '22'.repeat(16),
clientPubkey: '55'.repeat(32), iv: '33'.repeat(12),
relays: ['wss://relay.example'], wrappedKey: '44'.repeat(48),
budget: { maxMsat: 1000, periodMs: 60_000 }, createdAt: 1,
spent: { periodStart: 0, msat: 0 }, },
createdAt: 1, ]),
}, );
]), localStorage.setItem(
); 'sattle_nwc_connections',
localStorage.setItem('sattle_nwc_enabled', 'true'); JSON.stringify([
localStorage.setItem( {
'sattle_trusted_mints', clientPubkey: '55'.repeat(32),
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]), relays: ['wss://relay.example'],
); budget: { maxMsat: 1000, periodMs: 60_000 },
}, spent: { periodStart: 0, msat: 0 },
{ storedRecord: record, mintPubkey: MINT_PUBKEY }, createdAt: 1,
); },
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([
{
server: 'legacy.example',
mintPubkey: `02${'aa'.repeat(32)}`,
addedAt: 1,
locked: false,
},
]),
);
});
// When the app boots
await page.goto('/#/'); 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_passkey_slots', 'sattle_bearers',
'sattle_nwc_connections', 'sattle_passkey_slots',
'sattle_nwc_enabled', 'sattle_nwc_connections',
'sattle_trusted_mints', 'sattle_nwc_enabled',
].filter((key) => localStorage.getItem(key) !== null), 'sattle_trusted_mints',
}; 'sattle_biometric_wrap',
}); ],
expect(successor.ownerId).not.toBe(OWNER_ID); );
expect(successor.residueKeys).toEqual([]); expect(residue).toEqual([]);
}); });
}); });
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"name": "sattle", "name": "sattle",
"version": "0.0.4", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sattle", "name": "sattle",
"version": "0.0.4", "version": "0.0.5",
"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.5",
"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();
+434 -65
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);
const beforeUnlock = persistedState();
const unlocked = await unlockWalletMaterialWithBiometrics();
// Then both key parts return together and unlock does not rewrite persistence
expect(unlocked).toEqual(MATERIAL);
expect(persistedState()).toEqual(beforeUnlock);
});
it.each([
['malformed JSON', utf8ToBytes('{')],
['legacy key-only payload', new Uint8Array(32).fill(7)],
[
'altered root',
utf8ToBytes(
JSON.stringify({
...MATERIAL,
cashRootHex: '00'.repeat(64),
}),
),
],
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
// Given an authenticated non-canonical payload bound to the saved owner
await saveWalletMaterial(MATERIAL);
await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL));
const before = persistedState();
// When biometric unlock decrypts the payload
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither key part crosses the boundary and persistence is byte-identical
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for a wrong secure secret and preserves state', async () => {
// Given a valid wrap whose secure secret has been replaced
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32));
const before = persistedState();
// When biometric unlock authenticates with the wrong wrapping secret
const attempt = unlockWalletMaterialWithBiometrics();
// Then authenticated decryption fails without changing either store
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for altered ciphertext and preserves state', async () => {
// Given a valid wrap with one ciphertext nibble altered
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}');
const wrappedKey = record.wrappedKey;
if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext');
localStorage.setItem( localStorage.setItem(
'sattle_biometric_wrap', WRAP_RECORD_KEY,
JSON.stringify({ JSON.stringify({
...wrap, ...record,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`,
createdAt: 1,
}), }),
); );
const before = new Map([ const before = persistedState();
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')], // When biometric unlock verifies the AES-GCM tag
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')], const attempt = unlockWalletMaterialWithBiometrics();
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')], // Then neither key part returns and persistence remains untouched
]); await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
// When biometric unwrap reaches the pubkey proof check });
const attempt = unlockWithBiometrics();
it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => {
// Then no key crosses the capability boundary and no legacy namespace is adopted // Given material wrapped for one owner and saved material replaced by another
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
await saveWalletMaterial(OTHER_MATERIAL);
const before = persistedState();
// When the stale biometric record is used
const attempt = unlockWalletMaterialWithBiometrics();
// Then no old material returns and both stores remain byte-identical
await expect(attempt).rejects.toThrow('different wallet');
expect(persistedState()).toEqual(before);
});
it('rejects authenticated material owned by another linking key', async () => {
// Given the saved owner and wrap claim owner A but authenticated material owner B
await saveWalletMaterial(MATERIAL);
localStorage.setItem('sattle_linking_key', 'legacy-key-residue');
localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue');
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue');
await storeMaterialPayload(
OTHER_MATERIAL,
walletMaterialOwnerId(MATERIAL),
WRAP_SECRET,
walletMaterialHash(MATERIAL),
);
const before = persistedState();
// When biometric unlock checks the decrypted linking-key owner
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither foreign key part returns and persistence is unchanged
await expect(attempt).rejects.toThrow('different wallet'); 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);
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);
}; };
+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,
}; };
+43 -25
View File
@@ -1,3 +1,4 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
@@ -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';
@@ -132,28 +134,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, wallet.bearers.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 +190,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 +207,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 +216,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 +236,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 +253,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 {
+60 -19
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;
return committed;
} }
const committed = existing ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
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}) quoteCache.set(server, { at: Date.now(), fee });
return 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();
});
});
+166 -166
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)
@@ -176,102 +176,102 @@ describe('owner marker on legacy records', () => {
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point '02' + 'ff'.repeat(32), // hex of the right length, not a curve point
42, // wrong type 42, // wrong type
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);
}) });
}) });
+129 -157
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') if (!node.privateKey) throw new Error('Could not derive linking key');
return node.privateKey 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,99 +161,103 @@ 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,
parts: EncryptedRecordParts, parts: EncryptedRecordParts,
): Promise<unknown> => { ): Promise<unknown> => {
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{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));
} };
+48 -44
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);
}) });
}) });
+183 -130
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 Promise.resolve(new Response('not found', { status: 404 }));
} };
return impl 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(
fetch: capturingFetch(routesFor(payRequestFixture({})), seen), prepareMint('mint@mint.example', 21_000, {
}) fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
expect(prepared.mode).toBe('unnamed') persistOutput,
expect(prepared.noteSecret).toBeUndefined() allocateOutputSecrets,
// an old mint sees the byte-identical request this wallet always sent }),
const url = callbackUrl(seen) ).rejects.toThrow(/64-character output commitment/i);
expect(url.searchParams.get('comment')).toBeNull() expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
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(
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), prepareMint('mint@mint.example', 21_000, {
}) fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen),
expect(prepared.mode).toBe('named') persistOutput: () => Promise.reject(new Error('storage full')),
const noteSecret = prepared.noteSecret allocateOutputSecrets,
if (!noteSecret) throw new Error('named mode without a note secret') }),
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) ).rejects.toThrow(/storage full/);
}) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
}) });
it('rejects mintToHash without the mandatory comment capacity', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
persistOutput,
allocateOutputSecrets,
}),
).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
// The wallet's deterministic-secret and signed-output work depends on this
// exact installed API surface, so package drift must fail before funds code runs.
import { describe, expect, it } from 'vitest';
import {
cashNodeFromHex,
cashNodeToHex,
cashSecretSource,
deriveCashRoot,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
} from 'lnurlcash-kit';
import type { LnurlcashOptions } from 'lnurlcash-kit';
import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json';
import kitPackage from '../../node_modules/lnurlcash-kit/package.json';
const retryOptions = { mutationRetries: 1 } satisfies LnurlcashOptions;
const requiredExports = [
deriveCashRoot,
cashNodeToHex,
cashNodeFromHex,
cashSecretSource,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
] as const;
describe('lnurlcash 0.8 dependency contract', () => {
it('exposes the BIP-32, recovery, signature, and retry contracts', () => {
expect(requiredExports.every((value) => typeof value === 'function')).toBe(true);
expect(retryOptions.mutationRetries).toBe(1);
});
it('runs against the exact installed package versions', () => {
expect(kitPackage.version).toBe('0.8.0');
expect(conformancePackage.version).toBe('0.7.0');
});
});
+105 -76
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;
}, },
} };
} };
+63 -62
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),
@@ -124,26 +124,27 @@ export type NostrRestoreResult = RestoreResult & {
export const restoreFromNostr = async ( export const restoreFromNostr = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
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),
} };
} };
+101 -78
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,114 +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('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,
created_at: 1000, created_at: 1000,
tags: [['d', 'settings']], tags: [['d', 'settings']],
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)),
) );
}) });
}) });
+107 -104
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' } }]);
}) });
}) });
+183 -104
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(
transport, secretKey,
createdAt: 1000, { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
}) RELAYS,
{
transport,
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(
transport, secretKey,
createdAt: 2000, { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
}) RELAYS,
{
transport,
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';
+70 -67
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: {
mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}], bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
settings: {defaultMint: 'mint.example'}, nextByHost: { 'mint.example': 2 },
} },
const published = await publishBackup(secretKey, parts, RELAYS, {transport}) mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
expect(published.published).toEqual(['notes', 'mints', 'settings']) settings: { defaultMint: 'mint.example' },
};
const published = await publishBackup(secretKey, parts, RELAYS, { transport });
expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { 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';
+48 -48
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',
) );
}) });
}) });
+26 -26
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();
}) });
}) });
+122 -229
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);
const {relay, walletServicePubkey, state, stop} = await startTestService({ });
it('rejects a retirement time outside the safe integer range', () => {
const timestamp = 'q'.repeat(7);
const expiryTag = `xqv${'l'.repeat(12)}`;
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
});
it('stages an output, settles it in the background, and reports the preimage', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, 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);
}) });
}) });
+211 -125
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();
}) });
}) });
+86 -64
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,35 +55,46 @@ 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, {
relays: RELAYS, relays: RELAYS,
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
@@ -91,69 +102,80 @@ describe('service ownership', () => {
// snapshot from a previous wallet must not be served) // snapshot from a previous wallet must not be served)
const current = createConnection(LINKING_KEY, { const current = createConnection(LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
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();
}) });
}) });
+119 -119
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();
}) });
}) });
+104 -77
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();
});
});
+68 -68
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,119 +55,119 @@ 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(
'sattle_nwc_connections', 'sattle_nwc_connections',
JSON.stringify([ JSON.stringify([
{clientPubkey: 'nope'}, { clientPubkey: 'nope' },
{ {
version: 1, version: 1,
ownerId: OWNER_ID, ownerId: OWNER_ID,
clientPubkey: CLIENT_PUBKEY, clientPubkey: CLIENT_PUBKEY,
relays: RELAYS, relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000}, budget: { maxMsat: 1000, periodMs: 1000 },
spent: {periodStart: 0, msat: 0}, spent: { periodStart: 0, msat: 0 },
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, {
relays: RELAYS, relays: RELAYS,
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([
...raw, ...raw,
foreign, foreign,
{...current, ownerId: 'malformed'}, { ...current, ownerId: 'malformed' },
{ {
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))), clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
relays: RELAYS, relays: RELAYS,
budget: {maxMsat: 3000, periodMs: 3000}, budget: { maxMsat: 3000, periodMs: 3000 },
spent: {periodStart: 0, msat: 0}, spent: { periodStart: 0, msat: 0 },
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([
{ {
clientPubkey: CLIENT_PUBKEY, clientPubkey: CLIENT_PUBKEY,
relays: RELAYS, relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000}, budget: { maxMsat: 1000, periodMs: 1000 },
spent: {periodStart: 0, msat: 0}, spent: { periodStart: 0, msat: 0 },
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 [
{version: 1, ownerId: 'malformed', enabled: true}, { version: 1, ownerId: 'malformed', enabled: true },
{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';
+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,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, {
@@ -60,69 +60,69 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
clientPubkey: getPublicKey(STRANGER_SECRET), clientPubkey: getPublicKey(STRANGER_SECRET),
relays: RELAYS, relays: RELAYS,
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;
} };
+201 -81
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';
+82 -82
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);
}) });
}) });
+10 -10
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,14 +22,14 @@ 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
? {periodStart: nowMs, msat: amountMsat} ? { periodStart: nowMs, msat: amountMsat }
: { : {
periodStart: record.spent.periodStart, periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat, msat: record.spent.msat + amountMsat,
}, },
}) });
} };
+49 -49
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,21 +85,21 @@ 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,
clientPubkey, clientPubkey,
relays: options.relays, relays: options.relays,
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}.`);
} }
} };
+98 -148
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, },
}, );
) } catch (error) {
const add: NewBearer[] = [claimed.note] if (!(error instanceof MintedNoteSpentError)) throw error;
if (claimed.possibleCopy) add.push(claimed.possibleCopy) await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
await ctx.deps.applyChangeset( throw error;
{add, markSpent: []}, }
ctx.connection(), await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
'make_invoice', entry.settledAt = ctx.nowSeconds();
ctx.assertOwner, entry.state = 'settled';
) if (preimage) entry.preimage = preimage;
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
}
+102 -54
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,151 +32,198 @@ 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
// possible outputs carry fresh secrets that may be the only money // possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped // left - tracked unverified, never dropped
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []}, { add: err.possibleOutputs, markSpent: [] },
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.',
) );
} }
} };
+53 -52
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
@@ -112,8 +112,8 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// dropped silently, exactly what a relay full of strangers' traffic // dropped silently, exactly what a relay full of strangers' traffic
// 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 = typeof request.params === 'object' && request.params !== null ? request.params : {} const params =
return {respond: false, request: {method: request.method, params}, encryption} typeof request.params === 'object' && request.params !== null ? request.params : {};
} return { respond: false, request: { method: request.method, params }, encryption };
};
// signs the kind-23195 answer to a request, mirroring its encryption // 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,
) );
+112 -95
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,56 +141,56 @@ 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) const response = await dispatchSerialized(runtime, ctx, decrypted.request);
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response) 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,
invoices: new Map(), invoices: new Map(),
queue: Promise.resolve(), queue: Promise.resolve(),
// 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 = [] runtimes = [];
}) });
return stopPromise 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');
});
});
+330 -132
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');
});
});
+92 -101
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) });
await expect(
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
).rejects.toThrow(/not confirmed/i);
});
it('uses the required comment even when the optional h extension is advertised', async () => {
const instance = await mint({ mintToHash: true, testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(instance.state.noteState(preimage)).toBeNull() expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
}) });
it('keeps an unnamed mint on the preimage path', async () => { it('draws the staged note secret from the caller allocation path', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) const allocations: Array<{ server: string; count: number }> = [];
expect(prepared.mode).toBe('unnamed') const allocated = [secret('90')];
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
expect(boundInvoice(instance)).toBeUndefined() 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('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}), prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
).rejects.toThrow(/not confirmed/i) persistOutput: () => {
}) persisted += 1;
},
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => { expect(persisted).toBe(0);
// the mintToHashIgnoresH adversary: claims the capability, binds expect(instance.state.invoices.size).toBe(0);
// nothing, credits the payment hash - the preimage is still the money });
const instance = await mint({ });
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
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, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 300,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
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('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(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}),
).rejects.toThrow(/not confirmed/i)
// the money exists at the preimage but is unreachable without verify -
// the wallet must NOT report a success here
expect(instance.state.noteState(preimage)).toBe('outstanding')
})
})
+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');
});
});
+189 -70
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,
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, kit: { fetch: payeeFetch },
}) poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
expect(result.outcome).toBe('settled') });
expect(result.invoice).toMatch(/^lnbc/) expect(result.outcome).toBe('settled');
expect(payer.state.noteState(secret('31'))).toBe('burned') expect(result.invoice).toMatch(/^lnbc/);
}) expect(payer.state.noteState(secret('31'))).toBe('burned');
});
it('carves the exact amount out of a larger note before melting', async () => { 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 fetch(input, init);
} };
return impl return impl;
} };
+275 -199
View File
@@ -1,260 +1,336 @@
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('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;
};
+210 -244
View File
@@ -2,41 +2,72 @@
// 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
// excluded; the ops engine cannot mutate a secret it doesn't hold). Notes // excluded; the ops engine cannot mutate a secret it doesn't hold). Notes
@@ -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) assertFundOwner(options);
return result 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
note: { // stays exactly as staged - unverified, same URL
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature);
callback: settled.callback, const result: CarveResult = merged.rescued
amount: settled.amountMsat, ? { note: staged, consumed: pick }
verified: true, : {
mintPubkey: base.mintPubkey, note: {
}, ...staged,
consumed: pick, url: landedUrl,
}) verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
} catch { },
return checkpoint({note: unverified, consumed: pick}) consumed: pick,
} };
await retire(result.note);
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 = {
url: withNewK1(base.url, partK1, amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
const stagedChange: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
await stage({ note: stagedNote, change: stagedChange, consumed: pick });
let secretIndex = 0;
const preparedSecret = (): string => {
secretIndex += 1;
if (secretIndex === 1) return partK1;
if (secretIndex === 2) return changeK1;
throw new Error('The split requested more output secrets than were staged.');
};
assertFundOwner(options);
try { try {
const parts = await splitNote(base.callback, k1s, amountMsat, options) const parts = await splitNote(base.callback, k1s, amountMsat, {
partK1 = parts.k1 ...mutationOptions,
partSignature = parts.signature randomSecret: preparedSecret,
changeK1 = parts.change });
changeSignature = parts.changeSignature partSignature = parts.signature;
partVerified = true changeSignature = parts.changeSignature;
} catch (err) { } catch (err) {
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one // the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth // input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, mutationOptions);
if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'live') throw err; // nothing burned - a plain failure
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT // can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo // consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError( 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.', 'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[ [],
{ );
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
} }
// '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 };
}
};
+157 -299
View File
@@ -1,82 +1,65 @@
// Minting: receiving over Lightning. prepareMint resolves a mint and // Minting: receiving over Lightning. Current LUD-25 minting is always
// requests the invoice; paying it brings the note into existence; // comment-bound: the wallet chooses the future note secret, durably stages
// claimMintedNote watches the payment and converts it into a wallet-owned // an unverified bearer for it, and only then sends sha256(secret) with the
// bearer note. // invoice request. Paying the invoice brings that already-tracked note into
// // existence; claiming confirms its authoritative value and callback.
// Two mint generations exist on the wire, and this module speaks both:
//
// - UNNAMED (old): the minted note's k1 IS the payment preimage. The
// claim takes the preimage from the LUD-21 verify answer and rotates
// immediately - every routing node and anyone who saw the invoice knew
// that secret.
// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the
// wallet names the note at quote time with comment = sha256(secret) of
// a secret it chose, and the note lands at THAT secret. The preimage
// opens nothing, so no observer race exists and no rotate follows the
// claim; the claim poll itself is the placement proof (see
// claimFromSecret).
//
// The mode is decided per mint from the payRequest's capability
// advertisement (commentAllowed >= 64 or mintToHash), never assumed.
import { import {
AmbiguousMutationError,
buildNoteUrl, buildNoteUrl,
claimMintedNote as fetchMintClaim, claimMintedNote as fetchMintClaim,
defaultRandomSecret,
fetchInvoiceVerification,
fetchMintAddress, fetchMintAddress,
fetchNoteInfo,
fetchPayRequest, fetchPayRequest,
grossUpForMintFee, grossUpForMintFee,
hashK1, hashK1,
isPreimage,
lightningAddressUsername, lightningAddressUsername,
mintAddressUrl, mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote,
requestInvoice, requestInvoice,
noteDeclaredAmount,
noteK1,
resolveMintInput, resolveMintInput,
rotateNote,
sameInvoice,
serverOf, serverOf,
withNewK1, } from 'lnurlcash-kit';
} from 'lnurlcash-kit' import type { MintAddressInfo, MintClaim } from 'lnurlcash-kit';
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type { Bearer, NewBearer } from '../types';
import type {NewBearer} from '../types' import { ceilMsatToSat } from '../units';
import {ceilMsatToSat} from '../units' import type { OutputSecretAllocator } from './allocation';
import type {PollOptions} from './shared' import { requireOutputSecrets } from './allocation';
import type {FundOperationOptions} from './shared' import type { PollOptions } from './shared';
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared' import type { FundOperationOptions } from './shared';
import { assertFundOwner, pollUntil, withMutationSafety } from './shared';
export type PreparedMint = { export type PreparedMint = {
invoice: string invoice: string;
verifyUrl: string | null verifyUrl: string | null;
// how this mint keys the note the invoice pays for: 'named' mints credit // The wallet-chosen secret the minted note lands at. Its unverified note
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints // is persisted before the invoice request can put this secret's hash on
// credit the payment preimage // the wire.
mode: 'named' | 'unnamed' noteSecret: string;
// named mode only: the wallet-chosen secret the minted note lands at.
// Drawn before the invoice is requested (its hash binds the quote) and
// carried in memory only - never persisted, never logged. Losing it
// after payment loses the note, but nothing here was ever recoverable
// after a crash: the invoice and verify URL die in the same memory
noteSecret?: string
// the net note value asked for - the claim cross-checks the service's // the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it // authoritative maxWithdrawable against it
expectedNoteValueMsat: number expectedNoteValueMsat: number;
// the gross amount actually invoiced (net + mint fee, rounded up to a // the gross amount actually invoiced (net + mint fee, rounded up to a
// whole sat - sub-sat invoices aren't reliably payable) // whole sat - sub-sat invoices aren't reliably payable)
grossMsat: number grossMsat: number;
mintUrl: string mintUrl: string;
withdrawLink: string withdrawLink: string;
mintPubkey?: string mintPubkey?: string;
server: string server: string;
username: string | null username: string | null;
nodeInfo: MintAddressInfo | null nodeInfo: MintAddressInfo | null;
} };
export type PrepareMintOptions = FundOperationOptions & {
readonly persistOutput: (note: NewBearer) => void | Promise<void>;
readonly beforePersist?: (quote: {
readonly grossMsat: number;
readonly server: string;
}) => void | Promise<void>;
// the caller's durable allocation path for the staged note's secret -
// required: the wallet's reserved BIP-32 indices are the only source a
// restart can never reuse or lose
readonly allocateOutputSecrets?: OutputSecretAllocator;
};
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
// its mint address (best-effort LUD-25 experimental endpoint), read its // its mint address (best-effort LUD-25 experimental endpoint), read its
@@ -86,82 +69,90 @@ export type PreparedMint = {
export const prepareMint = async ( export const prepareMint = async (
mintInput: string, mintInput: string,
amountMsat: number, amountMsat: number,
options: FundOperationOptions = {}, options: PrepareMintOptions,
): Promise<PreparedMint> => { ): Promise<PreparedMint> => {
const { beforePersist, persistOutput, ...rest } = options;
// the forced mutation policy (see shared.ts) covers the invoice request
const kitOptions = withMutationSafety(rest);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.') throw new Error('Amount must be a positive whole number of msat.');
} }
const url = resolveMintInput(mintInput) const url = resolveMintInput(mintInput);
if (!url) throw new Error('Enter a mint LNURL or Lightning Address.') if (!url) throw new Error('Enter a mint LNURL or Lightning Address.');
// best-effort mint-address discovery - derived from `url`'s own // best-effort mint-address discovery - derived from `url`'s own
// .well-known/lnurlp/{name} path; when it succeeds, its payLink is the // .well-known/lnurlp/{name} path; when it succeeds, its payLink is the
// authoritative place to fetch the payRequest from // authoritative place to fetch the payRequest from
const addressUrl = mintAddressUrl(url) const addressUrl = mintAddressUrl(url);
let nodeInfo: MintAddressInfo | null = null let nodeInfo: MintAddressInfo | null = null;
let payUrl = url let payUrl = url;
if (addressUrl) { if (addressUrl) {
try { try {
nodeInfo = await fetchMintAddress(addressUrl, options) nodeInfo = await fetchMintAddress(addressUrl, kitOptions);
payUrl = nodeInfo.payLink payUrl = nodeInfo.payLink;
} catch (error) { } catch (error) {
// no mint-address support here - proceed with just the guess // no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
} }
} }
const info = await fetchPayRequest(payUrl, options) const info = await fetchPayRequest(payUrl, kitOptions);
if (!info.withdrawLink) { if (!info.withdrawLink) {
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).');
} }
const grossMsat = ceilMsatToSat( const grossMsat = ceilMsatToSat(
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat, info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat,
) );
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.") throw new Error("Amount is outside this mint's sendable range.");
} }
// a mint that names its outputs takes comment = sha256(secret) with the const server = serverOf(payUrl);
// quote; one that doesn't gets the byte-identical request this wallet await beforePersist?.({ grossMsat, server });
// always sent, and its note stays keyed by the payment preimage const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1);
const named = namesMintOutput(info) const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey;
let noteSecret: string | undefined const staged: NewBearer = {
let h: string | undefined url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
if (named) { callback: '',
noteSecret = defaultRandomSecret() // The URL retains the claimed amount for recovery, but the wallet's
h = hashK1(noteSecret) // balance stays at zero until the mint confirms the authoritative value.
} amount: 0,
const invoice = await requestInvoice( verified: false,
info.callback, pendingMint: mintPubkey ? { mintPubkey } : {},
grossMsat, };
h ? {...options, h} : options, assertFundOwner(options);
) await persistOutput(staged);
assertFundOwner(options);
const invoice = await requestInvoice(info.callback, grossMsat, {
...kitOptions,
h: hashK1(noteSecret),
});
const prepared: PreparedMint = { const prepared: PreparedMint = {
invoice: invoice.pr, invoice: invoice.pr,
verifyUrl: invoice.verify ?? null, verifyUrl: invoice.verify ?? null,
mode: named ? 'named' : 'unnamed', noteSecret,
expectedNoteValueMsat: amountMsat, expectedNoteValueMsat: amountMsat,
grossMsat, grossMsat,
mintUrl: payUrl, mintUrl: payUrl,
withdrawLink: info.withdrawLink, withdrawLink: info.withdrawLink,
server: serverOf(payUrl), server,
username: lightningAddressUsername(payUrl), username: lightningAddressUsername(payUrl),
nodeInfo, nodeInfo,
} };
if (noteSecret) prepared.noteSecret = noteSecret if (mintPubkey) prepared.mintPubkey = mintPubkey;
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey return prepared;
return prepared };
}
export type ClaimedNote = { export type ClaimedNote = {
note: NewBearer note: NewBearer;
// false when the note may be exposed to someone besides this wallet and rotated: boolean;
// the mint: an unnamed mint's fresh note whose rotate failed (its k1 was possibleCopy?: NewBearer;
// the payment preimage, transmitted and mint-known). A named mint's rotationError?: string;
// note always reports true - its secret never rode an invoice, so there };
// is nothing to rotate away from
rotated: boolean export class MintedNoteSpentError extends Error {
// set when the rotate's answer was lost and the probe couldn't tell: the override readonly name = 'MintedNoteSpentError';
// possible rotated copy, to track unverified alongside `note`
possibleCopy?: NewBearer constructor() {
rotationError?: string super('The mint reports the freshly minted note as already spent.');
}
} }
// Polls a named mint output's informational GET until the note is there: // Polls a named mint output's informational GET until the note is there:
@@ -177,231 +168,98 @@ export const pollMintClaim = async (
): Promise<MintClaim> => ): Promise<MintClaim> =>
pollUntil( pollUntil(
async (fetchOptions) => { async (fetchOptions) => {
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions) const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions);
return claim.state === 'minted' || claim.state === 'spent' ? claim : null return claim.state === 'minted' || claim.state === 'spent' ? claim : null;
}, },
'Minted note not confirmed', 'Minted note not confirmed',
poll, poll,
options, options,
) );
// what a claim needs once the preimage is known - PreparedMint satisfies
// this, and so does the target side of an inter-mint transfer (see
// transfer.ts)
export type ClaimTarget = { export type ClaimTarget = {
withdrawLink: string withdrawLink: string;
noteSecret: string;
// the net note value asked for - a claim, cross-checked against the // the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable // service's authoritative maxWithdrawable
expectedNoteValueMsat: number expectedNoteValueMsat: number;
mintPubkey?: string mintPubkey?: string;
} };
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's // The note was credited to the wallet's own secret, which rode to the mint
// verify URL and invoice as rescue anchors for a mint that took the hash // only as a hash inside the invoice request. The payment preimage opens
// but credited the preimage anyway. PreparedMint satisfies this. // nothing, so the claim needs no preimage and no rotate follows it.
export type NamedClaimTarget = ClaimTarget & {
noteSecret: string
verifyUrl?: string | null
invoice?: string
}
// The named-mode claim: the note was credited to the wallet's own secret,
// which rode to the mint as a HASH inside the invoice request - the
// payment preimage opens nothing, so the claim needs no preimage and no
// rotate follows it. The rescue covers the mint that claimed the
// capability but credited the preimage (the kit's mintToHashIgnoresH
// adversary: the wallet was told no rotate is needed while the preimage
// is still the money): when that mint still serves the quote's verify, one
// check recovers the preimage and the note is claimed through the old
// path, WITH the rotate that path exists for. When it serves none there
// is nothing to rescue with - the error stays loud.
export const claimFromSecret = async ( export const claimFromSecret = async (
target: NamedClaimTarget, target: ClaimTarget,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => {
assertFundOwner(options) assertFundOwner(options);
let claim: MintClaim const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options);
try {
claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
} catch (err) {
if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) {
try {
const result = await fetchInvoiceVerification(target.verifyUrl, options)
if (
result.settled &&
sameInvoice(result.pr, target.invoice) &&
result.preimage &&
isPreimage(result.preimage)
) {
return claimFromPreimage(target, result.preimage, options)
}
} catch {
// the rescue is best-effort - the original error stands
}
}
throw err
}
if (claim.state === 'spent') { if (claim.state === 'spent') {
// a fresh wallet-chosen secret cannot already be spent - the service throw new MintedNoteSpentError();
// is lying about the note it just credited
throw new Error('The mint reports the freshly minted note as already spent.')
} }
if (claim.k1 !== target.noteSecret) { if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried // belt and suspenders: the kit's claimMintedNote returns the queried
// secret by construction and fetchNoteInfo rejects a mismatched echo, // secret by construction and fetchNoteInfo rejects a mismatched echo,
// so this cannot trip through the kit - if it ever does, building a // so this cannot trip through the kit - if it ever does, building a
// note from the answer would track the wrong secret // note from the answer would track the wrong secret
throw new Error('The mint answered the claim for a different note secret.') throw new Error('The mint answered the claim for a different note secret.');
} }
if (claim.amountMsat === null || !claim.callback) { if (claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the minted note details.') throw new Error('The mint did not return the minted note details.');
} }
const note: NewBearer = { const note: NewBearer = {
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat), url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback, callback: claim.callback,
amount: claim.amountMsat, amount: claim.amountMsat,
verified: true, verified: true,
} };
if (target.mintPubkey) note.mintPubkey = target.mintPubkey if (target.mintPubkey) note.mintPubkey = target.mintPubkey;
return {note, rotated: true} return { note, rotated: true };
} };
// Polls until the minted note is claimable, then claims it. Unnamed mints: // Polls until the minted note is claimable at the wallet's staged secret.
// watch the LUD-21 verify URL for settlement, take the preimage (which IS
// the note secret there), and claim through claimFromPreimage with its
// mandatory rotate. Named mints: watch the note itself appear at the
// wallet's own secret (see claimFromSecret).
export const claimMintedNote = async ( export const claimMintedNote = async (
prepared: PreparedMint, prepared: PreparedMint,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options);
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
return claimFromSecret({...prepared, noteSecret}, poll, options)
}
if (!prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
const verifyUrl = prepared.verifyUrl
const result = await pollVerifyUntilSettled(verifyUrl, poll, options)
// a settled report only means this wallet's invoice was paid if it's for
// the invoice this wallet actually requested
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error('The payment settled but the service did not reveal the preimage.')
}
return claimFromPreimage(prepared, preimage, options)
}
// The claim itself, once the payment's preimage is known: the preimage IS // One bounded observation for durable recovery. An unpaid invoice is normal:
// the note secret. The claim (an informational GET) puts that secret on // keep the staged output and try again at the next wallet/service activation.
// the wire, and the mint has known it since it generated the invoice - so export type StagedMintRecovery =
// the fresh note is rotated immediately and unconditionally (observer | { readonly state: 'unminted' }
// race: anyone who saw the unpaid invoice knows the payment hash), before | { readonly state: 'pending' }
// anything else happens with it. | { readonly state: 'spent' }
export const claimFromPreimage = async ( | { readonly state: 'minted'; readonly note: NewBearer };
claim: ClaimTarget,
preimage: string, export const recoverStagedMintOutput = async (
staged: Bearer,
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<StagedMintRecovery> => {
// declare the invoiced amount (a claim - not yet confirmed) so the note const noteSecret = noteK1(staged.url);
// is self-describing even before the verifying GET below const expectedNoteValueMsat = noteDeclaredAmount(staged.url);
const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat) if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) {
assertFundOwner(options) throw new Error('The pending mint output is malformed.');
// the service's maxWithdrawable is authoritative - SERVICE's own fee }
// math might not match this wallet's estimate, and the note is worth const withdrawLink = new URL(staged.url);
// exactly maxWithdrawable regardless withdrawLink.searchParams.delete('k1');
const noteInfo = await fetchNoteInfo(declaredUrl, options) withdrawLink.searchParams.delete('amount');
const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey withdrawLink.searchParams.delete('sig');
const base: NewBearer = { assertFundOwner(options);
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options));
callback: noteInfo.callback, if (claim.state === 'unminted') return { state: 'unminted' };
amount: noteInfo.maxWithdrawable, if (claim.state === 'pending') return { state: 'pending' };
if (claim.state === 'spent') return { state: 'spent' };
if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the pending output details.');
}
const note: NewBearer = {
url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true, verified: true,
} };
if (mintPubkey) base.mintPubkey = mintPubkey if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey;
return { state: 'minted', note };
let url = base.url };
let rotated = true
let possibleCopy: NewBearer | undefined
let rotationError: string | undefined
try {
const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options)
url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature)
} catch (err) {
rotated = false
if (err instanceof AmbiguousMutationError) {
// the rotate request may have landed despite the failure - the fresh
// secret it carried is then the only copy of this note
const outcome = await probeBurnedNote(declaredUrl, options)
if (outcome === 'gone') {
// the burn landed - adopt the fresh secret as the note
url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
// can't tell: the preimage note is returned either way - the
// possible rotated copy goes alongside it, both refreshable
possibleCopy = {
url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
} else {
rotationError = err.message
}
} else {
// A classified refusal can still be a LANDED rotate: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// "Already spent" then PROVES the preimage note is burned - the
// carried fresh secret is the only money left, so probe it before
// believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1) {
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
if (outcome === 'live') {
// the rotate landed and this answer was its retried twin
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
possibleCopy = {
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
// the preimage note itself may be burned - it cannot stay verified
base.verified = false
rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`
} else if (err instanceof NoteSpentError) {
// a genuine already-spent refusal with no live output: the note
// the service just described is burned - it cannot stay verified
base.verified = false
rotationError = err.message
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
}
const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy
if (rotationError) claimed.rotationError = rotationError
return claimed
}
+122 -89
View File
@@ -16,15 +16,18 @@ import {
resolveLnurlInput, resolveLnurlInput,
rotateNote, rotateNote,
sameInvoice, sameInvoice,
serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type { LnurlcashOptions, MeltResult } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {CarveResult} from './carve' import type { OutputSecretAllocator } from './allocation';
import {ensureExactAmount} from './carve' import { requireOutputSecrets } from './allocation';
import type {PollOptions} from './shared' import type { CarveResult } from './carve';
import type {FundOperationOptions} from './shared' import { ensureExactAmount } from './carve';
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import type { PollOptions } from './shared';
import type { FundOperationOptions, MintSignatureKeys } from './shared';
import { assertFundOwner, pollVerifyUntilSettled, withMutationSafety } from './shared';
export type PayOutcome = export type PayOutcome =
| 'settled' | 'settled'
@@ -32,42 +35,51 @@ export type PayOutcome =
| 'unknown-still-pending' | 'unknown-still-pending'
// the service reports the carved note as already spent before the melt // the service reports the carved note as already spent before the melt
// even started - nothing was paid, and the note is definitively gone // even started - nothing was paid, and the note is definitively gone
| 'note-already-spent' | 'note-already-spent';
export type PayResult = { export type PayResult = {
outcome: PayOutcome outcome: PayOutcome;
carve: CarveResult carve: CarveResult;
// the invoice that was (attempted to be) paid // the invoice that was (attempted to be) paid
invoice: string invoice: string;
amountMsat: number amountMsat: number;
verifyUrl: string | null verifyUrl: string | null;
// on failed-funds-returned, when the classification rotate succeeded: rotatedNote?: NewBearer;
// the returned funds re-secured at a fresh secret. Kept OUT of `carve` rescuedNote?: NewBearer;
// (which always describes the carve as committed by the onCarve };
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked export class PayReturnCheckpointRequiredError extends Error {
// spent, this note added override readonly name = 'PayReturnCheckpointRequiredError';
rotatedNote?: NewBearer
// a fresh secret rescued from an ambiguous rotate during outcome constructor() {
// classification - the caller must track it unverified; if the rotate super('A pay-return classification requires a durable onReturnReady checkpoint.');
// landed, this is the only copy of the (returned) funds }
rescuedNote?: NewBearer
} }
export type PayOptions = { export type PayOptions = {
// required when `input` is a Lightning Address / LNURL-pay (a bolt11 // required when `input` is a Lightning Address / LNURL-pay (a bolt11
// carries its own amount) // carries its own amount)
amountMsat?: number amountMsat?: number;
// verify-poll budget - tests shrink this // verify-poll budget - tests shrink this
poll?: PollOptions poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
assertOwner?: () => void assertOwner?: () => void;
// carve checkpoint - committed before the melt and the settlement wait, // carve checkpoint - committed before the melt and the settlement wait,
// so an abort mid-wait never strands the carve (see // so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve) // FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve'];
} // the caller's durable allocation path: the carve's output secrets and -
// only when the return classification actually needs one - the recovery
// secret. Required for any mutating carve or return classification.
allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the carve's landed outputs verify against (see
// FundOperationOptions.mintSignatureKeys)
mintSignatureKeys?: MintSignatureKeys;
// Called before the classification rotate puts its chosen output secret
// on the wire. Production callers durably link it to the carved source.
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>;
};
// A melt's resolved promise only means the payment is in flight; the // A melt's resolved promise only means the payment is in flight; the
// outcome is classified by polling the melt's LUD-25 verify URL, then - if // outcome is classified by polling the melt's LUD-25 verify URL, then - if
@@ -82,69 +94,90 @@ export type PayOptions = {
export const payWithBearers = async ( export const payWithBearers = async (
bearers: Bearer[], bearers: Bearer[],
input: string, input: string,
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {}, {
amountMsat,
poll = {},
kit = {},
assertOwner,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
}: PayOptions = {},
): Promise<PayResult> => { ): Promise<PayResult> => {
const options: FundOperationOptions = { // the forced mutation policy (see shared.ts) covers the carve, the melt,
// and the classification rotate regardless of caller kit options
const options: FundOperationOptions = withMutationSafety({
...kit, ...kit,
...(assertOwner ? {assertOwner} : {}), ...(assertOwner ? { assertOwner } : {}),
...(onCarve ? {onCarve} : {}), ...(onCarve ? { onCarve } : {}),
} ...(mintSignatureKeys ? { mintSignatureKeys } : {}),
let invoice: string });
let amount: number let invoice: string;
const trimmed = input.trim() let amount: number;
const trimmed = input.trim();
if (isBolt11Invoice(trimmed)) { if (isBolt11Invoice(trimmed)) {
const decoded = decodeBolt11AmountMsat(trimmed) const decoded = decodeBolt11AmountMsat(trimmed);
if (decoded === null || decoded <= 0) { if (decoded === null || decoded <= 0) {
throw new Error( throw new Error(
"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.",
) );
} }
invoice = trimmed invoice = trimmed;
amount = decoded amount = decoded;
} else { } else {
// a Lightning Address (or LNURL-pay) has no invoice of its own yet - // a Lightning Address (or LNURL-pay) has no invoice of its own yet -
// resolving it gets a payRequest, and an amount is needed before an // resolving it gets a payRequest, and an amount is needed before an
// actual invoice exists // actual invoice exists
const url = resolveLnurlInput(trimmed) const url = resolveLnurlInput(trimmed);
if (!url) { if (!url) {
throw new Error('Not a valid bolt11 invoice or Lightning Address.') throw new Error('Not a valid bolt11 invoice or Lightning Address.');
} }
if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) { if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Enter an amount to pay to this address.') throw new Error('Enter an amount to pay to this address.');
} }
const info = await fetchPayRequest(url, options) const info = await fetchPayRequest(url, options);
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) { if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
throw new Error("Amount is outside the payee's sendable range.") throw new Error("Amount is outside the payee's sendable range.");
} }
const result = await requestInvoice(info.callback, amountMsat, options) const result = await requestInvoice(info.callback, amountMsat, options);
invoice = result.pr invoice = result.pr;
amount = amountMsat amount = amountMsat;
} }
const carve = await ensureExactAmount(bearers, amount, options) const carve = await ensureExactAmount(bearers, amount, {
const k1 = requireNoteK1(carve.note.url) ...options,
if (carve.consumed.length === 0) assertFundOwner(options) ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
let melt: MeltResult });
const k1 = requireNoteK1(carve.note.url);
if (carve.consumed.length === 0) assertFundOwner(options);
let melt: MeltResult;
try { try {
melt = await meltNote(carve.note.callback, k1, invoice, options) melt = await meltNote(carve.note.callback, k1, invoice, options);
} catch (err) { } catch (err) {
// this melt names a single note, so a NoteSpentError here is // this melt names a single note, so a NoteSpentError here is
// unambiguous - it's already gone, and gets locked spent the same way // unambiguous - it's already gone, and gets locked spent the same way
// a successful melt would have locked it // a successful melt would have locked it
if (err instanceof NoteSpentError) { if (err instanceof NoteSpentError) {
return {outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null} return { outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null };
} }
throw err throw err;
} }
if (!melt.verify) { if (!melt.verify) {
// no melt proof to poll - the note locking as spent locally is all the // no melt proof to poll - the note locking as spent locally is all the
// confirmation there is // confirmation there is
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl: null} return {
outcome: 'unknown-still-pending',
carve,
invoice,
amountMsat: amount,
verifyUrl: null,
};
} }
const verifyUrl = melt.verify const verifyUrl = melt.verify;
try { try {
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) const proof = await pollVerifyUntilSettled(verifyUrl, poll, options);
// a settled report is only this payment's proof when it's for the // a settled report is only this payment's proof when it's for the
// invoice this melt actually paid - a mint that mixes up proofs must // invoice this melt actually paid - a mint that mixes up proofs must
// not confirm the wrong payment. The verify URL is already scoped to // not confirm the wrong payment. The verify URL is already scoped to
@@ -153,56 +186,56 @@ export const payWithBearers = async (
// belongs to another payment, while an undecodable one says nothing // belongs to another payment, while an undecodable one says nothing
// either way (a service regenerating synthetic prs in proofs) and is // either way (a service regenerating synthetic prs in proofs) and is
// tolerated. // tolerated.
const proofAmount = decodeBolt11AmountMsat(proof.pr) const proofAmount = decodeBolt11AmountMsat(proof.pr);
if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) { if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) {
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
} }
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
} catch { } catch {
// the verify budget ran out - probe the note itself with a rotate: a // The verify budget ran out. Journal the exact output secret before the
// failed melt is only observable as the note becoming spendable again // classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement.
if (!onReturnReady) throw new PayReturnCheckpointRequiredError();
const [recoverySecret] = await requireOutputSecrets(
allocateOutputSecrets,
serverOf(carve.note.url),
1,
);
await onReturnReady(carve, recoverySecret);
try { try {
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, {
// the rotate succeeded, so the mint restored the note - and k1 had ...options,
// been on the wire since the melt, so the rotation doubles as the randomSecret: () => recoverySecret,
// required re-securing of the returned funds. Reported separately });
// from `carve` (see PayResult.rotatedNote): the carve may already be
// committed, and its note is what this rotate burned
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve, carve,
rotatedNote: { rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature),
callback: carve.note.callback, callback: carve.note.callback,
amount, amount,
verified: true, verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}), ...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}),
}, },
invoice, invoice,
amountMsat: amount, amountMsat: amount,
verifyUrl, verifyUrl,
} };
} catch (err) { } catch (err) {
if (err instanceof PendingNoteError) { if (err instanceof PendingNoteError) {
// still locked mid-melt - no outcome either way return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
} }
if (err instanceof NoteSpentError) { if (err instanceof NoteSpentError) {
// burned without a settled proof - the money is gone either way return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost. Had the note still been pending,
// the service would have said so cleanly - so the funds ARE back,
// but whether the rotation landed is unknown: the original k1 may
// be live, or the fresh secret may be the only copy. Surface both.
const rescuedNote: NewBearer = { const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], amount), url: withNewK1(carve.note.url, recoverySecret, amount),
callback: carve.note.callback, callback: carve.note.callback,
amount, amount,
verified: false, verified: false,
} };
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve, carve,
@@ -210,9 +243,9 @@ export const payWithBearers = async (
amountMsat: amount, amountMsat: amount,
verifyUrl, verifyUrl,
rescuedNote, rescuedNote,
} };
} }
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
} }
} }
} };
+108 -43
View File
@@ -3,6 +3,14 @@
// bech32, lnurlw://), verify it with the issuing service, then rotate // bech32, lnurlw://), verify it with the issuing service, then rotate
// immediately, since the previous holder (and anything that logged the URL // immediately, since the previous holder (and anything that logged the URL
// in transit) still knows the old secret. // in transit) still knows the old secret.
//
// Durability contract: the rotate's fresh secret comes from the caller's
// reservation path (options.allocateOutputSecrets) and the future rotated
// note is persisted through options.stageRotation BEFORE the rotate can
// land - a crash after the mint burned the sender's copy never loses the
// only secret the money answers to. The staged record carries the original
// k1 as its pendingMint.sourceRecoverySecret so wallet recovery can restore
// the received note when the rotate provably never landed.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
@@ -11,13 +19,51 @@ import {
PendingNoteError, PendingNoteError,
newSecretsOf, newSecretsOf,
probeBurnedNote, probeBurnedNote,
requireNoteK1,
serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import {receiveNote, secureReceivedNote} from '../receive' import { receiveNote, secureReceivedNote } from '../receive';
import type {ClaimedNote} from './mint' import type { OutputSecretAllocator } from './allocation';
import type {FundOperationOptions} from './shared' import { requireOutputSecrets } from './allocation';
import {assertFundOwner, probeMutationOutput} from './shared' import type { FundOperationOptions } from './shared';
import { assertFundOwner, probeMutationOutput, withMutationSafety } from './shared';
export class ReceiveRotationStagingRequiredError extends Error {
override readonly name = 'ReceiveRotationStagingRequiredError';
constructor() {
super('A receive rotation requires a durable stageRotation checkpoint.');
}
}
export type ReceiveBearerOptions = FundOperationOptions & {
// the caller's durable allocation path for the rotation's fresh secret -
// required when stageRotation is present
readonly allocateOutputSecrets?: OutputSecretAllocator;
// durably persist the future rotated note (unverified, pendingMint
// carrying the original k1 as sourceRecoverySecret) before the rotate
// reaches the mint. The record's fate is decided by the result's
// `stage` disposition.
readonly stageRotation?: (staged: NewBearer) => void | Promise<void>;
};
export type ReceivedNote = {
readonly note: NewBearer;
readonly rotated: boolean;
readonly rotationError?: string;
// how the caller disposes the staged rotation output:
// - 'finalize': the rotate landed - replace the staged record with `note`
// - 'keep': the outcome is genuinely ambiguous - the staged record IS
// the possible rotated copy, kept unverified alongside `note` (the
// original, demoted to unverified since its copy may be burned)
// - 'discard': the rotate provably never landed - drop the staged
// record; `note` is the original, still verified as received
// - 'none': no rotate was attempted (the note could not be verified) -
// nothing was staged
readonly stage: 'finalize' | 'keep' | 'discard' | 'none';
};
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are // NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note, // definitive and propagate; an unreachable service still yields the note,
@@ -25,16 +71,44 @@ import {assertFundOwner, probeMutationOutput} from './shared'
export const receiveBearer = async ( export const receiveBearer = async (
input: string, input: string,
existing: Bearer[], existing: Bearer[],
options: FundOperationOptions = {}, options: ReceiveBearerOptions = {},
): Promise<ClaimedNote> => { ): Promise<ReceivedNote> => {
const note = await receiveNote(input, existing) // the forced mutation policy (see shared.ts) covers the rotate below
const mutationOptions = withMutationSafety(options);
const note = await receiveNote(input, existing);
if (!note.verified || !note.callback) { if (!note.verified || !note.callback) {
return {note, rotated: false} return { note, rotated: false, stage: 'none' };
} }
// the rotate burns the sender's secret at a fresh one: stage that future
// note durably before the mutation can land, or a crash strands the money
const staging = options.stageRotation;
if (!staging) throw new ReceiveRotationStagingRequiredError();
const [rotationSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(note.url),
1,
);
const staged: NewBearer = {
url: withNewK1(note.url, rotationSecret, note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
pendingMint: {
sourceRecoverySecret: requireNoteK1(note.url),
...(note.mintPubkey ? { mintPubkey: note.mintPubkey } : {}),
},
};
await staging(staged);
const rotateOptions = { ...mutationOptions, randomSecret: () => rotationSecret };
// the disposition the caller applies to its staged record
const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => {
if (rotated) return 'finalize';
return ambiguous ? 'keep' : 'discard';
};
try { try {
assertFundOwner(options) assertFundOwner(options);
const rotatedUrl = await secureReceivedNote(note, options) const rotatedUrl = await secureReceivedNote(note, rotateOptions);
return {note: {...note, url: rotatedUrl}, rotated: true} return { note: { ...note, url: rotatedUrl }, rotated: true, stage: disposition(true, false) };
} catch (err) { } catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the // A classified rotate refusal can still be a LANDED rotate: the
// callback is a GET and HTTP stacks retry GETs, so the service may // callback is a GET and HTTP stacks retry GETs, so the service may
@@ -42,30 +116,25 @@ export const receiveBearer = async (
// already-spent input. The refusal then proves the sender's copy is // already-spent input. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe // burned and the carried fresh secret is the only money left - probe
// it before believing the refusal. // it before believing the refusal.
const carried = newSecretsOf(err) const carried = newSecretsOf(err);
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], options) const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions);
if (outcome === 'live') { if (outcome === 'live') {
return { return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, note: { ...note, url: withNewK1(note.url, carried[0], note.amount) },
rotated: true, rotated: true,
} stage: disposition(true, false),
};
} }
if (outcome === 'unknown') { if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, carried[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return { return {
// the sender's copy may be burned - it cannot stay verified // the sender's copy may be burned - it cannot stay verified; the
note: {...note, verified: false}, // staged record is the possible rotated copy
note: { ...note, verified: false },
rotated: false, rotated: false,
possibleCopy, stage: disposition(false, true),
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`, rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
} };
} }
// 'absent': the refusal is genuine - fall through to the distinct // 'absent': the refusal is genuine - fall through to the distinct
// handling below // handling below
@@ -78,10 +147,10 @@ export const receiveBearer = async (
err instanceof PendingNoteError || err instanceof PendingNoteError ||
err instanceof NoteUnknownError err instanceof NoteUnknownError
) { ) {
throw err throw err;
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(note.url, options) const outcome = await probeBurnedNote(note.url, mutationOptions);
if (outcome === 'gone') { if (outcome === 'gone') {
return { return {
note: { note: {
@@ -89,30 +158,26 @@ export const receiveBearer = async (
url: withNewK1(note.url, err.newSecrets[0], note.amount), url: withNewK1(note.url, err.newSecrets[0], note.amount),
}, },
rotated: true, rotated: true,
} stage: disposition(true, false),
};
} }
if (outcome === 'unknown') { if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, err.newSecrets[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return { return {
note, note,
rotated: false, rotated: false,
possibleCopy, stage: disposition(false, true),
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`,
} };
} }
} }
// a failed rotate never fails the receive - the note is money as it // a failed rotate never fails the receive - the note is money as it
// is; the caller warns that it must be treated as exposed // is; the caller warns that it must be treated as exposed. The rotate
// provably never landed, so the staged record is worthless.
return { return {
note, note,
rotated: false, rotated: false,
rotationError: err instanceof Error ? err.message : String(err), rotationError: err instanceof Error ? err.message : String(err),
} stage: disposition(false, false),
};
} }
} };
+123 -74
View File
@@ -2,22 +2,31 @@
// every flow that waits on a payment uses, and the uncertainty type a lost // every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as. // mutation answer surfaces as.
import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' import {
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' fetchInvoiceVerification,
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' fetchNoteInfo,
import type {NewBearer} from '../types' noteK1,
import type {CarveResult} from './carve' noteSignature,
serverOf,
verifyNoteSignature,
withNewK1,
} from 'lnurlcash-kit';
import type { LnurlcashOptions, VerifyResult } from 'lnurlcash-kit';
import { NoteSpentError, NoteUnknownError, noteDeclaredAmount } from 'lnurlcash-kit';
import type { NewBearer } from '../types';
import type { CarveResult } from './carve';
// a mutation's answer was lost AND the probe could not tell whether it // a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the // landed - the possible outputs the fresh secrets would control, for the
// caller to track unverified alongside the (kept) inputs. Never dropped: // caller to track unverified alongside the kept inputs. A pre-wire carve
// if the mutation did land, these are the only money left. // checkpoint may already have persisted them, in which case this list is
// empty so callers do not add duplicate records.
export class UncertainOutcomeError extends Error { export class UncertainOutcomeError extends Error {
readonly possibleOutputs: NewBearer[] readonly possibleOutputs: NewBearer[];
constructor(message: string, possibleOutputs: NewBearer[]) { constructor(message: string, possibleOutputs: NewBearer[]) {
super(message) super(message);
this.name = 'UncertainOutcomeError' this.name = 'UncertainOutcomeError';
this.possibleOutputs = possibleOutputs this.possibleOutputs = possibleOutputs;
} }
} }
@@ -25,31 +34,71 @@ export class UncertainOutcomeError extends Error {
// from budget exhaustion so the caller can treat it as normal teardown // from budget exhaustion so the caller can treat it as normal teardown
export class PollAbortedError extends Error { export class PollAbortedError extends Error {
constructor() { constructor() {
super('The wait was interrupted by shutdown.') super('The wait was interrupted by shutdown.');
this.name = 'PollAbortedError' this.name = 'PollAbortedError';
} }
} }
export type FundOperationOptions = LnurlcashOptions & { export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void readonly assertOwner?: () => void;
// called by a carve the moment its mutation has LANDED server-side (the // Two durable phases for a mutating carve. Before wire use, unverified
// inputs are burned, the outputs are known), before the flow moves on to // outputs are reported with `consumed: []`. After the mint mutation is
// anything slow or uncertain - the caller's one chance to durably commit // known to have landed, the already-staged note is reported again with
// the changeset so an abort during a long settlement wait can never // the inputs that can now be retired. A rejection in either phase stops
// strand the outputs or leave burned inputs looking spendable. If it // the flow; exact-note carves call neither phase.
// rejects, the flow stops BEFORE anything further is spent and the readonly onCarve?: (carve: CarveResult) => void | Promise<void>;
// rejection propagates: the carve state is then landed-but-maybe- // the trusted signing keys a landed mutation output's signature is
// uncommitted, which the caller must surface loudly. Never called for a // checked against before it may report verified: the mint's pinned
// carve that mutated nothing. // current key AND its previous one (a just-rotated mint's last notes
readonly onCarve?: (carve: CarveResult) => void | Promise<void> // stay verifiable). Production supplies the trusted-mint registry's
} // keys; an absent source (or empty list) keeps every landed output
// staged unverified rather than trusting it blindly.
readonly mintSignatureKeys?: MintSignatureKeys;
};
// the trusted signing keys available for one mint server
export type MintSignatureKeys = (server: string) => readonly string[];
// The mutation safety policy, forced where the engine talks to a mint:
// every mutation must come back signed (the signature is the only offline
// proof the mint issued the output) and a lost answer is retried exactly
// once, byte-identically (the replay the pre-wire staging exists to make
// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the
// caller spread means no caller's options object can quietly strip either
// guarantee.
export const withMutationSafety = <Options extends LnurlcashOptions>(
options: Options,
): Options => ({
...options,
requireSignatures: true,
mutationRetries: 1,
});
// A landed mutation output earns verified:true only when its signature
// (carried as the URL's sig param) verifies against a trusted key for its
// server. Missing, malformed, or wrong-key signatures - or no trusted keys
// on file at all - leave the note staged unverified; a refresh can repair
// it later. The money is never at stake here (the note exists at the mint
// either way), only the offline-verifiable badge is.
export const landedNoteVerifies = (
noteUrl: string,
keysFor: MintSignatureKeys | undefined,
): boolean => {
if (!keysFor) return false;
const k1 = noteK1(noteUrl);
const amountMsat = noteDeclaredAmount(noteUrl);
const signature = noteSignature(noteUrl);
if (!k1 || amountMsat === null || !signature) return false;
const keys = keysFor(serverOf(noteUrl));
return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]);
};
export const assertFundOwner = (options: FundOperationOptions): void => { export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.() options.assertOwner?.();
} };
// what probing a would-be mutation output can tell // what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown' export type OutputProbe = 'live' | 'absent' | 'unknown';
// A refused mutation can still have LANDED: the redeem callback is a GET // A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first // and HTTP stacks retry GETs, so the service may have executed the first
@@ -66,53 +115,53 @@ export const probeMutationOutput = async (
options: LnurlcashOptions = {}, options: LnurlcashOptions = {},
): Promise<OutputProbe> => { ): Promise<OutputProbe> => {
try { try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options) await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options);
return 'live' return 'live';
} catch (err) { } catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent' if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent';
return 'unknown' return 'unknown';
} }
} };
export type PollOptions = { export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs) // first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number intervalMs?: number;
intervalCapMs?: number intervalCapMs?: number;
// total budget before giving up // total budget before giving up
maxWaitMs?: number maxWaitMs?: number;
// aborts the wait promptly (shutdown). Only the WAIT is interruptible: // aborts the wait promptly (shutdown). Only the WAIT is interruptible:
// callers pass this for work whose observation phase may outlive the // callers pass this for work whose observation phase may outlive the
// caller - once pollVerifyUntilSettled has returned, the signal no // caller - once pollVerifyUntilSettled has returned, the signal no
// longer reaches anything // longer reaches anything
signal?: AbortSignal signal?: AbortSignal;
} };
const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = { const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 1000, intervalMs: 1000,
intervalCapMs: 5000, intervalCapMs: 5000,
maxWaitMs: 120_000, maxWaitMs: 120_000,
} };
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
// a sleep that ends immediately on abort instead of riding out its timer // a sleep that ends immediately on abort instead of riding out its timer
const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> => const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const onAbort = (): void => { const onAbort = (): void => {
clearTimeout(timer) clearTimeout(timer);
reject(new PollAbortedError()) reject(new PollAbortedError());
} };
const timer = setTimeout(() => { const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort);
resolve() resolve();
}, ms) }, ms);
if (signal.aborted) { if (signal.aborted) {
clearTimeout(timer) clearTimeout(timer);
reject(new PollAbortedError()) reject(new PollAbortedError());
return return;
} }
signal.addEventListener('abort', onAbort, {once: true}) signal.addEventListener('abort', onAbort, { once: true });
}) });
// polls `check` until it yields a value, with backoff, inside a total // polls `check` until it yields a value, with backoff, inside a total
// time budget. A single failed check isn't fatal - the next round tries // time budget. A single failed check isn't fatal - the next round tries
@@ -125,42 +174,42 @@ export const pollUntil = async <T>(
poll: PollOptions, poll: PollOptions,
options: LnurlcashOptions, options: LnurlcashOptions,
): Promise<T> => { ): Promise<T> => {
const {intervalMs, intervalCapMs, maxWaitMs, signal} = { const { intervalMs, intervalCapMs, maxWaitMs, signal } = {
...DEFAULT_POLL, ...DEFAULT_POLL,
...poll, ...poll,
} };
const fetchOptions: LnurlcashOptions = signal const fetchOptions: LnurlcashOptions = signal
? { ? {
...options, ...options,
fetch: (input, init) => { fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch const base = options.fetch ?? globalThis.fetch;
return base(input, {...init, signal}) return base(input, { ...init, signal });
}, },
} }
: options : options;
const deadline = Date.now() + maxWaitMs const deadline = Date.now() + maxWaitMs;
let delay = intervalMs let delay = intervalMs;
let lastError: unknown = null let lastError: unknown = null;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError();
try { try {
const result = await check(fetchOptions) const result = await check(fetchOptions);
if (result !== null) return result if (result !== null) return result;
lastError = null lastError = null;
} catch (err) { } catch (err) {
// the signal's own AbortError lands here on an interrupted fetch // the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError();
lastError = err lastError = err;
} }
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal) if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal);
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())));
delay = Math.min(delay * 2, intervalCapMs) delay = Math.min(delay * 2, intervalCapMs);
} }
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw new Error(`${exhausted}: ${lastError.message}`) throw new Error(`${exhausted}: ${lastError.message}`);
} }
throw new Error(`${exhausted} within the time budget.`) throw new Error(`${exhausted} within the time budget.`);
} };
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal - // backoff, inside a total time budget. A single failed check isn't fatal -
@@ -174,10 +223,10 @@ export const pollVerifyUntilSettled = (
): Promise<VerifyResult> => ): Promise<VerifyResult> =>
pollUntil( pollUntil(
async (fetchOptions) => { async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) const result = await fetchInvoiceVerification(verifyUrl, fetchOptions);
return result.settled ? result : null return result.settled ? result : null;
}, },
'Payment not confirmed', 'Payment not confirmed',
poll, poll,
options, options,
) );
+224 -231
View File
@@ -3,20 +3,9 @@
// that exist: this wallet requests an invoice FROM the target mint // that exist: this wallet requests an invoice FROM the target mint
// (grossed up for its advertised mint fee, so the note that comes out // (grossed up for its advertised mint fee, so the note that comes out
// nets the requested amount), melts source notes to pay it, then claims // nets the requested amount), melts source notes to pay it, then claims
// the target note exactly like any other minted receive. The target // the target note exactly like any other minted receive. The target note
// invoice settling is the transfer's ground truth: it can only settle if // appearing at the wallet's pre-staged secret is the transfer's ground
// the source melt's payment arrived. How settling is observed and claimed // truth: it can only appear if the source melt's payment arrived.
// depends on the target's minting mode (see mint.ts):
//
// - UNNAMED target: the verify URL is the observation - its settled
// response reveals the preimage, which IS the note's secret, and the
// claim rotates off it immediately.
// - NAMED target: the note itself is the observation - the wallet named
// the output with its own secret at quote time, so the informational
// GET reporting 'minted' IS the settlement proof, and the note is
// already at a secret nobody else has seen. No verify URL is needed
// (but one is used to rescue the mint that took the hash and credited
// the preimage anyway).
// //
// When settlement never confirms, the source note itself is the oracle - // When settlement never confirms, the source note itself is the oracle -
// a successful rotate proves the melt never burned it (funds returned), // a successful rotate proves the melt never burned it (funds returned),
@@ -25,28 +14,29 @@
import { import {
AmbiguousMutationError, AmbiguousMutationError,
NoteSpentError, NoteSpentError,
NoteUnknownError,
PendingNoteError, PendingNoteError,
buildNoteUrl, buildNoteUrl,
decodeBolt11AmountMsat, fetchNoteInfo,
fetchInvoiceVerification,
isPreimage,
meltNote, meltNote,
newSecretsOf,
noteK1, noteK1,
requireNoteK1, requireNoteK1,
rotateNote, rotateNote,
sameInvoice,
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
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 {CarveResult} from './carve' import type { OutputSecretAllocator } from './allocation';
import {ensureExactAmount} from './carve' import { requireOutputSecrets } from './allocation';
import type {ClaimedNote, PreparedMint} from './mint' import type { CarveResult } from './carve';
import {claimFromPreimage, pollMintClaim, prepareMint} from './mint' import { ensureExactAmount } from './carve';
import type {PollOptions} from './shared' import type { ClaimedNote, PreparedMint, PrepareMintOptions } from './mint';
import type {FundOperationOptions} from './shared' import { MintedNoteSpentError, claimFromSecret, prepareMint } from './mint';
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import type { PollOptions } from './shared';
import type { FundOperationOptions, MintSignatureKeys } from './shared';
import { assertFundOwner, withMutationSafety } from './shared';
export type TransferOutcome = export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated) // the melt settled and the target note was claimed (and rotated)
@@ -61,78 +51,95 @@ export type TransferOutcome =
| 'note-already-spent' | 'note-already-spent'
// the target invoice settled (the money arrived) but the claim could // the target invoice settled (the money arrived) but the claim could
// not complete - claimMaterial carries everything needed to retry it // not complete - claimMaterial carries everything needed to retry it
| 'settled-claim-failed' | 'settled-claim-failed';
export type TransferQuote = { export type TransferQuote = {
// the net value the user wants to land on the target mint // the net value the user wants to land on the target mint
requestedMsat: number requestedMsat: number;
// what the source side must cover - the target invoice, grossed up for // what the source side must cover - the target invoice, grossed up for
// the target's advertised mint fee and rounded to a whole sat // the target's advertised mint fee and rounded to a whole sat
grossMsat: number grossMsat: number;
// the target mint's receive fee as estimated by the gross-up (the // the target mint's receive fee as estimated by the gross-up (the
// service's own fee math is authoritative - the claimed note's amount // service's own fee math is authoritative - the claimed note's amount
// is what it actually withheld) // is what it actually withheld)
targetMintFeeMsat: number targetMintFeeMsat: number;
// LUD-25 melt has no fee field - the melted note must equal the invoice // LUD-25 melt has no fee field - the melted note must equal the invoice
// exactly, so no source-side reserve is even expressible // exactly, so no source-side reserve is even expressible
sourceMeltFeeReserveMsat: number sourceMeltFeeReserveMsat: number;
} };
// everything a caller needs to retry (or log) the target claim when the // everything a caller needs to retry (or log) the target claim when the
// transfer could not complete it - once the melt has settled this // transfer could not complete it - once the melt has settled this
// material IS the money, so it is never dropped // material IS the money, so it is never dropped
export type TransferClaimMaterial = { export type TransferClaimMaterial = {
invoice: string invoice: string;
withdrawLink: string withdrawLink: string;
expectedNoteValueMsat: number expectedNoteValueMsat: number;
// the preimage note, unverified, once the preimage is known - the note: NewBearer;
// preimage IS the note secret; the caller must track it and retry // the wallet-chosen secret the target note lands at.
note?: NewBearer
// named target only: the wallet-chosen secret the target note lands at.
// The note exists at this secret from the moment the invoice settles - // The note exists at this secret from the moment the invoice settles -
// the caller can rebuild the note URL from it and retry the claim // the caller can rebuild the note URL from it and retry the claim
noteSecret?: string noteSecret: string;
} };
export type TransferResult = { export type TransferResult = {
outcome: TransferOutcome outcome: TransferOutcome;
// the source-side changeset: consumed inputs and any change note // the source-side changeset: consumed inputs and any change note
carve: CarveResult carve: CarveResult;
quote: TransferQuote quote: TransferQuote;
// the invoice the source note was melted to pay // the invoice the source note was melted to pay
invoice: string invoice: string;
// the target invoice's verify URL - an unnamed target's ground truth // the target invoice's optional verify URL (used for a payment receipt;
// (a named target may not serve one at all; the note itself is observed) // the staged note itself is the settlement observation)
verifyUrl: string | null verifyUrl: string | null;
sourceServer: string sourceServer: string;
targetServer: string targetServer: string;
// the fresh target note, on 'settled' // the fresh target note, on 'settled'
mintedAtTarget?: ClaimedNote mintedAtTarget?: ClaimedNote;
// on failed-funds-returned, when the source-probe rotate succeeded: the // on failed-funds-returned, when the source-probe rotate succeeded: the
// returned funds re-secured at a fresh secret. Kept OUT of `carve` // returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve // (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can // checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked // both be persisted - the carved note the rotate burned must be marked
// spent, this note added // spent, this note added
rotatedNote?: NewBearer rotatedNote?: NewBearer;
// present whenever the claim could still complete later // present whenever the claim could still complete later
claimMaterial?: TransferClaimMaterial claimMaterial?: TransferClaimMaterial;
// a fresh secret rescued from an ambiguous rotate while classifying the // a fresh secret rescued from an ambiguous rotate while classifying the
// melt - the caller must track it unverified (same semantics as pay.ts) // melt - the caller must track it unverified (same semantics as pay.ts)
rescuedNote?: NewBearer rescuedNote?: NewBearer;
};
export class TransferMeltCheckpointRequiredError extends Error {
override readonly name = 'TransferMeltCheckpointRequiredError';
constructor() {
super('A source melt requires a durable onMeltReady checkpoint.');
}
} }
export type TransferOptions = { export type TransferOptions = {
// verify-poll budget - tests shrink this // verify-poll budget - tests shrink this
poll?: PollOptions poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
assertOwner?: () => void assertOwner?: () => void;
// carve checkpoint - committed before the melt and the target // carve checkpoint - committed before the melt and the target
// settlement wait, so an abort mid-wait never strands the carve (see // settlement wait, so an abort mid-wait can never strand the carve (see
// FundOperationOptions.onCarve) // FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve'];
} // persists the target note before its hash reaches the invoice callback
persistOutput: PrepareMintOptions['persistOutput'];
// the caller's durable allocation path: the staged target note's secret,
// the source carve's outputs, and the source recovery secret. Required:
// the wallet's reserved BIP-32 indices are the only source a restart can
// never reuse or lose.
allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the source carve's landed outputs verify against
mintSignatureKeys?: MintSignatureKeys;
// Last durable checkpoint before the source melt can become irreversible.
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>;
};
// the claim retry material for a named target: the note at the wallet's // the claim retry material for a named target: the note at the wallet's
// own secret, unverified until a claim succeeds // own secret, unverified until a claim succeeds
@@ -142,200 +149,185 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer =
callback: '', callback: '',
amount: prepared.expectedNoteValueMsat, amount: prepared.expectedNoteValueMsat,
verified: false, verified: false,
};
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey;
return note;
};
const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => {
const eligible = bearers.filter(
(bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url),
);
const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer);
if (eligible.length > 0 && candidates.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.");
} }
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey const totals = new Map<string, number>();
return note for (const bearer of candidates) {
} const server = serverOf(bearer.url);
totals.set(server, (totals.get(server) ?? 0) + bearer.amount);
}
if (![...totals.values()].some((total) => total >= grossMsat)) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
}
return candidates;
};
export type PendingTransferSourceRecovery =
| { readonly state: 'pending' }
| { readonly state: 'spent' }
| { readonly state: 'returned'; readonly note: NewBearer };
export const recoverPendingTransferSource = async (
staged: Bearer,
source: Bearer,
options: FundOperationOptions = {},
): Promise<PendingTransferSourceRecovery> => {
// a recovery only ever PROBES the melt outcome and rotates - it never
// replays the melt itself; the forced policy pins signature/retry safety
const mutationOptions = withMutationSafety(options);
const recoverySecret = staged.pendingMint?.sourceRecoverySecret;
if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.');
const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount);
assertFundOwner(options);
try {
const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions);
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable),
callback: recovered.callback,
amount: recovered.maxWithdrawable,
verified: true,
};
if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return { state: 'returned', note };
} catch (error) {
if (error instanceof NoteSpentError) return { state: 'spent' };
if (!(error instanceof NoteUnknownError)) throw error;
}
assertFundOwner(options);
try {
const rotated = await rotateNote(source.callback, requireNoteK1(source.url), {
...mutationOptions,
randomSecret: () => recoverySecret,
});
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature),
callback: source.callback,
amount: source.amount,
verified: true,
};
if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return { state: 'returned', note };
} catch (error) {
if (
error instanceof PendingNoteError ||
error instanceof NoteSpentError ||
error instanceof AmbiguousMutationError ||
newSecretsOf(error).includes(recoverySecret)
) {
return { state: 'pending' };
}
throw error;
}
};
export const transferBetweenMints = async ( export const transferBetweenMints = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
targetMint: string, targetMint: string,
{poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {}, {
poll = {},
kit = {},
assertOwner,
onCarve,
persistOutput,
allocateOutputSecrets,
mintSignatureKeys,
onMeltReady,
}: TransferOptions,
): Promise<TransferResult> => { ): Promise<TransferResult> => {
const options: FundOperationOptions = { const options: FundOperationOptions = withMutationSafety({
...kit, ...kit,
...(assertOwner ? {assertOwner} : {}), ...(assertOwner ? { assertOwner } : {}),
...(onCarve ? {onCarve} : {}), ...(onCarve ? { onCarve } : {}),
} ...(mintSignatureKeys ? { mintSignatureKeys } : {}),
});
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.');
} }
// resolving the target and requesting its invoice touches only the // resolving the target and requesting its invoice touches only the
// TARGET mint - a failure here (unreachable, no minting support, amount // TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched // out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options) const prepared = await prepareMint(targetMint, amountMsat, {
if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { ...options,
throw new Error( persistOutput,
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
) beforePersist: ({ grossMsat, server }) => {
} transferSources(bearers, server, grossMsat);
const verifyUrl = prepared.verifyUrl },
const targetServer = prepared.server });
const verifyUrl = prepared.verifyUrl;
const targetServer = prepared.server;
// the source must be a DIFFERENT mint - value "moved" within one mint // the source must be a DIFFERENT mint - value "moved" within one mint
// goes nowhere (melt pays an invoice; the same mint's invoice just // goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing) // re-mints into itself, paying fees for nothing)
const eligible = bearers.filter( const offTarget = transferSources(bearers, targetServer, prepared.grossMsat);
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer)
if (eligible.length > 0 && offTarget.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.")
}
const quote: TransferQuote = { const quote: TransferQuote = {
requestedMsat: amountMsat, requestedMsat: amountMsat,
grossMsat: prepared.grossMsat, grossMsat: prepared.grossMsat,
targetMintFeeMsat: prepared.grossMsat - amountMsat, targetMintFeeMsat: prepared.grossMsat - amountMsat,
sourceMeltFeeReserveMsat: 0, sourceMeltFeeReserveMsat: 0,
} };
// carving burns its inputs server-side, so it happens only once the // carving burns its inputs server-side, so it happens only once the
// target is known good and the invoice exists // target is known good and the invoice exists
const carve = await ensureExactAmount(offTarget, prepared.grossMsat, options) const carve = await ensureExactAmount(offTarget, prepared.grossMsat, {
const sourceServer = serverOf(carve.note.url) ...options,
const invoice = prepared.invoice ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
});
const sourceServer = serverOf(carve.note.url);
const invoice = prepared.invoice;
const claimMaterial: TransferClaimMaterial = { const claimMaterial: TransferClaimMaterial = {
invoice, invoice,
withdrawLink: prepared.withdrawLink, withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat, expectedNoteValueMsat: prepared.expectedNoteValueMsat,
} noteSecret: prepared.noteSecret,
if (prepared.noteSecret) { note: namedClaimNote(prepared, prepared.noteSecret),
claimMaterial.noteSecret = prepared.noteSecret };
// a named target's note is claimable from the wallet's secret alone,
// the moment the invoice settles - track it unverified from the start,
// so a melt that settles after the budget is never a lost note
claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret)
}
// from here on the carve's fresh secrets exist only in this result - the // from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them // flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const base = { carve, quote, invoice, verifyUrl, sourceServer, targetServer };
const k1 = requireNoteK1(carve.note.url) const k1 = requireNoteK1(carve.note.url);
if (carve.consumed.length === 0) assertFundOwner(options) if (!onMeltReady) throw new TransferMeltCheckpointRequiredError();
const [sourceRecoverySecret] = await requireOutputSecrets(
allocateOutputSecrets,
serverOf(carve.note.url),
1,
);
if (carve.consumed.length === 0) assertFundOwner(options);
await onMeltReady(carve, sourceRecoverySecret);
assertFundOwner(options);
try { try {
await meltNote(carve.note.callback, k1, invoice, options) await meltNote(carve.note.callback, k1, invoice, options);
} catch (err) { } catch (error) {
if (err instanceof NoteSpentError) { if (!(error instanceof Error)) throw error;
// this melt names a single note, so this is unambiguous - it was if (error instanceof NoteSpentError) {
// already gone before the melt even started return { ...base, outcome: 'note-already-spent' };
return {...base, outcome: 'note-already-spent'}
} }
// anything else - a clean refusal, a dropped response, a lost answer - // anything else - a clean refusal, a dropped response, a lost answer -
// is resolved below: the target invoice settles only if this melt's // is resolved below: the target invoice settles only if this melt's
// payment arrived, and the source probe tells the rest // payment arrived, and the source probe tells the rest
} }
try { try {
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('The target mint was prepared without a note secret.')
// the note appearing at the wallet's own secret IS the settlement
// proof - the target only credits it once the melt's payment landed
try {
const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options)
if (
claim.state !== 'minted' ||
claim.k1 !== noteSecret ||
claim.amountMsat === null ||
!claim.callback
) {
// 'spent' on a fresh wallet-chosen secret, a claim naming a
// secret other than the one polled (impossible through the kit,
// which returns the queried secret and rejects mismatched
// echoes - asserted anyway: building from it would track the
// wrong note), or an incomplete answer: the target misbehaved -
// the way back to the note is already in the claim material
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
// no rotate: the secret never rode an invoice (see mint.ts)
return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}}
} catch (err) {
// the note never appeared at the wallet's secret within budget.
// Rescue through the quote's verify when the target serves one:
// a mint that took the hash but credited the PREIMAGE (the
// mintToHashIgnoresH adversary) is claimable through the old path
if (verifyUrl) {
try {
const proof = await fetchInvoiceVerification(verifyUrl, options)
if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) {
if (isPreimage(proof.preimage)) {
try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch {
const note: NewBearer = {
url: buildNoteUrl(
prepared.withdrawLink,
proof.preimage,
prepared.expectedNoteValueMsat,
),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
}
}
}
}
} catch {
// best-effort rescue - fall through to the source-note oracle
}
}
throw err
}
}
if (!verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
// the proof-binding rule from pay.ts, extended for the gross-up: the
// verify URL is scoped to this invoice's payment hash, so an exact pr
// match binds it; short of that, a proof amount that is neither the
// invoiced gross nor the expected net belongs to another payment,
// while an undecodable one says nothing either way and is tolerated
const proofAmount = decodeBolt11AmountMsat(proof.pr)
if (
!sameInvoice(proof.pr, invoice) &&
proofAmount !== null &&
proofAmount !== prepared.grossMsat &&
proofAmount !== prepared.expectedNoteValueMsat
) {
return {...base, outcome: 'unknown-still-pending', claimMaterial}
}
if (!proof.preimage || !isPreimage(proof.preimage)) {
// settled, but the service won't reveal the preimage - the claim
// cannot complete automatically
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
try { try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options) const claimed = await claimFromSecret(prepared, poll, options);
return {...base, outcome: 'settled', mintedAtTarget: claimed} return { ...base, outcome: 'settled', mintedAtTarget: claimed };
} catch { } catch (error) {
// the melt settled - the money is now the preimage note at the if (error instanceof MintedNoteSpentError) {
// target and nowhere else; surface it rather than lose it return { ...base, outcome: 'settled-claim-failed', claimMaterial };
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
} }
throw new Error('The target note could not be confirmed at its staged secret.', {
cause: error,
});
} }
} catch { } catch {
// the target invoice never settled within budget - the source note is // the target invoice never settled within budget - the source note is
@@ -345,7 +337,10 @@ export const transferBetweenMints = async (
// left but never arrived within the budget - the claim material stays // left but never arrived within the budget - the claim material stays
// as the way back to the money if the invoice settles later // as the way back to the money if the invoice settles later
try { try {
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, {
...options,
randomSecret: () => sourceRecoverySecret,
});
// the rotate succeeded: the melt provably never landed, and the // the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the // funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the // wire. Reported separately from `carve` (see rotatedNote): the
@@ -359,28 +354,26 @@ export const transferBetweenMints = async (
callback: carve.note.callback, callback: carve.note.callback,
amount: carve.note.amount, amount: carve.note.amount,
verified: true, verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}), ...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}),
}, },
} };
} catch (err) { } catch (err) {
if (err instanceof PendingNoteError || err instanceof NoteSpentError) { if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
return {...base, outcome: 'unknown-still-pending', claimMaterial} return { ...base, outcome: 'unknown-still-pending', claimMaterial };
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost - pay.ts's reasoning: had the note // The rotate's answer was lost. Keep the transfer journal until
// still been pending the service would have said so, so the funds // recovery proves whether the original or recovery secret is live.
// ARE back, but whether the rotation landed is unknown. Surface
// the possible fresh copy alongside the unchanged note.
const rescuedNote: NewBearer = { const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
callback: carve.note.callback, callback: carve.note.callback,
amount: carve.note.amount, amount: carve.note.amount,
verified: false, verified: false,
} };
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return {...base, outcome: 'failed-funds-returned', rescuedNote} return { ...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote };
} }
return {...base, outcome: 'unknown-still-pending', claimMaterial} return { ...base, outcome: 'unknown-still-pending', claimMaterial };
} }
} }
} };

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