36 Commits
Author SHA1 Message Date
protom 9bea2d3043 Merge pull request #12 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.6
2026-09-07 14:45:09 +02:00
github-actions[bot] a057bde3ff chore(main): release sattle 0.0.6 2026-09-07 11:39:06 +00:00
protom 785b56de3b Merge pull request #11 from tompro/feat/notes-and-activity
feat: add note management and activity history
2026-09-07 13:38:49 +02:00
protom 644e64ee10 feat: add note management and activity history 2026-09-07 10:43:30 +02:00
protom f30f98fe49 Merge pull request #10 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.5
2026-09-05 19:07:58 +02:00
github-actions[bot] 9f1c9a7976 chore(main): release sattle 0.0.5 2026-09-05 17:04:37 +00:00
protom d026533456 Merge pull request #9 from tompro/feat/lnurlcash-protocol-0.6
feat: adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets
2026-09-05 19:04:10 +02:00
protom e6fef1e9cb test: stub Web Locks where the runtime lacks them 2026-09-05 18:58:04 +02:00
protom 4caa075052 Formatting 2026-09-05 18:30:35 +02:00
protom 454a9db1d5 test(e2e): clean the e2e program's diagnostics 2026-09-05 15:56:23 +02:00
protom 3a7aa682c9 test(e2e): warm the dev server before the suite runs 2026-09-05 15:46:47 +02:00
protom 1beb474012 test(e2e): cover signed BIP-32 wallet flows 2026-09-05 15:22:02 +02:00
protom 5d6e092ae4 docs(lnurlcash): update the minting contract and test harness description 2026-09-05 15:21:55 +02:00
protom b2b5974dae chore: ignore Playwright MCP artifacts 2026-09-05 15:21:55 +02:00
protom f62e5d7ad4 build(deps): pin lnurlcash-kit 0.8.0 and conformance 0.7.0 2026-09-05 15:21:47 +02:00
protom a91ef30498 refactor(ops): require durable allocation and checkpoints 2026-09-05 11:59:34 +02:00
protom 8e9c9dcbbc feat(composables): route fund dialogs through the durable seam 2026-09-05 11:44:29 +02:00
protom 04e62110b6 fix(composables): commit carve checkpoints by output secret 2026-09-05 11:44:29 +02:00
protom 29830dc9e3 fix(nwc): await durable commits before responses 2026-09-05 11:38:29 +02:00
protom 22b1c5595c feat(ops): draw mint-touching output secrets from caller allocation 2026-09-05 11:34:01 +02:00
protom e6d0712258 feat(wallet): allocate operation secrets from the cash root 2026-09-05 11:33:42 +02:00
protom 3876791a0b feat(funds): recover staged mutations 2026-09-05 10:22:51 +02:00
protom a30c4bb2cc feat(trust): keep previous mint signing key verifiable 2026-09-05 10:22:24 +02:00
protom 7b7d4edaf3 test(stores): stub mutation locks in trust and nwc-drain harnesses 2026-09-05 09:42:51 +02:00
protom ff9e8a073e test(funds): fence harness on v2 owner install 2026-09-05 09:29:05 +02:00
protom e94549a16e fix(wallet): activate complete wallet material 2026-09-05 09:17:12 +02:00
protom 641608eff0 feat(backup): preserve BIP-32 counters 2026-09-05 08:22:21 +02:00
protom 0cec423d6f feat(funds): persist BIP-32 counters atomically 2026-09-05 08:22:02 +02:00
protom 7e4e67e1cd fix(unlock): bind factors to wallet material 2026-09-05 07:28:03 +02:00
protom aaee045916 test(passkeys): reverify exact-material slot binding 2026-09-05 07:27:56 +02:00
protom 9374a87d0d fix(passkeys): bind slots to wallet material 2026-09-04 21:27:14 +02:00
protom b7c0a33a69 feat(biometrics): unlock complete wallet material 2026-09-04 20:53:45 +02:00
protom 6099bb4b6b feat(passkeys): wrap complete wallet material 2026-09-04 20:19:48 +02:00
protom d48deeb6e0 feat(keys): persist BIP-32 wallet material 2026-09-04 19:46:15 +02:00
protom 0677b65016 docs: record lnurlcash 0.8 dependency pins 2026-09-04 18:48:41 +02:00
protom cbe49a83af build(deps): adopt lnurlcash-kit 0.8 contracts 2026-09-04 18:48:11 +02:00
195 changed files with 16800 additions and 8313 deletions
+1
View File
@@ -28,3 +28,4 @@ yarn-error.log*
# Playwright (e2e/ itself is tracked - only its artifacts are ignored)
/playwright-report/
/test-results/
/.playwright-mcp/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.0.4"
".": "0.0.6"
}
+1 -1
View File
@@ -79,7 +79,7 @@ npm run cap:sync # build + cap sync android
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
`sass-embedded` is aliased to pure-JS `sass` via npm overrides.
- `lnurlcash-kit@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm
- `lnurlcash-kit@0.8.0` and `lnurlcash-conformance@0.7.0` are exact npm
registry packages. Keep their manifest pins, registry tarball URLs, and lock
integrity values intact.
- Credential and authorization records are bound to the canonical owner ID
+109 -81
View File
@@ -1,109 +1,137 @@
# Changelog
## [0.0.6](https://github.com/tompro/sattle/compare/sattle-v0.0.5...sattle-v0.0.6) (2026-09-07)
### Features
* add note management and activity history ([785b56d](https://github.com/tompro/sattle/commit/785b56de3b34fb3752abd7fa637aaa4773380924))
* add note management and activity history ([644e64e](https://github.com/tompro/sattle/commit/644e64ee10bba66609acf5161e176770b5bf5ee1))
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
### Features
* adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets ([d026533](https://github.com/tompro/sattle/commit/d02653345639539c9c9268c1b6f1fd2a0359bb30))
* **backup:** preserve BIP-32 counters ([641608e](https://github.com/tompro/sattle/commit/641608eff07464613570818cf1463ca28702d7c7))
* **biometrics:** unlock complete wallet material ([b7c0a33](https://github.com/tompro/sattle/commit/b7c0a33a6968756e372a8b99156d70b766c2691d))
* **composables:** route fund dialogs through the durable seam ([8e9c9dc](https://github.com/tompro/sattle/commit/8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071))
* **funds:** persist BIP-32 counters atomically ([0cec423](https://github.com/tompro/sattle/commit/0cec423d6fe6edafa1d4d3a6520d2bc659906a1c))
* **funds:** recover staged mutations ([3876791](https://github.com/tompro/sattle/commit/3876791a0bc12851d74f7dd98424c4b30b788207))
* **keys:** persist BIP-32 wallet material ([d48deeb](https://github.com/tompro/sattle/commit/d48deeb6e0b32ffd3f5abc5fbc2080198e4ecb5b))
* **ops:** draw mint-touching output secrets from caller allocation ([22b1c55](https://github.com/tompro/sattle/commit/22b1c5595cea73c8e796e22b381d8c6a5d7fd564))
* **passkeys:** wrap complete wallet material ([6099bb4](https://github.com/tompro/sattle/commit/6099bb4b6b994b682662a33ef18da388e675b07e))
* **trust:** keep previous mint signing key verifiable ([a30c4bb](https://github.com/tompro/sattle/commit/a30c4bb2cc60250a22c3c3f2c044393bd4d2d778))
* **wallet:** allocate operation secrets from the cash root ([e6d0712](https://github.com/tompro/sattle/commit/e6d0712258dd31d85690687824d25b48240ca16e))
### Bug Fixes
* **composables:** commit carve checkpoints by output secret ([04e6211](https://github.com/tompro/sattle/commit/04e62110b66c4be50f07a97aec48d3f68f6c9989))
* **nwc:** await durable commits before responses ([29830dc](https://github.com/tompro/sattle/commit/29830dc9e3d34d2e626d0d6df52c86beca02d4b8))
* **passkeys:** bind slots to wallet material ([9374a87](https://github.com/tompro/sattle/commit/9374a87d0d64cfd70f5bf938cdf4582331cea87a))
* **unlock:** bind factors to wallet material ([7e4e67e](https://github.com/tompro/sattle/commit/7e4e67e1cddc836830add3baf787a0c5368102f7))
* **wallet:** activate complete wallet material ([e94549a](https://github.com/tompro/sattle/commit/e94549a16e983875eaf445287108b62212255d61))
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
### Features
* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400))
* **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb))
* **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2))
* **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf))
* **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed))
* **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c))
* support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
- **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400))
- **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb))
- **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2))
- **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf))
- **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed))
- **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c))
- support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
### Bug Fixes
* commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62))
* commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306))
* **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927))
* **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86))
* **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df))
* replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7))
- commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62))
- commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306))
- **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927))
- **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86))
- **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df))
- replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7))
## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
### Features
* activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc))
* add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331))
* apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1))
* apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37))
* await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6))
* await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e))
* bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a))
* commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4))
* converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c))
* create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30))
* drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904))
* expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755))
* fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544))
* harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
* invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8))
* preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341))
* restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635))
* scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf))
* scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb))
* scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab))
* serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
* serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e))
* validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
- activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc))
- add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331))
- apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1))
- apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37))
- await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6))
- await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e))
- bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a))
- commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4))
- converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c))
- create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30))
- drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904))
- expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755))
- fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544))
- harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
- invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8))
- preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341))
- restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635))
- scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf))
- scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb))
- scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab))
- serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
- serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e))
- validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
### Bug Fixes
* await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece))
* await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205))
* clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329))
* commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354))
* consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6))
* drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18))
* fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5))
* fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a))
* fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60))
* fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6))
* fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c))
* hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382))
* mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff))
* normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a))
* preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155))
* publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c))
* surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa))
* validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
* validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504))
* **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8))
- await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece))
- await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205))
- clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329))
- commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354))
- consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6))
- drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18))
- fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5))
- fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a))
- fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60))
- fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6))
- fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c))
- hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382))
- mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff))
- normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a))
- preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155))
- publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c))
- surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa))
- validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
- validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504))
- **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8))
## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
### Features
* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
* capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
* capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
* deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
* inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
* manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
* native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
* nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
* nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
* nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
* pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
* reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d))
* sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0))
* send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f))
* wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898))
* wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178))
* webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
- backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
- capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
- capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
- deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
- inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
- manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
- native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
- nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
- nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
- nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
- pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
- reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d))
- sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0))
- send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f))
- wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898))
- wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178))
- webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
### Bug Fixes
* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
* discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
- bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
- discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
## Changelog
+40
View File
@@ -0,0 +1,40 @@
/// <reference types="node" />
import { chromium } from '@playwright/test';
import { execSync } from 'child_process';
// Warms the freshly booted dev server before any test runs. `quasar dev`
// answers HTTP the moment its port is up, but vite still transforms the
// module graph on the first page load - a window in which the first test's
// multi-step onboarding flow can race lazy compilation (observed as the
// create flow never reaching /#/). One full load plus a reload completes
// that work before the suite starts. Uses the same system-chromium
// resolution as the main config (CI falls back to the bundled browser).
const findSystemChromium = (): string | undefined => {
try {
return execSync('command -v chromium').toString().trim() || undefined;
} catch {
return undefined;
}
};
const globalSetup = async (): Promise<void> => {
const executablePath = process.env.CHROMIUM_PATH || findSystemChromium();
const browser = await chromium.launch({
...(executablePath ? { executablePath } : {}),
});
try {
const page = await (await browser.newContext({ baseURL: 'http://localhost:9333' })).newPage();
await page.goto('/');
await page
.getByRole('button', { name: 'Get started' })
.waitFor({ state: 'visible', timeout: 60_000 });
await page.reload();
await page
.getByRole('button', { name: 'Get started' })
.waitFor({ state: 'visible', timeout: 60_000 });
} finally {
await browser.close();
}
};
export default globalSetup;
+275 -29
View File
@@ -1,13 +1,26 @@
import type { Page, Route } from '@playwright/test';
import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
// The mock mint lives on a .test origin that never resolves - every request
// to it is intercepted by Playwright and fulfilled in-process, so the specs
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
// callback shape also answers a melt (meltNote sends k1 + pr to the same
// callback and only requires status "OK").
// mutating GET on the callback (rotate/split/merge/melt) expects
// {status: "OK"} plus the LUD-25 signature(s) over every minted output -
// the app forces the kit's requireSignatures policy, so an unsigned
// mutation answer is not a success the wallet accepts quietly.
//
// Signing: each origin has a stable secp256k1 signing key derived from its
// origin string, so a spec can pin the advertised mintPubkey via
// signingPubkey(origin). `invalidSignatures = true` signs every output with
// a foreign key instead - the notes are real mint-side, but the offline
// proof verifies against no key the mint advertises, which is exactly the
// "landed but unverifiable" case the wallet must refuse to mark spendable.
//
// Every method takes an optional origin, so a spec can stand up a SECOND
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
@@ -29,13 +42,28 @@ const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
});
};
// deterministic per-origin signing keys: the mock mints are stable across
// tests, and the key derivation needs nothing but the origin string
const signingKeyFor = (origin: string): Uint8Array =>
sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`));
// the key every output is signed with in invalid-signature mode: a real
// secp256k1 key, just never advertised by any mock mint
const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key'));
// a melt's settle proof reveals a payment receipt - derived from the paid
// invoice so it is stable per payment and never collides with a note secret
const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`)));
interface MockNoteInfoOptions {
// note value in msat, reported as maxWithdrawable
amountMsat?: number;
// when set, the mint answers the ERROR envelope with this reason instead
// (a reason matching /spent/i surfaces the "already been spent" UI)
spentReason?: string;
// the mint's signing pubkey, advertised as mintPubkey in responses
// the mint's signing pubkey, advertised as mintPubkey in responses -
// defaults to the mock origin's real signing key (kit 0.8 refuses a
// withdrawRequest that publishes none, so every mock mint has one)
mintPubkey?: string;
}
@@ -43,14 +71,14 @@ interface MockTargetMintOptions {
// the mint's signing pubkey (66 hex chars), advertised on the mint-address
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
// LUD-25 announces it on the withdraw side only, and a too-generous mock
// once masked a real bug
mintPubkey: string;
// once masked a real bug. Defaults to the origin's real signing key.
mintPubkey?: string;
// the invoice this mint hands out (and reports back as settled) - keep it
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
// amount cross-check
invoice: string;
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
// claimed note's secret)
// the payment preimage a settled verify reveals - a receipt distinct from
// the wallet-chosen secret committed in the invoice comment
preimage: string;
// value of the note a claim mints, in msat
noteAmountMsat: number;
@@ -62,10 +90,69 @@ interface MockTargetMintOptions {
export class MintMocker {
constructor(private page: Page) {}
// when true, every minted output is signed with a foreign key: the
// mutation lands mint-side, but the signature verifies against no key
// this mint advertises - the wallet must keep the outputs unverified
invalidSignatures = false;
// every signature this mock issued, in order - lets a spec assert the
// mint actually signed (the kit's requireSignatures policy makes an
// unsigned success impossible to confuse with a signed one)
readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = [];
// the mint's advertised signing pubkey for an origin (compressed, hex)
signingPubkey(origin = MINT_ORIGIN): string {
return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true));
}
// note id (sha256 of the secret) -> outstanding value in msat, per origin.
// A real mint keys its ledger by the hash; the mock does the same so a
// mutation can sign the output with the amount the inputs actually held.
private readonly outstanding = new Map<string, number>();
private readonly burned = new Set<string>();
// invoices this mint reports as settled on its verify endpoint, per origin
private readonly settledInvoices = new Map<string, string[]>();
private noteKey(origin: string, noteId: string): string {
return `${origin}|${noteId}`;
}
private sign(origin: string, h: string, amountMsat: number): string {
const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin);
// noble's 'recovered' layout is [recovery, r, s]; the wire layout is
// r || s || recovery (the kit accepts either, the trailing layout is
// what lnurl-mint emits)
const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, {
format: 'recovered',
prehash: false,
});
this.signaturesIssued.push({ origin, h, amountMsat });
return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]]));
}
// mint outputs at the given hashes, burning the inputs - the ledger
// transition every successful mutation makes
private remint(
origin: string,
inputIds: string[],
outputs: [h: string, amountMsat: number][],
): void {
for (const id of inputIds) {
this.outstanding.delete(this.noteKey(origin, id));
this.burned.add(this.noteKey(origin, id));
}
for (const [h, amountMsat] of outputs) {
this.outstanding.set(this.noteKey(origin, h), amountMsat);
}
}
// The note's informational GET: a spec-shaped withdrawRequest, or the
// ERROR envelope for a note the mint considers spent. The kit validates
// that the service echoes back the queried k1, so read it off the request.
// ERROR envelope for a note the mint considers spent or never issued. The
// kit validates that the service echoes back the queried k1, so read it
// off the request. A first-seen k1 registers at the configured amount -
// the mock's way of "issuing" the note a spec hands to the wallet.
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => {
@@ -74,27 +161,147 @@ export class MintMocker {
return;
}
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
if (!/^[0-9a-f]{64}$/i.test(k1)) {
// a by-hash lookup (h=) or a malformed k1 - this mock answers
// secret-keyed info only
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
const id = this.noteKey(origin, hashK1(k1));
if (this.burned.has(id)) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (!this.outstanding.has(id)) {
this.outstanding.set(id, options.amountMsat ?? 0);
}
const amountMsat = this.outstanding.get(id) ?? 0;
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`,
k1,
minWithdrawable: options.amountMsat ?? 0,
maxWithdrawable: options.amountMsat ?? 0,
minWithdrawable: amountMsat,
maxWithdrawable: amountMsat,
defaultDescription: 'mock mint note',
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
mintPubkey,
});
},
);
}
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
// params) both expect a plain {status: "OK"} confirmation. No sig is
// returned - a plain LUD-03-style service that does not sign notes.
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
// The settle proof a melt's verify URL serves: settled once the melt
// landed, carrying the paid invoice and its preimage receipt.
private async mockVerify(origin: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => {
const pr = new URL(route.request().url()).searchParams.get('pr') ?? '';
const settled = (this.settledInvoices.get(origin) ?? []).includes(pr);
await fulfillJson(route, {
settled,
...(settled ? { preimage: preimageFor(pr) } : {}),
pr,
});
},
);
}
// The mutating callback GET, signed per LUD-25:
// - melt (k1 + pr): burns the input, answers OK with a settle-proof URL
// - rotate (k1 + h): burns the input, mints at h for its full value
// - split (k1... + amount + h + h2): carves amount off the inputs' total,
// minting h for it and h2 for the change
// - merge (k1... + h): burns all inputs, mints h for their total
// Every minted output's signature rides in the response (sig / sig2), and
// a mutation naming an unknown or burned input is refused like a real
// mint's. A byte-identical replay gets the same success - the mutations
// are deterministic in their inputs, so the replayed answer re-derives
// the same signatures.
async mockMutationEndpoint(origin = MINT_ORIGIN): Promise<void> {
await this.mockVerify(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
async (route: Route) => {
await fulfillJson(route, { status: 'OK' });
const url = new URL(route.request().url());
const k1s = url.searchParams.getAll('k1');
if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
const pr = url.searchParams.get('pr');
const h = url.searchParams.get('h');
const h2 = url.searchParams.get('h2');
const amountRaw = url.searchParams.get('amount');
const inputIds = k1s.map((k1) => hashK1(k1));
const lookup = (id: string): number | 'unknown' | 'burned' => {
const key = this.noteKey(origin, id);
if (this.burned.has(key)) return 'burned';
return this.outstanding.get(key) ?? 'unknown';
};
// melt: the note burns once the payment settles; the mock answers
// OK (payment in flight) and the verify endpoint reports it settled
if (pr !== null) {
for (const id of inputIds) {
const state = lookup(id);
if (state === 'burned') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (state === 'unknown') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
}
this.remint(origin, inputIds, []);
const paid = this.settledInvoices.get(origin) ?? [];
paid.push(pr);
this.settledInvoices.set(origin, paid);
await fulfillJson(route, {
status: 'OK',
verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`,
});
return;
}
// every other mutation mints outputs whose signatures the wallet
// must be able to verify offline
const total = inputIds.reduce((sum, id) => {
const state = lookup(id);
return typeof state === 'number' ? sum + state : sum;
}, 0);
for (const id of inputIds) {
const state = lookup(id);
if (state === 'burned') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
return;
}
if (state === 'unknown') {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
}
if (h !== null && h2 !== null && amountRaw !== null) {
// split: amount carved off, the rest is change
const amountMsat = Number(amountRaw);
this.remint(origin, inputIds, [
[h, amountMsat],
[h2, total - amountMsat],
]);
await fulfillJson(route, {
status: 'OK',
sig: this.sign(origin, h, amountMsat),
sig2: this.sign(origin, h2, total - amountMsat),
});
return;
}
if (h !== null) {
// rotate (one input) or merge (several): full value at h
this.remint(origin, inputIds, [[h, total]]);
await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) });
return;
}
await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' });
},
);
}
@@ -105,7 +312,7 @@ export class MintMocker {
// nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises.
// The payLink points back at the lnurlp route below, as prepareMint treats
// it as the authoritative place to read the payRequest from.
private async mockMintAddress(options: MockTargetMintOptions, origin: string): Promise<void> {
private async mockMintAddress(origin: string, mintPubkey: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
async (route: Route) => {
@@ -114,7 +321,7 @@ export class MintMocker {
callback: `${origin}${CALLBACK_PATH}`,
minWithdrawable: 1000,
maxWithdrawable: 100_000_000_000,
mintPubkey: options.mintPubkey,
mintPubkey,
payLink: `${origin}/.well-known/lnurlp/mint`,
nodeCapacity: 500_000_000,
nodeNumChannels: 4,
@@ -127,15 +334,33 @@ export class MintMocker {
// Everything the target side of a transfer (or a Lightning receive)
// needs: the mint-address discovery endpoint carrying the mint metadata,
// the payRequest at the standard mint@ address, the invoice callback, an
// immediately-settled verify endpoint revealing the preimage, and the
// note info + rotate the claim then performs.
// immediately-settled verify endpoint revealing the payment receipt,
// and note info at the wallet-chosen committed secret.
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
await this.mockMintAddress(options, origin);
await this.mockNoteInfo(
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
origin,
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
let stagedHash: string | null = null;
let settled = false;
await this.mockMintAddress(origin, mintPubkey);
await this.mockMutationEndpoint(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
async (route: Route) => {
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) {
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
return;
}
await fulfillJson(route, {
tag: 'withdrawRequest',
callback: `${origin}${CALLBACK_PATH}`,
k1,
minWithdrawable: options.noteAmountMsat,
maxWithdrawable: options.noteAmountMsat,
defaultDescription: 'mock minted note',
mintPubkey,
});
},
);
await this.mockRotateOk(origin);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
async (route: Route) => {
@@ -144,6 +369,7 @@ export class MintMocker {
callback: `${origin}/pay`,
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: `${origin}${NOTE_PATH}`,
metadata: options.mintFeeMetadata ?? '[]',
});
@@ -152,18 +378,38 @@ export class MintMocker {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
async (route: Route) => {
const request = new URL(route.request().url());
const comment = request.searchParams.get('comment');
const commitment = request.searchParams.get('h');
if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) {
await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' });
return;
}
stagedHash = comment.toLowerCase();
await fulfillJson(route, {
pr: options.invoice,
verify: `${origin}/verify`,
});
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
async (route: Route) => {
const paidInvoice = new URL(route.request().url()).searchParams.get('pr');
if (paidInvoice !== options.invoice) {
await route.fallback();
return;
}
settled = true;
await fulfillJson(route, { status: 'OK' });
},
);
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
async (route: Route) => {
await fulfillJson(route, {
settled: true,
preimage: options.preimage,
settled,
...(settled ? { preimage: options.preimage } : {}),
pr: options.invoice,
});
},
+35
View File
@@ -1,4 +1,8 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
import { MINT_ORIGIN, NOTE_PATH } from './MintMocker';
import type { MintMocker } from './MintMocker';
// Drives onboarding end to end on a fresh browser context: /#/welcome ->
// "Create wallet" (empty password = stored unencrypted) -> confirm the
@@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise<void> => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.waitForURL(/#\/$/);
};
// Give a fresh wallet one verified, spendable note at the mock mint: the
// mock answers the note's informational GET and signs every mutation, and
// the receive's first-contact trust prompt is confirmed, pinning the mint's
// signing key so the wallet can verify what this mint mints.
export const fundMockMintWallet = async (
page: Page,
mint: MintMocker,
amountMsat: number,
): Promise<void> => {
await mint.mockNoteInfo({ amountMsat });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await dialog
.locator('textarea')
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat));
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible();
// the note advertises the mint's key, so the first-contact trust prompt
// opens as its own dialog - trust it (that also pins the signing key)
await page
.locator('.q-dialog', { hasText: 'New mint' })
.getByRole('button', { name: 'Trust this mint' })
.click();
await dialog.getByRole('button', { name: 'Done' }).click();
};
+135
View File
@@ -0,0 +1,135 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
import type { ActivityKind } from '../../src/lnurlcash/storage/activityLog';
// Settings chrome (version footer, no About entry) and the activity
// history: the home-page preview link and the full log's pagination.
type SeededEvent = { kind: ActivityKind; message: string; createdAt: number };
// Write encrypted activity records straight into localStorage, built with
// the app's own wrap (keys.ts: sha256(linking key + context) -> AES-GCM).
// The activity store only reads the log on unlock, so callers reload after
// seeding.
const seedActivityEvents = async (page: Page, events: SeededEvent[]): Promise<void> => {
const linkingKeyHex = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('enc' in saved) ||
saved.enc !== false ||
!('value' in saved) ||
typeof saved.value !== 'string'
) {
throw new Error('expected a plaintext saved wallet material record');
}
const material: unknown = JSON.parse(saved.value);
if (
typeof material !== 'object' ||
material === null ||
!('linkingKeyHex' in material) ||
typeof material.linkingKeyHex !== 'string'
) {
throw new Error('expected the saved material to carry its linking key');
}
return material.linkingKeyHex;
});
const material = sha256(
new Uint8Array([
...hexToBytes(linkingKeyHex),
...utf8ToBytes('lnurlcash-bearer-encryption-v1'),
]),
);
const aesKey = await crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt']);
const records = [];
for (const event of events) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
utf8ToBytes(JSON.stringify(event)),
),
);
records.push({
id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
});
}
await page.evaluate(
(json) => localStorage.setItem('sattle_activity', json),
JSON.stringify(records),
);
};
test.describe('Settings', () => {
test('shows the app version and no About entry', async ({ page }) => {
await createFreshWallet(page);
await page.goto('/#/settings');
const settings = page.locator('.q-page');
await expect(settings.getByText(/^sattle v\d+\.\d+\.\d+/)).toBeVisible();
await expect(settings.locator('.q-item', { hasText: 'About' })).toHaveCount(0);
// the advanced entries are live routes now
await expect(settings.locator('.q-item', { hasText: 'Notes' })).toBeVisible();
await expect(settings.locator('.q-item', { hasText: 'Activity log' })).toBeVisible();
});
});
test.describe('Activity history', () => {
test('the home history preview links to the full activity log', async ({ page, mint }) => {
await fundMockMintWallet(page, mint, 21_000);
// the preview sits inside the History expansion on the main page
const history = page.locator('.q-expansion-item', { hasText: 'History' });
await history.getByText('History', { exact: true }).click();
await expect(
history.locator('.history-list').getByText('Received 21 sats from mint.test.'),
).toBeVisible();
await history.getByRole('button', { name: 'View all activity' }).click();
await expect(page).toHaveURL(/#\/settings\/activity$/);
await expect(
page.locator('.q-page').getByText('Received 21 sats from mint.test.'),
).toBeVisible();
});
test('paginates the activity log', async ({ page }) => {
await createFreshWallet(page);
const base = Date.now();
const kinds: ActivityKind[] = ['mint', 'receive', 'refresh'];
await seedActivityEvents(
page,
Array.from({ length: 25 }, (_, index) => ({
kind: kinds[index % kinds.length] ?? 'mint',
message: `Seeded activity ${String(index).padStart(2, '0')}`,
createdAt: base - index * 60_000,
})),
);
// the log is decrypted on unlock - reload into the auto-unlock
await page.reload();
await page.goto('/#/settings/activity');
await expect(page.locator('.q-page').getByText('Activity log')).toBeVisible();
// page 1 shows the 20 newest events
const rows = page.locator('.q-page .history-list .q-item');
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('Seeded activity 00');
await expect(rows.last()).toContainText('Seeded activity 19');
const pagination = page.locator('.q-pagination');
await expect(pagination).toBeVisible();
await pagination.getByRole('button', { name: '2', exact: true }).click();
// page 2 shows the remaining five, still newest-first
await expect(rows).toHaveCount(5);
await expect(rows.first()).toContainText('Seeded activity 20');
await expect(rows.last()).toContainText('Seeded activity 24');
});
});
+107 -31
View File
@@ -1,5 +1,9 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { bytesToHex } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
// platform authenticator, so the security page is asserted in its honest
@@ -12,6 +16,72 @@ const VALID_PHRASE =
const readSettings = async (page: import('@playwright/test').Page) =>
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
// A v2 encrypted wallet-material record built with the app's own wrap
// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that
// need a saved, locked wallet without driving the onboarding UI.
const PASSWORD = 'correct horse battery staple';
const PBKDF2_ITERATIONS = 210_000;
const encryptedWalletMaterialRecord = async (
password: string,
): Promise<{
enc: true;
salt: string;
iv: string;
ciphertext: string;
materialHash: string;
ownerId: string;
version: 2;
}> => {
const linkingKeyHex = '07'.repeat(32);
const material = {
version: 2,
linkingKeyHex,
cashRootHex: '08'.repeat(64),
};
const serialized = JSON.stringify(material);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(serialized),
),
);
return {
enc: true,
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)),
version: 2,
};
};
const hexToBytesLocal = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
};
test.describe('Backup page', () => {
test('renders all sections and exports a JSON backup file', async ({ page }) => {
await createFreshWallet(page);
@@ -93,19 +163,22 @@ test.describe('Security page', () => {
});
await createFreshWallet(page);
await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('ownerId' in saved) ||
typeof saved.ownerId !== 'string'
typeof saved.ownerId !== 'string' ||
!('materialHash' in saved) ||
typeof saved.materialHash !== 'string'
) {
throw new Error('expected saved wallet owner');
}
const wrap = {
hkdfSalt: '11'.repeat(16),
iv: '22'.repeat(12),
wrappedKey: '33'.repeat(48),
materialHash: saved.materialHash,
wrappedMaterial: '33'.repeat(48),
createdAt: 1,
};
localStorage.setItem(
@@ -116,14 +189,14 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16),
name: 'Current wallet passkey',
ownerId: saved.ownerId,
version: 1,
version: 2,
},
{
...wrap,
credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 1,
version: 2,
},
]),
);
@@ -137,36 +210,39 @@ test.describe('Security page', () => {
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
});
test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => {
// Given an encrypted saved key and passkey slot without owner markers
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
wrappedKey: '77'.repeat(48),
createdAt: 1,
},
]),
);
});
test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
page,
}) => {
// Given an encrypted v2 saved wallet whose only passkey slot belongs to
// a DIFFERENT owner (a foreign wallet's residue on the same device)
const record = await encryptedWalletMaterialRecord(PASSWORD);
await page.addInitScript(
({ storedRecord }) => {
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
materialHash: '77'.repeat(32),
wrappedMaterial: '88'.repeat(48),
createdAt: 1,
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 2,
},
]),
);
},
{ storedRecord: record },
);
// When the locked wallet renders
await page.goto('/');
// Then passkey-first unlock is unavailable until another owner proof
// Then passkey-first unlock is unavailable - the one slot on this
// device is not this wallet's
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
});
+3 -2
View File
@@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise<void> => {
callback: 'https://mint.600.wtf/pay',
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: 'https://mint.600.wtf/note',
metadata: '[]',
}),
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
// hold a 21-sat note from the mock mint - holding funds locks the mint
// against removal
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: 21_000 });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
+6 -30
View File
@@ -1,13 +1,10 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
import { MINT2_ORIGIN } from '../helpers/MintMocker';
import { fundMockMintWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 50_000; // 50 sats
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
// 64 hex chars - a valid payment receipt, distinct from the claimed note secret
const PREIMAGE = 'ef'.repeat(32);
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
// amount cross-check against the requested value
@@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer';
// give the wallet a verified, spendable 50-sat note at the source mint
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await dialog
.locator('textarea')
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
// the note advertises a mint key, so the first-contact trust prompt opens
// as its own dialog - trust it (that also locks the mint)
await page
.locator('.q-dialog', { hasText: 'New mint' })
.getByRole('button', { name: 'Trust this mint' })
.click();
await dialog.getByRole('button', { name: 'Done' }).click();
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
};
// pick an option from a Quasar select identified by its label
@@ -78,7 +56,6 @@ test.describe('Move funds', () => {
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
await fundSourceMint(page, mint);
// the target mint: hands out the invoice, reports it settled with the
// preimage, and answers the claim's note info + rotation
// the target mint commits the wallet-chosen secret, hands out the invoice,
// and exposes that note only after the source-mint melt pays it
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
+156
View File
@@ -0,0 +1,156 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { fundMockMintWallet } from '../helpers/wallet';
// The Notes page (Settings -> Advanced -> Notes): per-note detail dialog,
// split/combine mutations against the mock mint, check & refresh rotation,
// and the hand-off into a selected-note move.
const AMOUNT_MSAT = 21_000; // 21 sats
// pick an option from a Quasar select identified by its label
const pickOption = async (page: Page, label: string, option: string) => {
await page.locator('.q-field', { hasText: label }).click();
await page.getByRole('option', { name: option }).click();
};
// split the wallet's 21-sat note into 8 + 13 sats through the notes UI
const splitInto8And13 = async (page: Page) => {
await page.goto('/#/settings/notes');
await page.locator('.q-page .q-item', { hasText: '21 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Split' }).click();
const splitDialog = page.locator('.q-dialog .split-dialog');
await splitDialog.getByLabel('New note amount').fill('8');
await splitDialog.getByRole('button', { name: 'Split' }).click();
await expect(page.locator('.q-notification').getByText('Note split.')).toBeVisible();
};
test.describe('Notes page', () => {
test('shows note details and check & refresh rotates to a fresh secret', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await page.goto('/#/settings/notes');
const list = page.locator('.q-page .q-list');
await expect(list.getByText('21 sats')).toBeVisible();
// the detail dialog shows the note's identity, status and actions
await list.getByText('21 sats').click();
const dialog = page.locator('.q-dialog .note-detail');
await expect(dialog.getByText('mint.test')).toBeVisible();
await expect(dialog.getByText('Note ID')).toBeVisible();
await expect(dialog.getByText('Mint signing key')).toBeVisible();
await expect(dialog.locator('.q-badge', { hasText: 'Valid locally' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Split' })).toBeEnabled();
await expect(dialog.getByRole('button', { name: 'Move' })).toBeEnabled();
// A refresh is a secret rotation of the same logical note, so private
// local metadata must follow the active replacement.
await dialog.getByLabel('Private label').fill('Emergency cash');
await dialog.getByRole('button', { name: 'Save label' }).click();
await expect(page.locator('.q-notification').getByText('Label saved.')).toBeVisible();
// the check verifies the note mint-side and rotates it to a fresh
// secret; completion is signalled by the toast
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
await expect(
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
).toBeVisible();
// the checked copy is retired and the rotated note stands valid - the
// wallet still holds exactly 21 spendable sats
await page.goto('/#/settings/notes');
await expect(page.locator('.q-page .q-item', { hasText: '21 sats' })).toHaveCount(2);
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(1);
await expect(
page.locator('.q-page .q-item', { hasText: 'Emergency cash' }).getByText('Valid locally'),
).toBeVisible();
// and the refresh is on the activity log
await page.goto('/#/settings/activity');
await expect(
page.locator('.q-page').getByText('Checked and refreshed a 21 sat note from mint.test.'),
).toBeVisible();
});
test('splits a note and combines the pieces back once the change is refreshed', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await splitInto8And13(page);
// the split retires the original and mints the 8-sat target verified;
// the 13-sat change stays unverified (an upper bound until refreshed),
// so it can neither be selected nor combined yet
const list = page.locator('.q-page .q-list');
await expect(list.locator('.q-item', { hasText: '8 sats' })).toHaveCount(1);
await expect(list.locator('.q-item', { hasText: '13 sats' })).toHaveCount(1);
await expect(list.locator('.q-item', { hasText: '13 sats' })).toContainText('Unverified');
await expect(page.locator('.q-checkbox[aria-label="Select 13 sat note"]')).toBeDisabled();
// refreshing the change note verifies and rotates it
await list.locator('.q-item', { hasText: '13 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
await expect(
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
).toBeVisible();
// now both pieces can be selected and combined back (the detail dialog
// stays open on the retired copy - dismiss it to reach the list)
await page.keyboard.press('Escape');
await expect(page.locator('.note-detail')).toHaveCount(0);
await page.locator('.q-checkbox[aria-label="Select 8 sat note"]').click();
await page.locator('.q-checkbox[aria-label="Select 13 sat note"]:not(.disabled)').click();
const mergeBar = page.locator('.merge-bar');
await expect(mergeBar.getByText('2 selected')).toBeVisible();
await mergeBar.getByRole('button', { name: 'Combine' }).click();
// one valid 21-sat note again; every intermediate copy shows as spent
await expect(page.locator('.q-notification').getByText('Notes combined.')).toBeVisible();
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(4);
// no sats were created or destroyed along the way
await page.goto('/#/');
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
test('moving from a note prefills its amount and locks the source to that note', async ({
page,
mint,
}) => {
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
await splitInto8And13(page);
// move the 8-sat piece: the move form opens with the note's mint and
// amount prefilled, and the source select is locked
await page.locator('.q-page .q-item', { hasText: '8 sats' }).click();
const dialog = page.locator('.q-dialog .note-detail');
await dialog.getByRole('button', { name: 'Move' }).click();
await expect(page).toHaveURL(/#\/settings\/move\?noteId=/);
const sourceField = page.locator('.q-field', { hasText: 'From mint' });
await expect(sourceField).toHaveClass(/q-field--disabled/);
await expect(sourceField).toContainText('mint.test - 8 sats available');
await expect(page.getByLabel('Amount')).toHaveValue('8');
// Max tops up to the selected note alone, not the wallet's 21 sats
await page.getByRole('button', { name: 'Max' }).click();
await expect(page.getByLabel('Amount')).toHaveValue('8');
// and asking for more than the locked note is refused inline
await pickOption(page, 'To mint', 'Another mint…');
await page.getByLabel('Target mint address').fill('@mint2.test');
await page.getByLabel('Amount').fill('9');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(
page.locator('.q-banner', { hasText: 'more than the 8 sats spendable at mint.test' }),
).toBeVisible();
});
});
+126 -1
View File
@@ -1,17 +1,34 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
import { hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent } from 'nostr-tools/pure';
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04';
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
// test that runs the service injects a fake transport through the dev-only
// window hook (stores/nwc.ts) before enabling - every other test stays
// UI-only with the service off.
// a Nostr event as the fake transport carries it - the plain JSON shape
// NIP-47 requests and responses take on the wire
type WireEvent = {
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
};
type FakeSub = { closed: boolean; close: () => void };
declare global {
interface Window {
__sattleNwcTest: { setTransport: (transport: unknown) => void };
__nwcSubs: FakeSub[];
__nwcPublished: WireEvent[];
__nwcOnEvent?: (event: WireEvent) => void;
}
}
@@ -146,4 +163,112 @@ test.describe('NWC settings page', () => {
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
});
test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => {
// a funded wallet: 50 sats at the mock mint, its signing key pinned
await fundMockMintWallet(page, mint, 50_000);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// create a connection and read its one-time URI: the wallet service
// pubkey is the host, the client secret the `secret` param
await openNwcPage(page);
await page.getByRole('button', { name: 'Create connection' }).click();
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
expect(uri).toBeTruthy();
if (!uri) throw new Error('Expected the one-time NWC connection string.');
await page.getByRole('button', { name: "Done - I've saved it" }).click();
const connectionUrl = new URL(uri);
const walletServicePubkey = connectionUrl.host;
const clientSecret = connectionUrl.searchParams.get('secret') ?? '';
// a fake transport that captures the request handler and every
// published event, installed before the service starts
await page.evaluate(() => {
window.__nwcSubs = [];
window.__nwcPublished = [];
window.__sattleNwcTest.setTransport({
publish: (_relays: string[], event: WireEvent) => {
window.__nwcPublished.push(event);
return Promise.resolve();
},
subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => {
window.__nwcOnEvent = onEvent;
const sub: FakeSub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click();
await expect(page.getByText('Service running', { exact: false })).toBeVisible();
await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function');
// a 21-sat invoice the mint will settle; the request is exactly what a
// NIP-47 client sends: kind 23194, NIP-04 encrypted to the service,
// schnorr-signed by the connection's client secret
const invoice = 'lnbc210n1pjqrstuvwxyz';
const content = nip04Encrypt(
clientSecret,
walletServicePubkey,
JSON.stringify({ method: 'pay_invoice', params: { invoice } }),
);
const request = finalizeEvent(
{
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', walletServicePubkey],
['encryption', 'nip04'],
],
content,
},
hexToBytes(clientSecret),
);
await page.evaluate((event) => window.__nwcOnEvent?.(event), request);
// the service pays and answers on the same scheme
await expect
.poll(async () =>
page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
),
)
.not.toBeNull();
const responseEvent = await page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
);
if (!responseEvent) throw new Error('the service published no response');
const response: unknown = JSON.parse(
nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content),
);
if (typeof response !== 'object' || response === null) {
throw new Error('the service response is not an object');
}
const result = response as { result_type?: unknown; error?: unknown; result?: unknown };
expect(result.result_type).toBe('pay_invoice');
expect(result.error).toBeNull();
// the receipt is the melt's payment preimage from the mint's own
// settle proof - 64 hex chars, never a note secret
const payload = result.result as { preimage?: unknown };
expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/);
// the wallet paid 21 of its 50 sats; the change stays in the wallet
await page.goto('/#/');
await expect(page.locator('.balance-card .text-h2')).toHaveText('29');
});
});
+17 -6
View File
@@ -5,7 +5,6 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
// a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note
@@ -26,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise<void> => {
test.describe('Receive bearer note', () => {
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk();
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
@@ -38,6 +37,18 @@ test.describe('Receive bearer note', () => {
// were really consumed (the kit bug this guards against silently fell
// back to the declared amount without any request)
await expect(dialog.locator('.q-banner')).toHaveCount(0);
// the mint signed the rotation's output (LUD-25) - the wallet's
// requireSignatures policy accepted a signed answer, not a rescue
expect(
mint.signaturesIssued.some(
(entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT,
),
).toBe(true);
// the note advertised the mint's key, so the first-contact trust prompt
// opened - dismiss it, then close the success screen
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await dialog.getByRole('button', { name: 'Done' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
@@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
@@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
+113
View File
@@ -0,0 +1,113 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import {
fromBech32Lnurl,
noteDeclaredAmount,
noteK1,
noteSignature,
verifyNoteSignature,
} from 'lnurlcash-kit';
import { fundMockMintWallet } from '../helpers/wallet';
const FUNDING_MSAT = 50_000; // 50 sats
const SEND_MSAT = 21_000; // 21 sats
// give the wallet a verified, spendable 50-sat note at the mock mint, with
// the mint's signing key pinned through the first-contact trust prompt
const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await fundMockMintWallet(page, mint, FUNDING_MSAT);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
};
// prepare a note hand-over and read the displayed note off the clipboard
const prepareNote = async (page: Page, sats: string): Promise<string> => {
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByLabel('Amount (sats)').fill(sats);
await dialog.getByRole('button', { name: 'Prepare note' }).click();
await expect(dialog.getByText('Tap to reveal')).toBeVisible();
await dialog.getByRole('button', { name: 'Copy' }).click();
return page.evaluate(() => navigator.clipboard.readText());
};
test.describe('Send a note', () => {
test('a carved note carries a signature that verifies against the mint', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint signed the split's two outputs (the carved note and the
// change), and the note the dialog hands over carries that signature
// in its URL
const beforeSend = mint.signaturesIssued.length;
const bech32 = await prepareNote(page, '21');
const issued = mint.signaturesIssued
.slice(beforeSend)
.map((entry) => entry.amountMsat)
.sort((a, b) => a - b);
expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]);
const url = fromBech32Lnurl(bech32.trim());
expect(url).not.toBeNull();
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
expect(k1).not.toBeNull();
expect(amountMsat).toBe(SEND_MSAT);
expect(signature).not.toBeNull();
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
// the offline proof verifies against the key the mint advertised and
// the wallet pinned - this is what marked the carved note spendable
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true);
// keep the note: nothing was handed over, the balance is untouched
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
test('outputs the mint signs with a foreign key never become spendable', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint's mutations land but are signed by a key it never advertised
mint.invalidSignatures = true;
const bech32 = await prepareNote(page, '21');
// the note exists (the split landed mint-side), but its offline proof
// verifies against no key this mint publishes
const url = fromBech32Lnurl(bech32.trim());
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false);
// keep the note: the balance is not corrupted by the bad proof
const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// the carve's outputs stayed unverified, so the wallet refuses to spend
// from them - a second preparation fails instead of moving bad notes
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const retry = page.locator('.q-dialog', { hasText: 'Send a note' });
await retry.getByLabel('Amount (sats)').fill('21');
await retry.getByRole('button', { name: 'Prepare note' }).click();
await expect(
retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }),
).toBeVisible();
await expect(retry.getByText('Tap to reveal')).toHaveCount(0);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
});
+9 -1
View File
@@ -3,6 +3,14 @@ import type { Page, TestInfo } from '@playwright/test';
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
// the dev-only lifecycle hook (boot/wallet.ts) used to drive the forget
// transition, which has no UI surface
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const MINT_KEY_A = `02${'aa'.repeat(32)}`;
const MINT_KEY_B = `03${'bb'.repeat(32)}`;
const MINT_KEY_C = `02${'cc'.repeat(32)}`;
@@ -23,7 +31,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise<
const ownerId = async (page: Page): Promise<string | null> =>
page.evaluate(() => {
const raw = localStorage.getItem('sattle_linking_key');
const raw = localStorage.getItem('sattle_wallet_material_v2');
if (raw === null) return null;
const saved: unknown = JSON.parse(raw);
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null;
+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';
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet
// with ownerless passkey/NWC/trust residue must migrate to the proven owner
// during password unlock - before the NWC service may start - and forgetting
// the wallet must drain/stop that service and remove every wallet-owned key
// before a successor wallet can be created without any of the old residue.
//
// The legacy encrypted record is produced here with the exact same KDF/wrap
// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the
// owner marker a current build would stamp.
// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
// install - the linking-key-only record, the pre-v2 bearer store and their
// ownerless residue - is never migrated (the cash root is unrecoverable
// from a linking key), so boot resets the device to the uninstalled state
// before any route can expose it, and a successor wallet starts without
// any of the old residue.
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const PASSWORD = 'correct horse battery staple';
const LINKING_KEY_HEX = '07'.repeat(32);
const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true));
const MINT_PUBKEY = '02' + 'aa'.repeat(32);
function hexToBytesLocal(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
const PBKDF2_ITERATIONS = 210_000;
const legacyEncryptedRecord = async (
valueHex: string,
password: string,
): Promise<{ salt: string; iv: string; ciphertext: string }> => {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(valueHex),
),
);
return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
const WALLET_KEYS = [
// every namespace the alpha wallet could have touched
const LEGACY_KEYS = [
'sattle_linking_key',
'sattle_bearers',
'sattle_activity',
'sattle_settings',
'sattle_wallet_material_v2',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
'sattle_activity',
'sattle_settings',
] as const;
test.describe('wallet lifecycle', () => {
test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
page,
}) => {
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD);
await page.addInitScript(
({ storedRecord, mintPubkey }) => {
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord }));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]),
);
},
{ storedRecord: record, mintPubkey: MINT_PUBKEY },
);
// Given a legacy encrypted linking-key record plus ownerless residue
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_bearers',
JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([
{
server: 'legacy.example',
mintPubkey: `02${'aa'.repeat(32)}`,
addedAt: 1,
locked: false,
},
]),
);
});
// When the app boots
await page.goto('/#/');
// the unlock screen proves the app boot finished and the dev hooks exist
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect
.poll(async () => page.evaluate(() => typeof window.__sattleNwcTest))
.toBe('object');
// no real relay traffic once the migrated enabled state starts the service
await page.evaluate(() => {
window.__nwcSubs = [];
window.__sattleNwcTest.setTransport({
publish: () => Promise.resolve(),
subscribe: () => {
const sub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
// When the holder proves the wallet by password
await page.locator('.unlock-card input').fill(PASSWORD);
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
// Then the wallet unlocks...
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// ...and every legacy namespace was migrated to the proven owner BEFORE
// the NWC service could start (the subscriptions below only exist because
// the migrated owner-scoped enabled record read true)
const migrated = await page.evaluate(() => {
const field = (storageKey: string, name: string): unknown => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
return typeof value === 'object' && value !== null
? Object.entries(value).find(([key]) => key === name)?.[1]
: undefined;
};
const fields = (storageKey: string, name: string): unknown[] => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
if (!Array.isArray(value)) return [];
return value.map((entry: unknown) =>
typeof entry === 'object' && entry !== null
? Object.entries(entry).find(([key]) => key === name)?.[1]
: undefined,
);
};
const mints: unknown = field('sattle_trusted_mints', 'mints');
return {
savedKeyOwner: field('sattle_linking_key', 'ownerId'),
passkeyOwners: fields('sattle_passkey_slots', 'ownerId'),
nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'),
nwcEnabled: field('sattle_nwc_enabled', 'enabled'),
nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'),
trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'),
trustedMintServers: Array.isArray(mints)
? mints.map((mint: unknown) =>
typeof mint === 'object' && mint !== null && 'server' in mint
? mint.server
: undefined,
)
: [],
};
});
expect(migrated.savedKeyOwner).toBe(OWNER_ID);
expect(migrated.passkeyOwners).toEqual([OWNER_ID]);
expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({
ownerId: OWNER_ID,
enabled: true,
});
expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]);
expect(migrated.trustedMintsOwner).toBe(OWNER_ID);
expect(migrated.trustedMintServers).toEqual(['legacy.example']);
await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0);
// When the wallet is forgotten
await page.evaluate(() => window.__sattleWalletTest.forget());
// Then the app lands on the no-wallet screen, the service is drained
// (every subscription closed), and no wallet-owned key remains
// Then the alpha reset landed on the uninstalled state - no unlock
// surface for a wallet that can never be derived again
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
await expect(page.getByText('Wallet locked')).toHaveCount(0);
// ...and every namespace the alpha wallet could have touched is gone
const remaining = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[...WALLET_KEYS],
[...LEGACY_KEYS],
);
expect(remaining).toEqual([]);
@@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => {
await page.getByRole('button', { name: 'Create wallet' }).click();
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// Then it is a different owner with zero adopted residue
const successor = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null');
return {
ownerId:
typeof saved === 'object' && saved !== null && 'ownerId' in saved
? saved.ownerId
: undefined,
residueKeys: [
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
].filter((key) => localStorage.getItem(key) !== null),
};
});
expect(successor.ownerId).not.toBe(OWNER_ID);
expect(successor.residueKeys).toEqual([]);
// Then it unlocks into a working wallet with zero adopted residue
// (settings/activity are the live app's own namespaces - the residue
// check covers only the stores a predecessor could have planted)
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
const residue = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[
'sattle_linking_key',
'sattle_bearers',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
],
);
expect(residue).toEqual([]);
});
});
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "sattle",
"version": "0.0.4",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sattle",
"version": "0.0.4",
"version": "0.0.6",
"hasInstallScript": true,
"dependencies": {
"@agicash/qr-scanner": "^0.1.2",
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.5.0",
"lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "0.4.0",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
@@ -5961,9 +5961,9 @@
}
},
"node_modules/lnurlcash-conformance": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz",
"integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz",
"integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5987,9 +5987,9 @@
}
},
"node_modules/lnurlcash-kit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz",
"integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz",
"integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.3.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "sattle",
"version": "0.0.4",
"version": "0.0.6",
"description": "A Quasar Project",
"productName": "sattle",
"author": "protom <office@protom.eu>",
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.5.0",
"lnurlcash-kit": "0.8.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "0.4.0",
"lnurlcash-conformance": "0.7.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
+1
View File
@@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => {
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
+1 -3
View File
@@ -4,9 +4,7 @@
"target": {
"namespace": "android_app",
"package_name": "eu.protom.sattle",
"sha256_cert_fingerprints": [
"REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"
]
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"]
}
}
]
+2 -2
View File
@@ -1,4 +1,4 @@
import { register } from "register-service-worker";
import { register } from 'register-service-worker';
// The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments.
@@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, {
error(/* err */) {
// console.error('Error during service worker registration:', err)
}
},
});
+7 -13
View File
@@ -4,13 +4,13 @@
* quasar.config file > pwa > workboxMode is set to "InjectManifest"
*/
import { clientsClaim } from "workbox-core";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from 'workbox-routing';
import {
cleanupOutdatedCaches,
createHandlerBoundToURL,
precacheAndRoute
} from "workbox-precaching";
precacheAndRoute,
} from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope & typeof globalThis;
@@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) {
// Non-SSR/SSG fallbacks to index.html
// Production SSR/SSG fallbacks to offline.html (except for dev)
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML),
{
denylist: [
new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX),
/workbox-(.)*\.js$/
]
}
)
new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), {
denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
}),
);
}
+6 -3
View File
@@ -13,9 +13,12 @@ declare global {
}
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away,
// a password-encrypted one lands on 'locked' for the unlock screen, and no
// key at all lands on 'none' for onboarding.
// wallet store at app start - a plaintext-stored material record unlocks
// straight away, a password-encrypted one lands on 'locked' for the unlock
// screen, and no record at all lands on 'none' for onboarding. init() first
// runs the alpha reset: unsupported legacy state (the linking-key-only
// record or the pre-v2 bearer namespace) is wiped, never migrated, before
// any route can expose it.
export default defineBoot(async () => {
const wallet = useWalletStore();
await wallet.init();
+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 { bytesToHex } from '@noble/hashes/utils.js';
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys';
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots';
import {
deriveWalletMaterial,
serializeWalletMaterial,
walletMaterialHash,
type WalletMaterialV2,
} from '@/lnurlcash/keys';
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage';
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
const pluginMocks = vi.hoisted(() => ({
authenticate: vi.fn<() => Promise<void>>(),
checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(),
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
secureRemove: vi.fn<(key: string) => Promise<void>>(),
secureSet: vi.fn<(key: string, value: string) => Promise<void>>(),
secureValues: new Map<string, string>(),
}));
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
AndroidBiometryStrength: { weak: 'weak' },
BiometricAuth: { authenticate: pluginMocks.authenticate },
BiometricAuth: {
authenticate: pluginMocks.authenticate,
checkBiometry: pluginMocks.checkBiometry,
},
BiometryError: class BiometryError extends Error {},
BiometryErrorType: { userCancel: 'userCancel' },
}));
vi.mock('@aparajita/capacitor-secure-storage', () => ({
SecureStorage: { get: pluginMocks.secureGet },
SecureStorage: {
get: pluginMocks.secureGet,
remove: pluginMocks.secureRemove,
set: pluginMocks.secureSet,
},
}));
vi.mock('./platform', () => ({ isNative: () => true }));
import { unlockWithBiometrics } from './biometricUnlock';
import {
disableBiometricUnlock,
enableBiometricUnlock,
unlockWalletMaterialWithBiometrics,
} from './biometricUnlock';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const SEED =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL = deriveWalletMaterial(SEED);
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED);
// same linking key (same owner) as MATERIAL, but a different, VALID cash root
const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = {
...MATERIAL,
cashRootHex: OTHER_MATERIAL.cashRootHex,
};
const WRAP_SECRET = new Uint8Array(32).fill(3);
const WRAP_RECORD_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2');
const persistedState = () => ({
legacyKey: localStorage.getItem('sattle_linking_key'),
legacyNwc: localStorage.getItem('sattle_nwc_connections'),
legacyPasskeys: localStorage.getItem('sattle_passkey_slots'),
legacyTrust: localStorage.getItem('sattle_trusted_mints'),
material: localStorage.getItem(MATERIAL_STORAGE_KEY),
secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null,
wrap: localStorage.getItem(WRAP_RECORD_KEY),
});
const deriveFixtureWrapKey = async (
secret: Uint8Array,
hkdfSalt: Uint8Array,
): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(hkdfSalt),
info: new Uint8Array(WRAP_KEY_HKDF_INFO),
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
};
const storePayload = async (
plaintext: Uint8Array,
pubkey: string,
materialHash: string,
secret = WRAP_SECRET,
): Promise<void> => {
const hkdfSalt = new Uint8Array(16).fill(4);
const iv = new Uint8Array(12).fill(5);
const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)),
);
localStorage.setItem(
WRAP_RECORD_KEY,
JSON.stringify({
version: 2,
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
pubkey,
materialHash,
createdAt: 1,
}),
);
pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret));
};
const storeMaterialPayload = async (
material: WalletMaterialV2,
pubkey = walletMaterialOwnerId(material),
secret = WRAP_SECRET,
materialHash = walletMaterialHash(material),
): Promise<void> =>
storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret);
beforeEach(() => {
vi.clearAllMocks();
stubLocalStorage();
pluginMocks.secureValues.clear();
pluginMocks.authenticate.mockResolvedValue();
pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET));
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', () => {
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => {
// Given an ownerless legacy wallet and credential residue plus a biometric wrap
// whose claimed pubkey does not match the key it unwraps
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) };
const legacySlot = {
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
};
const legacyNwc = [
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
];
const legacyTrust = [
{
server: 'legacy.example',
mintPubkey: '02' + 'aa'.repeat(32),
addedAt: 1,
locked: false,
},
];
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey));
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot]));
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc));
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust));
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY);
describe('biometric wallet material', () => {
it('rejects foreign enrollment before writing either biometric store', async () => {
// Given one proven saved owner and complete material for another owner
await saveWalletMaterial(MATERIAL);
const before = persistedState();
// When enrollment is attempted with the foreign material
const attempt = enableBiometricUnlock(OTHER_MATERIAL);
// Then ownership fails before native or local persistence changes
await expect(attempt).rejects.toThrow('proven saved wallet owner');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('does not create a local wrap when secure-secret persistence fails', async () => {
// Given valid material and a rejecting native secure store
await saveWalletMaterial(MATERIAL);
const before = persistedState();
pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed'));
// When enrollment reaches native secret persistence
const attempt = enableBiometricUnlock(MATERIAL);
// Then failure is reported without a misleading local enrollment record
await expect(attempt).rejects.toThrow('secure write failed');
expect(persistedState()).toEqual(before);
});
it('round-trips both key parts when enrollment belongs to the saved owner', async () => {
// Given complete canonical material persisted for its proven owner
await saveWalletMaterial(MATERIAL);
// When the capability enrolls and unlocks it through the native adapter
await enableBiometricUnlock(MATERIAL);
const beforeUnlock = persistedState();
const unlocked = await unlockWalletMaterialWithBiometrics();
// Then both key parts return together and unlock does not rewrite persistence
expect(unlocked).toEqual(MATERIAL);
expect(persistedState()).toEqual(beforeUnlock);
});
it.each([
['malformed JSON', utf8ToBytes('{')],
['legacy key-only payload', new Uint8Array(32).fill(7)],
[
'altered root',
utf8ToBytes(
JSON.stringify({
...MATERIAL,
cashRootHex: '00'.repeat(64),
}),
),
],
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
// Given an authenticated non-canonical payload bound to the saved owner
await saveWalletMaterial(MATERIAL);
await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL));
const before = persistedState();
// When biometric unlock decrypts the payload
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither key part crosses the boundary and persistence is byte-identical
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for a wrong secure secret and preserves state', async () => {
// Given a valid wrap whose secure secret has been replaced
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32));
const before = persistedState();
// When biometric unlock authenticates with the wrong wrapping secret
const attempt = unlockWalletMaterialWithBiometrics();
// Then authenticated decryption fails without changing either store
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('returns no material for altered ciphertext and preserves state', async () => {
// Given a valid wrap with one ciphertext nibble altered
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}');
const wrappedKey = record.wrappedKey;
if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext');
localStorage.setItem(
'sattle_biometric_wrap',
WRAP_RECORD_KEY,
JSON.stringify({
...wrap,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY),
createdAt: 1,
...record,
wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`,
}),
);
const before = new Map([
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')],
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')],
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')],
]);
// When biometric unwrap reaches the pubkey proof check
const attempt = unlockWithBiometrics();
// Then no key crosses the capability boundary and no legacy namespace is adopted
const before = persistedState();
// When biometric unlock verifies the AES-GCM tag
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither key part returns and persistence remains untouched
await expect(attempt).rejects.toThrow();
expect(persistedState()).toEqual(before);
});
it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => {
// Given material wrapped for one owner and saved material replaced by another
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(MATERIAL);
await saveWalletMaterial(OTHER_MATERIAL);
const before = persistedState();
// When the stale biometric record is used
const attempt = unlockWalletMaterialWithBiometrics();
// Then no old material returns and both stores remain byte-identical
await expect(attempt).rejects.toThrow('different wallet');
expect(persistedState()).toEqual(before);
});
it('rejects authenticated material owned by another linking key', async () => {
// Given the saved owner and wrap claim owner A but authenticated material owner B
await saveWalletMaterial(MATERIAL);
localStorage.setItem('sattle_linking_key', 'legacy-key-residue');
localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue');
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue');
await storeMaterialPayload(
OTHER_MATERIAL,
walletMaterialOwnerId(MATERIAL),
WRAP_SECRET,
walletMaterialHash(MATERIAL),
);
const before = persistedState();
// When biometric unlock checks the decrypted linking-key owner
const attempt = unlockWalletMaterialWithBiometrics();
// Then neither foreign key part returns and persistence is unchanged
await expect(attempt).rejects.toThrow('different wallet');
expect(savedKeyOwnerId()).toBeNull();
expect(readPasskeySlots()).toEqual([]);
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value);
expect(persistedState()).toEqual(before);
});
});
describe('biometric exact-material commitment', () => {
it('rejects same-owner altered-root enrollment before writing either biometric store', async () => {
// Given one proven saved owner and well-formed material with only the cash root swapped
await saveWalletMaterial(MATERIAL);
const before = persistedState();
// When enrollment is attempted with the same-owner altered-root material
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
// Then the exact-material commitment fails before native or local persistence changes
await expect(attempt).rejects.toThrow('saved wallet material');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects same-owner altered-root enrollment against encrypted saved material', async () => {
// Given the saved material envelope is password-encrypted (no plaintext available)
await saveWalletMaterial(MATERIAL, 'password');
const before = persistedState();
// When enrollment is attempted with the same-owner altered-root material
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
// Then the hash commitment alone rejects it before either store changes
await expect(attempt).rejects.toThrow('saved wallet material');
expect(persistedState()).toEqual(before);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects enrollment when same-owner saved material changes during the prompt', async () => {
// Given valid enrollment input and a prompt-time swap of only the saved cash root
await saveWalletMaterial(MATERIAL);
pluginMocks.authenticate.mockImplementationOnce(async () => {
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
});
// When enrollment runs the biometric prompt
const attempt = enableBiometricUnlock(MATERIAL);
// Then the post-prompt commitment recheck fails and no biometric store is written
await expect(attempt).rejects.toThrow('changed');
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
});
it('rejects an authenticated well-formed same-owner altered-root payload', async () => {
// Given an authenticated wrap that claims the saved commitment but whose
// decrypted material swaps only the cash root
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(
ALTERED_ROOT_MATERIAL,
walletMaterialOwnerId(ALTERED_ROOT_MATERIAL),
WRAP_SECRET,
walletMaterialHash(MATERIAL),
);
const before = persistedState();
// When biometric unlock decrypts the well-formed same-owner payload
const attempt = unlockWalletMaterialWithBiometrics();
// Then the material commitment rejects it and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(persistedState()).toEqual(before);
});
it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => {
// Given an authenticated wrap committed to same-owner altered-root material
await saveWalletMaterial(MATERIAL);
await storeMaterialPayload(ALTERED_ROOT_MATERIAL);
const before = persistedState();
// When biometric unlock inspects the record commitment
const attempt = unlockWalletMaterialWithBiometrics();
// Then it rejects before any prompt and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(pluginMocks.authenticate).not.toHaveBeenCalled();
expect(persistedState()).toEqual(before);
});
it('rejects a stale wrap after same-owner saved material replacement', async () => {
// Given an enrolled wrap and a later saved-material swap of only the cash root
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
const before = persistedState();
// When the now-stale biometric wrap is used
const attempt = unlockWalletMaterialWithBiometrics();
// Then the stale commitment rejects and both stores stay byte-identical
await expect(attempt).rejects.toThrow('wallet material');
expect(persistedState()).toEqual(before);
});
it('rejects unlock when same-owner saved material changes during the prompt', async () => {
// Given an enrolled wrap and a prompt-time swap of only the saved cash root
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
let swapped: ReturnType<typeof persistedState> | null = null;
pluginMocks.authenticate.mockImplementationOnce(async () => {
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
swapped = persistedState();
});
// When biometric unlock runs the prompt
const attempt = unlockWalletMaterialWithBiometrics();
// Then the post-prompt commitment recheck rejects and the ceremony writes nothing
await expect(attempt).rejects.toThrow('changed');
expect(swapped).not.toBeNull();
expect(persistedState()).toEqual(swapped);
});
it('rejects unlock when the wrap record is replaced during the prompt', async () => {
// Given an enrolled wrap replaced mid-prompt by another valid same-owner record
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
let swapped: ReturnType<typeof persistedState> | null = null;
pluginMocks.authenticate.mockImplementationOnce(async () => {
const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY);
if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret');
await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret));
swapped = persistedState();
});
// When biometric unlock runs the prompt
const attempt = unlockWalletMaterialWithBiometrics();
// Then the record recheck rejects and the ceremony writes nothing itself
await expect(attempt).rejects.toThrow('changed');
expect(swapped).not.toBeNull();
expect(persistedState()).toEqual(swapped);
});
});
describe('biometric deletion ordering', () => {
it('keeps native and local state retryable when secure-secret deletion fails', async () => {
// Given a complete enrolled biometric wrap
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
const before = persistedState();
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed'));
// When native secure-secret deletion fails
const attempt = disableBiometricUnlock();
// Then failure is reported before either persisted part is removed
await expect(attempt).rejects.toThrow('native deletion failed');
expect(persistedState()).toEqual(before);
});
it('supports repeated delete interruption followed by a successful retry', async () => {
// Given two interrupted deletion attempts for an enrolled wrap
await saveWalletMaterial(MATERIAL);
await enableBiometricUnlock(MATERIAL);
const before = persistedState();
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption'));
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption'));
// When deletion is retried until native removal succeeds
await expect(disableBiometricUnlock()).rejects.toThrow('first interruption');
expect(persistedState()).toEqual(before);
await expect(disableBiometricUnlock()).rejects.toThrow('second interruption');
expect(persistedState()).toEqual(before);
await disableBiometricUnlock();
// Then native state is gone before the local wrap is removed
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material);
});
});
+187 -49
View File
@@ -1,23 +1,25 @@
// Native biometric unlock: a THIRD wrap of the same linking key, alongside
// the password wrap (keys.ts) and the passkey slots (passkeys.ts).
// Native biometric unlock: a THIRD wrap of the same canonical wallet material,
// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts).
//
// Why native: Android WebView has no usable WebAuthn platform authenticator
// for this app (passkeys need Play Services / Digital Asset Links wiring we
// cannot rely on), so the biometric path on native is a device-credential
// prompt instead of a passkey ceremony.
//
// Design (mirrors the passkey-slot construction, reusing its wrap
// primitives verbatim): on enrollment a random 32-byte wrap secret is
// generated, the linking key is AES-GCM-wrapped under an HKDF of that
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32
// bytes of IKM), and only the SECRET goes into biometric-gated secure
// Design (mirrors the passkey-slot construction with a biometric-specific
// HKDF domain): on enrollment a random 32-byte wrap secret is generated,
// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret,
// and only the SECRET goes into biometric-gated secure
// storage (Android Keystore-backed AES-GCM via
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
// localStorage as a record shaped like a passkey slot, plus the linking
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale
// wrap behind, and unlocking with it must fail loudly (never activate the
// old wallet silently), so unlock verifies the unwrapped key against the
// recorded pubkey and tells the holder to re-enroll.
// localStorage as a versioned AES-GCM record, plus the linking
// pubkey as an identity check AND the SHA-256 commitment of the canonical
// saved material: restoring a DIFFERENT seed leaves a stale wrap behind,
// and so does replacing only the saved cash root with same-owner material.
// Unlocking with either must fail loudly (never activate the old wallet
// silently), so unlock verifies the record commitment and the unwrapped
// material against the saved wallet material and tells the holder to
// re-enroll.
//
// The biometric gate is app-level (a BiometricPrompt before the secure
// read), not a keystore key invalidated on biometric re-enrollment - 04
@@ -36,39 +38,90 @@ import {
BiometryErrorType,
} from '@aparajita/capacitor-biometric-auth';
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { linkingPubKeyHex } from '@/lnurlcash/keys';
import type { PasskeyWrap } from '@/lnurlcash/passkeys';
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import {
getPlainWalletMaterial,
parseWalletMaterial,
savedWalletMaterialHash,
savedWalletMaterialOwnerId,
serializeWalletMaterial,
walletMaterialHash,
type WalletMaterialV2,
} from '@/lnurlcash/keys';
import { isJsonObject } from '@/lnurlcash/jsonParsing';
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
import { isNative } from './platform';
type BiometricWrapRecord = PasskeyWrap & {
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps
createdAt: number;
type BiometricWrapRecord = {
readonly version: 2;
readonly hkdfSalt: string;
readonly iv: string;
readonly wrappedKey: string;
readonly pubkey: string;
readonly materialHash: string;
readonly createdAt: number;
};
// the wrapped blob is useless without the secure-storage secret, so (like
// the passkey slots) this record sits in plain localStorage
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
const BIOMETRIC_WRAP_VERSION = 2 as const;
const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2';
const WRAP_RECORD_KEYS = [
'version',
'hkdfSalt',
'iv',
'wrappedKey',
'pubkey',
'materialHash',
'createdAt',
] as const;
const deriveBiometricWrapKey = async (
secret: Uint8Array,
hkdfSalt: Uint8Array,
): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(hkdfSalt),
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)),
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
};
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
if (typeof record !== 'object' || record === null) return false;
const r = record as Record<string, unknown>;
if (!isJsonObject(record)) return false;
return (
typeof r.hkdfSalt === 'string' &&
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) &&
typeof r.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(r.iv) &&
typeof r.wrappedKey === 'string' &&
r.wrappedKey.length > 0 &&
r.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(r.wrappedKey) &&
typeof r.pubkey === 'string' &&
/^[0-9a-f]{66}$/i.test(r.pubkey) &&
typeof r.createdAt === 'number'
Object.keys(record).length === WRAP_RECORD_KEYS.length &&
Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) &&
record.version === BIOMETRIC_WRAP_VERSION &&
typeof record.hkdfSalt === 'string' &&
/^[0-9a-f]{32}$/.test(record.hkdfSalt) &&
typeof record.iv === 'string' &&
/^[0-9a-f]{24}$/.test(record.iv) &&
typeof record.wrappedKey === 'string' &&
record.wrappedKey.length > 0 &&
record.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/.test(record.wrappedKey) &&
typeof record.pubkey === 'string' &&
/^[0-9a-f]{66}$/.test(record.pubkey) &&
typeof record.materialHash === 'string' &&
/^[0-9a-f]{64}$/.test(record.materialHash) &&
typeof record.createdAt === 'number' &&
Number.isSafeInteger(record.createdAt) &&
record.createdAt >= 0
);
};
@@ -83,6 +136,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => {
}
};
const biometricWrapRecordsEqual = (
left: BiometricWrapRecord,
right: BiometricWrapRecord,
): boolean =>
left.version === right.version &&
left.hkdfSalt === right.hkdfSalt &&
left.iv === right.iv &&
left.wrappedKey === right.wrappedKey &&
left.pubkey === right.pubkey &&
left.materialHash === right.materialHash &&
left.createdAt === right.createdAt;
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
// the security page ask this during render/setup
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
@@ -121,52 +186,125 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
}
};
export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<void> => {
export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise<void> => {
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
const serialized = serializeWalletMaterial(material);
const canonicalMaterial = parseWalletMaterial(serialized);
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
const ownerId = walletMaterialOwnerId(canonicalMaterial);
if (savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('Biometric enrollment requires the proven saved wallet owner.');
}
// exact-material commitment (same check as passkey slots): the enrolled
// bytes must equal the canonical saved material, not just share its owner
const materialHash = savedWalletMaterialHash();
const plaintext = getPlainWalletMaterial();
if (
materialHash === null ||
walletMaterialHash(canonicalMaterial) !== materialHash ||
(plaintext !== null && serializeWalletMaterial(plaintext) !== serialized)
) {
throw new Error('Biometric enrollment requires the exact saved wallet material.');
}
const biometry = await BiometricAuth.checkBiometry();
if (!biometry.isAvailable) {
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
}
// prove presence before storing anything under the biometric gate
await authenticateOrThrow('Set up biometric unlock for your wallet');
// the prompt yields time: re-verify the commitment before writing anything
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
throw new Error('Saved wallet material changed during biometric enrollment.');
}
const secret = crypto.getRandomValues(new Uint8Array(32));
const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey);
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await deriveBiometricWrapKey(secret, hkdfSalt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
wrapKey,
new Uint8Array(utf8ToBytes(serialized)),
),
);
// secure storage first: if it fails, no record is written and the wallet
// simply stays unenrolled instead of carrying an unwrap-able-nothing
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
const record: BiometricWrapRecord = {
...wrap,
pubkey: linkingPubKeyHex(linkingKey),
version: BIOMETRIC_WRAP_VERSION,
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
pubkey: ownerId,
materialHash,
createdAt: Date.now(),
};
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
};
export const unlockWithBiometrics = async (): Promise<Uint8Array> => {
export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMaterialV2> => {
const record = readWrapRecord();
if (!isNative() || !record) {
throw new Error('Biometric unlock is not set up on this device.');
}
await authenticateOrThrow('Unlock your sattle wallet');
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
}
const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record);
if (linkingPubKeyHex(linkingKey) !== record.pubkey) {
const ownerId = savedWalletMaterialOwnerId();
if (ownerId === null || ownerId !== record.pubkey) {
throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
);
}
return linkingKey;
// the stored commitment must match the canonical saved material, or the
// wrap is stale (same-owner cash-root replacement) and must not unlock
const materialHash = savedWalletMaterialHash();
if (materialHash === null || record.materialHash !== materialHash) {
throw new Error(
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
);
}
await authenticateOrThrow('Unlock your sattle wallet');
// the prompt yields time: re-read the record and the saved commitment
// before any decrypted plaintext can reach the caller
const currentRecord = readWrapRecord();
if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) {
throw new Error('The biometric wrap changed during the unlock ceremony.');
}
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
throw new Error('Saved wallet material changed during the unlock ceremony.');
}
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
}
const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt));
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) },
wrapKey,
new Uint8Array(hexToBytes(record.wrappedKey)),
);
const material = parseWalletMaterial(new TextDecoder().decode(plaintext));
if (
material === null ||
savedWalletMaterialOwnerId() !== ownerId ||
walletMaterialOwnerId(material) !== ownerId
) {
throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
);
}
// the decrypted material itself must carry the committed cash root
if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) {
throw new Error(
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
);
}
return material;
};
// the localStorage record goes first and synchronously: even if the caller
// fire-and-forgets this (forgetWallet), a later unlock attempt can never
// reach the secure secret with a stale record
// Native deletion goes first so a failure leaves the complete enrollment
// available for the locked lifecycle to retry safely.
export const disableBiometricUnlock = async (): Promise<void> => {
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
if (isNative()) {
await SecureStorage.remove(SECURE_SECRET_KEY);
}
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
};
+14 -4
View File
@@ -1,19 +1,28 @@
<script setup lang="ts">
// Unified activity history: renders the activity store's events newest
// first, one row per wallet action. Self-contained - designed to sit inside
// a q-expansion-item on the home page.
// first, one row per wallet action. The caller can provide a page-sized slice
// or cap the store-backed list for the home-page preview.
import { computed } from 'vue';
import { useActivityStore } from '@/stores/activity';
import type { ActivityKind } from '@/lnurlcash/storage';
import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
const props = defineProps<{
events?: ActivityEvent[];
limit?: number;
}>();
const activity = useActivityStore();
// the store already prepends new events, so the array is newest-first
const events = computed(() => activity.events);
const events = computed(() => {
const source = props.events ?? activity.events;
return props.limit === undefined ? source : source.slice(0, props.limit);
});
const KIND_ICONS: Record<ActivityKind, string> = {
mint: 'arrow_downward',
receive: 'south_west',
refresh: 'refresh',
melt: 'north_east',
split: 'shuffle',
combine: 'shuffle',
@@ -26,6 +35,7 @@ const KIND_ICONS: Record<ActivityKind, string> = {
const KIND_COLORS: Record<ActivityKind, string> = {
mint: 'positive',
receive: 'positive',
refresh: 'info',
melt: 'primary',
split: 'info',
combine: 'info',
+4 -9
View File
@@ -19,19 +19,14 @@ onMounted(async () => {
return;
}
if (!videoEl.value) return;
scanner = new QrScanner(
videoEl.value,
(result) => emit('decode', result.data),
{ highlightScanRegion: true },
);
scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), {
highlightScanRegion: true,
});
await scanner.start();
starting.value = false;
} catch (err) {
starting.value = false;
emit(
'error',
err instanceof Error ? err.message : 'Camera access failed.',
);
emit('error', err instanceof Error ? err.message : 'Camera access failed.');
}
});
+4 -4
View File
@@ -50,9 +50,7 @@
</div>
<div class="col q-ml-md">
<div class="text-body1 text-weight-medium">Bearer note</div>
<div class="text-caption text-grey-5">
Paste or scan a note someone sent you
</div>
<div class="text-caption text-grey-5">Paste or scan a note someone sent you</div>
</div>
<q-icon name="chevron_right" color="grey-5" size="24px" />
</div>
@@ -108,7 +106,9 @@ const onReceived = () => {
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.action-row:hover,
@@ -113,6 +113,7 @@
{{ claimError }}
</q-banner>
<q-btn
v-if="claimRetryable"
unelevated
color="primary"
text-color="dark"
@@ -215,6 +216,7 @@ const {
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
+6 -1
View File
@@ -34,7 +34,12 @@ const onSent = () => emit('sent');
</script>
<template>
<q-dialog v-model="show" position="bottom" transition-show="slide-up" transition-hide="slide-down">
<q-dialog
v-model="show"
position="bottom"
transition-show="slide-up"
transition-hide="slide-down"
>
<q-card class="sattle-card drawer-card full-width">
<q-card-section class="row items-center q-pb-sm">
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
+1 -1
View File
@@ -131,7 +131,7 @@ const lookForBackup = async (): Promise<void> => {
looked.value = true;
if (parts.notes || parts.mints || parts.settings) {
found.value = {
notes: parts.notes?.length ?? 0,
notes: parts.notes?.bearers.length ?? 0,
mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined,
};
+67 -27
View File
@@ -1,5 +1,6 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit';
@@ -7,9 +8,10 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops';
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 { useWalletStore } from '@/stores/wallet';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
@@ -26,6 +28,7 @@ type TransferResult = Readonly<{
export const useMoveFundsPage = () => {
const router = useRouter();
const route = useRoute();
const $q = useQuasar();
const wallet = useWalletStore();
const mints = useMintsStore();
@@ -45,9 +48,20 @@ export const useMoveFundsPage = () => {
);
const CUSTOM_TARGET = '__custom__';
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const requestedNoteId = computed(() =>
typeof route.query.noteId === 'string' ? route.query.noteId : '',
);
const selectedNote = computed(() =>
requestedNoteId.value
? wallet.bearers.find((bearer) => bearer.id === requestedNoteId.value)
: undefined,
);
const sourceBearers = computed(() =>
selectedNote.value ? [selectedNote.value] : wallet.bearers,
);
const spendableByServerMsat = computed(() => {
const byServer = new Map<string, number>();
for (const bearer of wallet.bearers) {
for (const bearer of sourceBearers.value) {
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
continue;
const server = serverOf(bearer.url);
@@ -69,6 +83,15 @@ export const useMoveFundsPage = () => {
const inlineError = ref('');
const stage = ref('');
const result = ref<TransferResult | null>(null);
watch(
selectedNote,
(note) => {
if (!note) return;
sourceServer.value = serverOf(note.url);
amountSats.value = displaySats(note.amount);
},
{ immediate: true },
);
const targetOptions = computed<Option[]>(() => {
const options: Option[] = [];
for (const mint of mints.mints) {
@@ -132,28 +155,54 @@ export const useMoveFundsPage = () => {
if (!sats) return;
step.value = 'working';
stage.value = 'Asking the target mint for an invoice…';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
const ownerFence = wallet.captureOwnerFence();
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence, warn: warnCommitted };
let stagedOutput: Bearer | undefined;
let carvedOutput: Bearer | undefined;
// the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints(
wallet.bearers,
sourceBearers.value.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats),
targetInput.value,
{
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
persistOutput: async (note) => {
[stagedOutput] = await addCommittedBearers(wallet, [note], commitContext);
},
onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
},
onMeltReady: async (carve, sourceRecoverySecret) => {
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
carvedOutput =
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!carvedOutput) throw new Error('The carved note was not tracked.');
await wallet.reserveStagedMintSource(
stagedOutput.id,
carvedOutput.id,
sourceRecoverySecret,
ownerFence,
);
},
},
);
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
stage.value = 'Confirming the result…';
// a carve that mutated nothing fires no hook - the note is already
// tracked; look it up
const carved =
carvedOutput ??
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.');
@@ -162,13 +211,14 @@ export const useMoveFundsPage = () => {
}
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') {
await wallet.markSpent(carved.id, ownerFence);
const claimed = transfer.mintedAtTarget;
if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await addCommittedBearers(wallet, notes, commitContext);
try {
await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
}
await activity.log(
'transfer',
@@ -178,11 +228,7 @@ export const useMoveFundsPage = () => {
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) {
// the source-probe 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, [transfer.rotatedNote], commitContext);
await wallet.markSpent(carved.id, ownerFence);
await wallet.restoreStagedMintSource(stagedOutput.id, transfer.rotatedNote, ownerFence);
}
await activity.log(
'transfer',
@@ -191,22 +237,13 @@ export const useMoveFundsPage = () => {
);
} else if (transfer.outcome === 'unknown-still-pending') {
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(
'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
(error) => warnCommitted(error.message),
);
} else if (transfer.outcome === 'settled-claim-failed') {
await wallet.markSpent(carved.id, ownerFence);
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
await activity.log(
'transfer',
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
@@ -220,7 +257,7 @@ export const useMoveFundsPage = () => {
(error) => warnCommitted(error.message),
);
}
const claimNote = transfer.claimMaterial?.note ?? null;
const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
result.value = {
outcome: transfer.outcome,
requestedSats: sats,
@@ -237,6 +274,8 @@ export const useMoveFundsPage = () => {
: message;
toast('negative', inlineError.value);
step.value = 'form';
} finally {
fundOperation?.complete();
}
};
return {
@@ -252,6 +291,7 @@ export const useMoveFundsPage = () => {
setMax,
sourceOptions,
sourceServer,
selectedNote,
stage,
step,
targetChoice,
+131
View File
@@ -0,0 +1,131 @@
import { buildNoteUrl, NoteSpentError } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as OpsExports from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
const mocks = vi.hoisted(() => ({
addBearers: vi.fn(),
applyChangeset: vi.fn(),
notify: vi.fn(),
receiveBearer: vi.fn(),
removeNote: vi.fn(),
routerReplace: vi.fn(),
}));
const source = {
id: 'source',
url: buildNoteUrl('https://mint.test/note', '11'.repeat(32), 21_000),
callback: 'https://mint.test/callback',
amount: 21_000,
verified: true,
mintPubkey: `02${'aa'.repeat(32)}`,
label: 'Emergency cash',
createdAt: 1,
updatedAt: 1,
};
const staged = {
id: 'staged',
url: buildNoteUrl('https://mint.test/note', '22'.repeat(32), 21_000),
callback: source.callback,
amount: 0,
verified: false,
label: source.label,
pendingMint: {
refreshSourceBearerId: source.id,
sourceRecoverySecret: '11'.repeat(32),
},
createdAt: 2,
updatedAt: 2,
};
const wallet = {
state: 'unlocked' as const,
pubkey: null,
bearers: [source],
beginFundOperation: () => ({ ownerFence: () => undefined, complete: () => undefined }),
allocateOutputSecrets: vi.fn(),
addBearers: mocks.addBearers,
applyChangeset: mocks.applyChangeset,
commitCarve: vi.fn(),
finalizeStagedMintOutput: vi.fn(),
markSpent: vi.fn(),
removeNote: mocks.removeNote,
updateBearer: vi.fn(),
};
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(), replace: mocks.routerReplace }),
}));
vi.mock('quasar', () => ({
useQuasar: () => ({ notify: mocks.notify }),
}));
vi.mock('@/stores/wallet', () => ({
TrustedMintPostCommitError: class TrustedMintPostCommitError extends Error {},
useWalletStore: () => wallet,
}));
vi.mock('@/stores/activity', () => ({
useActivityStore: () => ({ log: vi.fn() }),
}));
vi.mock('@/lnurlcash/trustedMints', () => ({
getTrustedMintVerificationKeys: () => [],
}));
vi.mock('@/lnurlcash/ops', async (importOriginal) => {
const actual = await importOriginal<typeof OpsExports>();
return { ...actual, receiveBearer: mocks.receiveBearer };
});
import { useNotesPage } from './useNotesPage';
describe('note refresh persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
wallet.bearers = [source];
mocks.addBearers.mockResolvedValue([staged]);
mocks.applyChangeset.mockResolvedValue([]);
mocks.receiveBearer.mockImplementation(
async (
_input: string,
_existing: unknown[],
options: { stageRotation?: (note: NewBearer) => void | Promise<void> },
) => {
await options.stageRotation?.({
url: staged.url,
callback: staged.callback,
amount: source.amount,
verified: false,
pendingMint: { sourceRecoverySecret: '11'.repeat(32) },
});
throw new NoteSpentError('Note is spent');
},
);
});
it('stages at zero with local metadata and atomically retires a definitively dead source', async () => {
const notes = useNotesPage();
await notes.refreshNote(source);
expect(mocks.addBearers).toHaveBeenCalledWith(
[
expect.objectContaining({
amount: 0,
label: 'Emergency cash',
pendingMint: expect.objectContaining({ refreshSourceBearerId: source.id }),
}),
],
expect.any(Function),
);
expect(mocks.applyChangeset).toHaveBeenCalledWith(
{ add: [], markSpent: [source.id], remove: [staged.id] },
expect.any(Function),
);
expect(mocks.removeNote).not.toHaveBeenCalled();
});
});
+312
View File
@@ -0,0 +1,312 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
noteK1,
serverOf,
} from 'lnurlcash-kit';
import { compareBearerOrder } from '@/lnurlcash/storage';
import { ensureExactAmount, receiveBearer, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useActivityStore } from '@/stores/activity';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
export const useNotesPage = () => {
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const selectedIds = ref<string[]>([]);
const detail = ref<Bearer | null>(null);
const splitOpen = ref(false);
const splitAmount = ref<number | null>(null);
const busy = ref(false);
const notify = (type: 'positive' | 'negative' | 'warning', message: string): void => {
$q.notify({ type, message, position: 'top', timeout: 3500 });
};
const warnCommitted = (message: string): void => notify('warning', message);
const notes = computed(() => [...wallet.bearers].sort(compareBearerOrder));
const selectedNotes = computed(() =>
selectedIds.value
.map((id) => wallet.bearers.find((note) => note.id === id))
.filter((note): note is Bearer => note !== undefined),
);
const canMutate = (note: Bearer): boolean =>
note.verified &&
!note.spent &&
!note.pendingMint &&
!note.deviceId &&
note.callback !== '' &&
noteK1(note.url) !== null;
const canMerge = computed(() => {
if (selectedNotes.value.length < 2 || selectedNotes.value.some((note) => !canMutate(note))) {
return false;
}
const first = selectedNotes.value[0];
if (!first) return false;
const server = serverOf(first.url);
return selectedNotes.value.every((note) => serverOf(note.url) === server);
});
const formatSats = (amountMsat: number): string =>
msatToSats(amountMsat).toLocaleString(undefined, { maximumFractionDigits: 3 });
const status = (note: Bearer): string => {
if (note.spent) return 'Spent';
if (note.pendingMint) return 'Pending';
if (note.deviceId) return 'Paired device';
return note.verified ? 'Valid locally' : 'Unverified';
};
const statusColor = (note: Bearer): string => {
if (note.spent) return 'grey-6';
if (note.pendingMint) return 'warning';
return note.verified ? 'positive' : 'warning';
};
watch(
() => wallet.state,
(state) => {
if (state !== 'unlocked') void router.replace('/');
},
{ immediate: true },
);
watch(
() => wallet.bearers,
() => {
selectedIds.value = selectedIds.value.filter((id) => wallet.bearers.some((n) => n.id === id));
if (detail.value)
detail.value = wallet.bearers.find((n) => n.id === detail.value?.id) ?? null;
},
);
const carve = async (inputs: Bearer[], amountMsat: number, kind: 'split' | 'combine') => {
busy.value = true;
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
operation = wallet.beginFundOperation();
const ownerFence = operation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const result = await ensureExactAmount(inputs, amountMsat, {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (checkpoint) => {
await commitCarve(wallet, checkpoint, { ownerFence, warn: warnCommitted });
},
});
await activity.log(
kind,
kind === 'combine'
? `Combined ${inputs.length} notes into ${formatSats(result.note.amount)} sats.`
: `Split a note into ${formatSats(result.note.amount)} and ${formatSats(result.change?.amount ?? 0)} sats.`,
(error) => warnCommitted(error.message),
);
selectedIds.value = [];
splitOpen.value = false;
detail.value = null;
notify('positive', kind === 'combine' ? 'Notes combined.' : 'Note split.');
} catch (error) {
if (error instanceof UncertainOutcomeError && operation) {
await addCommittedBearers(wallet, error.possibleOutputs, {
ownerFence: operation.ownerFence,
warn: warnCommitted,
});
notify('warning', 'The mint result is uncertain. Possible outputs are saved unverified.');
} else {
notify('negative', error instanceof Error ? error.message : 'The note operation failed.');
}
} finally {
operation?.complete();
busy.value = false;
}
};
const mergeSelected = (): Promise<void> => {
if (!canMerge.value) return Promise.resolve();
return carve(
selectedNotes.value,
selectedNotes.value.reduce((total, note) => total + note.amount, 0),
'combine',
);
};
const openSplit = (note: Bearer): void => {
detail.value = note;
splitAmount.value = null;
splitOpen.value = true;
};
const confirmSplit = (): Promise<void> => {
const note = detail.value;
const sats = splitAmount.value;
if (!note || !sats || !Number.isInteger(sats) || satsToMsat(sats) >= note.amount) {
notify('negative', 'Enter a whole-sat amount smaller than the note.');
return Promise.resolve();
}
return carve([note], satsToMsat(sats), 'split');
};
const refreshNote = async (note: Bearer): Promise<void> => {
if (note.spent || note.pendingMint || note.deviceId || !noteK1(note.url)) return;
busy.value = true;
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
let refreshReturned = false;
try {
operation = wallet.beginFundOperation();
const ownerFence = operation.ownerFence;
const refreshed = await receiveBearer(
note.url,
wallet.bearers.filter((candidate) => candidate.id !== note.id),
{
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
stageRotation: async (output) => {
[staged] = await addCommittedBearers(
wallet,
[
{
...output,
amount: 0,
label: note.label,
pendingMint: { ...output.pendingMint, refreshSourceBearerId: note.id },
},
],
{ ownerFence, warn: warnCommitted },
);
},
},
);
refreshReturned = true;
if (refreshed.stage === 'finalize') {
if (!staged) throw new Error('The refreshed note was not staged.');
try {
await wallet.finalizeStagedMintOutput(staged.id, refreshed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
} else if (refreshed.stage === 'discard') {
if (staged) await wallet.removeNote(staged.id, ownerFence);
await wallet.updateBearer(
note.id,
{
callback: refreshed.note.callback,
amount: refreshed.note.amount,
verified: refreshed.note.verified,
mintPubkey: refreshed.note.mintPubkey,
},
ownerFence,
);
} else if (refreshed.stage === 'keep') {
await wallet.updateBearer(note.id, { verified: false }, ownerFence);
} else {
await wallet.updateBearer(
note.id,
{
callback: refreshed.note.callback,
amount: refreshed.note.amount,
verified: refreshed.note.verified,
},
ownerFence,
);
}
await activity.log(
'refresh',
`Checked and refreshed a ${formatSats(refreshed.note.amount)} sat note from ${serverOf(note.url)}.`,
(error) => warnCommitted(error.message),
);
detail.value = wallet.bearers.find((candidate) => candidate.id === note.id) ?? null;
notify(
refreshed.rotated ? 'positive' : 'warning',
refreshed.rotated
? 'Note is valid and has a fresh secret.'
: 'Note checked, but could not be rotated.',
);
} catch (error) {
if ((error instanceof NoteSpentError || error instanceof NoteUnknownError) && operation) {
try {
await wallet.applyChangeset(
{
add: [],
markSpent: [note.id],
...(staged ? { remove: [staged.id] } : {}),
},
operation.ownerFence,
);
notify(
'negative',
error instanceof NoteSpentError
? 'The mint reports this note spent.'
: 'The mint does not know this note.',
);
} catch (commitError) {
notify(
'negative',
commitError instanceof Error ? commitError.message : 'Could not save the note status.',
);
}
} else if (error instanceof PendingNoteError) {
if (staged && operation) {
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
}
notify('warning', 'The note is temporarily locked by a pending payment.');
} else {
if (staged && operation && !refreshReturned) {
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
}
notify('negative', error instanceof Error ? error.message : 'The validity check failed.');
}
} finally {
operation?.complete();
busy.value = false;
}
};
const saveLabel = async (note: Bearer, label: string): Promise<void> => {
const operation = wallet.beginFundOperation();
try {
await wallet.updateBearer(
note.id,
{ label: label.trim() || undefined },
operation.ownerFence,
);
notify('positive', 'Label saved.');
} catch (error) {
notify('negative', error instanceof Error ? error.message : 'Could not save the label.');
} finally {
operation.complete();
}
};
const moveNote = (note: Bearer): void => {
void router.push({ path: '/settings/move', query: { noteId: note.id } });
};
return {
busy,
canMerge,
canMutate,
confirmSplit,
detail,
formatSats,
mergeSelected,
moveNote,
notes,
openSplit,
refreshNote,
router,
saveLabel,
selectedIds,
serverOf,
splitAmount,
splitOpen,
status,
statusColor,
};
};
+60 -19
View File
@@ -1,10 +1,16 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
import {
decodeBolt11AmountMsat,
isBolt11Invoice,
resolveLnurlInput,
withNewK1,
} from 'lnurlcash-kit';
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
@@ -143,31 +149,71 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
step.value = 'working';
stage.value = 'Preparing the exact amount and sending the payment…';
let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence: fence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {};
let returnStage: Bearer | undefined;
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
};
const paid = await payWithBearers(
wallet.bearers,
payment.input,
payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve }
: { assertOwner: ownerFence, onCarve },
);
const allocateOutputSecrets = (server: string, count: number) =>
wallet.allocateOutputSecrets(server, count, fence);
const mintSignatureKeys = (server: string) => getTrustedMintVerificationKeys(server, ownerId);
// the pay-return recovery output: staged durably (allocated secret,
// zero value, pendingMint) and linked to the carved source BEFORE the
// classification rotate can burn it
const onReturnReady = async (
carve: Parameters<typeof commitCarve>[1],
recoverySecret: string,
): Promise<void> => {
const source =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.');
[returnStage] = await addCommittedBearers(
wallet,
[
{
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
callback: '',
amount: 0,
verified: false,
pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 },
},
],
commitContext,
);
if (!returnStage) throw new Error('The payment recovery output was not persisted.');
await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence);
};
const paid = await payWithBearers(wallet.bearers, payment.input, {
...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}),
assertOwner: fence,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
});
stage.value = 'Confirming the result…';
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
if (returnStage) {
const returned = paid.rotatedNote ?? paid.rescuedNote;
if (returned) {
await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence);
} else if (paid.outcome !== 'unknown-still-pending') {
await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence);
}
}
const sats = formatSats(msatToSats(paid.amountMsat));
if (paid.outcome === 'settled') {
@@ -178,13 +224,6 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('positive', `Paid ${sats} sats.`);
emit('sent');
} 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(
'transfer',
`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);
}
step.value = 'input';
} finally {
fundOperation?.complete();
}
};
const closeResult = (): void => {
+39 -18
View File
@@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
@@ -35,8 +35,10 @@ export const useReceiveLightningDialog = (
const preparing = ref(false);
const formError = ref('');
const prepared = ref<PreparedMint | null>(null);
const stagedOutput = ref<Bearer | null>(null);
const waiting = ref(false);
const claimError = ref('');
const claimRetryable = ref(true);
const receivedSats = ref(0);
const receivedServer = ref('');
const rotationWarning = ref('');
@@ -85,23 +87,21 @@ export const useReceiveLightningDialog = (
const onClaimed = async (
claimed: ClaimedNote,
from: PreparedMint,
staged: Bearer,
ownerFence: WalletOwnerFence,
): Promise<void> => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
rotationWarning.value = '';
await activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
@@ -130,23 +130,35 @@ export const useReceiveLightningDialog = (
}
};
const beginClaim = (): void => {
if (!prepared.value || claimRun) return;
if (!prepared.value || !stagedOutput.value || claimRun) return;
waiting.value = true;
claimError.value = '';
claimRetryable.value = true;
const current = prepared.value;
const staged = stagedOutput.value;
claimRun = (async () => {
const fundOperation = wallet.beginFundOperation();
try {
const ownerFence = wallet.captureOwnerFence();
const ownerFence = fundOperation.ownerFence;
await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current,
staged,
ownerFence,
);
} catch (error) {
if (!(error instanceof Error)) throw error;
if (error instanceof MintedNoteSpentError) {
await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence);
claimRetryable.value = false;
claimError.value = errorMessage(error);
Notify.create({ type: 'negative', message: claimError.value });
return;
}
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) });
} finally {
fundOperation.complete();
waiting.value = false;
}
})();
@@ -156,20 +168,26 @@ export const useReceiveLightningDialog = (
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats));
// named mints claim through the wallet's own secret and may
// legitimately serve no verify URL; unnamed mints need one (the
// preimage it reveals IS the claim)
if (next.mode === 'unnamed' && !next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
let persisted: Bearer | undefined;
const next = await prepareMint(input, satsToMsat(sats), {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
persistOutput: async (note) => {
[persisted] = await wallet.addBearers([note], ownerFence);
},
});
if (!persisted) throw new Error('The pending mint output was not persisted.');
stagedOutput.value = persisted;
prepared.value = next;
claimRun = null;
claimError.value = '';
claimRetryable.value = true;
step.value = 'invoice';
beginClaim();
} catch (error) {
@@ -177,6 +195,7 @@ export const useReceiveLightningDialog = (
formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value });
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
@@ -211,6 +230,7 @@ export const useReceiveLightningDialog = (
preparing.value = false;
formError.value = '';
prepared.value = null;
stagedOutput.value = null;
waiting.value = false;
rotationWarning.value = '';
},
@@ -219,6 +239,7 @@ export const useReceiveLightningDialog = (
CUSTOM_MINT,
amountSats,
claimError,
claimRetryable,
copyInvoice,
createInvoice,
customMint,
+35 -4
View File
@@ -8,7 +8,7 @@ import {
} from 'lnurlcash-kit';
import { receiveBearer } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import type { Bearer } from '@/lnurlcash/types';
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
@@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
if (busy.value || value === '') return;
busy.value = true;
clearError();
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
let staged: Bearer | undefined;
try {
const ownerFence = wallet.captureOwnerFence();
fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
// the rotate's fresh secret is reserved and its future note staged
// before the mutation can land - a crash mid-rotate never loses the
// money (wallet recovery reconciles the staged record)
const claimed = await receiveBearer(value, wallet.bearers, {
assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
stageRotation: async (note) => {
[staged] = await wallet.addBearers([note], ownerFence);
},
});
const note = claimed.note;
const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
if (claimed.stage === 'finalize') {
// the rotate landed: the staged record becomes the confirmed note
if (!staged) throw new Error('The rotated note was not staged.');
await wallet.finalizeStagedMintOutput(staged.id, note, ownerFence);
} else if (claimed.stage === 'discard') {
// the rotate provably never landed: drop the worthless stage and
// keep the original (exposed - see rotationWarning)
if (staged) await wallet.removeNote(staged.id, ownerFence);
await wallet.addBearers([note], ownerFence);
} else {
// 'keep': the staged record is the possible rotated copy; the
// original is tracked unverified alongside it. 'none': no rotate
// was attempted - the note goes in as received.
await wallet.addBearers([note], ownerFence);
}
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
@@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
trustPrompt.openTrust(server, note.mintPubkey);
}
} catch (error) {
// a definitive failure thrown after staging leaves the staged record
// behind - drop it best-effort (wallet recovery restores the original
// note from it if this discard itself is impossible right now)
if (staged && fundOperation) {
await wallet.removeNote(staged.id, fundOperation.ownerFence).catch(() => undefined);
}
classifyError(error instanceof Error ? error : new Error(String(error)));
} finally {
fundOperation?.complete();
busy.value = false;
}
};
+13 -2
View File
@@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit';
import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
@@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
preparing.value = true;
errorMessage.value = null;
let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
ownerFence = wallet.captureOwnerFence();
fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
// the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: fence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, fence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence,
@@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
: message;
toast('negative', errorMessage.value);
} finally {
fundOperation?.complete();
preparing.value = false;
}
};
@@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
const note = prepared.value;
if (!note) return;
removing.value = true;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try {
await wallet.markSpent(note.id, wallet.captureOwnerFence());
fundOperation = wallet.beginFundOperation();
await wallet.markSpent(note.id, fundOperation.ownerFence);
await activity.log(
'spent',
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
@@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
} catch (error) {
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
} finally {
fundOperation?.complete();
removing.value = false;
}
};
+46 -4
View File
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys';
import type { CarveResult } from '@/lnurlcash/ops';
import { loadBearers } from '@/lnurlcash/storage';
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types';
import { commitCarve } from './walletCarveCommit';
@@ -21,7 +22,7 @@ const failOnBearerWrite = (occurrence: number): void => {
const setItem = localStorage.setItem.bind(localStorage);
let writes = 0;
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
if (key === 'sattle_bearers') {
if (key === FUNDS_STORAGE_KEY) {
writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable');
}
@@ -29,10 +30,18 @@ const failOnBearerWrite = (occurrence: number): void => {
});
};
// a present-but-non-serializing LockManager fake: walletFunds serializes its
// own mutations, so single-store tests only need locks to EXIST
const stubPassthroughLocks = (): void => {
vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
});
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {});
stubPassthroughLocks();
stubLocalStorage();
setActivePinia(createPinia());
});
@@ -59,7 +68,7 @@ describe('commitCarve', () => {
});
// Then the whole rotation landed as ONE durable write
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
expect(committed.url).toBe(carve.note.url);
expect(wallet.bearers).toHaveLength(3);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
@@ -71,7 +80,7 @@ describe('commitCarve', () => {
it('survives a failure that would have hit the old split commit second write', async () => {
// Given a wallet holding the carve's input note, with the second
// sattle_bearers write poisoned (the old add-then-markSpent split wrote
// funds-document write poisoned (the old add-then-markSpent split wrote
// twice; the single-write commit never reaches a second write)
const wallet = useWalletStore();
await wallet.create();
@@ -127,4 +136,37 @@ describe('commitCarve', () => {
expect(persisted).toHaveLength(1);
expect(persisted[0]?.spent).toBeUndefined();
});
it('commits the staged and landed phases of the same output as one record', async () => {
// Given a wallet holding the carve's input note
const wallet = useWalletStore();
await wallet.create();
const ownerFence = wallet.captureOwnerFence();
const [input] = await wallet.addBearers([note('aa')], ownerFence);
if (!input) throw new Error('Expected the input bearer.');
const context = { ownerFence, warn: () => undefined };
// When the pre-wire phase stages the outputs (sig-less URLs), then the
// landed phase re-reports them with the mint's signature in the URL
await commitCarve(wallet, { note: note('bb'), change: note('cc'), consumed: [] }, context);
expect(wallet.bearers).toHaveLength(3);
const stagedNote = note('bb');
const committed = await commitCarve(
wallet,
{
note: { ...stagedNote, url: `${stagedNote.url}&sig=${'ab'.repeat(64)}`, verified: true },
consumed: [input],
},
context,
);
// Then the staged record was refreshed in place: still one record per
// output, the input retired, no duplicate from the URL difference
expect(wallet.bearers).toHaveLength(3);
expect(committed.verified).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
expect(wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32)))).toHaveLength(1);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toHaveLength(3);
});
});
+11 -24
View File
@@ -1,5 +1,4 @@
import type { CarveResult } from '@/lnurlcash/ops';
import type { BearerChangeset } from '@/lnurlcash/storage';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
@@ -7,10 +6,7 @@ import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = {
readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly applyChangeset: (
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
) => Promise<Bearer[]>;
readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
};
type CarveCommitContext = Readonly<{
@@ -35,33 +31,24 @@ export const addCommittedBearers = async (
// A carve is ONE logical rotation: the fresh notes (target + change) and the
// spent marks of the burned inputs must land together or not at all - the
// mint already destroyed the inputs server-side, so a partial commit (added
// but not spent, or vice versa) would strand or double-show money. Hence a
// single changeset through the wallet's one-write boundary, never an
// add-then-markSpent sequence of separate writes.
// but not spent, or vice versa) would strand or double-show money. Both
// checkpoint phases (pre-wire staging, landed retirement) go through the
// wallet's k1-aware one-write boundary: the landed phase's URLs carry the
// mint's signature the staged phase's could not have, so the store matches
// checkpoints by output SECRET and refreshes the staged record in place -
// a replayed checkpoint is a no-op, never a duplicate.
export const commitCarve = async (
wallet: CarveWallet,
carve: CarveResult,
context: CarveCommitContext,
): Promise<Bearer> => {
const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
const additions: NewBearer[] = [];
if (!existing) additions.push(carve.note);
if (carve.change) additions.push(carve.change);
let added: Bearer[];
try {
added = await wallet.applyChangeset(
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
},
context.ownerFence,
);
return await wallet.commitCarve(carve, context.ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message);
added = error.committedBearers;
const committed = 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)
- Rotate on every receive, and immediately after claiming a fresh mint
from an UNNAMED mint (one that keys the note by the payment preimage).
A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint
≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the
quote; that secret never rode an invoice, so the claim needs no rotate.
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.
- Rotate every externally received bearer. Minted notes are different:
current LUD-25 requires `commentAllowed >= 64`, so the wallet durably
stages its chosen secret before requesting the invoice and claims at
that secret without rotating. `mintToHash` is only an additive receipt
extension, never a capability fallback.
- A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or
@@ -68,7 +65,6 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS
`npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts`
(`conformance-mockmint.d.ts` augments its stale published declarations
with the comment-naming flags). The mock's `commentAllowed` option models
the named-mint generation; default options model the old one.
The adversarial mock mint from lnurlcash-conformance is wrapped by
`ops.testHarness.ts`; its default contract is mandatory comment-bound
minting.
-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,
resolveMintInput,
serverOf,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
import {ceilMsatToSat, floorMsatToSat} from './units'
} from 'lnurlcash-kit';
import type { LnurlcashOptions, MintFee } from 'lnurlcash-kit';
import { ceilMsatToSat, floorMsatToSat } from './units';
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
applyMintFee(grossMsat, fee)
applyMintFee(grossMsat, fee);
export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
ceilMsatToSat(grossUpForMintFee(netMsat, fee))
ceilMsatToSat(grossUpForMintFee(netMsat, fee));
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat;
// Fee quotes are per-mint and change rarely; cache briefly by server.
// nulls are cached too - an unreachable mint shouldn't be retried on
// every render either.
const QUOTE_TTL_MS = 60_000
const QUOTE_TIMEOUT_MS = 5_000
const QUOTE_TTL_MS = 60_000;
const QUOTE_TIMEOUT_MS = 5_000;
const quoteCache = new Map<string, {at: number; fee: MintFee | null}>()
const quoteCache = new Map<string, { at: number; fee: MintFee | null }>();
// tests can reset the cache between cases
export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
export const clearMintFeeQuoteCache = (): void => quoteCache.clear();
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting
// an invoice - the same resolution chain as prepareMint (mint-address
@@ -57,29 +57,29 @@ export const quoteMintFee = async (
mintInput: string,
options: LnurlcashOptions = {},
): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput)
if (!url) return null
const server = serverOf(url)
const cached = quoteCache.get(server)
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee
const url = resolveMintInput(mintInput);
if (!url) return null;
const server = serverOf(url);
const cached = quoteCache.get(server);
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee;
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options}
let payUrl = url
const addressUrl = mintAddressUrl(url)
const opts: LnurlcashOptions = { timeoutMs: QUOTE_TIMEOUT_MS, ...options };
let payUrl = url;
const addressUrl = mintAddressUrl(url);
if (addressUrl) {
try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
} catch (error) {
// no mint-address support - the plain payRequest guess still works
if (!(error instanceof Error)) throw error
if (!(error instanceof Error)) throw error;
}
}
let fee: MintFee | null
let fee: MintFee | null;
try {
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null;
} catch {
fee = null
fee = null;
}
quoteCache.set(server, {at: Date.now(), fee})
return fee
}
quoteCache.set(server, { at: Date.now(), fee });
return fee;
};
+12 -12
View File
@@ -1,20 +1,20 @@
export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseJsonObject = (source: string): Record<string, unknown> => {
const parsed: unknown = JSON.parse(source)
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.')
return parsed
}
const parsed: unknown = JSON.parse(source);
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.');
return parsed;
};
export const parseJsonArray = (source: string): unknown[] => {
const parsed: unknown = JSON.parse(source)
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.')
return Array.from(parsed, (value: unknown) => value)
}
const parsed: unknown = JSON.parse(source);
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.');
return Array.from(parsed, (value: unknown) => value);
};
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
parseJsonArray(source).map((value) => {
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.')
return value
})
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.');
return value;
});
@@ -0,0 +1,199 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { beforeEach, describe, expect, it } from 'vitest';
import {
decryptSavedWalletMaterial,
deriveWalletMaterial,
encryptSecretParts,
ensureSavedWalletMaterialOwner,
getPlainWalletMaterial,
isValidStoredWalletMaterial,
linkingPubKeyHex,
restoreWalletMaterialStored,
saveWalletMaterial,
serializeWalletMaterial,
savedWalletMaterialOwnerId,
walletMaterialLinkingKey,
WalletMaterialProofMismatchError,
} from './keys';
import { walletMaterialOwnerId } from './storage/storedSecret';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const OTHER_MNEMONIC =
'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const PASSWORD = 'correct horse battery staple';
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage();
});
describe('baseline: wallet material identity', () => {
it('derives identity only from the linking key when the cash root changes', () => {
const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC);
const alteredRoot = { ...material, cashRootHex: other.cashRootHex };
expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material));
expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material));
});
});
describe('saved wallet material commitment', () => {
it('stores the canonical material commitment on plaintext and encrypted saves', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const expectedHash = materialHash(serializeWalletMaterial(material));
await saveWalletMaterial(material);
const plaintext = readRawMaterialRecord();
await saveWalletMaterial(material, PASSWORD);
const encrypted = readRawMaterialRecord();
expect(plaintext.materialHash).toBe(expectedHash);
expect(encrypted.materialHash).toBe(expectedHash);
});
it('rejects authenticated encrypted material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) }),
);
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError,
);
});
it('rejects plaintext material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material);
const before = JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) });
localStorage.setItem(MATERIAL_STORAGE_KEY, before);
expect(getPlainWalletMaterial()).toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it('refuses to stamp same-owner material with an altered cash root', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
expect(() =>
ensureSavedWalletMaterialOwner({ ...material, cashRootHex: other.cashRootHex }),
).toThrow(WalletMaterialProofMismatchError);
});
it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const record = {
enc: false,
value: serialized,
materialHash: materialHash(serialized),
ownerId: walletMaterialOwnerId(material),
version: 2,
};
expect(isValidStoredWalletMaterial(record)).toBe(true);
expect(
isValidStoredWalletMaterial({ ...record, materialHash: record.materialHash.toUpperCase() }),
).toBe(false);
expect(
isValidStoredWalletMaterial({
enc: record.enc,
value: record.value,
ownerId: record.ownerId,
version: record.version,
}),
).toBe(false);
});
it('rejects malformed authenticated material after its correct commitment matches', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const malformed = JSON.stringify({ ...material, cashRootHex: '00'.repeat(64) });
const parts = await encryptSecretParts(malformed, PASSWORD);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: true,
...parts,
materialHash: materialHash(malformed),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
}),
);
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow('valid v2 wallet material');
});
it('preserves a verified restored commitment while discarding its owner claim', () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
restoreWalletMaterialStored({
enc: false,
value: serialized,
materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
});
expect(readRawMaterialRecord()).toEqual({
enc: false,
value: serialized,
materialHash: materialHash(serialized),
});
expect(getPlainWalletMaterial()).toEqual(material);
});
it('proves an encrypted restored commitment before owner stamping', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({
enc: true,
...parts,
materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
});
expect(savedWalletMaterialOwnerId()).toBeNull();
const proven = await decryptSavedWalletMaterial(PASSWORD);
ensureSavedWalletMaterialOwner(proven);
expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material));
});
it('rejects an unproven restored hash that contradicts encrypted material', async () => {
const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({
enc: true,
...parts,
materialHash: '00'.repeat(32),
ownerId: walletMaterialOwnerId(material),
version: 2,
});
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError,
);
expect(savedWalletMaterialOwnerId()).toBeNull();
});
});
@@ -0,0 +1,212 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { describe, expect, it } from 'vitest';
import {
decryptSavedWalletMaterial,
deriveWalletMaterial,
encryptSecretParts,
ensureSavedWalletMaterialOwner,
getPlainWalletMaterial,
isValidStoredWalletMaterial,
linkingPubKeyHex,
restoreWalletMaterialStored,
savedWalletMaterialExists,
savedWalletMaterialIsEncrypted,
savedWalletMaterialOwnerId,
saveWalletMaterial,
serializeWalletMaterial,
walletMaterialLinkingKey,
} from './keys';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const PASSWORD = 'correct horse battery staple';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const LEGACY_STORAGE_KEY = 'sattle_linking_key';
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw);
};
describe('WalletMaterialV2 persistence', () => {
it('round-trips canonical plaintext material in the new namespace', async () => {
// Given complete wallet material and an unrelated legacy record
stubLocalStorage();
localStorage.setItem(
LEGACY_STORAGE_KEY,
JSON.stringify({ enc: false, value: '07'.repeat(32) }),
);
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is saved without a password
await saveWalletMaterial(material);
// Then only the new namespace carries the exact complete material
expect(savedWalletMaterialExists()).toBe(true);
expect(savedWalletMaterialIsEncrypted()).toBe(false);
expect(getPlainWalletMaterial()).toEqual(material);
expect(readRawMaterialRecord()).toEqual({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
});
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC);
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"');
});
it('round-trips canonical password-encrypted material without plaintext fields', async () => {
// Given complete wallet material
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is password protected
await saveWalletMaterial(material, PASSWORD);
// Then only authenticated decryption returns both parts
expect(savedWalletMaterialIsEncrypted()).toBe(true);
expect(getPlainWalletMaterial()).toBeNull();
expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material);
await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow();
const raw = JSON.stringify(readRawMaterialRecord());
expect(raw).not.toContain(LINKING_KEY_HEX);
expect(raw).not.toContain(CASH_ROOT_HEX);
expect(raw).not.toContain(FIXED_MNEMONIC);
});
it('strips restored owner claims until the material proves its real owner', () => {
// Given plaintext material carrying a foreign owner claim
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9));
// When the record is restored and subsequently proven by plaintext unlock
restoreWalletMaterialStored({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: foreignOwner,
version: 2,
});
const beforeProof = readRawMaterialRecord();
const unlocked = getPlainWalletMaterial();
if (unlocked === null) throw new Error('expected restored plaintext material');
ensureSavedWalletMaterialOwner(unlocked);
// Then the claim was absent before proof and only the derived owner was stamped
expect(beforeProof).toEqual({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
});
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)));
});
it('rejects a plaintext owner mismatch without returning or stamping material', () => {
// Given canonical material stamped with a foreign current owner
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: false,
value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2,
}),
);
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When it is read or offered for proof
const unlocked = getPlainWalletMaterial();
const stamp = () => ensureSavedWalletMaterialOwner(material);
// Then neither operation trusts or rewrites the foreign record
expect(unlocked).toBeNull();
expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it('rejects an encrypted owner mismatch after authentication', async () => {
// Given valid encrypted material whose owner marker was replaced
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
await saveWalletMaterial(material, PASSWORD);
const foreign = {
...readRawMaterialRecord(),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
};
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign));
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When the authenticated material contradicts the current owner marker
const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then no material is returned or marker changed
await expect(unlock).rejects.toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
});
it.each([
['legacy key-only plaintext', LINKING_KEY_HEX, 2],
['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1],
[
'malformed material plaintext',
JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00' }),
2,
],
])('rejects %s records at the storage boundary', (_name, value, version) => {
// Given an invalid record under the v2 material namespace
stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC);
const record = {
enc: false,
value,
materialHash: materialHash(value),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version,
};
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record));
// When storage helpers parse it
// Then the record is unusable as wallet material
expect(isValidStoredWalletMaterial(record)).toBe(false);
expect(savedWalletMaterialExists()).toBe(false);
expect(getPlainWalletMaterial()).toBeNull();
});
it('rejects authenticated ciphertext containing malformed material', async () => {
// Given valid AES-GCM parts whose plaintext is not a v2 material envelope
stubLocalStorage();
const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD);
const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem(
MATERIAL_STORAGE_KEY,
JSON.stringify({
enc: true,
...parts,
materialHash: materialHash(LINKING_KEY_HEX),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2,
}),
);
// When the password successfully authenticates the malformed plaintext
const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then it still fails closed as invalid material
await expect(unlock).rejects.toThrow('valid v2 wallet material');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { getPublicKey } from 'nostr-tools/pure';
import { cashNodeToHex } from 'lnurlcash-kit';
import {
deriveBearerAesKey,
deriveWalletLinkingKey,
deriveWalletMaterial,
linkingPubKeyHex,
parseWalletMaterial,
serializeWalletMaterial,
walletMaterialCashRoot,
walletMaterialLinkingKey,
} from './keys';
import { backupPubkey, deriveBackupKey } from './nostrBackup';
import { deriveNwcWalletKey, nwcWalletPubkey } from './nwc';
const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17));
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
describe('fixed mnemonic identity characterization', () => {
it('pins every public linking-key-derived identity and bearer encryption behavior', async () => {
// Given a mnemonic accepted by the existing wallet derivation
const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC);
const bearerKey = await deriveBearerAesKey(linkingKey);
// When its owner, bearer, Nostr, and NWC observables are derived
const bearerCiphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: new Uint8Array(12) },
bearerKey,
new TextEncoder().encode('sattle-vector-v1'),
),
);
// Then they match the pre-v2 wallet's independently captured vectors
expect(linkingPubKeyHex(linkingKey)).toBe(
'0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481',
);
expect(bytesToHex(bearerCiphertext)).toBe(
'4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78',
);
expect(backupPubkey(deriveBackupKey(linkingKey))).toBe(
'6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577',
);
expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe(
'3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587',
);
});
});
describe('WalletMaterialV2 derivation and serialization', () => {
it('derives the unchanged linking key and kit cash root from one mnemonic', () => {
// Given the fixed recovery mnemonic
// When its complete wallet material is derived
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// Then both independently captured vectors and the exact schema are stable
expect(material).toEqual({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX,
});
expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']);
expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC));
expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX);
});
it('round-trips only the canonical exact serialization', () => {
// Given valid v2 material
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When it crosses the serialization boundary
const serialized = serializeWalletMaterial(material);
// Then its bytes and parsed value are canonical
expect(serialized).toBe(
`{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`,
);
expect(parseWalletMaterial(serialized)).toEqual(material);
});
it.each([
['legacy key only', LINKING_KEY_HEX],
[
'wrong version',
JSON.stringify({ version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX }),
],
['missing field', JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX })],
[
'extra field',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX,
seed: 'forbidden',
}),
],
[
'uppercase linking key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX.toUpperCase(),
cashRootHex: CASH_ROOT_HEX,
}),
],
[
'truncated linking key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX.slice(2),
cashRootHex: CASH_ROOT_HEX,
}),
],
[
'uppercase cash root',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX.toUpperCase(),
}),
],
[
'truncated cash root',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX.slice(2),
}),
],
[
'invalid cash root private key',
JSON.stringify({
version: 2,
linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: '00'.repeat(32) + CASH_ROOT_HEX.slice(64),
}),
],
[
'non-canonical field order',
JSON.stringify({ cashRootHex: CASH_ROOT_HEX, linkingKeyHex: LINKING_KEY_HEX, version: 2 }),
],
])('rejects %s', (_name, serialized) => {
// Given malformed or non-canonical serialized material
// When it crosses the parser
const parsed = parseWalletMaterial(serialized);
// Then no trusted material is returned
expect(parsed).toBeNull();
});
});
+166 -166
View File
@@ -5,8 +5,8 @@
// field that binds the saved key to its one proven wallet identity.
// Node env: in-memory localStorage stub, native WebCrypto.
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
decryptSavedLinkingKey,
@@ -20,153 +20,153 @@ import {
savedKeyOwnerMatches,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {isWalletOwnerId} from './storage/walletOwner'
import {parseJsonObject, stubLocalStorage} from './test-utils'
} from './keys';
import { isWalletOwnerId } from './storage/walletOwner';
import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.version.cases'
import './keys.version.cases';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const PASSWORD = 'hunter2'
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const PASSWORD = 'hunter2';
const STORAGE_KEY = 'sattle_linking_key'
const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) throw new Error('expected a saved linking-key record')
return parseJsonObject(raw)
}
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
// hand-written records in the pre-owner-marker shape - what every wallet
// created before this change has on disk
const saveLegacyPlaintext = (key: Uint8Array): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({enc: false, value: bytesToHex(key)}))
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({ enc: false, value: bytesToHex(key) }));
};
const saveLegacyEncrypted = async (
key: Uint8Array,
password: string,
): Promise<Record<string, unknown>> => {
const parts = await encryptSecretParts(bytesToHex(key), password)
const record: Record<string, unknown> = {enc: true, ...parts}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
return record
}
const parts = await encryptSecretParts(bytesToHex(key), password);
const record: Record<string, unknown> = { enc: true, ...parts };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
return record;
};
describe('baseline: saved-key record behavior', () => {
it('saves a plaintext key and reads it back', async () => {
await saveLinkingKey(LINKING_KEY)
await saveLinkingKey(LINKING_KEY);
expect(savedKeyExists()).toBe(true)
expect(savedKeyIsEncrypted()).toBe(false)
expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)})
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
})
expect(savedKeyExists()).toBe(true);
expect(savedKeyIsEncrypted()).toBe(false);
expect(readRawRecord()).toMatchObject({ enc: false, value: bytesToHex(LINKING_KEY) });
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
});
it('saves a password-encrypted key and decrypts it with the password', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyIsEncrypted()).toBe(true)
const record = readRawRecord()
expect(record.enc).toBe(true)
expect(record.value).toBeUndefined()
expect(savedKeyIsEncrypted()).toBe(true);
const record = readRawRecord();
expect(record.enc).toBe(true);
expect(record.value).toBeUndefined();
// an encrypted record never reads through the plaintext path
expect(getPlainLinkingKey()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
expect(getPlainLinkingKey()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('rejects the wrong password via the GCM auth tag', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow()
})
await saveLinkingKey(LINKING_KEY, PASSWORD);
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow();
});
it('throws when asked to decrypt a plaintext record', async () => {
await saveLinkingKey(LINKING_KEY)
await saveLinkingKey(LINKING_KEY);
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
'No encrypted linking key saved.',
)
})
);
});
it('restores an ownerless record verbatim and reads it back', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD)
const record = {enc: true as const, ...parts}
restoreLinkingKeyStored(record)
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
const record = { enc: true as const, ...parts };
restoreLinkingKeyStored(record);
expect(readRawRecord()).toEqual(record)
expect(savedKeyIsEncrypted()).toBe(true)
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
expect(readRawRecord()).toEqual(record);
expect(savedKeyIsEncrypted()).toBe(true);
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('drops a malformed stored record instead of trusting it', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({enc: true, salt: 'zz', iv: '00', ciphertext: ''}),
)
expect(savedKeyExists()).toBe(false)
localStorage.setItem(STORAGE_KEY, 'not json')
expect(savedKeyExists()).toBe(false)
expect(getPlainLinkingKey()).toBeNull()
})
})
JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }),
);
expect(savedKeyExists()).toBe(false);
localStorage.setItem(STORAGE_KEY, 'not json');
expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull();
});
});
describe('owner marker on new writes', () => {
it('matches the canonical owner derived from the saved linking key', async () => {
// Given a newly saved owner-bearing key
await saveLinkingKey(LINKING_KEY)
await saveLinkingKey(LINKING_KEY);
// When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY)
const matches = savedKeyOwnerMatches(LINKING_KEY);
// Then the saved owner matches
expect(matches).toBe(true)
})
expect(matches).toBe(true);
});
it('does not match a different linking key', async () => {
// Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY)
await saveLinkingKey(LINKING_KEY);
// When a foreign freshly derived linking key is compared
const matches = savedKeyOwnerMatches(OTHER_KEY)
const matches = savedKeyOwnerMatches(OTHER_KEY);
// Then the foreign key is rejected
expect(matches).toBe(false)
})
expect(matches).toBe(false);
});
it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
await saveLinkingKey(LINKING_KEY)
const plainOwner = savedKeyOwnerId()
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY))
await saveLinkingKey(LINKING_KEY);
const plainOwner = savedKeyOwnerId();
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
await saveLinkingKey(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBe(plainOwner)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
})
await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBe(plainOwner);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
});
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
await saveLinkingKey(LINKING_KEY)
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/)
})
})
await saveLinkingKey(LINKING_KEY);
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/);
});
});
describe('owner marker on legacy records', () => {
it('reads a legacy record without ownerId as ownerless', async () => {
saveLegacyPlaintext(LINKING_KEY)
expect(savedKeyOwnerId()).toBeNull()
saveLegacyPlaintext(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull();
// the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
it('rejects malformed owner-bearing records instead of treating them as legacy', () => {
const real = linkingPubKeyHex(LINKING_KEY)
const real = linkingPubKeyHex(LINKING_KEY);
const junk: unknown[] = [
real.slice(1), // wrong length (65)
real + '00', // wrong length (68)
@@ -176,102 +176,102 @@ describe('owner marker on legacy records', () => {
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point
42, // wrong type
null,
{pubkey: real},
{ pubkey: real },
'',
]
];
for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY)
localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId}))
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
// the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull()
expect(savedKeyOwnerId()).toBeNull();
// ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false)
expect(getPlainLinkingKey()).toBeNull()
expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull();
}
})
});
it('stamps the owner after a password unlock without touching the ciphertext', async () => {
// Given a legacy encrypted record with no owner marker
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
// When ownership is proven by a successful unlock and then stamped
const linkingKey = await decryptSavedLinkingKey(PASSWORD)
ensureSavedKeyOwner(linkingKey)
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey);
// Then the marker names the proven key and every ciphertext byte is
// preserved
const after = readRawRecord()
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
expect(after.ciphertext).toBe(before.ciphertext)
expect(after.salt).toBe(before.salt)
expect(after.iv).toBe(before.iv)
})
const after = readRawRecord();
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
expect(after.ciphertext).toBe(before.ciphertext);
expect(after.salt).toBe(before.salt);
expect(after.iv).toBe(before.iv);
});
it('stamps a plaintext legacy record after a plaintext unlock', () => {
saveLegacyPlaintext(LINKING_KEY)
const linkingKey = getPlainLinkingKey()
if (linkingKey === null) throw new Error('expected a plaintext key')
ensureSavedKeyOwner(linkingKey)
saveLegacyPlaintext(LINKING_KEY);
const linkingKey = getPlainLinkingKey();
if (linkingKey === null) throw new Error('expected a plaintext key');
ensureSavedKeyOwner(linkingKey);
expect(readRawRecord()).toEqual({
enc: false,
value: bytesToHex(LINKING_KEY),
version: 1,
ownerId: linkingPubKeyHex(LINKING_KEY),
})
})
});
});
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
const linkingKey = await decryptSavedLinkingKey(PASSWORD)
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey)
const afterFirst = localStorage.getItem(STORAGE_KEY)
ensureSavedKeyOwner(linkingKey)
ensureSavedKeyOwner(linkingKey);
const afterFirst = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(linkingKey);
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst)
expect(readRawRecord().ciphertext).toBe(before.ciphertext)
})
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst);
expect(readRawRecord().ciphertext).toBe(before.ciphertext);
});
it('writes nothing when a new-format record is already correctly stamped', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
const raw = localStorage.getItem(STORAGE_KEY)
ensureSavedKeyOwner(LINKING_KEY)
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw)
})
await saveLinkingKey(LINKING_KEY, PASSWORD);
const raw = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(LINKING_KEY);
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw);
});
it('refuses to restamp a record owned by a different wallet', async () => {
await saveLinkingKey(OTHER_KEY, PASSWORD)
const before = localStorage.getItem(STORAGE_KEY)
await saveLinkingKey(OTHER_KEY, PASSWORD);
const before = localStorage.getItem(STORAGE_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow()
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
// the failed stamp leaves the record - marker included - untouched
expect(localStorage.getItem(STORAGE_KEY)).toBe(before)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY))
})
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY));
});
it('refuses to stamp a key that contradicts a plaintext record', () => {
saveLegacyPlaintext(OTHER_KEY)
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow()
expect(savedKeyOwnerId()).toBeNull()
})
saveLegacyPlaintext(OTHER_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
expect(savedKeyOwnerId()).toBeNull();
});
it('does not adopt a record carrying a junk owner marker', () => {
saveLegacyPlaintext(LINKING_KEY)
saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({...readRawRecord(), ownerId: 'obviously junk'}),
)
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyOwnerId()).toBeNull()
expect(savedKeyExists()).toBe(false)
})
JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }),
);
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull();
expect(savedKeyExists()).toBe(false);
});
it('is a no-op when no record is saved at all', () => {
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyExists()).toBe(false)
})
})
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyExists()).toBe(false);
});
});
describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => {
@@ -281,36 +281,36 @@ describe('owner marker on restore', () => {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY),
})
expect(savedKeyOwnerId()).toBeNull()
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
});
expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
// the first proven unlock then establishes the true owner
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
})
ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
});
it('strips a junk ownerId from a restored encrypted record', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD)
restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42})
expect(savedKeyOwnerId()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
})
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
restoreLinkingKeyStored({ enc: true, ...parts, ownerId: 42 });
expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
});
});
describe('isWalletOwnerId', () => {
it('accepts exactly what linkingPubKeyHex produces', () => {
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true)
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true)
})
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true);
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true);
});
it('rejects everything else', () => {
const real = linkingPubKeyHex(LINKING_KEY)
expect(isWalletOwnerId(real.toUpperCase())).toBe(false)
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false)
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false)
expect(isWalletOwnerId(66)).toBe(false)
expect(isWalletOwnerId(undefined)).toBe(false)
expect(isWalletOwnerId(null)).toBe(false)
})
})
const real = linkingPubKeyHex(LINKING_KEY);
expect(isWalletOwnerId(real.toUpperCase())).toBe(false);
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false);
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false);
expect(isWalletOwnerId(66)).toBe(false);
expect(isWalletOwnerId(undefined)).toBe(false);
expect(isWalletOwnerId(null)).toBe(false);
});
});
+129 -157
View File
@@ -1,10 +1,11 @@
import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39'
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {HDKey, HARDENED_OFFSET} from '@scure/bip32'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {secp256k1} from '@noble/curves/secp256k1.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import { mnemonicToSeedSync, generateMnemonic, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey, HARDENED_OFFSET } from '@scure/bip32';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode } from 'lnurlcash-kit';
import {
parseStoredSecret,
@@ -14,144 +15,111 @@ import {
stripStoredSecretOwner,
STORED_SECRET_VERSION,
type StoredSecret,
} from './storage/storedSecret'
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
type WalletMaterialV2,
} from './storage/storedSecret';
import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
export {isValidStoredSecret} from './storage/storedSecret'
export type {StoredSecret} from './storage/storedSecret'
export {
isValidStoredSecret,
isValidStoredWalletMaterial,
parseWalletMaterial,
serializeWalletMaterial,
walletMaterialHash,
} from './storage/storedSecret';
export type { StoredSecret, StoredWalletMaterial, WalletMaterialV2 } from './storage/storedSecret';
export { decryptSecretParts, encryptSecretParts } from './passwordWrap';
export type { EncryptedSecretParts } from './passwordWrap';
export * from './walletMaterialStorage';
// The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where
// this static build happens to be hosted - github.io, a mirror, file://.
export const WALLET_DOMAIN = 'sattle'
export const WALLET_DOMAIN = 'sattle';
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128)
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128);
export const isValidSeedPhrase = (phrase: string): boolean =>
validateMnemonic(phrase.trim().toLowerCase(), wordlist)
validateMnemonic(phrase.trim().toLowerCase(), wordlist);
const readUint32BE = (bytes: Uint8Array, offset: number): number =>
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0
0;
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
// a seed restored there or here produces the same identity for a given domain
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
const master = HDKey.fromMasterSeed(seed)
const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
const master = HDKey.fromMasterSeed(seed);
const hashingKeyNode = master.derive("m/138'/0")
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key')
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
const hashingKeyNode = master.derive("m/138'/0");
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key');
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain);
// path suffix longs are raw BIP32 child indices: whether each level ends up
// hardened depends solely on its own magnitude (>= 2^31), never forced
let node = master.deriveChild(138 + HARDENED_OFFSET)
let node = master.deriveChild(138 + HARDENED_OFFSET);
for (const index of suffix) {
node = node.deriveChild(index)
node = node.deriveChild(index);
}
if (!node.privateKey) throw new Error('Could not derive linking key')
return node.privateKey
}
if (!node.privateKey) throw new Error('Could not derive linking key');
return node.privateKey;
};
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array =>
deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain);
// the HMAC half of the derivation, split out so the LUD-05 test vector
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
// directly - see keys.test.ts
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
return [0, 4, 8, 12].map((i) => readUint32BE(material, i))
}
const material = hmac(sha256, hashingKey, utf8ToBytes(domain));
return [0, 4, 8, 12].map((i) => readUint32BE(material, i));
};
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN);
export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase());
try {
return {
version: 2,
linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)),
cashRootHex: cashNodeToHex(deriveCashRoot(seed)),
};
} finally {
seed.fill(0);
}
};
export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array =>
hexToBytes(material.linkingKeyHex);
export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode =>
cashNodeFromHex(material.cashRootHex);
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true))
// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the
// stored value is either plaintext or, if the holder opted in with a
// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password -
// GCM's auth tag doubles as the "wrong password" check on decrypt.
const PBKDF2_ITERATIONS = 210_000
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true));
const readSecret = (storageKey: string): StoredSecret | null => {
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw)
return parseStoredSecret(parsed)?.secret ?? null
const parsed: unknown = JSON.parse(raw);
return parseStoredSecret(parsed)?.secret ?? null;
} catch {
return null
return null;
}
}
};
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
crypto.subtle
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
.then((baseKey) =>
crypto.subtle.deriveKey(
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
{
name: 'PBKDF2',
salt: new Uint8Array(salt),
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
baseKey,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt'],
),
)
export type EncryptedSecretParts = {
salt: string
iv: string
ciphertext: string
}
export const encryptSecretParts = async (
value: string,
password: string,
): Promise<EncryptedSecretParts> => {
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const aesKey = await deriveAesKeyFromPassword(password, salt)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)),
)
return {
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
}
}
// rejects (WebCrypto's own auth-tag check) if the password is wrong
export const decryptSecretParts = async (
parts: EncryptedSecretParts,
password: string,
): Promise<string> => {
const salt = hexToBytes(parts.salt)
const iv = hexToBytes(parts.iv)
const aesKey = await deriveAesKeyFromPassword(password, salt)
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
aesKey,
hexToBytes(parts.ciphertext),
)
return new TextDecoder().decode(plaintext)
}
// The linking key is the only secret this wallet persists - the seed phrase
// it was derived from is shown once at setup and never stored. Everything
// else at rest (the bearer tokens) is encrypted with a key derived from it,
// so protecting this one record with a password protects the whole wallet.
// The legacy alpha lifecycle below persists only the linking key under its old
// namespace. Task 5 replaces that lifecycle with the v2 material repository;
// these APIs stay byte-compatible until the serialized reset can switch every
// caller atomically rather than creating a split-key wallet.
//
// The record also carries an ownerId marker: the lowercase compressed
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
@@ -167,24 +135,24 @@ export const decryptSecretParts = async (
// preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record.
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null;
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true;
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY)
readSecret(LINKING_KEY_STORAGE_KEY);
// the proven owner of the saved key, or null when there is no record or it
// carries no current version-1 marker (ownerless or compatible unversioned)
export const savedKeyOwnerId = (): string | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
return stored === null ? null : storedSecretOwnerId(stored)
}
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
return stored === null ? null : storedSecretOwnerId(stored);
};
// Compares only against an owner freshly derived from a linking key. An
// ownerless or unversioned saved marker never matches.
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
savedKeyOwnerId() === linkingPubKeyHex(linkingKey)
savedKeyOwnerId() === linkingPubKeyHex(linkingKey);
// Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext
@@ -193,99 +161,103 @@ export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
// already-correct marker: no write. A DIFFERENT valid owner, or a key that
// contradicts a plaintext record, throws and leaves storage untouched.
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null) return
const ownerId = linkingPubKeyHex(linkingKey)
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null) return;
const ownerId = linkingPubKeyHex(linkingKey);
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) {
throw new Error('Proven key does not match the saved wallet key.')
throw new Error('Proven key does not match the saved wallet key.');
}
const claimedOwnerId = storedSecretClaimedOwnerId(stored)
if (storedSecretOwnerId(stored) === ownerId) return
const claimedOwnerId = storedSecretClaimedOwnerId(stored);
if (storedSecretOwnerId(stored) === ownerId) return;
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
throw new Error('Saved wallet key is owned by a different wallet.')
throw new Error('Saved wallet key is owned by a different wallet.');
}
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
)
}
);
};
export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null || stored.enc === true) return null
return hexToBytes(stored.value)
}
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null || stored.enc === true) return null;
return hexToBytes(stored.value);
};
export const saveLinkingKey = async (
linkingPrivKey: Uint8Array,
password?: string,
): Promise<void> => {
const hex = bytesToHex(linkingPrivKey)
const ownerId = linkingPubKeyHex(linkingPrivKey)
const hex = bytesToHex(linkingPrivKey);
const ownerId = linkingPubKeyHex(linkingPrivKey);
if (!password) {
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}),
)
return
JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }),
);
return;
}
const parts = await encryptSecretParts(hex, password)
const parts = await encryptSecretParts(hex, password);
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION}),
)
}
JSON.stringify({ enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION }),
);
};
// installs a record from a backup/relay. Any ownerId it carries is an
// unproven claim by whoever produced that file, so the marker is stripped
// here - the first proven unlock re-establishes it (see the failure-model
// comment above)
export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)))
}
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)));
};
export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
return hexToBytes(await decryptSecretParts(stored, password))
}
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.');
return hexToBytes(await decryptSecretParts(stored, password));
};
export const clearSavedLinkingKey = (): void => {
localStorage.removeItem(LINKING_KEY_STORAGE_KEY)
}
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
};
// The bearer-encryption key is derived (not random): sha256 over the linking
// key plus a fixed context string. Deterministic derivation is what makes
// backup/restore work with nothing but the seed phrase - restore the seed on
// a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1';
export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]))
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt'])
}
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]));
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']);
};
export type EncryptedRecordParts = {iv: string; ciphertext: string}
export type EncryptedRecordParts = { iv: string; ciphertext: string };
export const encryptRecord = async (
aesKey: CryptoKey,
value: object,
): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))),
)
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
}
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
utf8ToBytes(JSON.stringify(value)),
),
);
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
export const decryptRecord = async (
aesKey: CryptoKey,
parts: EncryptedRecordParts,
): Promise<unknown> => {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: hexToBytes(parts.iv)},
{ name: 'AES-GCM', iv: hexToBytes(parts.iv) },
aesKey,
hexToBytes(parts.ciphertext),
)
return JSON.parse(new TextDecoder().decode(plaintext))
}
);
return JSON.parse(new TextDecoder().decode(plaintext));
};
+48 -44
View File
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import { beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
ensureSavedKeyOwner,
@@ -9,50 +9,54 @@ import {
savedKeyExists,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, stubLocalStorage} from './test-utils'
} from './keys';
import { parseJsonObject, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const STORAGE_KEY = 'sattle_linking_key'
import './keys.material.cases';
import './keys.material-commitment.cases';
import './keys.material-storage.cases';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) throw new Error('expected a saved linking-key record')
return parseJsonObject(raw)
}
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw);
};
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
describe('saved-key schema version', () => {
it('writes version 1 on current plaintext and encrypted records', async () => {
// Given a linking key saved through each current persistence path
await saveLinkingKey(LINKING_KEY)
const plaintext = readRawRecord()
await saveLinkingKey(LINKING_KEY, 'correct horse')
const encrypted = readRawRecord()
await saveLinkingKey(LINKING_KEY);
const plaintext = readRawRecord();
await saveLinkingKey(LINKING_KEY, 'correct horse');
const encrypted = readRawRecord();
// When the persisted schema metadata is inspected
// Then both owner-bearing records carry the recognized discriminator
expect(plaintext.version).toBe(1)
expect(encrypted.version).toBe(1)
})
expect(plaintext.version).toBe(1);
expect(encrypted.version).toBe(1);
});
it('upgrades an unversioned same-owner record only after key proof', () => {
// Given the valid owner-bearing shape written before schema versioning
const ownerId = linkingPubKeyHex(LINKING_KEY)
const ownerId = linkingPubKeyHex(LINKING_KEY);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
)
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId }),
);
// When it is read before and then stamped after the plaintext key proves ownership
expect(savedKeyOwnerId()).toBeNull()
const provenKey = getPlainLinkingKey()
if (provenKey === null) throw new Error('expected the compatible plaintext key')
ensureSavedKeyOwner(provenKey)
expect(savedKeyOwnerId()).toBeNull();
const provenKey = getPlainLinkingKey();
if (provenKey === null) throw new Error('expected the compatible plaintext key');
ensureSavedKeyOwner(provenKey);
// Then it becomes an explicitly versioned current record
expect(readRawRecord()).toEqual({
@@ -60,8 +64,8 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY),
ownerId,
version: 1,
})
})
});
});
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
// Given an otherwise valid owner-bearing record with unrecognized metadata
@@ -70,16 +74,16 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY),
version,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
// When the saved-key boundary parses it
// Then the record is neither usable nor eligible for legacy adoption
expect(isValidStoredSecret(record)).toBe(false)
expect(savedKeyExists()).toBe(false)
expect(savedKeyOwnerId()).toBeNull()
expect(getPlainLinkingKey()).toBeNull()
})
expect(isValidStoredSecret(record)).toBe(false);
expect(savedKeyExists()).toBe(false);
expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toBeNull();
});
it('rejects a foreign current owner at the proven-key stamping boundary', () => {
// Given a versioned record whose owner conflicts with its plaintext key
@@ -88,15 +92,15 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
const before = localStorage.getItem(STORAGE_KEY)
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
const before = localStorage.getItem(STORAGE_KEY);
// When the actual key is proven
const stamp = () => ensureSavedKeyOwner(LINKING_KEY)
const stamp = () => ensureSavedKeyOwner(LINKING_KEY);
// Then the foreign claim fails closed without rewriting storage
expect(stamp).toThrow('different wallet')
expect(localStorage.getItem(STORAGE_KEY)).toBe(before)
})
})
expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
});
});
+183 -130
View File
@@ -1,5 +1,5 @@
// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 /
// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows
// Wire-contract coverage for the pinned lnurlcash-kit 0.8.0 /
// lnurlcash-conformance 0.7.0 artifacts. Contracts the app's mint flows
// rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
@@ -8,44 +8,56 @@
// mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability
// (commentAllowed >= 64 or mintToHash): a new-generation mint
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
// see the byte-identical request this wallet always sent.
// only when the payRequest advertises commentAllowed >= 64. The wallet's
// output secret must be durably staged before that quote request is sent;
// mintToHash alone is only an additive receipt extension, not permission
// to mint.
import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit'
import { afterEach, describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { buildNoteUrl, fetchMintAddress, hashK1 } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint} from './ops'
import {mintAddressCacheInfo} from './trustedMints'
import { claimMintedNote, prepareMint } from './ops';
import { mintAddressCacheInfo } from './trustedMints';
import './mintContract.v08.cases';
type Mint = Awaited<ReturnType<typeof createMockMint>>
type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = []
const mints: Mint[] = [];
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()))
})
await Promise.all(mints.splice(0).map((m) => m.close()));
});
// paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step)
const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1)
if (!paymentHash) throw new Error('no invoice requested yet')
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`)
const invoice = m.state.invoices.get(paymentHash)
if (!invoice) throw new Error('settled invoice vanished from the mock')
return invoice.preimage
}
const paymentHash = [...m.state.invoices.keys()].at(-1);
if (!paymentHash) throw new Error('no invoice requested yet');
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`);
const invoice = m.state.invoices.get(paymentHash);
if (!invoice) throw new Error('settled invoice vanished from the mock');
return invoice.preimage;
};
const MINT_PUBKEY = `02${'ab'.repeat(32)}`
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path
let allocatedCounter = 0;
const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => {
allocatedCounter += 1;
return `03${allocatedCounter.toString(16).padStart(62, '0')}`;
});
return Promise.resolve(secrets);
};
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// node stats under their WIRE names - `nodeCapacity` is msat like every
@@ -62,26 +74,26 @@ const mintAddressFixture = {
nodeCapacity: 500_000_000,
nodeNumChannels: 4,
nodeNumPeers: 6,
}
};
const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: {'content-type': 'application/json'},
})
headers: { 'content-type': 'application/json' },
});
// a fetch that serves fixture bodies by URL prefix and 404s everything else,
// so a test drives the real kit HTTP boundary without any network
const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => {
const impl: typeof fetch = (input, _init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
for (const [prefix, body] of routes) {
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body))
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body));
}
return Promise.resolve(new Response('not found', {status: 404}))
}
return impl
}
return Promise.resolve(new Response('not found', { status: 404 }));
};
return impl;
};
// fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire
@@ -90,69 +102,75 @@ const capturingFetch = (
seen: string[],
): typeof fetch => {
const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
seen.push(url)
return fixtureFetch(routes)(input, init)
}
return impl
}
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
seen.push(url);
return fixtureFetch(routes)(input, init);
};
return impl;
};
describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
})
});
// renamed fields have to be mapped, not spread: the spread carries the
// wire name through and the typed one reads undefined forever
expect(info.nodePubkey).toBe(MINT_PUBKEY)
expect(info.nodeCapacityMsat).toBe(500_000_000)
expect(info.nodeNumChannels).toBe(4)
expect(info.nodeNumPeers).toBe(6)
})
expect(info.nodePubkey).toBe(MINT_PUBKEY);
expect(info.nodeCapacityMsat).toBe(500_000_000);
expect(info.nodeNumChannels).toBe(4);
expect(info.nodeNumPeers).toBe(6);
});
it('carries node stats into the cached trusted-mint display metadata', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
})
const cached = mintAddressCacheInfo(info, 'mint')
expect(cached?.nodeCapacityMsat).toBe(500_000_000)
expect(cached?.nodeNumChannels).toBe(4)
expect(cached?.nodeNumPeers).toBe(6)
})
});
const cached = mintAddressCacheInfo(info, 'mint');
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
expect(cached?.nodeNumChannels).toBe(4);
expect(cached?.nodeNumPeers).toBe(6);
});
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
const m = await mint()
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
const m = await mint();
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
// the metadata is advertised at the mint-address endpoint itself -
// the payRequest never carried it
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey)
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000)
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4)
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6)
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username)
expect(cached?.nodeCapacityMsat).toBe(500_000_000)
})
})
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey);
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000);
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4);
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6);
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username);
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
});
});
describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
// conformance 0.4.0 defaults to plain https links; lnurlw:// (the form
// lnurl-mint emits under LUD-17) is an explicit option
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// conformance 0.7.0 defaults to plain https links; lnurlw:// is an
// explicit LUD-17 transport form
const m = await mint({ testHooks: true, withdrawLinkForm: 'lnurlw' });
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//);
// and the link is fully usable: settle the invoice, claim the note
const preimage = await settleLastInvoice(m)
const preimage = await settleLastInvoice(m);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(m.state.noteState(preimage)).toBe('burned')
})
});
expect(claimed.note.amount).toBe(21_000);
expect(m.state.noteState(preimage)).toBeNull();
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding');
});
it('accepts an HTTPS withdraw link', async () => {
const fetch = fixtureFetch([
@@ -165,25 +183,30 @@ describe('withdraw-link forms', () => {
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
commentAllowed: 64,
metadata: '[]',
},
],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
])
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch})
expect(prepared.withdrawLink).toBe('https://mint.example/note')
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
]);
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch,
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toBe('https://mint.example/note');
// the mint-address payLink is authoritative - the payRequest came from it
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint')
})
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint');
});
it('builds the same note URL from both withdraw-link forms', () => {
const k1 = 'ab'.repeat(32)
const k1 = 'ab'.repeat(32);
expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe(
buildNoteUrl('https://mint.example/note', k1, 21_000),
)
})
})
);
});
});
describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({
@@ -194,60 +217,90 @@ describe('quote output-naming wire contract', () => {
withdrawLink: 'https://mint.example/note',
metadata: '[]',
...extra,
})
});
const routesFor = (payRequest: Record<string, unknown>) =>
[
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
] as const
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
] as const;
const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'))
if (!hit) throw new Error('the quote request never hit the wire')
return new URL(hit)
}
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'));
if (!hit) throw new Error('the quote request never hit the wire');
return new URL(hit);
};
it('sends no comment/h to a mint that never advertised output naming', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
})
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// an old mint sees the byte-identical request this wallet always sent
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
})
it('rejects a mint that cannot carry the mandatory output commitment', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
persistOutput,
allocateOutputSecrets,
}),
).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => {
const seen: string[] = []
it('persists the wallet output before sending its hash as comment and h', async () => {
const seen: string[] = [];
const events: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen)
allocateOutputSecrets,
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery');
return capturingFetch(
routesFor(payRequestFixture({ commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}` })),
seen,
)(input, init);
},
persistOutput: async (note) => {
events.push('persist');
expect(note.verified).toBe(false);
expect(note.callback).toBe('');
expect(note.amount).toBe(0);
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY);
},
});
const noteSecret = prepared.noteSecret;
const url = callbackUrl(seen);
// both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment')
const h = url.searchParams.get('h')
expect(comment).toMatch(/^[0-9a-f]{64}$/)
expect(h).toBe(comment)
expect(comment).toBe(hashK1(noteSecret))
})
const comment = url.searchParams.get('comment');
const h = url.searchParams.get('h');
expect(comment).toMatch(/^[0-9a-f]{64}$/);
expect(h).toBe(comment);
expect(comment).toBe(hashK1(noteSecret));
expect(prepared.mintPubkey).toBe(MINT_PUBKEY);
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0);
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'));
});
it('names the output when only the mintToHash spelling is advertised', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
})
})
it('does not request an invoice when output persistence fails', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen),
persistOutput: () => Promise.reject(new Error('storage full')),
allocateOutputSecrets,
}),
).rejects.toThrow(/storage full/);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
it('rejects mintToHash without the mandatory comment capacity', async () => {
const seen: string[] = [];
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
persistOutput,
allocateOutputSecrets,
}),
).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
// The wallet's deterministic-secret and signed-output work depends on this
// exact installed API surface, so package drift must fail before funds code runs.
import { describe, expect, it } from 'vitest';
import {
cashNodeFromHex,
cashNodeToHex,
cashSecretSource,
deriveCashRoot,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
} from 'lnurlcash-kit';
import type { LnurlcashOptions } from 'lnurlcash-kit';
import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json';
import kitPackage from '../../node_modules/lnurlcash-kit/package.json';
const retryOptions = { mutationRetries: 1 } satisfies LnurlcashOptions;
const requiredExports = [
deriveCashRoot,
cashNodeToHex,
cashNodeFromHex,
cashSecretSource,
restoreFromSeed,
verifyNoteSignature,
verifyNoteSignatureAgainst,
verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst,
] as const;
describe('lnurlcash 0.8 dependency contract', () => {
it('exposes the BIP-32, recovery, signature, and retry contracts', () => {
expect(requiredExports.every((value) => typeof value === 'function')).toBe(true);
expect(retryOptions.mutationRetries).toBe(1);
});
it('runs against the exact installed package versions', () => {
expect(kitPackage.version).toBe('0.8.0');
expect(conformancePackage.version).toBe('0.7.0');
});
});
+105 -76
View File
@@ -9,33 +9,36 @@
//
// Three d-tags carry three payloads in separate replaceable slots, so a
// notes publish never clobbers settings:
// notes - the encrypted bearer records exactly as they sit in
// localStorage (each note is already an AES-GCM ciphertext
// under the seed-derived bearer key, so the blob goes up
// as-is; the linking key itself is NEVER part of a payload -
// the seed phrase is its recovery path)
// notes - the funds projection: encrypted bearer records exactly as
// they sit in the funds document (each note is already an
// AES-GCM ciphertext under the seed-derived bearer key, so the
// blob goes up as-is; the linking key itself is NEVER part of a
// payload - the seed phrase is its recovery path) plus the
// per-host BIP-32 next-unused counters, so a restored device
// never reissues a burned index. Pending journal state is
// device-local recovery data and is never published.
// mints - the trusted-mint registry
// settings - plaintext wallet settings
import {sha256} from '@noble/hashes/sha2.js'
import {utf8ToBytes} from '@noble/hashes/utils.js'
import type {Event as NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import type { Event as NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import type {EncryptedBearerRecord} from '../storage/bearers'
import type {TrustedMint} from '../trustedMints'
import type {WalletSettings} from '../storage/settings'
import type { EncryptedBearerRecord } from '../storage/bearers';
import type { TrustedMint } from '../trustedMints';
import type { WalletSettings } from '../storage/settings';
export type {NostrEvent}
export type { NostrEvent };
// addressable (parametrized-replaceable) app-data event - relays keep only
// the newest event per (pubkey, kind, d-tag)
export const BACKUP_EVENT_KIND = 30078
export const BACKUP_EVENT_KIND = 30078;
export type BackupPart = 'notes' | 'mints' | 'settings'
export type BackupPart = 'notes' | 'mints' | 'settings';
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings']
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'];
// the backup key is dedicated to this wallet (derived with a
// sattle-specific context, see below), so its pubkey is ours alone and the
@@ -44,49 +47,68 @@ export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes',
mints: 'mints',
settings: 'settings',
}
};
// the notes payload: bearers plus the counter map, from one consistent
// funds-document read
export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number>;
};
// the decrypted payload of each part, without its envelope
export type BackupPartPayload = {
notes: EncryptedBearerRecord[]
mints: TrustedMint[]
settings: WalletSettings
}
notes: NotesBackupPayload;
mints: TrustedMint[];
settings: WalletSettings;
};
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1'
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1';
// Deterministic: sha256(linking key || context), mirroring keys.ts's
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
// backup - it signs and decrypts backup events, nothing else.
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]))
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]));
// the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey)
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey);
// NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else
const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
// a relay could serve a multi-megabyte content string; JSON.parse and
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of
// kilobytes per part - this is far beyond generous (applyBackup's own
// per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024;
export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? ''
export const dTagOf = (event: NostrEvent): string =>
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
// per-part envelope versions: the notes part went v2 with the funds
// document (counters joined the payload - a breaking change); mints and
// settings envelopes are unchanged
const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
notes: 2,
mints: 1,
settings: 1,
};
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) {
case 'notes':
return JSON.stringify({version: 1, bearers: payload})
case 'notes': {
const notes = payload as NotesBackupPayload;
return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
}
case 'mints':
return JSON.stringify({version: 1, trustedMints: payload})
return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings':
return JSON.stringify({version: 1, settings: payload})
return JSON.stringify({ version: 1, settings: payload });
}
}
};
// signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>(
@@ -103,7 +125,7 @@ export const buildBackupEvent = <P extends BackupPart>(
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey,
)
);
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
@@ -111,33 +133,33 @@ export const buildBackupEvents = (
parts: Partial<BackupPartPayload>,
createdAt?: number,
): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000)
const events: NostrEvent[] = []
const at = createdAt ?? Math.floor(Date.now() / 1000);
const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) {
const payload = parts[part]
if (payload === undefined) continue
events.push(buildBackupEvent(secretKey, part, payload, at))
const payload = parts[part];
if (payload === undefined) continue;
events.push(buildBackupEvent(secretKey, part, payload, at));
}
return events
}
return events;
};
export type ParsedBackupEvent =
| {part: 'notes'; bearers: EncryptedBearerRecord[]}
| {part: 'mints'; trustedMints: TrustedMint[]}
| {part: 'settings'; settings: WalletSettings}
| { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
| { part: 'mints'; trustedMints: TrustedMint[] }
| { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, pubkey patterns) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file
// (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / commitFundsRestore on the restore path, same as file
// backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null
const envelope = data as Record<string, unknown>
if (envelope.version !== 1) return null
if (typeof data !== 'object' || data === null) return null;
const envelope = data as Record<string, unknown>;
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) {
case 'notes': {
if (!Array.isArray(envelope.bearers)) return null
const bearers = envelope.bearers as unknown[]
if (!Array.isArray(envelope.bearers)) return null;
const bearers = envelope.bearers as unknown[];
if (
!bearers.every(
(r) =>
@@ -146,13 +168,20 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
)
) {
return null
return null;
}
return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]}
if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
const counters = envelope.nextByHost as Record<string, unknown>;
if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
return {
part: 'notes',
bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>,
};
}
case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null
const mints = envelope.trustedMints as unknown[]
if (!Array.isArray(envelope.trustedMints)) return null;
const mints = envelope.trustedMints as unknown[];
if (
!mints.every(
(m) =>
@@ -160,22 +189,22 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (m as TrustedMint)?.mintPubkey === 'string',
)
) {
return null
return null;
}
return {part: 'mints', trustedMints: mints as TrustedMint[]}
return { part: 'mints', trustedMints: mints as TrustedMint[] };
}
case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
return null
return null;
}
const settings = envelope.settings as Record<string, unknown>
const settings = envelope.settings as Record<string, unknown>;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null
return null;
}
return {part: 'settings', settings: settings as WalletSettings}
return { part: 'settings', settings: settings as WalletSettings };
}
}
}
};
// Validates and decrypts one backup event. Returns null for anything that
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
@@ -185,23 +214,23 @@ export const parseBackupEvent = (
secretKey: Uint8Array,
event: NostrEvent,
): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null
if (event.pubkey !== getPublicKey(secretKey)) return null
const dTag = dTagOf(event)
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null
if (!verifyEvent(event)) return null
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null
let plaintext: string
if (event.kind !== BACKUP_EVENT_KIND) return null;
if (event.pubkey !== getPublicKey(secretKey)) return null;
const dTag = dTagOf(event);
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
if (!verifyEvent(event)) return null;
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
let plaintext: string;
try {
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey))
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch {
return null
return null;
}
let data: unknown
let data: unknown;
try {
data = JSON.parse(plaintext)
data = JSON.parse(plaintext);
} catch {
return null
return null;
}
return parsePayload(dTag, data)
}
return parsePayload(dTag, data);
};
+35 -33
View File
@@ -2,74 +2,76 @@
// edits (a mint plus a receive plus a reorder) collapse into one publish
// of the final state. Trailing-edge debounce with in-flight coalescing.
import type {BackupPartPayload} from './events'
import type { BackupPartPayload } from './events';
export type BackupPublisher = {
// record that local state changed - the latest snapshot wins, and only
// one publish fires per quiet window no matter how many changes landed
schedule: (parts: Partial<BackupPartPayload>) => void
schedule: (parts: Partial<BackupPartPayload>) => void;
// publish any pending snapshot immediately (app backgrounding, logout)
flush: () => Promise<void>
flush: () => Promise<void>;
// drop any pending snapshot without publishing
cancel: () => void
}
cancel: () => void;
};
export type BackupPublisherOptions = {
publish: (parts: Partial<BackupPartPayload>) => Promise<void>
delayMs: number
publish: (parts: Partial<BackupPartPayload>) => Promise<void>;
delayMs: number;
// a debounced publish has no caller to throw to - errors surface here;
// the next scheduled change retries
onError?: (error: unknown) => void
}
onError?: (error: unknown) => void;
};
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null
let pending: Partial<BackupPartPayload> | null = null
let running: Promise<void> | null = null
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Partial<BackupPartPayload> | null = null;
let running: Promise<void> | null = null;
const clearTimer = (): void => {
if (timer !== null) {
clearTimeout(timer)
timer = null
clearTimeout(timer);
timer = null;
}
}
};
const drain = async (): Promise<void> => {
// a change can land mid-publish - keep looping until the newest
// snapshot is the one that's out
while (pending !== null) {
const snapshot = pending
pending = null
const snapshot = pending;
pending = null;
try {
await options.publish(snapshot)
await options.publish(snapshot);
} catch (error) {
options.onError?.(
error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}),
)
error instanceof Error
? error
: new Error('Backup publication failed.', { cause: error }),
);
}
}
}
};
const fire = (): Promise<void> => {
clearTimer()
clearTimer();
// an in-flight drain picks up anything pending itself - a second drain
// would double-publish the same snapshot
running ??= drain().finally(() => {
running = null
})
return running
}
running = null;
});
return running;
};
return {
schedule: (parts) => {
pending = parts
clearTimer()
timer = setTimeout(() => void fire(), options.delayMs)
pending = parts;
clearTimer();
timer = setTimeout(() => void fire(), options.delayMs);
},
flush: fire,
cancel: () => {
clearTimer()
pending = null
clearTimer();
pending = null;
},
}
}
};
};
+63 -62
View File
@@ -2,17 +2,17 @@
//
// v1 is single-device last-writer-wins at the relay (addressable events
// replace). Restore merges locally through storage/backup.ts's applyBackup
// - the SAME entry point as file restore (union by record id, mints merged
// unconfirmed, settings fill-only) - so the two restore paths can't drift
// apart. The note-level dedupe (same note under different record ids,
// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly
// as with a file backup.
// - the SAME entry point as file restore (union by record id, counters
// merged upward-only, mints merged unconfirmed, settings fill-only) - so the
// two restore paths can't drift apart. The note-level dedupe (same note
// under different record ids, spent-wins) happens after decrypt in
// bearers.ts's mergeBearers, exactly as with a file backup.
import type {RestoreResult} from '../storage/backup'
import {applyBackup} from '../storage/backup'
import {linkingPubKeyHex} from '../keys'
import type { RestoreResult } from '../storage/backup';
import { applyBackup } from '../storage/backup';
import { linkingPubKeyHex } from '../keys';
import type {BackupPart, BackupPartPayload, NostrEvent} from './events'
import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
import {
BACKUP_EVENT_KIND,
BACKUP_PARTS,
@@ -21,18 +21,18 @@ import {
deriveBackupKey,
dTagOf,
parseBackupEvent,
} from './events'
import type {BackupTransport} from './transport'
import {defaultTransport} from './transport'
} from './events';
import type { BackupTransport } from './transport';
import { defaultTransport } from './transport';
export type PublishBackupOptions = {
transport?: BackupTransport
createdAt?: number
}
transport?: BackupTransport;
createdAt?: number;
};
export type PublishBackupResult = {
published: BackupPart[]
}
published: BackupPart[];
};
// builds and publishes one event per part present in `parts`
export const publishBackup = async (
@@ -41,26 +41,26 @@ export const publishBackup = async (
relays: string[],
options: PublishBackupOptions = {},
): Promise<PublishBackupResult> => {
const at = options.createdAt ?? Math.floor(Date.now() / 1000)
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined)
if (present.length === 0) return {published: []}
const transport = options.transport ?? (await defaultTransport())
const published: BackupPart[] = []
const at = options.createdAt ?? Math.floor(Date.now() / 1000);
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
if (present.length === 0) return { published: [] };
const transport = options.transport ?? (await defaultTransport());
const published: BackupPart[] = [];
for (const part of present) {
const payload = parts[part]
if (payload === undefined) continue
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at))
published.push(part)
const payload = parts[part];
if (payload === undefined) continue;
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
published.push(part);
}
return {published}
}
return { published };
};
export type FetchBackupOptions = {
// the pubkey alone can fetch the ciphertext, but only the key holder
// reads it - decryption is part of fetching
secretKey: Uint8Array
transport?: BackupTransport
}
secretKey: Uint8Array;
transport?: BackupTransport;
};
// fetches the newest valid event per d-tag and decrypts it. Honest relays
// already replace addressable events, but a stale or misbehaving relay may
@@ -71,49 +71,49 @@ export const fetchBackup = async (
relays: string[],
options: FetchBackupOptions,
): Promise<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport())
const transport = options.transport ?? (await defaultTransport());
const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND],
authors: [pubkey],
})
const byTag = new Map<string, NostrEvent[]>()
});
const byTag = new Map<string, NostrEvent[]>();
for (const event of events) {
const d = dTagOf(event)
if (!d) continue
const candidates = byTag.get(d)
if (candidates) candidates.push(event)
else byTag.set(d, [event])
const d = dTagOf(event);
if (!d) continue;
const candidates = byTag.get(d);
if (candidates) candidates.push(event);
else byTag.set(d, [event]);
}
const parts: Partial<BackupPartPayload> = {}
const parts: Partial<BackupPartPayload> = {};
for (const candidates of byTag.values()) {
// a hostile relay may serve a tampered "newest" copy - walk
// newest-first and take the first that validates and decrypts
candidates.sort((a, b) => b.created_at - a.created_at)
candidates.sort((a, b) => b.created_at - a.created_at);
for (const event of candidates) {
const parsed = parseBackupEvent(options.secretKey, event)
if (!parsed) continue
const parsed = parseBackupEvent(options.secretKey, event);
if (!parsed) continue;
switch (parsed.part) {
case 'notes':
parts.notes = parsed.bearers
break
parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
break;
case 'mints':
parts.mints = parsed.trustedMints
break
parts.mints = parsed.trustedMints;
break;
case 'settings':
parts.settings = parsed.settings
break
parts.settings = parsed.settings;
break;
}
break
break;
}
}
return parts
}
return parts;
};
export type NostrRestoreResult = RestoreResult & {
// which d-tags carried a valid payload at all - lets the caller
// distinguish "nothing backed up yet" from "restored an empty wallet"
found: BackupPart[]
}
found: BackupPart[];
};
// The restore path end to end: re-derive the backup key from the linking
// key (the holder already re-entered the seed phrase to get that far),
@@ -124,26 +124,27 @@ export type NostrRestoreResult = RestoreResult & {
export const restoreFromNostr = async (
linkingPrivKey: Uint8Array,
relays: string[],
options: {transport?: BackupTransport} = {},
options: { transport?: BackupTransport } = {},
): Promise<NostrRestoreResult> => {
const secretKey = deriveBackupKey(linkingPrivKey)
const secretKey = deriveBackupKey(linkingPrivKey);
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey,
transport: options.transport,
})
});
const result = await applyBackup(
{
type: 'sattle-backup',
version: 1,
version: 2,
createdAt: Date.now(),
bearers: parts.notes ?? [],
bearers: parts.notes?.bearers ?? [],
nextByHost: parts.notes?.nextByHost ?? {},
trustedMints: parts.mints,
settings: parts.settings,
},
linkingPubKeyHex(linkingPrivKey),
)
);
return {
...result,
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
}
}
};
};
+12 -12
View File
@@ -3,30 +3,30 @@
// lazily: merely importing the backup module must never open (or even
// reference) a WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter'
import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './events'
import type { NostrEvent } from './events';
export type {NostrFilter}
export type { NostrFilter };
// publish throws when the event was accepted NOWHERE
export type BackupTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void>
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>
}
publish: (relays: string[], event: NostrEvent) => Promise<void>;
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>;
};
export const defaultTransport = async (): Promise<BackupTransport> => {
const {SimplePool} = await import('nostr-tools/pool')
const pool = new SimplePool()
const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool();
return {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.')
throw new Error('No relay accepted the backup event.');
}
},
fetch: (relays, filter) => pool.querySync(relays, filter),
}
}
};
};
+101 -78
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,114 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY);
const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below
{id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
]
const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}]
const settings = {defaultMint: 'mint.example'}
{ id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
];
const notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
const mints = [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true }];
const settings = { defaultMint: 'mint.example' };
it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000)
expect(events).toHaveLength(3)
const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
expect(events).toHaveLength(3);
expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND,
])
]);
expect(events.map((e) => e.tags)).toEqual([
[['d', 'notes']],
[['d', 'mints']],
[['d', 'settings']],
])
expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true)
]);
expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true);
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes',
bearers: records,
})
nextByHost: { 'mint.example': 3 },
});
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints',
trustedMints: mints,
})
});
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings',
settings,
})
})
});
});
it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', records)
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
expect(event.content).not.toContain('ciphertext')
})
const event = buildBackupEvent(secretKey, 'notes', notes);
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
expect(event.content).not.toContain('ciphertext');
});
it('builds events only for the parts present', () => {
const events = buildBackupEvents(secretKey, {settings}, 1000)
expect(events).toHaveLength(1)
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']])
})
const events = buildBackupEvents(secretKey, { settings }, 1000);
expect(events).toHaveLength(1);
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
});
it('rejects a payload encrypted for a different key', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull()
})
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
});
it('rejects the wrong kind', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull()
})
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
});
it('rejects an unknown d-tag', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull()
})
const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
});
it('rejects a modified ciphertext - the signature no longer matches', () => {
const event = buildBackupEvent(secretKey, 'settings', settings)
const tampered = {...event, content: tamperContent(event.content)}
expect(parseBackupEvent(secretKey, tampered)).toBeNull()
})
const event = buildBackupEvent(secretKey, 'settings', settings);
const tampered = { ...event, content: tamperContent(event.content) };
expect(parseBackupEvent(secretKey, tampered)).toBeNull();
});
it('rejects an event signed by a different key', () => {
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings)
expect(parseBackupEvent(secretKey, foreign)).toBeNull()
})
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
expect(parseBackupEvent(secretKey, foreign)).toBeNull();
});
it('rejects a validly signed event whose payload is not a backup envelope', () => {
// a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const event = finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'settings']],
content: nip44v2.encrypt(JSON.stringify({version: 2, settings: {}}), conversationKey),
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
},
secretKey,
)
expect(parseBackupEvent(secretKey, event)).toBeNull()
})
})
);
expect(parseBackupEvent(secretKey, event)).toBeNull();
});
it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const build = (payload: unknown): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'notes']],
content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
},
secretKey,
);
// v1 notes carried bare bearer arrays and no counters - pre-funds-document
expect(parseBackupEvent(secretKey, build({ version: 1, bearers: records }))).toBeNull();
expect(parseBackupEvent(secretKey, build({ version: 2, bearers: records }))).toBeNull();
// a v2 envelope whose counters are malformed is rejected, not coerced
expect(
parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })),
).toBeNull();
});
});
+39 -39
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,21 +78,21 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => {
@@ -101,12 +101,12 @@ describe('deriveBackupKey', () => {
// derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
)
})
);
});
it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY)),
)
})
})
);
});
});
+107 -104
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,134 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
})
});
publisher.schedule({settings: {defaultMint: 'a'}})
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.schedule({settings: {defaultMint: 'c'}})
await vi.advanceTimersByTimeAsync(999)
expect(published).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(published).toEqual([{settings: {defaultMint: 'c'}}])
})
publisher.schedule({ settings: { defaultMint: 'a' } });
publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.schedule({ settings: { defaultMint: 'c' } });
await vi.advanceTimersByTimeAsync(999);
expect(published).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
});
it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
})
});
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}])
})
publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000);
publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
{ settings: { defaultMint: 'a' } },
{ settings: { defaultMint: 'b' } },
]);
});
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {}
const holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
published.push(p);
// a local change lands while the first publish is in flight
if (published.length === 1) {
holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}})
holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
}
return Promise.resolve()
return Promise.resolve();
},
delayMs: 1000,
})
holder.publisher = publisher
});
holder.publisher = publisher;
publisher.schedule({settings: {defaultMint: 'first'}})
await vi.advanceTimersByTimeAsync(1000)
publisher.schedule({ settings: { defaultMint: 'first' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([
{settings: {defaultMint: 'first'}},
{settings: {defaultMint: 'mid-flight'}},
])
})
{ settings: { defaultMint: 'first' } },
{ settings: { defaultMint: 'mid-flight' } },
]);
});
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
published.push(p);
return Promise.resolve();
},
delayMs: 60_000,
})
});
publisher.schedule({settings: {defaultMint: 'a'}})
await publisher.flush()
expect(published).toEqual([{settings: {defaultMint: 'a'}}])
publisher.schedule({ settings: { defaultMint: 'a' } });
await publisher.flush();
expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.cancel()
await vi.advanceTimersByTimeAsync(60_000)
expect(published).toHaveLength(1)
})
publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.cancel();
await vi.advanceTimersByTimeAsync(60_000);
expect(published).toHaveLength(1);
});
it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const errors: unknown[] = []
let failing = true
vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [];
const errors: unknown[] = [];
let failing = true;
const publisher = createBackupPublisher({
publish: (p) => {
if (failing) return Promise.reject(new Error('relay down'))
published.push(p)
return Promise.resolve()
if (failing) return Promise.reject(new Error('relay down'));
published.push(p);
return Promise.resolve();
},
delayMs: 1000,
onError: (e) => {
errors.push(e)
errors.push(e);
},
})
});
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([])
expect(errors).toHaveLength(1)
publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([]);
expect(errors).toHaveLength(1);
failing = false
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([{settings: {defaultMint: 'b'}}])
})
})
failing = false;
publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
});
});
+183 -104
View File
@@ -2,14 +2,19 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import {
deriveBearerAesKey,
deriveWalletMaterial,
linkingPubKeyHex,
saveWalletMaterial,
} from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +26,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +35,30 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
readNextByHost,
} from './storage';
import { readFundsDocument, writeFundsDocument } from './storage/bearers';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
// full v2 material for the fixed harness linking key (real BIP-32 cash root)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
};
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +69,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,146 +92,211 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A: one note, one trusted mint, one setting - all published
await saveLinkingKey(LINKING_KEY)
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID})
persistSettings({defaultMint: 'mint.example'})
await saveWalletMaterial(MATERIAL);
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
persistSettings({ defaultMint: 'mint.example' });
await publishBackup(
secretKey,
{
notes: readEncryptedBearers(),
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
mints: readTrustedMints(OWNER_ID),
settings: loadSettings(),
},
RELAYS,
{transport},
)
{ transport },
);
// device B: the same seed on empty storage
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
stubLocalStorage();
await saveWalletMaterial(MATERIAL);
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes', 'mints', 'settings'])
expect(result.added).toBe(1)
expect(result.trustedMintsAdded).toBe(1)
expect(result.settingsRestored).toBe(true)
expect(result.found).toEqual(['notes', 'mints', 'settings']);
expect(result.added).toBe(1);
expect(result.trustedMintsAdded).toBe(1);
expect(result.settingsRestored).toBe(true);
// the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path
expect(result.linkingKeyRestored).toBe(false)
expect(result.linkingKeyRestored).toBe(false);
// the note decrypts under this device's bearer key - same seed
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})])
expect(loadSettings()).toEqual({defaultMint: 'mint.example'})
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
// a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged
expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true)
})
expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true);
});
it('unions records by id and lets a spent copy win after decrypt', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
transport,
createdAt: 1000,
})
await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport,
createdAt: 1000,
},
);
// device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store
stubLocalStorage()
await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
transport,
createdAt: 2000,
})
stubLocalStorage();
await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport,
createdAt: 2000,
},
);
// device C restores from the final published state
stubLocalStorage()
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
stubLocalStorage();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed
expect(result.added).toBe(2)
expect(result.added).toBe(2);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['rec-a', 'rec-b'])
).toEqual(['rec-a', 'rec-b']);
// after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money
const merged = mergeBearers([], await loadBearers(aesKey))
expect(merged).toHaveLength(1)
expect(requiredValue(merged[0]).id).toBe('rec-b')
expect(requiredValue(merged[0]).spent).toBe(true)
})
const merged = mergeBearers([], await loadBearers(aesKey));
expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('rec-b');
expect(requiredValue(merged[0]).spent).toBe(true);
});
it('never overwrites local state: records union, settings keep local values', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
await persistBearer(aesKey, bearerFixture({id: 'remote'}))
persistSettings({defaultMint: 'remote.example'})
await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
persistSettings({ defaultMint: 'remote.example' });
await publishBackup(
secretKey,
{notes: readEncryptedBearers(), settings: loadSettings()},
{
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
settings: loadSettings(),
},
RELAYS,
{transport, createdAt: 1000},
)
{ transport, createdAt: 1000 },
);
// this device already has its own wallet state
stubLocalStorage()
stubLocalStorage();
await persistBearer(
aesKey,
bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
)
persistSettings({defaultMint: 'local.example'})
bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
);
persistSettings({ defaultMint: 'local.example' });
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.added).toBe(1)
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.added).toBe(1);
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['local', 'remote'])
expect(result.settingsRestored).toBe(false)
expect(loadSettings()).toEqual({defaultMint: 'local.example'})
})
).toEqual(['local', 'remote']);
expect(result.settingsRestored).toBe(false);
expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
});
it('reports nothing found when the relays hold no backup', async () => {
const {transport} = createRecordingTransport()
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.found).toEqual([])
expect(result.added).toBe(0)
})
})
const { transport } = createRecordingTransport();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual([]);
expect(result.added).toBe(0);
});
it('merges relayed counters upward-only and never carries pending state', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY);
const { transport } = createRecordingTransport();
// device A: counter at 5 for mint.example, one note, all published
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
writeFundsDocument({
version: 2,
bearers: readEncryptedBearers(),
pending: [
{
id: 'device-local-pending',
kind: 'cash-allocation',
phase: 'reserved',
iv: '00',
ciphertext: '00',
},
],
nextByHost: { 'mint.example': 5 },
revision: 3,
});
await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{ transport, createdAt: 1000 },
);
// device B: same seed, a HIGHER local counter for one host and a lower
// incoming one for the other; the pending record must not cross over
stubLocalStorage();
writeFundsDocument({
version: 2,
bearers: [],
pending: [],
nextByHost: { 'mint.example': 2, 'local-only.example': 7 },
revision: 1,
});
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes']);
expect(result.added).toBe(1);
expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5,
'local-only.example': 7,
});
expect(readFundsDocument().pending).toEqual([]);
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
});
});
+5 -5
View File
@@ -1,5 +1,5 @@
import './nostrBackup.keys.cases'
import './nostrBackup.codec.cases'
import './nostrBackup.transport.cases'
import './nostrBackup.restore.cases'
import './nostrBackup.publisher.cases'
import './nostrBackup.keys.cases';
import './nostrBackup.codec.cases';
import './nostrBackup.transport.cases';
import './nostrBackup.restore.cases';
import './nostrBackup.publisher.cases';
+70 -67
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import {
BACKUP_EVENT_KIND,
backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
} from './nostrBackup';
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import {
loadBearers,
loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './storage';
import type { Bearer } from './types';
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
});
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
transport: BackupTransport;
events: NostrEvent[];
} => {
const events: NostrEvent[] = []
const events: NostrEvent[] = [];
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
events.push(event);
return Promise.resolve();
},
fetch: (_relays, filter) =>
Promise.resolve(
@@ -78,83 +78,86 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
};
return { transport, events };
};
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => {
stubLocalStorage()
})
stubLocalStorage();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport()
const { transport } = createRecordingTransport();
const parts = {
notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}],
mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}],
settings: {defaultMint: 'mint.example'},
}
const published = await publishBackup(secretKey, parts, RELAYS, {transport})
expect(published.published).toEqual(['notes', 'mints', 'settings'])
notes: {
bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
nextByHost: { 'mint.example': 2 },
},
mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
settings: { defaultMint: 'mint.example' },
};
const published = await publishBackup(secretKey, parts, RELAYS, { transport });
expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
})
expect(fetched).toEqual(parts)
})
});
expect(fetched).toEqual(parts);
});
it('publishes nothing when no parts are given', async () => {
const {transport, events} = createRecordingTransport()
const result = await publishBackup(secretKey, {}, RELAYS, {transport})
expect(result.published).toEqual([])
expect(events).toEqual([])
})
const { transport, events } = createRecordingTransport();
const result = await publishBackup(secretKey, {}, RELAYS, { transport });
expect(result.published).toEqual([]);
expect(events).toEqual([]);
});
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
const {transport} = createRecordingTransport()
await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, {
const { transport } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport,
createdAt: 1000,
})
await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, {
});
await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
transport,
createdAt: 2000,
})
});
// the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
})
expect(parts.settings).toEqual({defaultMint: 'new.example'})
})
});
expect(parts.settings).toEqual({ defaultMint: 'new.example' });
});
it('falls back to an older valid copy when the newest event is tampered', async () => {
const {transport, events} = createRecordingTransport()
await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, {
const { transport, events } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport,
createdAt: 1000,
})
const latest = requiredValue(events[0])
});
const latest = requiredValue(events[0]);
events.push({
...latest,
content: tamperContent(latest.content),
created_at: 3000,
})
});
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey,
transport,
})
expect(parts.settings).toEqual({defaultMint: 'mint.example'})
})
})
});
expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
});
});
+7 -7
View File
@@ -25,18 +25,18 @@ export {
buildBackupEvent,
buildBackupEvents,
parseBackupEvent,
} from './nostr/events'
export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events'
} from './nostr/events';
export type { NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent } from './nostr/events';
export type {BackupTransport, NostrFilter} from './nostr/transport'
export type { BackupTransport, NostrFilter } from './nostr/transport';
export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync'
export { publishBackup, fetchBackup, restoreFromNostr } from './nostr/sync';
export type {
PublishBackupOptions,
PublishBackupResult,
FetchBackupOptions,
NostrRestoreResult,
} from './nostr/sync'
} from './nostr/sync';
export {createBackupPublisher} from './nostr/publisher'
export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher'
export { createBackupPublisher } from './nostr/publisher';
export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
+48 -48
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,90 +55,90 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
'wss://relay-a.example',
'wss://relay-b.example/path?q=1',
])
]);
expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
)
);
expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
})
})
});
});
it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull()
expect(parseConnectionString('https://example.com')).toBeNull()
expect(parseConnectionString('not a uri')).toBeNull();
expect(parseConnectionString('https://example.com')).toBeNull();
// missing secret
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
).toBeNull()
).toBeNull();
// missing relay
expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
).toBeNull()
).toBeNull();
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
),
).toBeNull()
})
).toBeNull();
});
it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
})
const parsed = parseConnectionString(connection.connectionString)
});
const parsed = parseConnectionString(connection.connectionString);
expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32),
relays: RELAYS,
})
});
// the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored
const records = readNwcConnections(OWNER_ID)
expect(records).toHaveLength(1)
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY)
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
})
})
const records = readNwcConnections(OWNER_ID);
expect(records).toHaveLength(1);
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
});
});
describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
)
);
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
)
})
);
});
it('derives a distinct key per client and per linking key', () => {
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY));
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
base,
)
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base)
})
);
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base);
});
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET,
})
});
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
)
})
})
);
});
});
+26 -26
View File
@@ -4,29 +4,29 @@
// no longer possible. What must not happen is any stale-owner WRITE:
// no bearer changeset, no budget debit, no success response.
import {describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import {readNwcConnections} from './nwc'
import { readNwcConnections } from './nwc';
import {
methodRequest,
OTHER_LINKING_KEY,
OTHER_OWNER_ID,
OWNER_ID,
waitFor,
} from './nwc.testProtocol'
import {makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: post-boundary stale-owner fencing', () => {
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
// Given a running service whose next mint call coincides with a second
// tab installing its own wallet (the first fetch is the melt itself:
// the exact-match carve performs no mint call of its own)
const m = await mint()
let swapped = false
const m = await mint();
let swapped = false;
const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) {
swapped = true
swapped = true;
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
@@ -35,32 +35,32 @@ describe('service: post-boundary stale-owner fencing', () => {
ownerId: OTHER_OWNER_ID,
version: 1,
}),
)
);
}
return fetch(input, init)
}
const {relay, walletServicePubkey, state, stop} = await startTestService({
kit: {fetch: swappingFetch},
})
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)]
return fetch(input, init);
};
const { relay, walletServicePubkey, state, stop } = await startTestService({
kit: { fetch: swappingFetch },
});
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
// When the pay flows past the irreversible boundary and reaches the
// first owner-bound persistence (the conservative budget debit)
relay.emit(request)
await waitFor(() => state.errors.length > 0)
relay.emit(request);
await waitFor(() => state.errors.length > 0);
// Then the melt genuinely happened (we are past the boundary) ...
expect(swapped).toBe(true)
expect(m.state.noteState('d7'.repeat(32))).toBe('burned')
expect(swapped).toBe(true);
expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
// ... but nothing under the stale owner moved: no budget debit, no
// bearer changeset, no success response (the failure surfaced through
// onError instead)
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0)
expect(state.changesets).toEqual([])
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
await stop()
})
})
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
await stop();
});
});
+122 -229
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredString, requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,21 +55,36 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import { invoiceRetireAfter } from './nwc/invoices';
export const SIGNED_BOLT11_FIXTURE =
'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz';
describe('service: make_invoice / lookup_invoice', () => {
it('issues an invoice, settles it in the background, and reports the preimage', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
it('derives a safe retirement time from a signed BOLT11 invoice', () => {
expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0);
});
it('rejects a retirement time outside the safe integer range', () => {
const timestamp = 'q'.repeat(7);
const expiryTag = `xqv${'l'.repeat(12)}`;
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
});
it('stages an output, settles it in the background, and reports the preimage', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
description: 'nwc test',
expiry: 3600,
})
expect(made.error).toBeNull()
});
expect(made.error).toBeNull();
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
@@ -77,253 +92,131 @@ describe('service: make_invoice / lookup_invoice', () => {
description: 'nwc test',
created_at: nowSeconds(),
expires_at: nowSeconds() + 3600,
})
const invoice = requiredValue(made.result).invoice
});
const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice')
throw new TypeError('make_invoice did not return an invoice');
}
const paymentHash = made.result?.payment_hash
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
expect(invoice).toMatch(/^lnbc/)
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
expect(invoice).toMatch(/^lnbc/);
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/);
const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')));
expect(staged.verified).toBe(false);
expect(staged.callback).toBe('');
// before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(pending.error).toBeNull()
expect(pending.result?.state).toBe('pending')
expect(pending.result?.preimage).toBeUndefined()
});
expect(pending.error).toBeNull();
expect(pending.result?.state).toBe('pending');
expect(pending.result?.preimage).toBeUndefined();
// the "payer" pays the invoice; the background claim settles and
// mints the note
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleRes.ok).toBe(true);
await waitFor(() => state.bearers.some((bearer) => bearer.verified));
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
expect(settled.result?.settled_at).toBe(nowSeconds())
const preimage = requiredString(settled.result?.preimage)
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
});
expect(settled.error).toBeNull();
expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds());
const preimage = requiredString(settled.result?.preimage);
expect(preimage).toMatch(/^[0-9a-f]{64}$/);
// the minted note was claimed AND rotated before settlement was
// recorded: the preimage the client just learned is a burned secret,
// and the wallet's fresh note is the only live one
expect(m.state.noteState(preimage)).toBe('burned')
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
expect(noteK1(minted.url)).not.toBe(preimage)
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding')
await stop()
})
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()
})
// The staged record was updated in place before settlement became
// visible. The payment preimage is only a receipt and keys no note.
expect(m.state.noteState(preimage)).toBeNull();
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
expect(minted.amount).toBe(21_000);
expect(minted.verified).toBe(true);
expect(noteK1(minted.url)).not.toBe(preimage);
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
await stop();
});
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true})
const commit = deferred()
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleResponse.ok).toBe(true)
await waitFor(() => commitStarted)
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(pending.result?.state).toBe('pending')
expect(pending.result?.settled_at).toBeUndefined()
expect(pending.result?.preimage).toBeUndefined()
});
expect(pending.result?.state).toBe('pending');
expect(pending.result?.settled_at).toBeUndefined();
expect(pending.result?.preimage).toBeUndefined();
commit.resolve()
await waitFor(() => state.changesets.length === 1)
commit.resolve();
await waitFor(() => state.changesets.length === 1);
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
expect(settled.result?.settled_at).toBe(nowSeconds())
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/)
await stop()
})
});
expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds());
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
await stop();
});
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
const m = await mint({testHooks: true})
const commit = deferred()
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleResponse.ok).toBe(true)
await waitFor(() => commitStarted)
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false
let stopped = false;
const firstStop = stop().then(() => {
stopped = true
return state.changesets.length
})
const repeatedStop = stop()
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
stopped = true;
return state.changesets.length;
});
const repeatedStop = stop();
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false)
expect(state.changesets).toHaveLength(0)
expect(stopped).toBe(false);
expect(state.changesets).toHaveLength(0);
commit.resolve()
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop])
expect(changesetsAtStop).toBe(1)
expect(state.changesets).toHaveLength(1)
})
})
commit.resolve();
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
expect(changesetsAtStop).toBe(1);
expect(state.changesets).toHaveLength(1);
});
});
+211 -125
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,187 +55,273 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import { SIGNED_BOLT11_FIXTURE } from './nwc.invoice-a.cases';
describe('service: make_invoice / lookup_invoice (continued)', () => {
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
const m = await mint({testHooks: true})
const commit = deferred()
const commitError = new Error('invoice commit rejected during stop')
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const commit = deferred();
const commitError = new Error('invoice commit rejected during stop');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleResponse.ok).toBe(true)
await waitFor(() => commitStarted)
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
let stopped = false
let stopped = false;
const stopping = stop().then(() => {
stopped = true
return state.errors.length
})
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
expect(stopped).toBe(false)
stopped = true;
return state.errors.length;
});
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
commit.reject(commitError)
expect(await stopping).toBe(1)
expect(state.errors).toEqual([commitError])
expect(state.changesets).toHaveLength(0)
await stop()
})
commit.reject(commitError);
expect(await stopping).toBe(1);
expect(state.errors).toEqual([commitError]);
expect(state.changesets).toHaveLength(0);
await stop();
});
it('does not block stop on an invoice whose claim is still polling', async () => {
// an unpaid invoice's claim poll can legally run for minutes (the
// client pays whenever it pays) - stop must interrupt the wait, not
// sit on it; a settlement that already REACHED the commit phase is
// still awaited (see the drain tests above)
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000},
})
claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
})
expect(made.error).toBeNull()
});
expect(made.error).toBeNull();
// nobody pays the invoice; the claim keeps polling. stop must resolve
// promptly regardless (an un-interrupted stop would wait out the
// whole 60s claim budget)
await stop()
expect(state.changesets).toHaveLength(0)
expect(state.errors).toHaveLength(0)
})
await stop();
expect(state.changesets).toHaveLength(0);
expect(state.errors).toHaveLength(0);
});
it('does not start invoice settlement after stop begins during preparation', async () => {
const m = await mint({testHooks: true})
const prepare = deferred()
let prepareStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const prepare = deferred();
let prepareStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
kit: {
fetch: async (input, init) => {
prepareStarted = true
await prepare.promise
return fetch(input, init)
prepareStarted = true;
await prepare.promise;
return fetch(input, init);
},
},
})
});
const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
relay.emit(request)
await waitFor(() => prepareStarted)
});
relay.emit(request);
await waitFor(() => prepareStarted);
let stopped = false
let stopped = false;
const stopping = stop().then(() => {
stopped = true
})
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve()
expect(stopped).toBe(false)
stopped = true;
});
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false);
prepare.resolve()
await stopping
prepare.resolve();
await stopping;
expect(state.changesets).toHaveLength(0)
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
})
expect(state.changesets).toHaveLength(0);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
});
it('marks a paid invoice failed when its bearer commit rejects', async () => {
const m = await mint({testHooks: true})
const commit = deferred()
const commitError = new Error('invoice bearer commit failed')
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ testHooks: true });
const commit = deferred();
const commitError = new Error('invoice bearer commit failed');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
const paymentHash = made.result?.payment_hash
});
const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
throw new TypeError('make_invoice did not return a payment hash');
}
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleResponse.ok).toBe(true)
await waitFor(() => commitStarted)
commit.reject(commitError)
await waitFor(() => state.errors.length === 1)
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted);
commit.reject(commitError);
await waitFor(() => state.errors.length === 1);
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(failed.result?.state).toBe('failed')
expect(failed.result?.settled_at).toBeUndefined()
expect(failed.result?.preimage).toBeUndefined()
expect(state.errors).toEqual([commitError])
await stop()
})
});
expect(failed.result?.state).toBe('failed');
expect(failed.result?.settled_at).toBeUndefined();
expect(failed.result?.preimage).toBeUndefined();
expect(state.errors).toEqual([commitError]);
await stop();
});
it('finds an invoice by its invoice string too', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, stop} = await startTestService({
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10},
})
claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
});
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000,
})
const invoice = made.result?.invoice
});
const invoice = made.result?.invoice;
if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice')
throw new TypeError('make_invoice did not return an invoice');
}
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: invoice.toUpperCase(),
})
expect(found.error).toBeNull()
expect(found.result?.payment_hash).toBe(made.result?.payment_hash)
await stop()
})
});
expect(found.error).toBeNull();
expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
await stop();
});
it('answers an unknown invoice with NOT_FOUND', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32),
})
expect(response.error?.code).toBe('NOT_FOUND')
await stop()
})
});
expect(response.error?.code).toBe('NOT_FOUND');
await stop();
});
it('answers make_invoice without a default mint with INTERNAL', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({
const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: null,
})
});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
expect(response.error?.code).toBe('INTERNAL')
await stop()
})
});
expect(response.error?.code).toBe('INTERNAL');
await stop();
});
it('rejects invoice creation when the durable pending-output cap is reached', async () => {
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: 'mint@example.com',
});
for (let index = 0; index < 20; index += 1) {
state.bearers.push({
id: `pending-${index}`,
url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`,
callback: '',
amount: 0,
verified: false,
pendingMint: {},
createdAt: index,
updatedAt: index,
});
}
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
});
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
expect(state.bearers).toHaveLength(20);
await stop();
});
it('removes staged outputs when invoice preparation fails before publication', async () => {
const m = await mint({ testHooks: true });
let rejectQuotes = true;
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
kit: {
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (rejectQuotes && url.includes('comment=')) {
return Promise.resolve(new Response('quote failed', { status: 500 }));
}
return fetch(input, init);
},
},
});
for (let attempt = 0; attempt < 20; attempt += 1) {
const failed = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(failed.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0);
}
rejectQuotes = false;
const made = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(made.error).toBeNull();
expect(state.bearers).toHaveLength(1);
await stop();
});
it('removes an unpublished stage when retirement persistence fails', async () => {
const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
kit: {
fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('comment=')) {
return Promise.resolve(
new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
return fetch(input, init);
},
},
});
const response = await call(relay, walletServicePubkey, 'make_invoice', { amount: 25_000 });
expect(response.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0);
await stop();
});
it('answers a make_invoice with a bad amount with OTHER', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5,
})
expect(response.error?.code).toBe('OTHER')
await stop()
})
})
});
expect(response.error?.code).toBe('OTHER');
await stop();
});
});
+86 -64
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,35 +55,46 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service ownership', () => {
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
})
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
storeForeignConnection(foreign.record)
const relay = createFakeRelay()
});
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
storeForeignConnection(foreign.record);
const relay = createFakeRelay();
const service = await startService(LINKING_KEY, {
assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
persistMintOutput: () => Promise.reject(new Error('unused')),
discardMintOutput: () => Promise.reject(new Error('unused')),
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
finalizeMintOutput: () => Promise.reject(new Error('unused')),
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
})
});
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
expect(relay.subscriptionCount()).toBe(1)
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
expect(service.connections).not.toContainEqual(foreign)
await service.stop()
})
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
expect(service.connections).not.toContainEqual(foreign);
await service.stop();
});
it('ignores foreign records handed in through the records snapshot', async () => {
// the injected-records path bypasses storage, so the service's own
@@ -91,69 +102,80 @@ describe('service ownership', () => {
// snapshot from a previous wallet must not be served)
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 0,
})
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
const relay = createFakeRelay()
});
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
const relay = createFakeRelay();
const service = await startService(
LINKING_KEY,
{
assertCurrentOwner: () => undefined,
allocateOutputSecrets: () => Promise.resolve([]),
mintSignatureKeys: () => [],
recoverPendingMints: () => Promise.resolve(),
reserveMintOutputSource: () => Promise.resolve(),
finalizePaymentReturn: () => Promise.resolve(),
getBearers: () => [],
getDefaultMint: () => null,
persistMintOutput: () => Promise.reject(new Error('unused')),
discardMintOutput: () => Promise.reject(new Error('unused')),
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
finalizeMintOutput: () => Promise.reject(new Error('unused')),
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
commitCarve: () => Promise.reject(new Error('unused')),
applyChangeset: () => Promise.resolve(),
transport: relay.transport,
nowSeconds,
},
[current.record, foreign.record],
)
);
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
expect(relay.subscriptionCount()).toBe(1)
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1);
// the foreign snapshot record must not be persisted for the new owner
expect(readNwcConnections(OWNER_ID)).toEqual([current.record])
await service.stop()
})
})
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
await service.stop();
});
});
describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND))
expect(info.pubkey).toBe(walletServicePubkey)
expect(info.content).toContain('pay_invoice')
expect(info.content).toContain('make_invoice')
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
await stop()
})
const { relay, walletServicePubkey, stop } = await startTestService({});
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
expect(info.pubkey).toBe(walletServicePubkey);
expect(info.content).toContain('pay_invoice');
expect(info.content).toContain('make_invoice');
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
await stop();
});
it('answers get_info with the connection identity and method list', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'get_info', {})
expect(response.error).toBeNull()
expect(response.result_type).toBe('get_info')
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_info', {});
expect(response.error).toBeNull();
expect(response.result_type).toBe('get_info');
expect(response.result).toMatchObject({
alias: 'sattle',
pubkey: walletServicePubkey,
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
})
await stop()
})
});
await stop();
});
it('answers get_balance with the spendable total only', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000),
{...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true},
]
const response = await call(relay, walletServicePubkey, 'get_balance', {})
expect(response.error).toBeNull()
expect(response.result).toEqual({balance: 26_000})
await stop()
})
})
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
];
const response = await call(relay, walletServicePubkey, 'get_balance', {});
expect(response.error).toBeNull();
expect(response.result).toEqual({ balance: 26_000 });
await stop();
});
});
+119 -119
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredString, requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,159 +55,159 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice', () => {
it('applies the settled changeset before publishing success', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)]
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
expect(response.error).toBeNull()
expect(state.changesets).toHaveLength(1)
expect(state.bearers[0]?.spent).toBe(true)
await stop()
})
expect(response.error).toBeNull();
expect(state.changesets).toHaveLength(1);
expect(state.bearers[0]?.spent).toBe(true);
await stop();
});
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000,
})
});
const foreign = foreignConnectionFixture(
{maxMsat: 99_000, periodMs: 86_400_000},
{ maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000,
)
storeForeignConnection(foreign.record)
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
);
storeForeignConnection(foreign.record);
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
expect(response.error).toBeNull()
expect(requiredString(response.result?.preimage)).toHaveLength(64)
});
expect(response.error).toBeNull();
expect(requiredString(response.result?.preimage)).toHaveLength(64);
// the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
expect(requiredValue(state.bearers[0]).spent).toBe(true)
expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
expect(requiredValue(state.bearers[0]).spent).toBe(true);
// the spend was recorded against the budget, persisted
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000)
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
await stop()
})
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
await stop();
});
it('waits for a deferred bearer commit before publishing payment success', async () => {
const m = await mint()
const commit = deferred()
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
await waitFor(() => commitStarted)
relay.emit(request);
await waitFor(() => commitStarted);
// a non-awaiting engine publishes within milliseconds of the commit
// call (one local verify re-read + encrypt); this window is far wider
// than that, so an early response here can only mean a missing barrier
await new Promise((resolve) => setTimeout(resolve, 250))
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
commit.resolve()
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
await stop()
})
await new Promise((resolve) => setTimeout(resolve, 250));
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
commit.resolve();
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
await stop();
});
it('reports a rejected bearer commit without publishing payment success', async () => {
const m = await mint()
const commit = deferred()
const commitError = new Error('bearer commit failed')
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const commit = deferred();
const commitError = new Error('bearer commit failed');
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
await waitFor(() => commitStarted)
commit.reject(commitError)
relay.emit(request);
await waitFor(() => commitStarted);
commit.reject(commitError);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
)
);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
expect(state.errors).toEqual([commitError])
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
expect(state.errors).toEqual([commitError]);
// the conservative budget debit is persisted separately from bearer
// storage and deliberately NOT rolled back: the budget was debited,
// the bearer was never locked spent
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
expect(state.bearers[0]?.spent).toBeUndefined()
await stop()
})
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
expect(state.bearers[0]?.spent).toBeUndefined();
await stop();
});
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
const m = await mint()
const commit = deferred()
let commitStarted = false
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const commit = deferred();
let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => {
commitStarted = true
return commit.promise
commitStarted = true;
return commit.promise;
},
})
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
await waitFor(() => commitStarted)
relay.emit(request);
await waitFor(() => commitStarted);
// stop closes subscriptions; in-flight handlers still finish (their
// changesets hold money) - a repeated stop interrupts nothing twice
let stopped = false
let stopped = false;
const firstStop = stop().then(() => {
stopped = true
})
const repeatedStop = stop()
await Promise.resolve()
expect(stopped).toBe(false)
stopped = true;
});
const repeatedStop = stop();
await Promise.resolve();
expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes
expect(relay.subscriptionCount()).toBe(0)
expect(relay.subscriptionCount()).toBe(0);
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {})
relay.emitAfterClose(afterStop)
await new Promise((resolve) => setTimeout(resolve, 25))
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull()
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(afterStop);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
commit.resolve()
await Promise.all([firstStop, repeatedStop])
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
expect(state.bearers[0]?.spent).toBe(true)
})
commit.resolve();
await Promise.all([firstStop, repeatedStop]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
expect(state.bearers[0]?.spent).toBe(true);
});
it('rejects a stale saved owner before touching the mint', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)]
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
@@ -216,20 +216,20 @@ describe('service: pay_invoice', () => {
ownerId: OTHER_OWNER_ID,
version: 1,
}),
)
);
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
});
relay.emit(request)
relay.emit(request);
await waitFor(
() =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
)
);
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding')
expect(state.changesets).toEqual([])
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
await stop()
})
})
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
await stop();
});
});
+104 -77
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,104 +55,131 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice (continued)', () => {
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000,
})
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
});
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
});
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
// nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
expect(requiredValue(state.bearers[0]).spent).toBeUndefined()
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
await stop()
})
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop();
});
it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 21_000,
periodMs: 60_000,
})
});
// simulate a fully spent budget from a period that ended long ago
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0])
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]);
writeNwcConnections(OWNER_ID, [
{...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}},
])
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
{ ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
]);
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
expect(response.error).toBeNull()
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
await stop()
})
});
expect(response.error).toBeNull();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
await stop();
});
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
await stop()
})
});
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
await stop();
});
it('rejects a request amount that mismatches the invoice amount', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000,
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/match/i)
await stop()
})
});
expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/match/i);
await stop();
});
it('rejects an amount-less invoice instead of guessing', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz',
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/amount/i)
await stop()
})
});
expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/amount/i);
await stop();
});
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({meltAlwaysFails: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
// a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
const original = await makeBearer(m, '01'.repeat(32), 21_000);
state.bearers = [original];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
})
expect(response.error?.code).toBe('PAYMENT_FAILED')
});
expect(response.error?.code).toBe('PAYMENT_FAILED');
// the funds came back, re-secured: the old secret burned, a fresh one
// tracked unspent via the changeset - and no budget spend recorded
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(returned.spent).toBeUndefined()
expect(returned.amount).toBe(21_000)
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
await stop()
})
})
expect(m.state.noteState('01'.repeat(32))).toBe('burned');
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
expect(
state.bearers.find(
(bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
),
).toBeDefined();
expect(
state.bearers
.filter((bearer) => !bearer.spent)
.reduce((sum, bearer) => sum + bearer.amount, 0),
).toBe(21_000);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop();
});
it('conserves balance when a split payment fails and returns its carved funds', async () => {
const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
});
expect(response.error?.code).toBe('PAYMENT_FAILED');
expect(
state.bearers
.filter((bearer) => !bearer.spent)
.reduce((sum, bearer) => sum + bearer.amount, 0),
).toBe(30_000);
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined();
await stop();
});
});
+68 -68
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,119 +55,119 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('storage validation', () => {
it('drops malformed records instead of throwing', () => {
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{clientPubkey: 'nope'},
{ clientPubkey: 'nope' },
{
version: 1,
ownerId: OWNER_ID,
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
spent: {periodStart: 0, msat: 0},
budget: { maxMsat: 1000, periodMs: 1000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
)
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY)
})
);
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
});
it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{')
expect(readNwcConnections(OWNER_ID)).toEqual([])
})
localStorage.setItem('sattle_nwc_connections', '{{{');
expect(readNwcConnections(OWNER_ID)).toEqual([]);
});
it('returns only strictly parsed records belonging to the requested owner', () => {
const current = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET,
now: 10,
}).record
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
}).record;
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
...raw,
foreign,
{...current, ownerId: 'malformed'},
{ ...current, ownerId: 'malformed' },
{
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
relays: RELAYS,
budget: {maxMsat: 3000, periodMs: 3000},
spent: {periodStart: 0, msat: 0},
budget: { maxMsat: 3000, periodMs: 3000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 30,
},
]),
)
);
expect(readNwcConnections(OWNER_ID)).toEqual([current])
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign])
})
expect(readNwcConnections(OWNER_ID)).toEqual([current]);
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
});
it('adopts ownerless connections and enabled state only after owner proof', async () => {
await saveLinkingKey(LINKING_KEY)
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}')
await saveLinkingKey(LINKING_KEY);
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
if (typeof saved !== 'object' || saved === null) {
throw new TypeError('Expected a saved linking-key record')
throw new TypeError('Expected a saved linking-key record');
}
Reflect.deleteProperty(saved, 'ownerId')
Reflect.deleteProperty(saved, 'version')
localStorage.setItem('sattle_linking_key', JSON.stringify(saved))
Reflect.deleteProperty(saved, 'ownerId');
Reflect.deleteProperty(saved, 'version');
localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
spent: {periodStart: 0, msat: 0},
budget: { maxMsat: 1000, periodMs: 1000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 0,
},
]),
)
localStorage.setItem('sattle_nwc_enabled', 'true')
);
localStorage.setItem('sattle_nwc_enabled', 'true');
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow()
expect(readNwcConnections(OWNER_ID)).toEqual([])
expect(readNwcEnabled(OWNER_ID)).toBe(false)
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
expect(readNwcConnections(OWNER_ID)).toEqual([]);
expect(readNwcEnabled(OWNER_ID)).toBe(false);
ensureSavedKeyOwner(LINKING_KEY)
ensureSavedKeyOwner(LINKING_KEY);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 1,
enabled: true,
})
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
expect(readNwcEnabled(OWNER_ID)).toBe(true)
});
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 0,
enabled: false,
})
})
});
});
it('does not expose one wallet enabled state to another owner', () => {
writeNwcEnabled(OWNER_ID, true)
writeNwcEnabled(OWNER_ID, true);
expect(readNwcEnabled(OWNER_ID)).toBe(true)
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false)
})
expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
});
it('treats malformed owner-bearing enabled records as disabled', () => {
for (const value of [
{version: 1, ownerId: 'malformed', enabled: true},
{version: 2, ownerId: OWNER_ID, enabled: true},
{version: 1, ownerId: OWNER_ID, enabled: 'true'},
{ version: 1, ownerId: 'malformed', enabled: true },
{ version: 2, ownerId: OWNER_ID, enabled: true },
{ version: 1, ownerId: OWNER_ID, enabled: 'true' },
]) {
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value))
expect(readNwcEnabled(OWNER_ID)).toBe(false)
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
expect(readNwcEnabled(OWNER_ID)).toBe(false);
}
})
})
});
});
+9 -9
View File
@@ -1,9 +1,9 @@
import './nwc.connection.cases'
import './nwc.storage.cases'
import './nwc.ownership.cases'
import './nwc.validation.cases'
import './nwc.pay-a.cases'
import './nwc.pay-b.cases'
import './nwc.fencing.cases'
import './nwc.invoice-a.cases'
import './nwc.invoice-b.cases'
import './nwc.connection.cases';
import './nwc.storage.cases';
import './nwc.ownership.cases';
import './nwc.validation.cases';
import './nwc.pay-a.cases';
import './nwc.pay-b.cases';
import './nwc.fencing.cases';
import './nwc.invoice-a.cases';
import './nwc.invoice-b.cases';
+80 -80
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,29 +29,29 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
export const LINKING_KEY = new Uint8Array(32).fill(7)
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
export const CLIENT_SECRET = hexToBytes('11'.repeat(32))
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
export const STRANGER_SECRET = hexToBytes('22'.repeat(32))
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY)
export const LINKING_KEY = new Uint8Array(32).fill(7);
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
// never connected - the in-memory relay below stands in
export const RELAYS = ['wss://relay-a.example']
export const RELAYS = ['wss://relay-a.example'];
let NOW = 1_800_000_000
export const nowSeconds = (): number => NOW
let NOW = 1_800_000_000;
export const nowSeconds = (): number => NOW;
export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, {
@@ -60,69 +60,69 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
clientPubkey: getPublicKey(STRANGER_SECRET),
relays: RELAYS,
budget,
spent: {periodStart: now, msat: 0},
spent: { periodStart: now, msat: 0 },
createdAt: now,
})
});
export const storeForeignConnection = (record: NwcConnectionRecord): void => {
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]))
}
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
};
// an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records
export const createFakeRelay = (): {
transport: NwcTransport
published: NostrEvent[]
emit: (event: NostrEvent) => void
emitAfterClose: (event: NostrEvent) => void
subscriptionCount: () => number
transport: NwcTransport;
published: NostrEvent[];
emit: (event: NostrEvent) => void;
emitAfterClose: (event: NostrEvent) => void;
subscriptionCount: () => number;
} => {
const published: NostrEvent[] = []
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
const published: NostrEvent[] = [];
const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const transport: NwcTransport = {
publish: (_relays, event) => {
published.push(event)
return Promise.resolve()
published.push(event);
return Promise.resolve();
},
subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent}
subs.push(sub)
allSubs.push(sub)
const sub = { filter, onEvent };
subs.push(sub);
allSubs.push(sub);
return {
close: () => {
const index = subs.indexOf(sub)
if (index >= 0) subs.splice(index, 1)
const index = subs.indexOf(sub);
if (index >= 0) subs.splice(index, 1);
},
}
};
},
}
};
const deliver = (
targets: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[],
targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
event: NostrEvent,
): void => {
for (const sub of [...targets]) {
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind)
const wanted = sub.filter['#p']
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''))
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
const wanted = sub.filter['#p'];
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''));
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
}
}
const emit = (event: NostrEvent): void => deliver(subs, event)
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event)
};
const emit = (event: NostrEvent): void => deliver(subs, event);
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
return {
transport,
published,
emit,
emitAfterClose,
subscriptionCount: () => subs.length,
}
}
};
};
export type Encryption = 'nip44_v2' | 'nip04' | 'none'
export type Encryption = 'nip44_v2' | 'nip04' | 'none';
// a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret
@@ -132,8 +132,8 @@ export const clientRequest = (
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW,
): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]]
if (scheme !== 'none') tags.push(['encryption', scheme])
const tags: string[][] = [['p', walletServicePubkey]];
if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -148,8 +148,8 @@ export const clientRequest = (
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
},
CLIENT_SECRET,
)
}
);
};
export const methodRequest = (
walletServicePubkey: string,
@@ -158,31 +158,31 @@ export const methodRequest = (
scheme: Encryption = 'nip44_v2',
createdAt?: number,
): NostrEvent =>
clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt)
clientRequest(walletServicePubkey, JSON.stringify({ method, params }), scheme, createdAt);
// generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out
export const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) {
await new Promise((resolve) => setTimeout(resolve, 5))
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(cond()).toBe(true)
}
expect(cond()).toBe(true);
};
export const deferred = (): {
promise: Promise<void>
resolve: () => void
reject: (reason: Error) => void
promise: Promise<void>;
resolve: () => void;
reject: (reason: Error) => void;
} => {
let resolve = (): void => undefined
let reject = (_reason: Error): void => undefined
let resolve = (): void => undefined;
let reject = (_reason: Error): void => undefined;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return {promise, resolve, reject}
}
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
export const resetNow = (): void => {
NOW = 1_800_000_000
}
NOW = 1_800_000_000;
};
+201 -81
View File
@@ -6,13 +6,14 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,14 +30,14 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
import {isJsonObject} from './jsonParsing'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import { isJsonObject } from './jsonParsing';
import {
CLIENT_SECRET,
@@ -49,13 +50,13 @@ import {
nowSeconds,
resetNow,
waitFor,
} from './nwc.testProtocol'
import type {Encryption} from './nwc.testProtocol'
} from './nwc.testProtocol';
import type { Encryption } from './nwc.testProtocol';
export type NwcResponsePayload = {
result_type: string
error: {code: string; message: string} | null
result: Record<string, unknown> | null
}
result_type: string;
error: { code: string; message: string } | null;
result: Record<string, unknown> | null;
};
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) &&
@@ -64,7 +65,7 @@ const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
(isJsonObject(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result))
(value.result === null || isJsonObject(value.result));
export const readResponse = (
published: NostrEvent[],
@@ -73,19 +74,19 @@ export const readResponse = (
): NwcResponsePayload | null => {
const event = published.find(
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId),
)
if (!event) return null
);
if (!event) return null;
const plaintext =
scheme === 'nip44_v2'
? nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
)
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content)
const parsed: unknown = JSON.parse(plaintext)
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.')
return parsed
}
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
const parsed: unknown = JSON.parse(plaintext);
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
return parsed;
};
// drives one full request/response round trip over the fake relay
export const call = async (
@@ -95,30 +96,31 @@ export const call = async (
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2',
): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme)
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null)
return requiredValue(readResponse(relay.published, request.id, scheme))
}
const request = methodRequest(walletServicePubkey, method, params, scheme);
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
return requiredValue(readResponse(relay.published, request.id, scheme));
};
export type Mint = Awaited<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
const m = await createMockMint(options);
mints.push(m);
return m;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close()))
})
await Promise.all(mints.splice(0).map((m) => m.close()));
});
let bearerCounter = 0
let bearerCounter = 0;
let allocationCounter = 0;
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat)
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
const info = await fetchNoteInfo(url)
bearerCounter += 1
m.state.creditNote(k1, amountMsat);
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
const info = await fetchNoteInfo(url);
bearerCounter += 1;
return {
id: `bearer-${bearerCounter}`,
url,
@@ -128,80 +130,198 @@ export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promi
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
};
};
// the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret
export const startTestService = async (options: {
budgetMsat?: number
periodMs?: number
defaultMint?: string | null
linkingKey?: Uint8Array
poll?: typeof FAST_POLL
claimPoll?: typeof FAST_POLL
kit?: NwcServiceDeps['kit']
commitChangeset?: (changeset: NwcChangeset) => Promise<void>
budgetMsat?: number;
periodMs?: number;
defaultMint?: string | null;
linkingKey?: Uint8Array;
poll?: typeof FAST_POLL;
claimPoll?: typeof FAST_POLL;
kit?: NwcServiceDeps['kit'];
commitChangeset?: (changeset: NwcChangeset) => Promise<void>;
setMintOutputRetirement?: () => Promise<void>;
}): Promise<{
relay: ReturnType<typeof createFakeRelay>
walletServicePubkey: string
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]}
stop: () => Promise<void>
relay: ReturnType<typeof createFakeRelay>;
walletServicePubkey: string;
state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
stop: () => Promise<void>;
}> => {
const budgetMsat = options.budgetMsat ?? 1_000_000_000
const budgetMsat = options.budgetMsat ?? 1_000_000_000;
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000},
budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
clientSecret: CLIENT_SECRET,
now: nowSeconds() * 1000,
})
const relay = createFakeRelay()
const state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} = {
});
const relay = createFakeRelay();
const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
bearers: [],
changesets: [],
errors: [],
}
};
const service = await startService(options.linkingKey ?? LINKING_KEY, {
assertCurrentOwner: () => undefined,
// deterministic stand-in for the wallet's BIP-32 allocation path
allocateOutputSecrets: (_server, count) => {
const secrets = Array.from({ length: count }, () => {
allocationCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)));
});
return Promise.resolve(secrets);
},
// the keys a production caller serves from the trusted-mint registry:
// each created mock mint's current key plus any it retired
mintSignatureKeys: (server) =>
mints.flatMap((m) =>
new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [],
),
recoverPendingMints: () => Promise.resolve(),
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
persistMintOutput: (note) => {
bearerCounter += 1;
const bearer: Bearer = {
...note,
id: `staged-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
};
state.bearers.push(bearer);
return Promise.resolve(bearer);
},
discardMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve();
},
setMintOutputRetirement: (staged, retireAfter) => {
const override = options.setMintOutputRetirement?.();
if (override) return override;
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'));
const updated: Bearer = {
...staged,
pendingMint: { ...staged.pendingMint, retireAfter },
updatedAt: Date.now(),
};
state.bearers[index] = updated;
return Promise.resolve();
},
reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'));
source.spent = true;
state.bearers[index] = {
...state.bearers[index]!,
pendingMint: {
...state.bearers[index]!.pendingMint,
sourceBearerId: sourceId,
sourceRecoverySecret: recoverySecret,
},
};
return Promise.resolve();
},
finalizeMintOutput: async (staged, note) => {
const changeset: NwcChangeset = { add: [], markSpent: [] };
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.');
state.bearers[index] = { ...staged, ...note, pendingMint: undefined, updatedAt: Date.now() };
},
finalizePaymentReturn: async (staged, note) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.');
const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId;
if (sourceId) {
const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (source) source.spent = true;
}
state.bearers[index] = {
...state.bearers[index]!,
...note,
pendingMint: undefined,
updatedAt: Date.now(),
};
},
finalizeSpentMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve();
},
commitCarve: async (carve) => {
// a checkpoint is identified by its output secret: the landed phase's
// URL carries a signature the staged phase's did not have yet
const sameOutput = (url: string, noteUrl: string): boolean =>
serverOf(url) === serverOf(noteUrl) &&
noteK1(url) !== null &&
noteK1(url) === noteK1(noteUrl);
const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
(note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
);
const changeset: NwcChangeset = {
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
};
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
for (const bearer of state.bearers) {
if (changeset.markSpent.includes(bearer.id)) bearer.spent = true;
}
for (const note of additions) {
bearerCounter += 1;
state.bearers.unshift({
...note,
id: `carve-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url));
if (!committed) throw new Error('The carved note was not committed.');
return committed;
},
applyChangeset: async (changeset: NwcChangeset) => {
await options.commitChangeset?.(changeset)
state.changesets.push(changeset)
await options.commitChangeset?.(changeset);
state.changesets.push(changeset);
for (const id of changeset.markSpent) {
const bearer = state.bearers.find((b) => b.id === id)
if (bearer) bearer.spent = true
const bearer = state.bearers.find((b) => b.id === id);
if (bearer) bearer.spent = true;
}
for (const note of changeset.add) {
bearerCounter += 1
bearerCounter += 1;
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now(),
})
});
}
},
onError: (err) => {
state.errors.push(err)
state.errors.push(err);
},
transport: relay.transport,
kit: options.kit,
poll: options.poll ?? FAST_POLL,
claimPoll: options.claimPoll ?? FAST_POLL,
nowSeconds,
})
});
return {
relay,
walletServicePubkey: connection.walletServicePubkey,
state,
stop: service.stop,
}
}
};
};
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
resetNow()
})
stubLocalStorage();
await saveLinkingKey(LINKING_KEY);
resetNow();
});
+16 -16
View File
@@ -37,7 +37,7 @@
// per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts.
export type {NwcBudget, NwcBudgetSpend, NwcConnectionRecord} from './storage/nwcConnections'
export type { NwcBudget, NwcBudgetSpend, NwcConnectionRecord } from './storage/nwcConnections';
export {
clearNwcStorageForOwner,
clearUnownedNwcStorage,
@@ -46,9 +46,9 @@ export {
readNwcConnections,
removeNwcConnection,
writeNwcConnections,
} from './storage/nwcConnections'
export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled'
export type {NwcLegacyMigrationResult} from './storage/nwcConnections'
} from './storage/nwcConnections';
export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export {
buildConnectionString,
@@ -57,13 +57,13 @@ export {
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString,
} from './nwc/connection'
} from './nwc/connection';
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString,
} from './nwc/connection'
} from './nwc/connection';
export {
NWC_INFO_KIND,
@@ -75,7 +75,7 @@ export {
decryptRequest,
errResult,
okResult,
} from './nwc/protocol'
} from './nwc/protocol';
export type {
DecryptedNwcRequest,
NostrEvent,
@@ -84,15 +84,15 @@ export type {
NwcMethod,
NwcRequest,
NwcResponse,
} from './nwc/protocol'
} from './nwc/protocol';
export {defaultNwcTransport} from './nwc/transport'
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport'
export { defaultNwcTransport } from './nwc/transport';
export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
export {budgetRemainingMsat, recordSpend} from './nwc/budget'
export type {PendingInvoice, RequestContext} from './nwc/context'
export {invoiceResult, resolvePaymentHash} from './nwc/invoices'
export {payChangeset} from './nwc/pay'
export { budgetRemainingMsat, recordSpend } from './nwc/budget';
export type { PendingInvoice, RequestContext } from './nwc/context';
export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
export { payChangeset } from './nwc/pay';
export {startService} from './nwc/service'
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service'
export { startService } from './nwc/service';
export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
+82 -82
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {
NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService,
writeNwcEnabled,
writeNwcConnections,
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
import {requiredValue, stubLocalStorage} from './test-utils'
} from './nwc';
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type { NostrFilter } from './nwc/transport';
import type { NwcChangeset } from './nwc';
import type { Bearer } from './types';
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import { requiredValue, stubLocalStorage } from './test-utils';
import {
CLIENT_PUBKEY,
@@ -55,45 +55,45 @@ import {
nowSeconds,
storeForeignConnection,
waitFor,
} from './nwc.testProtocol'
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
} from './nwc.testProtocol';
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'get_payments', {})
expect(response.result_type).toBe('get_payments')
expect(response.error?.code).toBe('NOT_IMPLEMENTED')
expect(response.result).toBeNull()
await stop()
})
const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_payments', {});
expect(response.result_type).toBe('get_payments');
expect(response.error?.code).toBe('NOT_IMPLEMENTED');
expect(response.result).toBeNull();
await stop();
});
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, 'this is not json')
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'))
expect(response.error?.code).toBe('OTHER')
await stop()
})
const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, 'this is not json');
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
expect(response.error?.code).toBe('OTHER');
await stop();
});
it('answers a JSON request without a method with an error', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}}))
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
'OTHER',
)
await stop()
})
);
await stop();
});
it('ignores a request signed by a stranger key - silently', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip44v2.encrypt(
JSON.stringify({method: 'get_balance', params: {}}),
JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
)
);
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -105,20 +105,20 @@ describe('service: request validation', () => {
content,
},
STRANGER_SECRET,
)
relay.emit(forged)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
await stop()
})
);
relay.emit(forged);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop();
});
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
JSON.stringify({method: 'get_balance', params: {}}),
)
JSON.stringify({ method: 'get_balance', params: {} }),
);
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
@@ -130,58 +130,58 @@ describe('service: request validation', () => {
content,
},
CLIENT_SECRET,
)
relay.emit(request)
);
relay.emit(request);
// the error answer goes out in the legacy scheme every client reads
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null)
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION',
)
await stop()
})
);
await stop();
});
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme)
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null)
const response = requiredValue(readResponse(relay.published, request.id, 'nip04'))
expect(response.error).toBeNull()
expect(response.result).toEqual({balance: 0})
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
expect(response.error).toBeNull();
expect(response.result).toEqual({ balance: 0 });
// the response mirrors the request's scheme
const event = requiredValue(
relay.published.find(
(e) =>
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id),
),
)
expect(event.tags).toContainEqual(['encryption', 'nip04'])
);
expect(event.tags).toContainEqual(['encryption', 'nip04']);
}
await stop()
})
await stop();
});
it('drops requests older than the replay window unanswered', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const { relay, walletServicePubkey, stop } = await startTestService({});
const stale = methodRequest(
walletServicePubkey,
'get_balance',
{},
'nip44_v2',
nowSeconds() - 1200,
)
relay.emit(stale)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
await stop()
})
);
relay.emit(stale);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop();
});
it('picks up no new requests after stop', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
await stop()
const request = methodRequest(walletServicePubkey, 'get_balance', {})
relay.emitAfterClose(request)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0)
})
})
const { relay, walletServicePubkey, stop } = await startTestService({});
await stop();
const request = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(request);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
});
});
+10 -10
View File
@@ -5,14 +5,14 @@
// check + record below atomic enough: no two pays of one connection ever
// run this concurrently.
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import { persistNwcConnection } from '../storage/nwcConnections';
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
const {maxMsat, periodMs} = record.budget
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
return Math.max(0, maxMsat - record.spent.msat)
}
const { maxMsat, periodMs } = record.budget;
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
return Math.max(0, maxMsat - record.spent.msat);
};
// rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write)
@@ -22,14 +22,14 @@ export const recordSpend = (
amountMsat: number,
nowMs: number,
): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
return persistNwcConnection(ownerId, {
...record,
spent: expired
? {periodStart: nowMs, msat: amountMsat}
? { periodStart: nowMs, msat: amountMsat }
: {
periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat,
},
})
}
});
};
+49 -49
View File
@@ -20,17 +20,17 @@
// The connection string follows NIP-47 exactly:
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPublicKey} from 'nostr-tools/pure'
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { getPublicKey } from 'nostr-tools/pure';
import {linkingPubKeyHex} from '../keys'
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
import { linkingPubKeyHex } from '../keys';
import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
import { persistNwcConnection } from '../storage/nwcConnections';
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1'
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1';
const HEX_64 = /^[0-9a-f]{64}$/i
const HEX_64 = /^[0-9a-f]{64}$/i;
// Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's
@@ -43,16 +43,16 @@ export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: str
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey),
]),
)
);
// the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
getPublicKey(walletSecretKey)
getPublicKey(walletSecretKey);
export type NwcConnectionInfo = {
record: NwcConnectionRecord
walletServicePubkey: string
}
record: NwcConnectionRecord;
walletServicePubkey: string;
};
// the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity
@@ -62,21 +62,21 @@ export const connectionInfoOf = (
): NwcConnectionInfo => ({
record,
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
})
});
export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store
connectionString: string
}
connectionString: string;
};
export type CreateConnectionOptions = {
relays: string[]
budget: NwcBudget
relays: string[];
budget: NwcBudget;
// test hook: a fixed client secret (32 bytes) instead of a random one
clientSecret?: Uint8Array
now?: number
}
clientSecret?: Uint8Array;
now?: number;
};
// Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above.
@@ -85,21 +85,21 @@ export const createConnection = (
options: CreateConnectionOptions,
): CreatedConnection => {
if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.')
throw new Error('A connection needs at least one relay.');
}
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientPubkey = getPublicKey(clientSecret)
const ownerId = linkingPubKeyHex(linkingPrivKey)
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
const clientPubkey = getPublicKey(clientSecret);
const ownerId = linkingPubKeyHex(linkingPrivKey);
const record = persistNwcConnection(ownerId, {
version: 1,
ownerId,
clientPubkey,
relays: options.relays,
budget: options.budget,
spent: {periodStart: options.now ?? Date.now(), msat: 0},
spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(),
})
const info = connectionInfoOf(linkingPrivKey, record)
});
const info = connectionInfoOf(linkingPrivKey, record);
return {
...info,
connectionString: buildConnectionString(
@@ -107,43 +107,43 @@ export const createConnection = (
bytesToHex(clientSecret),
record.relays,
),
}
}
};
};
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: string,
relays: string[],
): string => {
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&')
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
}
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
};
export type ParsedConnectionString = {
walletServicePubkey: string
clientSecret: string
relays: string[]
}
walletServicePubkey: string;
clientSecret: string;
relays: string[];
};
// parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse)
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
let url: URL
let url: URL;
try {
url = new URL(uri.trim())
url = new URL(uri.trim());
} catch {
return null
return null;
}
if (url.protocol !== 'nostr+walletconnect:') return null
if (url.protocol !== 'nostr+walletconnect:') return null;
// the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex)
const walletServicePubkey = url.host.toLowerCase()
if (!HEX_64.test(walletServicePubkey)) return null
const secret = url.searchParams.get('secret')
if (!secret || !HEX_64.test(secret)) return null
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay))
if (relays.length === 0) return null
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
}
const walletServicePubkey = url.host.toLowerCase();
if (!HEX_64.test(walletServicePubkey)) return null;
const secret = url.searchParams.get('secret');
if (!secret || !HEX_64.test(secret)) return null;
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
if (relays.length === 0) return null;
return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
};
+78 -42
View File
@@ -5,83 +5,119 @@
// no logic, so every other nwc/ module can import from here without
// cycles.
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import type {PreparedMint} from '../ops'
import type {PollOptions} from '../ops/shared'
import type { Bearer, NewBearer } from '../types';
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import type { PreparedMint } from '../ops';
import type { CarveResult } from '../ops';
import type { OutputSecretAllocator } from '../ops/allocation';
import type { MintSignatureKeys } from '../ops/shared';
import type { PollOptions } from '../ops/shared';
import type {NwcConnectionInfo} from './connection'
import type {NwcMethod} from './protocol'
import type {NwcTransport} from './transport'
import type { NwcConnectionInfo } from './connection';
import type { NwcMethod } from './protocol';
import type { NwcTransport } from './transport';
// the state delta the caller's store applies after an op ran: new notes
// to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state
export type NwcChangeset = {
add: NewBearer[]
markSpent: string[]
}
add: NewBearer[];
markSpent: string[];
};
export type NwcServiceDeps = {
getBearers: () => Bearer[]
getBearers: () => Bearer[];
// the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null
assertCurrentOwner: () => void
getDefaultMint: () => string | null;
assertCurrentOwner: () => void;
// the wallet's durable allocation path (BIP-32 reservation) for every
// note output secret a request stages
allocateOutputSecrets: (
server: string,
count: number,
assertOwner: () => void,
) => Promise<readonly string[]>;
// trusted current+previous signing keys a landed carve output verifies
// against - the trusted-mint registry, owner-scoped
mintSignatureKeys: MintSignatureKeys;
recoverPendingMints: (assertOwner: () => void) => Promise<void>;
persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer>;
discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
setMintOutputRetirement: (
staged: Bearer,
retireAfter: number,
assertOwner: () => void,
) => Promise<void>;
reserveMintOutputSource: (
staged: Bearer,
sourceId: string,
recoverySecret: string,
assertOwner: () => void,
) => Promise<void>;
finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void>;
finalizePaymentReturn: (
staged: Bearer,
note: NewBearer,
assertOwner: () => void,
) => Promise<void>;
finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer>;
applyChangeset: (
changeset: NwcChangeset,
connection: NwcConnectionInfo,
method: NwcMethod,
assertOwner: () => void,
) => Promise<void>
transport?: NwcTransport
) => Promise<void>;
transport?: NwcTransport;
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
kit?: LnurlcashOptions;
// verify-poll budget for pay_invoice outcome classification
poll?: PollOptions
poll?: PollOptions;
// verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it
claimPoll?: PollOptions
claimPoll?: PollOptions;
// background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here
onError?: (error: unknown, connection: NwcConnectionInfo) => void
onError?: (error: unknown, connection: NwcConnectionInfo) => void;
// test hook: pinned clock for event timestamps and expirations
nowSeconds?: () => number
}
nowSeconds?: () => number;
};
export type PendingInvoice = {
invoice: string
paymentHash: string
invoice: string;
paymentHash: string;
// the gross invoiced amount (net + mint fee) - what the payer pays
amountMsat: number
description?: string
createdAt: number // unix seconds
expiresAt?: number
prepared: PreparedMint
state: 'pending' | 'settled' | 'failed'
preimage?: string
settledAt?: number
}
amountMsat: number;
description?: string;
createdAt: number; // unix seconds
expiresAt?: number;
prepared: PreparedMint;
stagedOutput: Bearer;
state: 'pending' | 'settled' | 'failed';
preimage?: string;
settledAt?: number;
};
// what a method handler sees of its connection's runtime: the deps, the
// (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = {
deps: NwcServiceDeps
connection: () => NwcConnectionInfo
updateRecord: (record: NwcConnectionRecord) => void
invoices: Map<string, PendingInvoice>
nowSeconds: () => number
assertOwner: () => void
deps: NwcServiceDeps;
connection: () => NwcConnectionInfo;
updateRecord: (record: NwcConnectionRecord) => void;
invoices: Map<string, PendingInvoice>;
nowSeconds: () => number;
assertOwner: () => void;
// Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup.
startBackground: (work: () => Promise<void>) => boolean
startBackground: (work: () => Promise<void>) => boolean;
// fires when the service stops: long OBSERVATION waits (the invoice
// claim poll) must interrupt themselves on it. Work that has already
// reached a fund-critical commit must NOT consult it - stop awaits
// those tasks through its drain
stopSignal: AbortSignal
}
stopSignal: AbortSignal;
};
+115 -49
View File
@@ -4,23 +4,25 @@
// results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself.
import {noteK1, sameInvoice} from 'lnurlcash-kit'
import { noteK1, sameInvoice } from 'lnurlcash-kit';
import type {Bearer} from '../types'
import type {PreparedMint} from '../ops'
import {prepareMint} from '../ops'
import {PollAbortedError} from '../ops/shared'
import type { Bearer } from '../types';
import type { PreparedMint } from '../ops';
import { prepareMint } from '../ops';
import { PollAbortedError } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context'
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
import {handlePayInvoice} from './pay'
import type {NwcRequest, NwcResponse} from './protocol'
import {NWC_METHODS, errResult, okResult} from './protocol'
import type { PendingInvoice, RequestContext } from './context';
import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
import { handlePayInvoice } from './pay';
import type { NwcRequest, NwcResponse } from './protocol';
import { NWC_METHODS, errResult, okResult } from './protocol';
const MAX_PENDING_MINT_OUTPUTS = 20;
// the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean =>
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url)
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url);
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', {
@@ -33,7 +35,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: [],
})
});
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', {
@@ -41,25 +43,88 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0),
})
});
const handleMakeInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const amountMsat = Number(params.amount)
const amountMsat = Number(params.amount);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.')
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.');
}
const mint = ctx.deps.getDefaultMint()
const mint = ctx.deps.getDefaultMint();
if (!mint) {
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.');
}
let prepared: PreparedMint
const now = ctx.nowSeconds();
if (
ctx.deps
.getBearers()
.some(
(bearer) =>
!bearer.spent &&
bearer.pendingMint?.retireAfter !== undefined &&
bearer.pendingMint.retireAfter <= now,
)
) {
await ctx.deps.recoverPendingMints(ctx.assertOwner);
}
const pendingOutputs = ctx.deps
.getBearers()
.filter((bearer) => bearer.pendingMint && !bearer.spent).length;
if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.');
}
let prepared: PreparedMint;
let stagedOutput: Bearer | undefined;
try {
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
prepared = await prepareMint(mint, amountMsat, {
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
allocateOutputSecrets: (server, count) =>
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
persistOutput: async (note) => {
stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner);
},
});
} catch (err) {
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err))
if (stagedOutput) {
try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) {
return errResult(
'make_invoice',
'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
);
}
}
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err));
}
if (!stagedOutput) {
return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.');
}
const retireAfter = invoiceRetireAfter(prepared.invoice);
if (retireAfter !== null) {
try {
await ctx.deps.setMintOutputRetirement(stagedOutput, retireAfter, ctx.assertOwner);
} catch (error) {
try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) {
return errResult(
'make_invoice',
'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
);
}
return errResult(
'make_invoice',
'INTERNAL',
error instanceof Error ? error.message : String(error),
);
}
}
const entry: PendingInvoice = {
invoice: prepared.invoice,
@@ -67,69 +132,70 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
stagedOutput,
state: 'pending',
}
};
if (typeof params.description === 'string' && params.description) {
entry.description = params.description
entry.description = params.description;
}
const expiry = Number(params.expiry)
const expiry = Number(params.expiry);
if (Number.isInteger(expiry) && expiry > 0) {
entry.expiresAt = entry.createdAt + expiry
entry.expiresAt = entry.createdAt + expiry;
}
ctx.invoices.set(entry.paymentHash, entry)
ctx.invoices.set(entry.paymentHash, entry);
// phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed'
entry.state = 'failed';
// an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing
if (err instanceof PollAbortedError) return
ctx.deps.onError?.(err, ctx.connection())
if (err instanceof PollAbortedError) return;
ctx.deps.onError?.(err, ctx.connection());
}),
)
);
if (!started) {
entry.state = 'failed'
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.')
entry.state = 'failed';
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
}
return okResult('make_invoice', invoiceResult(entry))
}
return okResult('make_invoice', invoiceResult(entry));
};
const handleLookupInvoice = (ctx: RequestContext, params: Record<string, unknown>): NwcResponse => {
const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined
const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
const hashParam =
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined;
if (!invoiceParam && !hashParam) {
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.')
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined;
if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) {
entry = candidate
break
entry = candidate;
break;
}
}
}
if (!entry) {
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.')
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
}
return okResult('lookup_invoice', invoiceResult(entry))
}
return okResult('lookup_invoice', invoiceResult(entry));
};
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
switch (request.method) {
case 'get_info':
return handleGetInfo(ctx)
return handleGetInfo(ctx);
case 'get_balance':
return handleGetBalance(ctx)
return handleGetBalance(ctx);
case 'make_invoice':
return await handleMakeInvoice(ctx, request.params)
return await handleMakeInvoice(ctx, request.params);
case 'pay_invoice':
return await handlePayInvoice(ctx, request.params)
return await handlePayInvoice(ctx, request.params);
case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params)
return handleLookupInvoice(ctx, request.params);
default:
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`)
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
}
}
};
+98 -148
View File
@@ -1,49 +1,77 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims
// the minted note, and only THEN records the settlement the lookup
// reports.
//
// 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.
// shares. A minted note always lives at the wallet-chosen secret that was
// durably staged before quote creation; the payment preimage is only a
// receipt. Settlement becomes visible only after the staged bearer has
// been replaced with the confirmed note details.
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import { isPreimage, sameInvoice } from 'lnurlcash-kit';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops'
import {claimFromPreimage, claimFromSecret} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared'
import type { PreparedMint } from '../ops';
import { MintedNoteSpentError } from '../ops';
import { claimFromSecret } from '../ops/mint';
import { pollVerifyUntilSettled } from '../ops/shared';
import type { PollOptions } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context'
import type { PendingInvoice, RequestContext } from './context';
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000,
}
};
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
// convention) - that suffix is the invoice's real payment hash. A service
// that shapes its verify URL differently gets a wallet-local correlation
// id instead (sha256 of the invoice): still unique and stable for
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
// The kit has already validated the invoice before this parser runs. Read
// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
// durable forever rather than risking retirement while it could still settle.
export const invoiceRetireAfter = (invoice: string): number | null => {
const normalized = invoice.toLowerCase();
const separator = normalized.lastIndexOf('1');
if (separator < 1 || normalized.length - separator <= 6) return null;
const words = [...normalized.slice(separator + 1, -6)].map((character) =>
BECH32_CHARSET.indexOf(character),
);
if (words.length < 7 || words.some((word) => word < 0)) return null;
const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(timestamp)) return null;
let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS;
let offset = 7;
// Conformance invoices omit the 65-byte signature; real BOLT11 invoices
// include its fixed 104 words after the tagged fields.
const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length;
while (offset + 3 <= taggedEnd) {
const tag = words[offset];
const firstLength = words[offset + 1];
const secondLength = words[offset + 2];
if (tag === undefined || firstLength === undefined || secondLength === undefined) return null;
const length = firstLength * 32 + secondLength;
const valueStart = offset + 3;
const valueEnd = valueStart + length;
if (valueEnd > taggedEnd) return null;
if (tag === BOLT11_EXPIRY_TAG) {
expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(expiry)) return null;
}
offset = valueEnd;
}
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS;
return Number.isSafeInteger(retireAfter) ? retireAfter : null;
};
// LUD-21 verify URLs conventionally end in the payment hash. A service
// that uses another shape gets a stable wallet-local correlation id.
export const resolvePaymentHash = (prepared: PreparedMint): string => {
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
if (fromVerify) return fromVerify.toLowerCase()
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
}
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
if (fromVerify) return fromVerify.toLowerCase();
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
};
// the NIP-47 transaction object make_invoice and lookup_invoice share
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
const result: Record<string, unknown> = {
type: 'incoming',
@@ -53,126 +81,48 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
amount: entry.amountMsat,
created_at: entry.createdAt,
metadata: {},
}
if (entry.description) result.description = entry.description
if (entry.expiresAt) result.expires_at = entry.expiresAt
if (entry.state === 'settled' && entry.preimage) {
result.preimage = entry.preimage
}
if (entry.settledAt) result.settled_at = entry.settledAt
return result
}
};
if (entry.description) result.description = entry.description;
if (entry.expiresAt) result.expires_at = entry.expiresAt;
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
if (entry.settledAt) result.settled_at = entry.settledAt;
return result;
};
// the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately 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> => {
if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry)
if (!entry.prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
// the observation half is interruptible (the client may never pay, so
// the poll can legally outlive the service); once settlement is seen,
// everything below - claim, rotate, bearer commit - is fund-critical and
// deliberately ignores the stop signal: stop's drain awaits it
const 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
const prepared = entry.prepared;
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
let preimage: string | null = null;
if (prepared.verifyUrl) {
// the interruptible observation half: watch settlement on verify,
// then claim by secret below (fund-critical, uninterruptible)
// Observation remains interruptible. Once settlement is observed, the
// claim and durable update ignore shutdown while stop drains the task.
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
{...claimPoll, signal: ctx.stopSignal},
{ ...claimPoll, signal: ctx.stopSignal },
ctx.deps.kit ?? {},
)
);
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
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
// stays interruptible; after a verify-observed settlement the note is
// already there and this returns on the first check
const claimed = await claimFromSecret(
{...prepared, noteSecret},
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
},
)
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
await ctx.deps.applyChangeset(
{add, markSpent: []},
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
// 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
}
let claimed;
try {
claimed = await claimFromSecret(
prepared,
prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
},
);
} catch (error) {
if (!(error instanceof MintedNoteSpentError)) throw error;
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
throw error;
}
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
entry.settledAt = ctx.nowSeconds();
entry.state = 'settled';
if (preimage) entry.preimage = preimage;
};
+102 -54
View File
@@ -10,16 +10,17 @@ import {
fetchInvoiceVerification,
isBolt11Invoice,
noteK1,
} from 'lnurlcash-kit'
withNewK1,
} from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types'
import type {PayResult} from '../ops'
import {UncertainOutcomeError, payWithBearers} from '../ops'
import type { Bearer, NewBearer } from '../types';
import type { PayResult } from '../ops';
import { UncertainOutcomeError, payWithBearers } from '../ops';
import {budgetRemainingMsat, recordSpend} from './budget'
import type {NwcChangeset, RequestContext} from './context'
import type {NwcResponse} from './protocol'
import {errResult, okResult} from './protocol'
import { budgetRemainingMsat, recordSpend } from './budget';
import type { NwcChangeset, RequestContext } from './context';
import type { NwcResponse } from './protocol';
import { errResult, okResult } from './protocol';
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds
@@ -31,151 +32,198 @@ import {errResult, okResult} from './protocol'
// the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map((b) => b.id)
if (result.carve.change) add.push(result.carve.change)
const add: NewBearer[] = [];
const markSpent: string[] = result.carve.consumed.map((b) => b.id);
// the staged change's URL may lack the signature the landed result's URL
// carries - identify outputs by their secret, not the whole URL
const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null;
if (
result.carve.change &&
!bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
) {
add.push(result.carve.change);
}
const carvedK1 = noteK1(result.carve.note.url);
const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined;
if (result.outcome === 'failed-funds-returned') {
add.push(result.rotatedNote ?? result.carve.note)
if (existing) {
const index = markSpent.indexOf(existing.id);
if (index >= 0) markSpent.splice(index, 1);
}
} else {
// settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match
// carve), lock that bearer; a freshly carved note is never added -
// it was born spent
const carvedK1 = noteK1(result.carve.note.url)
const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
if (existing) markSpent.push(existing.id)
if (existing) markSpent.push(existing.id);
}
if (result.rescuedNote) add.push(result.rescuedNote)
return {add, markSpent}
}
return { add, markSpent };
};
export const handlePayInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : ''
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
if (!isBolt11Invoice(invoice)) {
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.');
}
// the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request
const amountMsat = decodeBolt11AmountMsat(invoice)
const amountMsat = decodeBolt11AmountMsat(invoice);
if (amountMsat === null || amountMsat <= 0) {
return errResult(
'pay_invoice',
'OTHER',
"Could not read this invoice's amount - amount-less invoices are not supported.",
)
);
}
if (params.amount !== undefined && params.amount !== amountMsat) {
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.")
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
}
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
"This payment exceeds the connection's budget.",
)
);
}
const bearers = ctx.deps.getBearers()
let result: PayResult
const bearers = ctx.deps.getBearers();
let result: PayResult;
let committedCarve: Bearer | undefined;
let returnStage: Bearer | undefined;
try {
result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {},
assertOwner: ctx.assertOwner,
})
allocateOutputSecrets: (server, count) =>
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
mintSignatureKeys: ctx.deps.mintSignatureKeys,
onCarve: async (carve) => {
committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner);
},
onReturnReady: async (carve, recoverySecret) => {
const source =
committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.');
returnStage = await ctx.deps.persistMintOutput(
{
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
callback: '',
amount: 0,
verified: false,
pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 },
},
ctx.assertOwner,
);
await ctx.deps.reserveMintOutputSource(
returnStage,
source.id,
recoverySecret,
ctx.assertOwner,
);
},
});
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the
// possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped
await ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []},
{ add: err.possibleOutputs, markSpent: [] },
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
return errResult(
'pay_invoice',
'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.',
)
);
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
return errResult(
'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message,
)
);
}
if (returnStage) {
const returned = result.rotatedNote ?? result.rescuedNote;
if (returned) {
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
} else if (result.outcome !== 'unknown-still-pending') {
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
}
}
const spendRecorded = (): void => {
// This conservative budget debit is persisted separately from bearer
// storage. A later bearer commit failure does not roll it back.
const connection = ctx.connection().record
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()))
}
const connection = ctx.connection().record;
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
};
switch (result.outcome) {
case 'settled': {
spendRecorded()
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
// the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one.
let preimage = ''
let preimage = '';
if (result.verifyUrl) {
try {
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {})
preimage = proof.preimage ?? ''
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
preimage = proof.preimage ?? '';
} catch (error) {
// the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome
if (!(error instanceof Error)) throw error
if (!(error instanceof Error)) throw error;
}
}
return okResult('pay_invoice', {preimage})
return okResult('pay_invoice', { preimage });
}
case 'failed-funds-returned':
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.',
)
);
case 'note-already-spent':
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.',
)
);
case 'unknown-still-pending':
spendRecorded()
spendRecorded();
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
payChangeset(ctx.deps.getBearers(), result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
);
return errResult(
'pay_invoice',
'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.',
)
);
}
}
};
+53 -52
View File
@@ -13,16 +13,16 @@
// - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes
import type {Event as NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import type { Event as NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
export type {NostrEvent}
export type { NostrEvent };
export const NWC_REQUEST_KIND = 23194
export const NWC_RESPONSE_KIND = 23195
export const NWC_INFO_KIND = 13194
export const NWC_REQUEST_KIND = 23194;
export const NWC_RESPONSE_KIND = 23195;
export const NWC_INFO_KIND = 13194;
export const NWC_METHODS = [
'get_info',
@@ -30,9 +30,9 @@ export const NWC_METHODS = [
'make_invoice',
'pay_invoice',
'lookup_invoice',
] as const
] as const;
export type NwcMethod = (typeof NWC_METHODS)[number]
export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode =
| 'RATE_LIMITED'
@@ -45,38 +45,38 @@ export type NwcErrorCode =
| 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED'
| 'NOT_FOUND'
| 'OTHER'
| 'OTHER';
export type NwcRequest = {
method: string
params: Record<string, unknown>
}
method: string;
params: Record<string, unknown>;
};
export type NwcResponse = {
result_type: string
error: {code: NwcErrorCode; message: string} | null
result: unknown
}
result_type: string;
error: { code: NwcErrorCode; message: string } | null;
result: unknown;
};
export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method,
error: null,
result,
})
});
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method,
error: {code, message},
error: { code, message },
result: null,
})
});
// the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04'
export type NwcEncryption = 'nip44_v2' | 'nip04';
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = (
scheme: NwcEncryption,
@@ -86,7 +86,7 @@ export const encryptFor = (
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = (
scheme: NwcEncryption,
@@ -96,10 +96,10 @@ const decryptFrom = (
): string =>
scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content)
: nip04Decrypt(walletSecretKey, clientPubkey, content);
const tagValue = (event: NostrEvent, name: string): string | undefined =>
event.tags.find((t) => t[0] === name)?.[1]
event.tags.find((t) => t[0] === name)?.[1];
// The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can
@@ -112,8 +112,8 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// dropped silently, exactly what a relay full of strangers' traffic
// demands
export type DecryptedNwcRequest =
| {respond: false; request: NwcRequest; encryption: NwcEncryption}
| {respond: true; response: NwcResponse; encryption: NwcEncryption}
| { respond: false; request: NwcRequest; encryption: NwcEncryption }
| { respond: true; response: NwcResponse; encryption: NwcEncryption };
export const decryptRequest = (
walletSecretKey: Uint8Array,
@@ -122,24 +122,24 @@ export const decryptRequest = (
event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => {
if (event.kind !== NWC_REQUEST_KIND) return null
if (event.kind !== NWC_REQUEST_KIND) return null;
// only the authorized client may talk to this connection, and the
// request must actually be addressed to it
if (event.pubkey !== clientPubkey) return null
if (tagValue(event, 'p') !== walletServicePubkey) return null
if (!verifyEvent(event)) return null
if (event.pubkey !== clientPubkey) return null;
if (tagValue(event, 'p') !== walletServicePubkey) return null;
if (!verifyEvent(event)) return null;
// an expired request is ignored, never answered (NIP-47)
const expiration = tagValue(event, 'expiration')
const expiration = tagValue(event, 'expiration');
if (expiration !== undefined && Number(expiration) < nowSeconds) {
return null
return null;
}
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
const advertised = tagValue(event, 'encryption')
let encryption: NwcEncryption
const advertised = tagValue(event, 'encryption');
let encryption: NwcEncryption;
if (advertised === undefined || advertised === 'nip04') {
encryption = 'nip04'
encryption = 'nip04';
} else if (advertised === 'nip44_v2') {
encryption = 'nip44_v2'
encryption = 'nip44_v2';
} else {
// the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read
@@ -151,24 +151,24 @@ export const decryptRequest = (
'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`,
),
}
};
}
let plaintext: string
let plaintext: string;
try {
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
} catch {
// undecryptable - indistinguishable from relay noise; stay silent
return null
return null;
}
let data: unknown
let data: unknown;
try {
data = JSON.parse(plaintext)
data = JSON.parse(plaintext);
} catch {
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
}
};
}
if (
typeof data !== 'object' ||
@@ -180,12 +180,13 @@ export const decryptRequest = (
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.'),
}
};
}
const request = data as NwcRequest
const params = typeof request.params === 'object' && request.params !== null ? request.params : {}
return {respond: false, request: {method: request.method, params}, encryption}
}
const request = data as NwcRequest;
const params =
typeof request.params === 'object' && request.params !== null ? request.params : {};
return { respond: false, request: { method: request.method, params }, encryption };
};
// signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag
@@ -209,7 +210,7 @@ export const buildResponseEvent = (
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
},
walletSecretKey,
)
);
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
@@ -224,4 +225,4 @@ export const buildInfoEvent = (
content: NWC_METHODS.join(' '),
},
walletSecretKey,
)
);
+112 -95
View File
@@ -14,60 +14,60 @@
// foreground-only design itself is documented in the nwc.ts façade
// header.
import {linkingPubKeyHex} from '../keys'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {readNwcConnections} from '../storage/nwcConnections'
import {assertSavedKeyOwner} from '../storage/currentOwner'
import { linkingPubKeyHex } from '../keys';
import type { NwcConnectionRecord } from '../storage/nwcConnections';
import { readNwcConnections } from '../storage/nwcConnections';
import { assertSavedKeyOwner } from '../storage/currentOwner';
import type {NwcConnectionInfo} from './connection'
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
import {dispatch} from './dispatch'
import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol'
import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol'
import type {NwcSubscription} from './transport'
import {defaultNwcTransport} from './transport'
import type { NwcConnectionInfo } from './connection';
import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
import { dispatch } from './dispatch';
import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
import type { NwcSubscription } from './transport';
import { defaultNwcTransport } from './transport';
export type {NwcConnectionInfo}
export type {NwcChangeset, NwcServiceDeps} from './context'
export type { NwcConnectionInfo };
export type { NwcChangeset, NwcServiceDeps } from './context';
// requests older than this are dropped unanswered (see the header)
const MAX_REQUEST_AGE_SECONDS = 600
const MAX_REQUEST_AGE_SECONDS = 600;
type ConnectionRuntime = {
info: NwcConnectionInfo
info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains
walletSecret: Uint8Array | null
walletSecret: Uint8Array | null;
// invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet
invoices: Map<string, PendingInvoice>
invoices: Map<string, PendingInvoice>;
// serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave
queue: Promise<unknown>
sub: NwcSubscription
}
queue: Promise<unknown>;
sub: NwcSubscription;
};
export type NwcService = {
// a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state
connections: NwcConnectionInfo[]
connections: NwcConnectionInfo[];
// closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up
stop: () => Promise<void>
}
stop: () => Promise<void>;
};
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records?: NwcConnectionRecord[],
): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport())
const ownerId = linkingPubKeyHex(linkingPrivKey)
const transport = deps.transport ?? (await defaultNwcTransport());
const ownerId = linkingPubKeyHex(linkingPrivKey);
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId,
)
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
);
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
const publishResponse = async (
runtime: ConnectionRuntime,
@@ -86,43 +86,54 @@ export const startService = async (
response,
nowSeconds(),
),
)
}
);
};
// pay_invoice goes through the connection's queue (budget atomicity);
// everything else dispatches directly
let invoiceQueue: Promise<unknown> = Promise.resolve();
// Payments serialize per connection for budget atomicity. Invoice
// creation serializes across the service so different clients cannot
// race the wallet-wide durable pending-output cap.
const dispatchSerialized = (
runtime: ConnectionRuntime,
ctx: RequestContext,
request: NwcRequest,
): Promise<NwcResponse> => {
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
if (request.method === 'make_invoice') {
const run = invoiceQueue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request),
);
invoiceQueue = run.catch(() => undefined);
return run;
}
if (request.method !== 'pay_invoice') return dispatch(ctx, request);
const run = runtime.queue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request),
)
runtime.queue = run.catch(() => undefined)
return run
}
);
runtime.queue = run.catch(() => undefined);
return run;
};
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent,
): Promise<void> => {
const walletSecret = runtime.walletSecret
if (walletSecret === null) return
const at = nowSeconds()
const walletSecret = runtime.walletSecret;
if (walletSecret === null) return;
const at = nowSeconds();
// replay safety (see the header): too-old requests are dropped
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return;
const decrypted = decryptRequest(
walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at,
)
if (decrypted === null) return
);
if (decrypted === null) return;
if (decrypted.respond) {
await publishResponse(
runtime,
@@ -130,56 +141,56 @@ export const startService = async (
event.id,
decrypted.encryption,
decrypted.response,
)
return
);
return;
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request);
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
};
let accepting = true
let accepting = true;
// interrupts long observation waits (the invoice claim poll) at stop;
// the drain below still awaits tasks that reached a fund-critical commit
const stopController = new AbortController()
const inFlight = new Set<Promise<void>>()
const stopController = new AbortController();
const inFlight = new Set<Promise<void>>();
const track = (task: Promise<void>): void => {
inFlight.add(task)
inFlight.add(task);
void task.then(
() => inFlight.delete(task),
() => inFlight.delete(task),
)
}
);
};
const startBackground = (work: () => Promise<void>): boolean => {
if (!accepting) return false
track(work())
return true
}
if (!accepting) return false;
track(work());
return true;
};
let runtimes = ownedRecords.map((record) => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
const runtime: ConnectionRuntime = {
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
walletSecret,
invoices: new Map(),
queue: Promise.resolve(),
// replaced below, immediately - the field exists because the
// subscription callback closes over the runtime
sub: {close: () => undefined},
}
sub: { close: () => undefined },
};
const ctx: RequestContext = {
deps,
connection: () => runtime.info,
updateRecord: (updated) => {
runtime.info = {...runtime.info, record: updated}
runtime.info = { ...runtime.info, record: updated };
},
invoices: runtime.invoices,
nowSeconds,
assertOwner: () => {
deps.assertCurrentOwner()
assertSavedKeyOwner(ownerId)
deps.assertCurrentOwner();
assertSavedKeyOwner(ownerId);
},
startBackground,
stopSignal: stopController.signal,
}
};
runtime.sub = transport.subscribe(
record.relays,
{
@@ -188,56 +199,62 @@ export const startService = async (
since: nowSeconds(),
},
(event) => {
if (!accepting) return
if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info)
})
track(handler)
deps.onError?.(err, runtime.info);
});
track(handler);
},
)
return runtime
})
);
return runtime;
});
// info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) {
const walletSecret = runtime.walletSecret
if (walletSecret === null) continue
const walletSecret = runtime.walletSecret;
if (walletSecret === null) continue;
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(walletSecret, nowSeconds()),
)
);
} catch (error) {
deps.onError?.(
error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}),
error instanceof Error
? error
: new Error('NWC info publication failed.', { cause: error }),
runtime.info,
)
);
}
}
const connections = runtimes.map((runtime) => runtime.info)
let stopPromise: Promise<void> | null = null
const connections = runtimes.map((runtime) => runtime.info);
let stopPromise: Promise<void> | null = null;
const stop = (): Promise<void> => {
if (stopPromise !== null) return stopPromise
accepting = false
for (const runtime of runtimes) runtime.sub.close()
stopController.abort()
stopPromise = Promise.all([...inFlight])
.then(() => undefined)
if (stopPromise !== null) return stopPromise;
accepting = false;
for (const runtime of runtimes) runtime.sub.close();
stopController.abort();
stopPromise = Promise.allSettled([...inFlight])
.then((results) => {
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
})
.finally(() => {
for (const runtime of runtimes) {
runtime.walletSecret?.fill(0)
runtime.walletSecret = null
runtime.invoices.clear()
runtime.walletSecret?.fill(0);
runtime.walletSecret = null;
runtime.invoices.clear();
}
runtimes = []
})
return stopPromise
}
runtimes = [];
});
return stopPromise;
};
return {
connections,
stop,
}
}
};
};
+16 -15
View File
@@ -4,36 +4,37 @@
// importing the service module never opens (or even references) a
// WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter'
import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './protocol'
import type { NostrEvent } from './protocol';
export type {NostrFilter}
export type { NostrFilter };
export type NwcSubscription = {
close: () => void
}
close: () => void;
};
export type NwcTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void>
publish: (relays: string[], event: NostrEvent) => Promise<void>;
subscribe: (
relays: string[],
filter: NostrFilter,
onEvent: (event: NostrEvent) => void,
) => NwcSubscription
}
) => NwcSubscription;
};
export const defaultNwcTransport = async (): Promise<NwcTransport> => {
const {SimplePool} = await import('nostr-tools/pool')
const pool = new SimplePool()
const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool();
return {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay accepting is enough - same rule as the backup
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.')
throw new Error('No relay accepted the event.');
}
},
subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}),
}
}
subscribe: (relays, filter, onEvent) =>
pool.subscribeMany(relays, filter, { onevent: onEvent }),
};
};
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import type { CarveResult } from './ops';
import { ensureExactAmount, UnsupportedMultiBatchMergeError } from './ops';
import { requiredValue } from './test-utils';
import { makeBearer, mint, secret } from './ops.testHarness';
const allocatedSecrets = (
...secrets: string[]
): ((server: string, count: number) => Promise<readonly string[]>) => {
let served = false;
return (_server, count) => {
if (served) throw new Error('The test expected a single allocation.');
served = true;
return Promise.resolve(secrets.slice(0, count));
};
};
describe('ensureExactAmount crash safety', () => {
it('stages both split secrets before either hash reaches the mint', async () => {
const instance = await mint();
const sourceSecret = secret('70');
const targetSecret = secret('71');
const changeSecret = secret('72');
const bearer = await makeBearer(instance, sourceSecret, 21_000);
let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret);
expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret);
expect(instance.state.noteState(sourceSecret)).toBe('outstanding');
}
return fetch(input, init);
};
const result = await ensureExactAmount([bearer], 5_000, {
fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret),
onCarve: (prepared) => {
staged = prepared;
},
});
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
});
it('stages the merge secret before its hash reaches the mint', async () => {
const instance = await mint();
const firstSecret = secret('73');
const secondSecret = secret('74');
const mergedSecret = secret('75');
const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000);
let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret);
expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding');
}
return fetch(input, init);
};
await ensureExactAmount([first, second], 7_000, {
fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(mergedSecret),
onCarve: (prepared) => {
staged = prepared;
},
});
expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]);
});
it('does not expose a staged split output to a second ambiguous rotation', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('76'), 21_000);
let mutationCount = 0;
const dropSecondMutation: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('/w/cb')) return fetch(input, init);
mutationCount += 1;
const response = await fetch(input, init);
if (mutationCount === 2) {
await response.arrayBuffer();
throw new Error('second mutation response lost');
}
return response;
};
const result = await ensureExactAmount([bearer], 5_000, {
fetch: dropSecondMutation,
mutationRetries: 0,
allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')),
onCarve: () => undefined,
});
expect(mutationCount).toBe(1);
const targetSecret = requiredValue(noteK1(result.note.url));
const changeSecret = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(targetSecret)).toBe('outstanding');
expect(instance.state.noteState(changeSecret)).toBe('outstanding');
});
it('rejects a multi-batch merge before any partial fold can land', async () => {
const instance = await mint();
const bearers = await Promise.all(
Array.from({ length: 21 }, (_, index) =>
makeBearer(instance, secret((128 + index).toString(16)), 1_000),
),
);
let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init);
};
const failure = ensureExactAmount(bearers, 21_000, {
fetch: countingFetch,
onCarve: () => undefined,
});
await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError);
expect(mutationCount).toBe(0);
expect(
bearers.every(
(bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding',
),
).toBe(true);
});
it('checks ownership again immediately before a merge mutation', async () => {
const instance = await mint();
const firstSecret = secret('79');
const secondSecret = secret('7a');
const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000);
let ownerChecks = 0;
let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init);
};
await expect(
ensureExactAmount([first, second], 7_000, {
fetch: countingFetch,
allocateOutputSecrets: allocatedSecrets(secret('7b')),
assertOwner: () => {
ownerChecks += 1;
if (ownerChecks === 3) throw new Error('wallet owner changed');
},
onCarve: () => undefined,
}),
).rejects.toThrow(/owner changed/);
expect(mutationCount).toBe(0);
expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding');
});
});
+330 -132
View File
@@ -1,163 +1,361 @@
import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
import type {Bearer} from './types'
import type {CarveResult} from './ops'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
import type { Bearer } from './types';
import type { CarveResult } from './ops';
import {
CarveCheckpointRequiredError,
OutputSecretAllocationRequiredError,
UncertainOutcomeError,
ensureExactAmount,
} from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
secret,
signatureKeysFor,
} from './ops.testHarness';
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const instance = await mint()
const k1 = secret('01')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 21_000)
expect(noteK1(result.note.url)).toBe(k1)
expect(result.consumed).toEqual([])
expect(result.change).toBeUndefined()
expect(instance.state.noteState(k1)).toBe('outstanding')
})
const instance = await mint();
const k1 = secret('01');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 21_000);
expect(noteK1(result.note.url)).toBe(k1);
expect(result.consumed).toEqual([]);
expect(result.change).toBeUndefined();
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('split path: carves an exact note off a larger one, with change', async () => {
const instance = await mint()
const k1 = secret('02')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(instance.state.noteState(k1)).toBe('burned')
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
const instance = await mint();
const k1 = secret('02');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.amount).toBe(5_000);
expect(result.note.verified).toBe(true);
expect(result.change?.amount).toBe(16_000);
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
});
it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('03'), 3_000)
const second = await makeBearer(instance, secret('04'), 4_000)
const result = await ensureExactAmount([first, second], 7_000)
expect(result.note.amount).toBe(7_000)
expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned')
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000)
})
const instance = await mint();
const first = await makeBearer(instance, secret('03'), 3_000);
const second = await makeBearer(instance, secret('04'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
expect(result.note.amount).toBe(7_000);
expect(result.change).toBeUndefined();
expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000);
});
it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('05'), 3_000)
const second = await makeBearer(instance, secret('06'), 4_000)
const result = await ensureExactAmount([first, second], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000)
})
const instance = await mint();
const first = await makeBearer(instance, secret('05'), 3_000);
const second = await makeBearer(instance, secret('06'), 4_000);
const result = await ensureExactAmount([first, second], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
expect(result.note.amount).toBe(5_000);
expect(result.change?.amount).toBe(2_000);
expect(result.consumed).toHaveLength(2);
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000);
});
it('excludes spent and unverified notes from selection', async () => {
const instance = await mint()
const spentBearer = await makeBearer(instance, secret('07'), 50_000)
const instance = await mint();
const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
callback: '',
}
verified: false,
};
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
).rejects.toThrow(/enough/)
})
ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
).rejects.toThrow(/enough/);
});
it('refuses an amount no mint can cover', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('09'), 5_000)
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/)
})
const instance = await mint();
const bearer = await makeBearer(instance, secret('09'), 5_000);
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/);
});
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('10')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(partK1).not.toBe(k1)
expect(instance.state.noteState(k1)).toBe('burned')
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
const instance = await mint({ dropAfterMutation: true });
const k1 = secret('10');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
});
const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(partK1).not.toBe(k1);
expect(instance.state.noteState(k1)).toBe('burned');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
});
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(instance, k1, 21_000)
const probeKillingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return fetch(input, init)
return Promise.reject(new Error('probe unreachable'))
}
const instance = await mint();
const k1 = secret('11');
const bearer = await makeBearer(instance, k1, 21_000);
// every mutation answer is lost - the first attempt's AND the forced
// byte-identical replay's (the mint executed both; the replay is the
// recorded success served again, then dropped by the wrapper) - and
// every informational probe is unreachable, so neither the mutation
// nor the probe can be confirmed
const everythingDyingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
const response = await fetch(input, init);
await response.arrayBuffer();
throw new Error('mutation response lost');
}
return Promise.reject(new Error('probe unreachable'));
};
const checkpoints: CarveResult[] = [];
const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch,
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2)
const first = requiredValue(failure.possibleOutputs[0])
const second = requiredValue(failure.possibleOutputs[1])
expect(first.amount).toBe(5_000)
expect(second.amount).toBe(16_000)
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, {
fetch: everythingDyingFetch,
allocateOutputSecrets,
onCarve: (carve) => {
seen.push(carve)
checkpoints.push(carve);
},
})
// the hook saw exactly the returned result, once
expect(seen).toHaveLength(1)
expect(seen[0]).toBe(result)
expect(result.consumed).toHaveLength(1)
})
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(UncertainOutcomeError);
if (!(failure instanceof UncertainOutcomeError)) throw failure;
expect(failure.possibleOutputs).toEqual([]);
expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]);
expect(checkpoints[0]?.note.amount).toBe(5_000);
expect(checkpoints[0]?.change?.amount).toBe(16_000);
});
it('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
it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('12'), 21_000);
const seen: CarveResult[] = [];
const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => {
seen.push(carve);
},
})
expect(called).toBe(false)
expect(result.consumed).toEqual([])
})
mintSignatureKeys: signatureKeysFor(instance),
});
expect(seen).toHaveLength(2);
expect(seen[0]?.note.verified).toBe(false);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined();
expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url));
expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
noteK1(requiredValue(result.change).url),
);
// the landed checkpoint re-reports the SAME staged output (same secret)
// - its URL has since gained the mint's signature
expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url));
expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull();
expect(seen[1]?.note.verified).toBe(true);
expect(seen[1]?.change).toBeUndefined();
expect(seen[1]?.consumed).toEqual(result.consumed);
expect(result.consumed).toHaveLength(1);
});
it('does not stage the split change again during the landed checkpoint', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('17'), 21_000);
const trackedSecrets = new Set<string>();
const retiredIds = new Set<string>();
const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => {
// checkpoints identify an output by its secret: the landed phase's
// URL has gained a signature the staged phase could not have had
const k1 = noteK1(carve.note.url);
if (k1) trackedSecrets.add(k1);
const changeK1 = carve.change ? noteK1(carve.change.url) : null;
if (changeK1) trackedSecrets.add(changeK1);
for (const consumed of carve.consumed) retiredIds.add(consumed.id);
},
});
expect(trackedSecrets).toEqual(
new Set([
requiredValue(noteK1(result.note.url)),
requiredValue(noteK1(requiredValue(result.change).url)),
]),
);
expect(retiredIds).toEqual(new Set([bearer.id]));
});
it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
const instance = await mint();
const sourceSecret = secret('15');
const bearer = await makeBearer(instance, sourceSecret, 21_000);
const seen: CarveResult[] = [];
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(
ensureExactAmount([bearer], 5_000, {
onCarve: () => {
throw new Error('commit failed')
allocateOutputSecrets,
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 {hashK1, noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint} from './ops'
import {requiredValue} from './test-utils'
import type {Mint} from './ops.testHarness'
import {mint, settleLastInvoice} from './ops.testHarness'
import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
import { requiredValue } from './test-utils';
import type { Mint } from './ops.testHarness';
import {
allocateOutputSecrets,
mint,
persistOutput,
secret,
settleLastInvoice,
} from './ops.testHarness';
const HEX32 = /^[0-9a-f]{64}$/
const HEX32 = /^[0-9a-f]{64}$/;
// the invoice whose quote the wallet bound to its own output hash
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 () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
const noteSecret = requiredValue(prepared.noteSecret)
expect(noteSecret).toMatch(HEX32)
// the mint bound the quote to sha256 of the wallet's secret - the kit
// sends it as both comment and h, the mock read one of them
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
})
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.noteSecret).toMatch(HEX32);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
});
it('claims a named output at the wallet secret, without a rotate', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const noteSecret = requiredValue(prepared.noteSecret)
const preimage = await settleLastInvoice(instance)
it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
const preimage = await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
expect(noteK1(claimed.note.url)).toBe(noteSecret)
// no rotate happened: the note still stands at the wallet's own
// secret, and the payment preimage keys nothing at all
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(instance.state.noteState(preimage)).toBeNull()
})
});
expect(claimed.note.verified).toBe(true);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
expect(instance.state.noteState(preimage)).toBeNull();
});
it('names the output through the mintToHash spelling too', async () => {
const instance = await mint({mintToHash: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret)))
const preimage = await settleLastInvoice(instance)
it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
await expect(
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
).rejects.toThrow(/not confirmed/i);
});
it('uses the required comment even when the optional h extension is advertised', async () => {
const instance = await mint({ mintToHash: true, testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull()
})
});
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
});
it('keeps an unnamed mint on the preimage path', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output
expect(boundInvoice(instance)).toBeUndefined()
})
it('draws the staged note secret from the caller allocation path', async () => {
const instance = await mint({ testHooks: true });
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('90')];
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
});
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(prepared.noteSecret).toBe(allocated[0]);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0])));
});
it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => {
const instance = await mint({ testHooks: true });
let persisted = 0;
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(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput: () => {
persisted += 1;
},
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => {
// the mintToHashIgnoresH adversary: claims the capability, binds
// 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')
})
})
expect(persisted).toBe(0);
expect(instance.state.invoices.size).toBe(0);
});
});
+157 -94
View File
@@ -1,4 +1,4 @@
import {describe, expect, it} from 'vitest'
import { describe, expect, it } from 'vitest';
import {
NoteSpentError,
PendingNoteError,
@@ -6,118 +6,181 @@ import {
meltNote,
noteK1,
rotateNote,
} from 'lnurlcash-kit'
} from 'lnurlcash-kit';
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness'
describe('mint -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
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')
})
import {
claimMintedNote,
OutputSecretAllocationRequiredError,
prepareMint,
receiveBearer,
ReceiveRotationStagingRequiredError,
} from './ops';
import { requiredValue } from './test-utils';
import type { NewBearer } from './types';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
persistOutput,
secret,
settleLastInvoice,
stageRotation,
} from './ops.testHarness';
describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
await settleLastInvoice(instance)
const instance = await mint({ testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000 });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
persistOutput,
allocateOutputSecrets,
});
expect(prepared.grossMsat).toBeGreaterThan(100_000);
await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
})
});
// the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat)
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)
})
})
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000);
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
});
});
describe('receiveBearer', () => {
it('stages the rotation output before the rotate can reach the mint', async () => {
const instance = await mint();
const senderK1 = secret('94');
instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('95');
let staged: NewBearer | undefined;
const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) expect(staged).toBeDefined();
return fetch(input, init);
};
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: observingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => {
staged = note;
},
});
expect(received.stage).toBe('finalize');
expect(received.rotated).toBe(true);
expect(noteK1(received.note.url)).toBe(rotationSecret);
expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret);
expect(requiredValue(staged).verified).toBe(false);
expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(rotationSecret)).toBe('outstanding');
});
it('reports discard when the rotate provably never landed', async () => {
const instance = await mint();
const senderK1 = secret('96');
instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('97');
let staged: NewBearer | undefined;
const failingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'));
return fetch(input, init);
};
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: failingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => {
staged = note;
},
});
expect(received.stage).toBe('discard');
expect(received.rotated).toBe(false);
expect(received.note.verified).toBe(true);
expect(received.rotationError).toBeDefined();
// nothing landed: both the sender's copy and the would-be output live on
expect(instance.state.noteState(senderK1)).toBe('outstanding');
expect(instance.state.noteState(rotationSecret)).toBeNull();
expect(staged).toBeDefined();
});
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
const instance = await mint()
const senderK1 = secret('20')
instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [])
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(received.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(received.note.url))
expect(newK1).not.toBe(senderK1)
expect(instance.state.noteState(senderK1)).toBe('burned')
expect(instance.state.noteState(newK1)).toBe('outstanding')
})
const instance = await mint();
const senderK1 = secret('20');
instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
allocateOutputSecrets,
stageRotation,
});
expect(received.rotated).toBe(true);
expect(received.stage).toBe('finalize');
expect(received.note.amount).toBe(21_000);
expect(received.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(received.note.url));
expect(newK1).not.toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(newK1)).toBe('outstanding');
});
it('refuses a note the wallet already holds', async () => {
const instance = await mint()
const senderK1 = secret('21')
const existing = await makeBearer(instance, senderK1, 21_000)
const instance = await mint();
const senderK1 = secret('21');
const existing = await makeBearer(instance, senderK1, 21_000);
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
/already/,
)
})
);
});
it('surfaces a spent note as definitively spent', async () => {
const instance = await mint()
const k1 = secret('22')
const bearer = await makeBearer(instance, k1, 21_000)
const info = await fetchNoteInfo(bearer.url)
await rotateNote(info.callback, k1)
const instance = await mint();
const k1 = secret('22');
const bearer = await makeBearer(instance, k1, 21_000);
const info = await fetchNoteInfo(bearer.url);
await rotateNote(info.callback, k1);
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError,
)
})
);
});
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const instance = await mint({meltNeverSettles: true})
const k1 = secret('23')
const bearer = await makeBearer(instance, k1, 21_000)
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz')
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
PendingNoteError,
)
})
})
const instance = await mint({ meltNeverSettles: true });
const k1 = secret('23');
const bearer = await makeBearer(instance, k1, 21_000);
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
await expect(
receiveBearer(noteUrl(instance, k1, 21_000), [], {
allocateOutputSecrets,
stageRotation,
}),
).rejects.toBeInstanceOf(PendingNoteError);
});
it('refuses to rotate without a durable staging checkpoint', async () => {
const instance = await mint();
const senderK1 = secret('9a');
instance.state.creditNote(senderK1, 21_000);
await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }),
).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError);
// the sender's copy is untouched - no rotation was attempted
expect(instance.state.noteState(senderK1)).toBe('outstanding');
});
it('refuses to rotate without an allocation path', async () => {
const instance = await mint();
const senderK1 = secret('9b');
instance.state.creditNote(senderK1, 21_000);
await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(senderK1)).toBe('outstanding');
});
});
+189 -70
View File
@@ -1,98 +1,217 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import {payWithBearers} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness'
import {
OutputSecretAllocationRequiredError,
PayReturnCheckpointRequiredError,
payWithBearers,
} from './ops';
import { requiredValue } from './test-utils';
import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('30'), 21_000)
const instance = await mint();
const bearer = await makeBearer(instance, secret('30'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
})
expect(result.outcome).toBe('settled')
expect(instance.state.noteState(secret('30'))).toBe('burned')
})
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
});
expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('30'))).toBe('burned');
});
it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint()
const payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, {
const payer = await mint();
const bearer = await makeBearer(payer, secret('31'), 21_000);
const payeeFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
return Promise.resolve(
Response.json({
tag: 'payRequest',
callback: 'https://payee.example/invoice',
minSendable: 1_000,
maxSendable: 100_000_000,
metadata: '[]',
}),
);
}
if (url.startsWith('https://payee.example/invoice')) {
return Promise.resolve(Response.json({ pr: 'lnbc210n1pjqrstuvwxyz' }));
}
return fetch(input, init);
};
const result = await payWithBearers([bearer], 'alice@payee.example', {
amountMsat: 21_000,
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
})
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(payer.state.noteState(secret('31'))).toBe('burned')
})
kit: { fetch: payeeFetch },
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
});
expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/);
expect(payer.state.noteState(secret('31'))).toBe('burned');
});
it('carves the exact amount out of a larger note before melting', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('32'), 50_000)
const instance = await mint();
const bearer = await makeBearer(instance, secret('32'), 50_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
})
expect(result.outcome).toBe('settled')
expect(instance.state.noteState(secret('32'))).toBe('burned')
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(result.carve.change?.amount).toBe(29_000)
const change = requiredValue(result.carve.change)
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding')
})
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets,
onCarve: () => undefined,
});
expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('32'))).toBe('burned');
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.carve.change?.amount).toBe(29_000);
const change = requiredValue(result.carve.change);
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding');
});
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const instance = await mint({meltAlwaysFails: true})
const bearer = await makeBearer(instance, secret('33'), 21_000)
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('33'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
expect(result.outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('33'))).toBe('burned')
// the returned funds come back re-secured at the classification
// rotate's fresh secret - carried as rotatedNote, NOT folded into the
// carve (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote)
expect(rotatedNote.verified).toBe(true)
const returnedK1 = requiredValue(noteK1(rotatedNote.url))
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(rotatedNote.amount).toBe(21_000)
})
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: () => undefined,
});
expect(result.outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('33'))).toBe('burned');
const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true);
expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
});
it('waits for durable recovery-secret staging before rotating returned funds', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('39'), 21_000);
let releaseStage: (() => void) | undefined;
const stageGate = new Promise<void>((resolve) => {
releaseStage = resolve;
});
let stageStarted: (() => void) | undefined;
const stageEntered = new Promise<void>((resolve) => {
stageStarted = resolve;
});
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: async () => {
stageStarted?.();
await stageGate;
},
});
await stageEntered;
expect(instance.state.noteState(secret('39'))).toBe('outstanding');
releaseStage?.();
expect((await payment).outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('39'))).toBe('burned');
});
it('classifies a never-settling melt as unknown-still-pending', async () => {
const instance = await mint({meltNeverSettles: true})
const bearer = await makeBearer(instance, secret('34'), 21_000)
const instance = await mint({ meltNeverSettles: true });
const bearer = await makeBearer(instance, secret('34'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
expect(result.outcome).toBe('unknown-still-pending')
expect(instance.state.noteState(secret('34'))).toBe('pending')
})
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
onReturnReady: () => undefined,
});
expect(result.outcome).toBe('unknown-still-pending');
expect(instance.state.noteState(secret('34'))).toBe('pending');
});
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('35'), 21_000)
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/)
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i)
})
const instance = await mint();
const bearer = await makeBearer(instance, secret('35'), 21_000);
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/);
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i);
});
it('aborts before the melt when the carve commit hook fails', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('36'), 21_000)
const instance = await mint();
const bearer = await makeBearer(instance, secret('36'), 21_000);
// paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start
await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
allocateOutputSecrets,
onCarve: () => {
throw new Error('commit failed')
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the mint
expect(instance.state.noteState(secret('36'))).toBe('burned')
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
})
})
).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the mint.
expect(instance.state.noteState(secret('36'))).toBe('outstanding');
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
});
it('draws the return recovery secret from the caller allocation path', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8a'), 21_000);
const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('8b')];
const stagedSecrets: string[] = [];
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count));
},
onReturnReady: (_carve, recoverySecret) => {
stagedSecrets.push(recoverySecret);
},
});
expect(result.outcome).toBe('failed-funds-returned');
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(stagedSecrets).toEqual(allocated);
expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]);
});
it('allocates nothing while the melt settles cleanly', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('8c'), 21_000);
let allocations = 0;
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets: () => {
allocations += 1;
return Promise.resolve([]);
},
});
expect(result.outcome).toBe('settled');
expect(allocations).toBe(0);
});
it('refuses the return classification without its durable checkpoint', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8d'), 21_000);
await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets,
}),
).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError);
// the payment failed and the mint restored the note; the refusal came
// before the classification rotate, so nothing else moved
expect(instance.state.noteState(secret('8d'))).toBe('outstanding');
});
it('refuses the return classification without an allocation path', async () => {
const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8e'), 21_000);
await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
onReturnReady: () => undefined,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(secret('8e'))).toBe('outstanding');
});
});
+70 -63
View File
@@ -1,17 +1,18 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { noteK1 } from 'lnurlcash-kit';
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import type { CarveResult } from './ops';
import { ensureExactAmount, receiveBearer } from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
stageRotation,
} from './ops.testHarness';
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
@@ -21,70 +22,76 @@ import {
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint()
const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
const instance = await mint();
const k1 = secret('60');
const bearer = await makeBearer(instance, k1, 50_000);
const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined,
});
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
expect(result.consumed).toHaveLength(1)
const partK1 = requiredValue(noteK1(result.note.url))
expect(partK1).not.toBe(k1)
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(partK1)).toBe('outstanding')
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(instance.state.noteState(changeK1)).toBe('outstanding')
})
expect(instance.state.noteState(k1)).toBe('burned');
expect(result.consumed).toHaveLength(1);
const partK1 = requiredValue(noteK1(result.note.url));
expect(partK1).not.toBe(k1);
expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(partK1)).toBe('outstanding');
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(changeK1)).toBe('outstanding');
});
it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000)
const b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
})
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
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')
})
const instance = await mint();
const a = await makeBearer(instance, secret('61'), 20_000);
const b = await makeBearer(instance, secret('62'), 5_000);
const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined,
});
expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(secret('61'))).toBe('burned');
expect(instance.state.noteState(secret('62'))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(mergedK1)).toBe('outstanding');
});
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint()
const senderK1 = secret('63')
instance.state.creditNote(senderK1, 21_000)
const instance = await mint();
const senderK1 = secret('63');
instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
})
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(instance.state.noteState(senderK1)).toBe('burned')
const k1 = requiredValue(noteK1(received.note.url))
expect(k1).not.toBe(senderK1)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
allocateOutputSecrets,
stageRotation,
});
expect(received.rotated).toBe(true);
expect(received.note.amount).toBe(21_000);
expect(instance.state.noteState(senderK1)).toBe('burned');
const k1 = requiredValue(noteK1(received.note.url));
expect(k1).not.toBe(senderK1);
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint()
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
const instance = await mint();
const k1 = secret('64');
const bearer = await makeBearer(instance, k1, 50_000);
instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = [];
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
).rejects.toThrow(/spent/i)
})
})
ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: (carve) => {
checkpoints.push(carve);
},
}),
).rejects.toThrow(/spent/i);
expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { noteK1, noteSignature } from 'lnurlcash-kit';
import { ensureExactAmount } from './ops';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
makeBearer,
mint,
noteUrl,
secret,
signatureKeysFor,
} from './ops.testHarness';
import { fetchNoteInfo } from 'lnurlcash-kit';
// Landed split/merge outputs carry the mint's signature in their URL (sig
// param), and earn verified:true only when that signature checks against a
// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or
// wrong-key signatures - and mints with no trusted keys at all - keep the
// staged outputs unverified; the money is tracked either way (the notes
// exist at the mint), only the offline-verifiable badge is withheld.
describe('landed mutation signature verification', () => {
it('verifies a landed split against the current key and preserves its signature', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a0'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.verified).toBe(true);
const signature = noteSignature(result.note.url);
expect(signature).not.toBeNull();
expect(noteSignature(requiredValue(result.change).url)).not.toBeNull();
// change stays unverified (its true post-fee value is unknown until a
// refresh) even though its signature is preserved
expect(result.change?.verified).toBe(false);
});
it('verifies a landed merge against the current key and preserves its signature', async () => {
const instance = await mint();
const first = await makeBearer(instance, secret('a1'), 3_000);
const second = await makeBearer(instance, secret('a2'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull();
});
it('verifies against the previous key after the mint rotated its signing key', async () => {
// the mint signs every output under its RETIRED key while advertising
// the current one - exactly the rekey window a holder must tolerate
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
});
const bearer = await makeBearer(instance, secret('a3'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
expect(instance.state.previousPubkeys).toHaveLength(1);
expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull();
});
it('rejects verification when the previous key is not trusted', async () => {
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
});
const bearer = await makeBearer(instance, secret('a4'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// only the current key is trusted - the signature under the retired
// key must not verify
mintSignatureKeys: () => [instance.state.pubkey],
});
expect(result.note.verified).toBe(false);
// the note and its signature are still tracked, recoverable by refresh
expect(noteSignature(result.note.url)).not.toBeNull();
const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding');
expect(instance.state.noteState(secret('a4'))).toBe('burned');
});
it('keeps a wrong-key signature staged unverified', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a5'), 21_000);
const stranger = await mint({ privateKey: '33'.repeat(32) });
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// the only trusted key on file belongs to a DIFFERENT mint
mintSignatureKeys: () => [stranger.state.pubkey],
});
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull();
expect(instance.state.noteState(secret('a5'))).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
});
it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => {
const instance = await mint({ signatures: false });
const k1 = secret('a6');
const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance),
});
// the mutation landed (the kit refused the missing signature, the
// rescue probe proved the burn): the inputs are retired, the staged
// outputs are tracked at exactly their staged secrets, unverified
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).toBeNull();
expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
});
it('never verifies a landed mutation without any trusted keys on file', async () => {
const instance = await mint();
const bearer = await makeBearer(instance, secret('a7'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
allocateOutputSecrets,
// no key source at all: nothing may be trusted blindly
});
expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull();
expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
});
});
+8 -6
View File
@@ -1,6 +1,8 @@
import './ops.carve.cases'
import './ops.mint-named.cases'
import './ops.mint-receive.cases'
import './ops.pay.cases'
import './ops.retry-refusal.cases'
import './ops.transfer.cases'
import './ops.carve.cases';
import './ops.carve-crash.cases';
import './ops.signature.cases';
import './ops.mint-named.cases';
import './ops.mint-receive.cases';
import './ops.pay.cases';
import './ops.retry-refusal.cases';
import './ops.transfer.cases';
+70 -43
View File
@@ -1,42 +1,69 @@
import {afterEach, expect} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit'
import { afterEach, expect } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
import type {Bearer} from './types'
import {requiredValue} from './test-utils'
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
export type Mint = Awaited<ReturnType<typeof createMockMint>>
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = []
const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options)
mints.push(instance)
return instance
}
const instance = await createMockMint(options);
mints.push(instance);
return instance;
};
afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close()))
})
await Promise.all(mints.splice(0).map((instance) => instance.close()));
});
export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)));
export const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path: answers
// every reservation with fresh unique secrets, never reusing one
let allocatedSecretCounter = 0;
export const allocateOutputSecrets = (
_server: string,
count: number,
): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => {
allocatedSecretCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)));
});
return Promise.resolve(secrets);
};
// the receive-rotation staging checkpoint - engine tests keep no wallet,
// so staging is a recorded no-op for them
export const stageRotation = async (): Promise<void> => undefined;
// the trusted signing keys a production caller would serve from the
// trusted-mint registry: the mock mint's current key plus any it retired
export const signatureKeysFor = (instance: Mint) => (): readonly string[] => [
instance.state.pubkey,
...instance.state.previousPubkeys,
];
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
buildNoteUrl(`${instance.url}/w`, k1, amountMsat)
buildNoteUrl(`${instance.url}/w`, k1, amountMsat);
let fixtureCounter = 0
let fixtureCounter = 0;
export const makeBearer = async (
instance: Mint,
k1: string,
amountMsat: number,
): Promise<Bearer> => {
instance.state.creditNote(k1, amountMsat)
const url = noteUrl(instance, k1, amountMsat)
const info = await fetchNoteInfo(url)
fixtureCounter += 1
instance.state.creditNote(k1, amountMsat);
const url = noteUrl(instance, k1, amountMsat);
const info = await fetchNoteInfo(url);
fixtureCounter += 1;
return {
id: `fixture-${fixtureCounter}`,
url,
@@ -46,29 +73,29 @@ export const makeBearer = async (
mintPubkey: instance.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
};
};
export const settleLastInvoice = async (instance: Mint): Promise<string> => {
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1))
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`)
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`)
return requiredValue(instance.state.invoices.get(paymentHash)).preimage
}
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1));
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`);
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`);
return requiredValue(instance.state.invoices.get(paymentHash)).preimage;
};
export const settleWhenRequested = async (instance: Mint): Promise<string> => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10));
}
return settleLastInvoice(instance)
}
return settleLastInvoice(instance);
};
export const expectBurned = async (instance: Mint, k1: string): Promise<void> => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(instance.state.noteState(k1)).toBe('burned')
}
expect(instance.state.noteState(k1)).toBe('burned');
};
// a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
@@ -78,13 +105,13 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
// ever sees
export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) {
const first = await fetch(input, init)
await first.arrayBuffer()
return fetch(input, init)
const first = await fetch(input, init);
await first.arrayBuffer();
return fetch(input, init);
}
return fetch(input, init)
}
return impl
}
return fetch(input, init);
};
return impl;
};
+298 -199
View File
@@ -1,260 +1,359 @@
import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {hashK1, noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import {transferBetweenMints} from './ops'
import type {CarveResult} from './ops'
import {requiredValue} from './test-utils'
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
import type { CarveResult, TransferOptions } from './ops';
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
expectBurned,
makeBearer,
mint,
persistOutput,
secret,
settleWhenRequested,
} from './ops.testHarness';
const transferBetweenMints = (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
options: Omit<TransferOptions, 'persistOutput'> = {},
) =>
transferBetweenMintsEngine(bearers, amountMsat, targetMint, {
allocateOutputSecrets,
onMeltReady: () => undefined,
...options,
persistOutput,
});
describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
const fastPoll = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
it('draws the target and source secrets from the caller allocation path', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('91'), 21_000);
const targetSecret = secret('92');
const sourceRecoverySecret = secret('93');
const allocations: Array<{ server: string; count: number }> = [];
const meltReadiness: string[] = [];
it('moves value to another mint: melt at source, claim + rotate at target', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('40')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
allocateOutputSecrets: (server, count) => {
allocations.push({ server, count });
const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
return Promise.resolve(secrets.slice(0, count));
},
onMeltReady: (_carve, recovery) => {
meltReadiness.push(recovery);
},
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
// the staged target note first, then the source recovery secret - an
// exact source carve allocates nothing
expect(allocations).toEqual([
{ server: `127.0.0.1:${target.port}`, count: 1 },
{ server: `127.0.0.1:${source.port}`, count: 1 },
]);
expect(meltReadiness).toEqual([sourceRecoverySecret]);
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret);
});
it('refuses the source melt without its durable checkpoint', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('98'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
allocateOutputSecrets,
persistOutput,
}),
).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError);
// the target stage happened (invoice exists) but the source note is
// untouched and nothing melted
expect(source.state.noteState(secret('98'))).toBe('outstanding');
});
it('refuses at target staging without an allocation path', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('99'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => undefined,
persistOutput,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(source.state.noteState(secret('99'))).toBe('outstanding');
});
it('moves value to another mint at a wallet-chosen target secret', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('40');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
});
const preimage = await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/);
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0,
})
expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
});
expect(result.sourceServer).not.toBe(result.targetServer);
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
expect(target.state.noteState(preimage)).toBeNull();
const newK1 = requiredValue(noteK1(claimed.note.url));
expect(newK1).not.toBe(preimage);
expect(target.state.noteState(newK1)).toBe('outstanding');
});
it('refuses an amount no source mint can cover', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000)
const source = await mint();
const target = await mint();
const k1 = secret('41');
const bearer = await makeBearer(source, k1, 5_000);
let persisted = false;
await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true;
},
}),
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('refuses an invoice the single selected note cannot cover, even when its mint holds more', async () => {
const source = await mint();
const target = await mint();
const selectedK1 = secret('55');
const untappedK1 = secret('56');
// the move page's selected-note constraint: the engine's source list is
// ONLY the picked note, so the sibling note at the same mint must not
// top the amount up
const selected = await makeBearer(source, selectedK1, 5_000);
await makeBearer(source, untappedK1, 50_000);
let persisted = false;
await expect(
transferBetweenMintsEngine([selected], 21_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true;
},
}),
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
expect(source.state.noteState(selectedK1)).toBe('outstanding');
expect(source.state.noteState(untappedK1)).toBe('outstanding');
});
it('rejects a transfer onto the mint the notes are already on', async () => {
const instance = await mint()
const k1 = secret('42')
const bearer = await makeBearer(instance, k1, 21_000)
const instance = await mint();
const k1 = secret('42');
const bearer = await makeBearer(instance, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
).rejects.toThrow(/different target/)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
).rejects.toThrow(/different target/);
expect(instance.state.noteState(k1)).toBe('outstanding');
});
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint()
const dead = await createMockMint()
const deadAddress = `mint@127.0.0.1:${dead.port}`
await dead.close()
const k1 = secret('43')
const bearer = await makeBearer(source, k1, 50_000)
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow()
expect(source.state.noteState(k1)).toBe('outstanding')
})
const source = await mint();
const dead = await createMockMint();
const deadAddress = `mint@127.0.0.1:${dead.port}`;
await dead.close();
const k1 = secret('43');
const bearer = await makeBearer(source, k1, 50_000);
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow();
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('reports a definitively spent source before waiting on the target', async () => {
const source = await mint();
const target = await mint();
const k1 = secret('54');
const bearer = await makeBearer(source, k1, 21_000);
source.state.settleMelt(k1);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`);
expect(result.outcome).toBe('note-already-spent');
});
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true})
const k1 = secret('44')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint({ unconfirmedMutation: true });
const target = await mint({ testHooks: true });
const k1 = secret('44');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
expect(result.mintedAtTarget?.note.amount).toBe(21_000)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
const source = await mint()
const target = await mint({testHooks: true, echoWrongK1: true})
const k1 = secret('45')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled-claim-failed')
await expectBurned(source, k1)
const note = requiredValue(result.claimMaterial?.note)
expect(noteK1(note.url)).toBe(preimage)
expect(note.verified).toBe(false)
expect(note.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
})
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
expect(result.mintedAtTarget?.note.amount).toBe(21_000);
});
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint()
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
const k1 = secret('46')
const bearer = await makeBearer(source, k1, 100_000)
const source = await mint();
const target = await mint({ baseFeeMsat: 1_000, feePpm: 2_000 });
const k1 = secret('46');
const bearer = await makeBearer(source, k1, 100_000);
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
).rejects.toThrow(/enough/);
expect(source.state.noteState(k1)).toBe('outstanding');
});
it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true})
const target = await mint({testHooks: true})
const k1 = secret('47')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint({ meltAlwaysFails: true });
const target = await mint({ testHooks: true });
const k1 = secret('47');
const bearer = await makeBearer(source, k1, 21_000);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
expect(result.outcome).toBe('failed-funds-returned')
expect(result.mintedAtTarget).toBeUndefined()
expect(source.state.noteState(k1)).toBe('burned')
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
expect(result.outcome).toBe('failed-funds-returned');
expect(result.mintedAtTarget).toBeUndefined();
expect(source.state.noteState(k1)).toBe('burned');
// the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote)
expect(rotatedNote.verified).toBe(true)
const returnedK1 = requiredValue(noteK1(rotatedNote.url))
expect(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(rotatedNote.amount).toBe(21_000)
})
const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true);
const returnedK1 = requiredValue(noteK1(rotatedNote.url));
expect(returnedK1).not.toBe(k1);
expect(source.state.noteState(returnedK1)).toBe('outstanding');
expect(rotatedNote.amount).toBe(21_000);
});
it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => {
const source = await mint()
const target = await mint({commentAllowed: 64, testHooks: true})
const k1 = secret('48')
const bearer = await makeBearer(source, k1, 21_000)
it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('48');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(claimed.note.url))
});
const preimage = await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(claimed.note.url));
// the note stands at the wallet's own secret - the quote was bound to
// its hash, and the payment preimage keys nothing at the target
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1))
expect(target.state.noteState(newK1)).toBe('outstanding')
expect(target.state.noteState(preimage)).toBeNull()
})
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(preimage)).toBeNull();
});
it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true})
const target = await mint({commentAllowed: 64, testHooks: true})
const k1 = secret('49')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint({ meltNeverSettles: true });
const target = await mint({ testHooks: true });
const k1 = secret('49');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
// the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) {
await new Promise((resolve) => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await pending
expect(result.outcome).toBe('unknown-still-pending')
expect(result.mintedAtTarget).toBeUndefined()
const result = await pending;
expect(result.outcome).toBe('unknown-still-pending');
expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's
// payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note
const noteSecret = requiredValue(result.claimMaterial?.noteSecret)
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/)
const material = requiredValue(result.claimMaterial?.note)
expect(material.verified).toBe(false)
expect(noteK1(material.url)).toBe(noteSecret)
})
const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
const material = requiredValue(result.claimMaterial?.note);
expect(material.verified).toBe(false);
expect(noteK1(material.url)).toBe(noteSecret);
});
it('rescues a named transfer through the preimage when the target credited it anyway', async () => {
const source = await mint()
// the mintToHashIgnoresH adversary: advertises naming, credits the
// payment hash - and (non-compliantly) still serves verify
const target = await mint({
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[] = []
it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('51'), 21_000);
const seen: CarveResult[] = [];
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
onCarve: (carve) => {
seen.push(carve)
// at hook time the melt must NOT have started: the carved note is
// still outstanding (a melt would leave it pending)
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
seen.push(carve);
if (carve.consumed.length === 0) {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding');
} else {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding');
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned');
}
},
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(seen).toHaveLength(1)
expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
})
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
});
it('aborts before the melt when the carve commit hook fails', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('52')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('52');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => {
throw new Error('commit failed')
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the source, nothing was ever paid
expect(source.state.noteState(k1)).toBe('burned')
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
})
})
).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the source.
expect(source.state.noteState(k1)).toBe('outstanding');
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
});
it('aborts before an exact-note melt when the final checkpoint fails', async () => {
const source = await mint();
const target = await mint();
const k1 = secret('53');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => {
throw new Error('source link commit failed');
},
}),
).rejects.toThrow(/source link commit failed/);
expect(source.state.noteState(k1)).toBe('outstanding');
});
});
+30 -17
View File
@@ -5,12 +5,9 @@
// new/changed notes out - it never mutates wallet state itself.
//
// Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint
// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice
// knows the payment hash, and the mint necessarily saw the preimage -
// the preimage IS the note secret). 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
// - rotate every externally received bearer. Minted notes are staged at a
// wallet-chosen secret before invoice creation and claimed there without
// rotating; the payment preimage is not bearer material
// - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -28,20 +25,36 @@
// source, mint + claim at target)
// ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared'
export {ensureExactAmount} from './ops/carve'
export type {CarveResult} from './ops/carve'
export {prepareMint, claimMintedNote} from './ops/mint'
export type {PreparedMint, ClaimedNote} from './ops/mint'
export {receiveBearer} from './ops/receiveBearer'
export {payWithBearers} from './ops/pay'
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
export {transferBetweenMints} from './ops/transfer'
export { UncertainOutcomeError } from './ops/shared';
export type { FundOperationOptions, PollOptions } from './ops/shared';
export { OutputSecretAllocationRequiredError } from './ops/allocation';
export type { OutputSecretAllocator } from './ops/allocation';
export {
CarveCheckpointRequiredError,
ensureExactAmount,
UnsupportedMultiBatchMergeError,
} from './ops/carve';
export type { CarveOptions, CarveResult } from './ops/carve';
export {
MintedNoteSpentError,
prepareMint,
claimMintedNote,
recoverStagedMintOutput,
} from './ops/mint';
export type { PreparedMint, PrepareMintOptions, ClaimedNote, StagedMintRecovery } from './ops/mint';
export { receiveBearer, ReceiveRotationStagingRequiredError } from './ops/receiveBearer';
export type { ReceiveBearerOptions, ReceivedNote } from './ops/receiveBearer';
export { payWithBearers, PayReturnCheckpointRequiredError } from './ops/pay';
export type { PayOutcome, PayResult, PayOptions } from './ops/pay';
export {
recoverPendingTransferSource,
transferBetweenMints,
TransferMeltCheckpointRequiredError,
} from './ops/transfer';
export type {
TransferClaimMaterial,
TransferOptions,
TransferOutcome,
TransferQuote,
TransferResult,
} from './ops/transfer'
} from './ops/transfer';
+43
View File
@@ -0,0 +1,43 @@
// Output-secret allocation: a mint-touching operation never invents the
// secrets its outputs will live at. It asks the caller's durable allocation
// path (the wallet's BIP-32 counter reservation, see
// stores/walletFunds.ts) for exactly the secrets it is about to stage, at
// the moment the target server is known - before any hash of them can
// reach a mint. The reservation commit (counter bump plus encrypted
// journal record) is what makes a crash between staging and wire safe:
// indices may be burned, never reused, and the staged secrets are
// recoverable from the funds document.
//
// The ops layer stays framework-free: this is just the option type and the
// point-of-use requirement. Tests inject deterministic allocators;
// production callers serve reserveCashIndices-backed secrets.
export type OutputSecretAllocator = (server: string, count: number) => Promise<readonly string[]>;
export class OutputSecretAllocationRequiredError extends Error {
override readonly name = 'OutputSecretAllocationRequiredError';
constructor() {
super('This operation requires durably allocated output secrets.');
}
}
// The one place secrets enter an operation: exactly `count` fresh secrets
// for `server`, or a rejection before anything is staged or sent. A
// short/long/wrong-shaped answer is a caller bug - fail the operation
// rather than stage a note at an unmappable secret.
export const requireOutputSecrets = async (
allocate: OutputSecretAllocator | undefined,
server: string,
count: number,
): Promise<readonly string[]> => {
if (!allocate) throw new OutputSecretAllocationRequiredError();
const secrets = await allocate(server, count);
if (
secrets.length !== count ||
secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret))
) {
throw new Error('The output secret allocation did not match the request.');
}
return secrets;
};
+210 -244
View File
@@ -2,41 +2,72 @@
// and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from.
//
// A carve whose mutation landed reports itself through options.onCarve
// BEFORE returning, so a caller about to do something slow (a settlement
// wait) can durably commit first - a flow aborted mid-wait must never
// leave burned inputs looking spendable or strand the fresh outputs.
// Every output secret is chosen and reported through options.onCarve BEFORE
// its hash reaches the mint. That first checkpoint stages possible outputs
// without retiring inputs; a second checkpoint retires the inputs only after
// the mutation is known to have landed.
import {
AmbiguousMutationError,
mergeNotes,
mergeBatches,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
serverOf,
settleNote,
splitNote,
withNewK1,
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
} from 'lnurlcash-kit';
import type { Bearer, NewBearer } from '../types';
import type { OutputSecretAllocator } from './allocation';
import { requireOutputSecrets } from './allocation';
import { mergeAmbiguitySafe } from './carveRecovery';
import type { FundOperationOptions } from './shared';
import {
assertFundOwner,
landedNoteVerifies,
probeMutationOutput,
UncertainOutcomeError,
withMutationSafety,
} from './shared';
// the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input
// server-side, so the outputs are the only money left and must be tracked
// first; a crash between the two must strand a duplicate, never a secret
// The same shape serves both checkpoint phases: the pre-wire phase carries
// note/change with an empty consumed list, and the landed phase carries the
// already-staged note plus the inputs the caller can now retire.
export type CarveResult = {
// the exact-amount note, ready to hand over or melt
note: NewBearer
note: NewBearer;
// the remainder note, when the carve split a larger input
change?: NewBearer
change?: NewBearer;
// the input notes burned server-side by the carve (empty when a single
// note already held exactly the target amount)
consumed: Bearer[]
consumed: Bearer[];
};
export class UnsupportedMultiBatchMergeError extends Error {
override readonly name = 'UnsupportedMultiBatchMergeError';
constructor() {
super('This carve would require multiple merge requests and cannot be performed safely.');
}
}
export class CarveCheckpointRequiredError extends Error {
override readonly name = 'CarveCheckpointRequiredError';
constructor() {
super('A mutating carve requires a durable onCarve checkpoint.');
}
}
export type CarveOptions = FundOperationOptions & {
// the caller's durable allocation path for the carve's output secrets
// (split: target + change; merge: the combined note). Required for any
// mutating carve: the secrets come from the wallet's reserved BIP-32
// indices so a restart can never reuse or lose them.
readonly allocateOutputSecrets?: OutputSecretAllocator;
};
// Selection: only notes that can actually take part - verified (callback
// known), not locally spent, holding a real k1 (device-backed mirrors are
// excluded; the ops engine cannot mutate a secret it doesn't hold). Notes
@@ -46,53 +77,59 @@ export type CarveResult = {
//
// Execution, mirroring lnurl-wallet's SendDialog:
// - one note already exact: returned as-is, nothing burned
// - several notes summing exactly: one merge, then settle (reads the true
// post-fee value back and rotates, since the read put k1 on the wire)
// - several notes summing exactly: one merge; folds spanning multiple
// requests are rejected because partial progress needs a larger journal
// - total above target (one or many notes): a single split request (LUD-25
// split takes many k1s - no merge round trip first), then the change is
// settled for its true value; the target part carries the mint's
// signature and needs no settle
// split takes many k1s - no merge round trip first); change stays
// unverified until refresh because a second settle mutation would create
// another crash window
export const ensureExactAmount = async (
bearers: Bearer[],
amountMsat: number,
options: FundOperationOptions = {},
options: CarveOptions = {},
): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
throw new Error('Amount must be a positive whole number of msat.');
}
// the forced mutation policy (signatures required, one byte-identical
// replay) applies to every mint call below regardless of caller options
const mutationOptions = withMutationSafety(options);
const eligible = bearers.filter(
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
(b) => b.verified && !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
);
// per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at
// all is always better than carving)
const byServer = new Map<string, Bearer[]>()
const byServer = new Map<string, Bearer[]>();
for (const b of eligible) {
const server = serverOf(b.url)
byServer.set(server, [...(byServer.get(server) ?? []), b])
const server = serverOf(b.url);
byServer.set(server, [...(byServer.get(server) ?? []), b]);
}
let pick: Bearer[] | null = null
let pick: Bearer[] | null = null;
for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount)
const exact = sorted.find((b) => b.amount === amountMsat)
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
if (!candidate) continue
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
const sorted = [...group].sort((a, b) => a.amount - b.amount);
const exact = sorted.find((b) => b.amount === amountMsat);
const candidate = exact ? [exact] : accumulate(sorted, amountMsat);
if (!candidate) continue;
if (!pick || better(candidate, pick, amountMsat)) pick = candidate;
}
if (!pick) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
}
const base = pick[0]
const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map((b) => requireNoteK1(b.url))
const base = pick[0];
const total = pick.reduce((sum, b) => sum + b.amount, 0);
const k1s = pick.map((b) => requireNoteK1(b.url));
// a carve that burned inputs reports itself the moment its outcome is
// known, before the caller's flow moves on to anything slow or
// uncertain - see FundOperationOptions.onCarve
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
}
const checkpoint = async (result: CarveResult): Promise<void> => {
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
assertFundOwner(options);
await options.onCarve(result);
assertFundOwner(options);
};
const stage = (result: CarveResult): Promise<void> => checkpoint({ ...result, consumed: [] });
const retire = (note: NewBearer): Promise<void> => checkpoint({ note, consumed: pick });
if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched
@@ -105,256 +142,185 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey,
},
consumed: [],
}
};
}
if (total === amountMsat) {
// merge path: many notes, exact sum - merge into one, then settle it
// (true value + fresh secret; a failed settle leaves an unverified
// note a refresh can repair, not a lost secret)
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
const unverified: NewBearer = {
url: withNewK1(base.url, merged.k1, total, merged.signature),
if (mergeBatches(base.callback, k1s).length !== 1) {
throw new UnsupportedMultiBatchMergeError();
}
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
const [mergeSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
1,
);
const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
}
// a merge whose answer was lost leaves the service in an unknown
// state from here - settling fires another mutation (the rotate
// inside settleNote) at it, whose own ambiguous failure would strand
// the rescued secret. Don't compound: return unverified and let a
// refresh repair.
if (merged.rescued) return checkpoint({note: unverified, consumed: pick})
try {
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return checkpoint({
note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
},
consumed: pick,
})
} catch {
return checkpoint({note: unverified, consumed: pick})
}
};
await stage({ note: staged, consumed: pick });
assertFundOwner(options);
const merged = await mergeAmbiguitySafe(base, k1s, {
...mutationOptions,
randomSecret: () => mergeSecret,
});
// a landed merge reports its signature (carried in the URL's sig param)
// and earns verified only when that signature checks against a trusted
// current or previous key; a rescued merge saw no answer, so its note
// stays exactly as staged - unverified, same URL
const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature);
const result: CarveResult = merged.rescued
? { note: staged, consumed: pick }
: {
note: {
...staged,
url: landedUrl,
verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
},
consumed: pick,
};
await retire(result.note);
return result;
}
// split path: total above target - one split request across all picked
// k1s, carving the target off and leaving the change as a fresh note
let partK1: string
let partSignature: string | undefined
let changeK1: string
let changeSignature: string | undefined
let partVerified = false
// true when the split's answer was lost and the probe proved the burn -
// the carried secrets were rescued, but the service is in an unknown
// state, so the change is NOT settled (that would fire another mutation
// at it, whose own ambiguous failure would strand the rescued secret)
let rescued = false
assertFundOwner(options)
if (!options.onCarve) {
throw new CarveCheckpointRequiredError();
}
const [partK1, changeK1] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
2,
);
let partSignature: string | undefined;
let changeSignature: string | undefined;
const stagedNote: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
const stagedChange: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
};
await stage({ note: stagedNote, change: stagedChange, consumed: pick });
let secretIndex = 0;
const preparedSecret = (): string => {
secretIndex += 1;
if (secretIndex === 1) return partK1;
if (secretIndex === 2) return changeK1;
throw new Error('The split requested more output secrets than were staged.');
};
assertFundOwner(options);
try {
const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1
partSignature = parts.signature
changeK1 = parts.change
changeSignature = parts.changeSignature
partVerified = true
const parts = await splitNote(base.callback, k1s, amountMsat, {
...mutationOptions,
randomSecret: preparedSecret,
});
partSignature = parts.signature;
changeSignature = parts.changeSignature;
} catch (err) {
if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
const outcome = await probeBurnedNote(base.url, mutationOptions);
if (outcome === 'live') throw err; // nothing burned - a plain failure
if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[],
);
}
// 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else {
// A classified refusal can still be a LANDED split: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless.
const carried = newSecretsOf(err)
if (carried.length !== 2) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err // never landed - a plain refusal
const carried = newSecretsOf(err);
if (carried.length !== 2) throw err;
const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions);
if (outcome === 'absent') throw err; // never landed - a plain refusal
if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
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,
},
],
)
'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.',
[],
);
}
// 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left
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 = {
url: withNewK1(base.url, partK1, amountMsat, partSignature),
url: noteUrl,
callback: base.callback,
amount: amountMsat,
verified: partVerified,
verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey,
}
// settleNote: the change may be worth less than total - amount if this
// mint charges split fees (LUD-25 deducts them from change, never the
// split-off amount) - it comes back at its true value, or stays
// unverified at the naive pre-fee one for a refresh to repair
let change: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature),
};
// The change may be worth less than total - amount when the mint charges
// split fees. It remains unverified at that upper bound until refresh;
// settling it here would rotate to an unstaged secret in a second request.
const changeUrl =
changeSignature === undefined
? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature);
const change: NewBearer = {
url: changeUrl,
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
}
if (!rescued) {
try {
const settled = await settleNote(
base.url,
changeK1,
total - amountMsat,
changeSignature,
options,
)
change = {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
}
} catch (error) {
// settle is best-effort - the unverified change above is still tracked
if (!(error instanceof Error)) throw error
}
}
return checkpoint({note, change, consumed: pick})
}
};
const result = { note, change, consumed: pick };
await retire(note);
return result;
};
// smallest-first accumulation until the target is covered; null when the
// whole group can't reach it
const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => {
const picked: Bearer[] = []
let total = 0
const picked: Bearer[] = [];
let total = 0;
for (const b of sorted) {
picked.push(b)
total += b.amount
if (total >= amountMsat) return picked
picked.push(b);
total += b.amount;
if (total >= amountMsat) return picked;
}
return null
}
return null;
};
// the better carve plan: less waste first, then fewer notes burned
const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0)
const wasteA = sum(a) - target
const wasteB = sum(b) - target
if (wasteA !== wasteB) return wasteA < wasteB
return a.length < b.length
}
// merge with the full ambiguity protocol: probe one input, rescue the
// carried secret only once the burn is confirmed, surface it unverified
// when the probe can't tell either
const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
total: number,
options: FundOperationOptions,
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
try {
const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) {
if (err instanceof AmbiguousMutationError) {
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}
}
}
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0);
const wasteA = sum(a) - target;
const wasteB = sum(b) - target;
if (wasteA !== wasteB) return wasteA < wasteB;
return a.length < b.length;
};
+50
View File
@@ -0,0 +1,50 @@
// A merge failure can hide a landed mutation because callback GETs may be
// retried below the engine. This module owns the probes that distinguish a
// plain refusal from a live or still-uncertain wallet-chosen output.
import { AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote } from 'lnurlcash-kit';
import type { Bearer } from '../types';
import type { FundOperationOptions } from './shared';
import { probeMutationOutput, UncertainOutcomeError } from './shared';
export type MergeOutcome = {
readonly rescued: boolean;
// the mint's signature over the combined note - present only when the
// merge's own answer was seen; a rescued merge (lost answer, retried
// twin) has none to offer
readonly signature?: string;
};
export const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
options: FundOperationOptions,
): Promise<MergeOutcome> => {
try {
const merged = await mergeNotes(base.callback, k1s, options);
return { rescued: false, signature: merged.signature };
} catch (error) {
if (error instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options);
if (outcome === 'live') throw error;
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.',
[],
);
}
return { rescued: true };
}
const carried = newSecretsOf(error);
if (carried.length !== 1) throw error;
const outcome = await probeMutationOutput(base.url, carried[0], options);
if (outcome === 'absent') throw error;
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.',
[],
);
}
return { rescued: true };
}
};

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