mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30f98fe49 | ||
|
|
9f1c9a7976 | ||
|
|
d026533456 | ||
|
|
e6fef1e9cb | ||
|
|
4caa075052 | ||
|
|
454a9db1d5 | ||
|
|
3a7aa682c9 | ||
|
|
1beb474012 | ||
|
|
5d6e092ae4 | ||
|
|
b2b5974dae | ||
|
|
f62e5d7ad4 | ||
|
|
a91ef30498 | ||
|
|
8e9c9dcbbc | ||
|
|
04e62110b6 | ||
|
|
29830dc9e3 | ||
|
|
22b1c5595c | ||
|
|
e6d0712258 | ||
|
|
3876791a0b | ||
|
|
a30c4bb2cc | ||
|
|
7b7d4edaf3 | ||
|
|
ff9e8a073e | ||
|
|
e94549a16e | ||
|
|
641608eff0 | ||
|
|
0cec423d6f | ||
|
|
7e4e67e1cd | ||
|
|
aaee045916 | ||
|
|
9374a87d0d | ||
|
|
b7c0a33a69 | ||
|
|
6099bb4b6b | ||
|
|
d48deeb6e0 | ||
|
|
0677b65016 | ||
|
|
cbe49a83af | ||
|
|
9408cf91d6 | ||
|
|
dc59f2a08d | ||
|
|
09f7fe0287 | ||
|
|
9e37fee841 | ||
|
|
587dd11ba8 | ||
|
|
f3d5cc4b31 | ||
|
|
50b2427b8d | ||
|
|
48be23dceb | ||
|
|
4cc8e0aba2 | ||
|
|
6bb07ff9ec | ||
|
|
637376e291 | ||
|
|
39a144cf50 | ||
|
|
ae5064b69a | ||
|
|
c9963934aa | ||
|
|
67b2122e52 | ||
|
|
58268b2921 | ||
|
|
9f4ae3b0f5 | ||
|
|
bb147faef1 | ||
|
|
bfa0572090 | ||
|
|
6892a3cc07 | ||
|
|
338b2812e3 | ||
|
|
e892a330fc | ||
|
|
72f387f6e5 | ||
|
|
76d59d8d08 | ||
|
|
a40079200d | ||
|
|
578613c869 | ||
|
|
3c2a2cc4a5 | ||
|
|
401a26dc6b | ||
|
|
f087ebe2f2 | ||
|
|
c59521a421 | ||
|
|
3f6010b312 | ||
|
|
99e6ae1b12 | ||
|
|
81c28ec16d | ||
|
|
e98373491f | ||
|
|
2e0f0038f7 | ||
|
|
3acc63e968 | ||
|
|
c319e11d3e | ||
|
|
62b93baa05 | ||
|
|
5b3a69e910 | ||
|
|
9fa4abdaf3 | ||
|
|
54031dc8de | ||
|
|
e9bbb358aa | ||
|
|
83c9e5b95f | ||
|
|
d735aca885 | ||
|
|
82f008b33f | ||
|
|
d890a69c40 | ||
|
|
7890a492f3 | ||
|
|
3ed214c157 | ||
|
|
513c67fc14 | ||
|
|
09b7b63c9d | ||
|
|
fff24a75b2 | ||
|
|
b52172c0cb | ||
|
|
228a92b0f0 | ||
|
|
9f6bcad706 | ||
|
|
3054834cdb | ||
|
|
845ec410e5 | ||
|
|
02f901c87c | ||
|
|
c4c9d8a94a | ||
|
|
7f69f781f5 | ||
|
|
6fa4283ed6 | ||
|
|
22420b9112 | ||
|
|
f55f208836 | ||
|
|
7607e47358 | ||
|
|
0862a10b01 | ||
|
|
586d12b666 | ||
|
|
69fbdb68ee | ||
|
|
1dbf5788f9 | ||
|
|
12e4ec9199 | ||
|
|
d9be457221 | ||
|
|
058b10ddf1 | ||
|
|
acfab83438 | ||
|
|
0e0133f550 | ||
|
|
b258d86af7 | ||
|
|
2a90343e7e | ||
|
|
b6fff605a1 | ||
|
|
8548afcd21 | ||
|
|
30d1d0aeae | ||
|
|
84441f53e3 | ||
|
|
cb5e0e3586 | ||
|
|
22e987bbb3 |
@@ -28,3 +28,4 @@ yarn-error.log*
|
|||||||
# Playwright (e2e/ itself is tracked - only its artifacts are ignored)
|
# Playwright (e2e/ itself is tracked - only its artifacts are ignored)
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
/.playwright-mcp/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.0.2"
|
".": "0.0.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,25 +25,25 @@ e2e/ # Playwright (own AGENTS.md)
|
|||||||
|
|
||||||
## WHERE TO LOOK
|
## WHERE TO LOOK
|
||||||
|
|
||||||
| Task | Location | Notes |
|
| Task | Location | Notes |
|
||||||
|------|----------|-------|
|
| ------------------- | ------------------------------------------------------ | ---------------------------------------------- |
|
||||||
| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state |
|
| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state |
|
||||||
| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern |
|
| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern |
|
||||||
| Native feature | `src/capabilities/` | never import plugins elsewhere |
|
| Native feature | `src/capabilities/` | never import plugins elsewhere |
|
||||||
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
|
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
|
||||||
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
|
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
|
||||||
|
|
||||||
## CODE MAP
|
## CODE MAP
|
||||||
|
|
||||||
| Module | Role |
|
| Module | Role |
|
||||||
|--------|------|
|
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer |
|
| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer |
|
||||||
| `lnurlcash/storage/` | AES-GCM bearers+activity, backup (merge entry point), settings |
|
| `lnurlcash/storage/` | AES-GCM bearers+activity, owner-bound credentials and trusted mints, backup (merge entry point), settings |
|
||||||
| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap |
|
| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap |
|
||||||
| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key |
|
| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key |
|
||||||
| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup |
|
| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup |
|
||||||
| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) |
|
| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) |
|
||||||
| `stores/wallet.ts` | state none/locked/unlocked; linking key in memory only while unlocked |
|
| `stores/wallet.ts` | serialized owner lifecycle; state none/locked/unlocked; linking key in memory only while unlocked |
|
||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
|
||||||
@@ -79,8 +79,22 @@ npm run cap:sync # build + cap sync android
|
|||||||
|
|
||||||
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
|
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
|
||||||
`sass-embedded` is aliased to pure-JS `sass` via npm overrides.
|
`sass-embedded` is aliased to pure-JS `sass` via npm overrides.
|
||||||
- lnurlcash-kit comes from `github:TheCryptoDonkey/lnurlcash-kit` pinned to
|
- `lnurlcash-kit@0.8.0` and `lnurlcash-conformance@0.7.0` are exact npm
|
||||||
a commit — the `prepare` script (our merged PR#1) builds dist on install.
|
registry packages. Keep their manifest pins, registry tarball URLs, and lock
|
||||||
|
integrity values intact.
|
||||||
|
- Credential and authorization records are bound to the canonical owner ID
|
||||||
|
derived from the linking key. Ordinary writes require an exact match with the
|
||||||
|
persisted saved-key owner. Ownerless legacy records migrate only after a
|
||||||
|
proven unlock, never from a restored file claim or passkey-first attempt.
|
||||||
|
- Wallet create, unlock, lock, restore, and forget transitions are serialized.
|
||||||
|
Forget locks first, drains NWC, clears runtime keys and owner namespaces,
|
||||||
|
then removes the saved key only after biometric deletion succeeds.
|
||||||
|
- Storage events are wakeups, not authoritative payloads. Re-read matching
|
||||||
|
storage on an event, including `key === null`, before converging state or
|
||||||
|
invalidating a stale owner tab. Web Locks serialize supported browsers but
|
||||||
|
do not make another tab's localStorage cache current; trusted mints reconcile
|
||||||
|
through a durable IndexedDB commit mirror before success. `withStorageLock`
|
||||||
|
falls back to local execution without a cross-tab guarantee.
|
||||||
- tsconfig deliberately relaxed (`exactOptionalPropertyTypes` etc. off) to
|
- tsconfig deliberately relaxed (`exactOptionalPropertyTypes` etc. off) to
|
||||||
keep the protocol core untouched; `src/lnurlcash` has an eslint override.
|
keep the protocol core untouched; `src/lnurlcash` has an eslint override.
|
||||||
- Gitea remote dropped; origin = GitHub. Gitea mirror = pull-mirror on the
|
- Gitea remote dropped; origin = GitHub. Gitea mirror = pull-mirror on the
|
||||||
|
|||||||
+116
-20
@@ -1,33 +1,129 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
|
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
|
* adopt lnurlcash-kit 0.8 with BIP-32-derived note secrets ([d026533](https://github.com/tompro/sattle/commit/d02653345639539c9c9268c1b6f1fd2a0359bb30))
|
||||||
* capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
|
* **backup:** preserve BIP-32 counters ([641608e](https://github.com/tompro/sattle/commit/641608eff07464613570818cf1463ca28702d7c7))
|
||||||
* capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
|
* **biometrics:** unlock complete wallet material ([b7c0a33](https://github.com/tompro/sattle/commit/b7c0a33a6968756e372a8b99156d70b766c2691d))
|
||||||
* deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
|
* **composables:** route fund dialogs through the durable seam ([8e9c9dc](https://github.com/tompro/sattle/commit/8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071))
|
||||||
* inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
|
* **funds:** persist BIP-32 counters atomically ([0cec423](https://github.com/tompro/sattle/commit/0cec423d6fe6edafa1d4d3a6520d2bc659906a1c))
|
||||||
* manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
|
* **funds:** recover staged mutations ([3876791](https://github.com/tompro/sattle/commit/3876791a0bc12851d74f7dd98424c4b30b788207))
|
||||||
* native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
|
* **keys:** persist BIP-32 wallet material ([d48deeb](https://github.com/tompro/sattle/commit/d48deeb6e0b32ffd3f5abc5fbc2080198e4ecb5b))
|
||||||
* nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
|
* **ops:** draw mint-touching output secrets from caller allocation ([22b1c55](https://github.com/tompro/sattle/commit/22b1c5595cea73c8e796e22b381d8c6a5d7fd564))
|
||||||
* nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
|
* **passkeys:** wrap complete wallet material ([6099bb4](https://github.com/tompro/sattle/commit/6099bb4b6b994b682662a33ef18da388e675b07e))
|
||||||
* nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
|
* **trust:** keep previous mint signing key verifiable ([a30c4bb](https://github.com/tompro/sattle/commit/a30c4bb2cc60250a22c3c3f2c044393bd4d2d778))
|
||||||
* pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
|
* **wallet:** allocate operation secrets from the cash root ([e6d0712](https://github.com/tompro/sattle/commit/e6d0712258dd31d85690687824d25b48240ca16e))
|
||||||
* 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
|
### Bug Fixes
|
||||||
|
|
||||||
* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
|
* **composables:** commit carve checkpoints by output secret ([04e6211](https://github.com/tompro/sattle/commit/04e62110b66c4be50f07a97aec48d3f68f6c9989))
|
||||||
* discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
|
* **nwc:** await durable commits before responses ([29830dc](https://github.com/tompro/sattle/commit/29830dc9e3d34d2e626d0d6df52c86beca02d4b8))
|
||||||
|
* **passkeys:** bind slots to wallet material ([9374a87](https://github.com/tompro/sattle/commit/9374a87d0d64cfd70f5bf938cdf4582331cea87a))
|
||||||
|
* **unlock:** bind factors to wallet material ([7e4e67e](https://github.com/tompro/sattle/commit/7e4e67e1cddc836830add3baf787a0c5368102f7))
|
||||||
|
* **wallet:** activate complete wallet material ([e94549a](https://github.com/tompro/sattle/commit/e94549a16e983875eaf445287108b62212255d61))
|
||||||
|
|
||||||
|
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400))
|
||||||
|
- **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb))
|
||||||
|
- **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2))
|
||||||
|
- **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf))
|
||||||
|
- **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed))
|
||||||
|
- **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c))
|
||||||
|
- support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62))
|
||||||
|
- commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306))
|
||||||
|
- **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927))
|
||||||
|
- **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86))
|
||||||
|
- **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df))
|
||||||
|
- replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7))
|
||||||
|
|
||||||
|
## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc))
|
||||||
|
- add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331))
|
||||||
|
- apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1))
|
||||||
|
- apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37))
|
||||||
|
- await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6))
|
||||||
|
- await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e))
|
||||||
|
- bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a))
|
||||||
|
- commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4))
|
||||||
|
- converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c))
|
||||||
|
- create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30))
|
||||||
|
- drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904))
|
||||||
|
- expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755))
|
||||||
|
- fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544))
|
||||||
|
- harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
|
||||||
|
- invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8))
|
||||||
|
- preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341))
|
||||||
|
- restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635))
|
||||||
|
- scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf))
|
||||||
|
- scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb))
|
||||||
|
- scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab))
|
||||||
|
- serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
|
||||||
|
- serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e))
|
||||||
|
- validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece))
|
||||||
|
- await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205))
|
||||||
|
- clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329))
|
||||||
|
- commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354))
|
||||||
|
- consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6))
|
||||||
|
- drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18))
|
||||||
|
- fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5))
|
||||||
|
- fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a))
|
||||||
|
- fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60))
|
||||||
|
- fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6))
|
||||||
|
- fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c))
|
||||||
|
- hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382))
|
||||||
|
- mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff))
|
||||||
|
- normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a))
|
||||||
|
- preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155))
|
||||||
|
- publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c))
|
||||||
|
- surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa))
|
||||||
|
- validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
|
||||||
|
- validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504))
|
||||||
|
- **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8))
|
||||||
|
|
||||||
|
## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09))
|
||||||
|
- capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece))
|
||||||
|
- capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c))
|
||||||
|
- deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd))
|
||||||
|
- inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6))
|
||||||
|
- manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607))
|
||||||
|
- native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7))
|
||||||
|
- nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f))
|
||||||
|
- nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0))
|
||||||
|
- nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a))
|
||||||
|
- pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2))
|
||||||
|
- reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d))
|
||||||
|
- sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0))
|
||||||
|
- send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f))
|
||||||
|
- wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898))
|
||||||
|
- wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178))
|
||||||
|
- webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792))
|
||||||
|
- discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 protom
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,38 +1,67 @@
|
|||||||
# sattle (sattle)
|
<p align="center">
|
||||||
|
<img src="src/assets/sattle-wallet.png" alt="sattle wallet logo" width="150" />
|
||||||
|
<br />
|
||||||
|
<img src="src/assets/sattle-text.png" alt="sattle" width="220" />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Install the dependencies
|
<p align="center">
|
||||||
|
A Lightning wallet that feels like handing someone cash.
|
||||||
|
</p>
|
||||||
|
|
||||||
```bash
|
<p align="center">
|
||||||
pnpm install
|
<a href="https://github.com/tompro/sattle/actions/workflows/ci.yml"><img src="https://github.com/tompro/sattle/actions/workflows/ci.yml/badge.svg" alt="ci" /></a>
|
||||||
# or: yarn/npm/bun install
|
<a href="https://github.com/tompro/sattle/releases"><img src="https://img.shields.io/github/v/release/tompro/sattle" alt="latest release" /></a>
|
||||||
```
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
### Start the app in development mode (HMR, error reporting, etc.)
|
---
|
||||||
|
|
||||||
```bash
|
## What is sattle?
|
||||||
quasar dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Format & Lint the files
|
sattle is a wallet for **Lightning bearer notes** (LNURLcash, LUD-25). A bearer
|
||||||
|
note is a link that _is_ the money: receive one and the sats are yours, send one
|
||||||
|
and they move — no invoices to request, no amounts to negotiate, no Lightning
|
||||||
|
jargon to learn. Receiving and sending takes seconds.
|
||||||
|
|
||||||
```bash
|
It's an installable web app (PWA) that works on any modern phone or desktop, with
|
||||||
pnpm run lint
|
an Android app wrapping the same experience. Built for people who just want to
|
||||||
# or: yarn/npm/bun run lint
|
send and receive.
|
||||||
```
|
|
||||||
|
|
||||||
...or just check formatting & linting:
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/main.png" alt="Main screen" width="260" />
|
||||||
|
<img src="docs/screenshots/receive.png" alt="Receive — Lightning or bearer note" width="260" />
|
||||||
|
<img src="docs/screenshots/manage-mints.png" alt="Manage mints" width="260" />
|
||||||
|
</p>
|
||||||
|
|
||||||
```bash
|
## Highlights
|
||||||
pnpm run lint:check
|
|
||||||
# or: yarn/npm/bun run lint:check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build the app for production
|
- **Instant notes** — receive sats as a link or QR code; pass them on the same
|
||||||
|
way. No invoice round-trips, no address book.
|
||||||
|
- **Mint trust, made visible** — notes are issued by mints you choose to trust.
|
||||||
|
sattle pins each mint's signing key and asks you to review any key change
|
||||||
|
before accepting it, so a mint can't quietly swap its identity.
|
||||||
|
- **Move funds between mints** — rebalance from one mint to another in a few
|
||||||
|
taps.
|
||||||
|
- **Backups that fit your habits** — export an encrypted backup file, or let
|
||||||
|
sattle keep an encrypted backup on nostr (kind 30078) that only you can read.
|
||||||
|
- **Passkey and biometric unlock** — open the wallet with your device's passkey
|
||||||
|
or fingerprint. A password fallback is always available.
|
||||||
|
- **Connect other apps** — built-in Nostr Wallet Connect (NWC) service lets
|
||||||
|
external apps pay from your wallet while sattle is open and unlocked, with
|
||||||
|
per-connection budgets you control.
|
||||||
|
|
||||||
```bash
|
## How it works
|
||||||
quasar build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customize the configuration
|
sattle uses LNURLcash (LUD-25) bearer notes: each note is a URL that carries
|
||||||
|
spendable Lightning value. Your notes live **encrypted on your device** — the
|
||||||
|
underlying sats are held by the mints that issued the notes, so choosing and
|
||||||
|
reviewing mints is part of the model, and sattle surfaces that rather than
|
||||||
|
hiding it. Spend a note and the mint pays out over Lightning; receive one and
|
||||||
|
your wallet claims it instantly.
|
||||||
|
|
||||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-file).
|
## For developers
|
||||||
|
|
||||||
|
sattle is a Quasar / Vue 3 PWA with a Capacitor Android wrapper (no signed
|
||||||
|
release yet). The protocol core is framework-free and lives in
|
||||||
|
[`src/lnurlcash/`](src/lnurlcash/). Contributor docs, architecture notes and
|
||||||
|
project conventions are in [AGENTS.md](AGENTS.md).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Warms the freshly booted dev server before any test runs. `quasar dev`
|
||||||
|
// answers HTTP the moment its port is up, but vite still transforms the
|
||||||
|
// module graph on the first page load - a window in which the first test's
|
||||||
|
// multi-step onboarding flow can race lazy compilation (observed as the
|
||||||
|
// create flow never reaching /#/). One full load plus a reload completes
|
||||||
|
// that work before the suite starts. Uses the same system-chromium
|
||||||
|
// resolution as the main config (CI falls back to the bundled browser).
|
||||||
|
const findSystemChromium = (): string | undefined => {
|
||||||
|
try {
|
||||||
|
return execSync('command -v chromium').toString().trim() || undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalSetup = async (): Promise<void> => {
|
||||||
|
const executablePath = process.env.CHROMIUM_PATH || findSystemChromium();
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await (await browser.newContext({ baseURL: 'http://localhost:9333' })).newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Get started' })
|
||||||
|
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
|
await page.reload();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Get started' })
|
||||||
|
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
+297
-36
@@ -1,13 +1,26 @@
|
|||||||
import type { Page, Route } from '@playwright/test';
|
import type { Page, Route } from '@playwright/test';
|
||||||
|
import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit';
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
// The mock mint lives on a .test origin that never resolves - every request
|
// The mock mint lives on a .test origin that never resolves - every request
|
||||||
// to it is intercepted by Playwright and fulfilled in-process, so the specs
|
// to it is intercepted by Playwright and fulfilled in-process, so the specs
|
||||||
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
|
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
|
||||||
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
|
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
|
||||||
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
|
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
|
||||||
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
|
// mutating GET on the callback (rotate/split/merge/melt) expects
|
||||||
// callback shape also answers a melt (meltNote sends k1 + pr to the same
|
// {status: "OK"} plus the LUD-25 signature(s) over every minted output -
|
||||||
// callback and only requires status "OK").
|
// the app forces the kit's requireSignatures policy, so an unsigned
|
||||||
|
// mutation answer is not a success the wallet accepts quietly.
|
||||||
|
//
|
||||||
|
// Signing: each origin has a stable secp256k1 signing key derived from its
|
||||||
|
// origin string, so a spec can pin the advertised mintPubkey via
|
||||||
|
// signingPubkey(origin). `invalidSignatures = true` signs every output with
|
||||||
|
// a foreign key instead - the notes are real mint-side, but the offline
|
||||||
|
// proof verifies against no key the mint advertises, which is exactly the
|
||||||
|
// "landed but unverifiable" case the wallet must refuse to mark spendable.
|
||||||
//
|
//
|
||||||
// Every method takes an optional origin, so a spec can stand up a SECOND
|
// Every method takes an optional origin, so a spec can stand up a SECOND
|
||||||
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
|
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
|
||||||
@@ -29,26 +42,43 @@ const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// deterministic per-origin signing keys: the mock mints are stable across
|
||||||
|
// tests, and the key derivation needs nothing but the origin string
|
||||||
|
const signingKeyFor = (origin: string): Uint8Array =>
|
||||||
|
sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`));
|
||||||
|
|
||||||
|
// the key every output is signed with in invalid-signature mode: a real
|
||||||
|
// secp256k1 key, just never advertised by any mock mint
|
||||||
|
const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key'));
|
||||||
|
|
||||||
|
// a melt's settle proof reveals a payment receipt - derived from the paid
|
||||||
|
// invoice so it is stable per payment and never collides with a note secret
|
||||||
|
const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`)));
|
||||||
|
|
||||||
interface MockNoteInfoOptions {
|
interface MockNoteInfoOptions {
|
||||||
// note value in msat, reported as maxWithdrawable
|
// note value in msat, reported as maxWithdrawable
|
||||||
amountMsat?: number;
|
amountMsat?: number;
|
||||||
// when set, the mint answers the ERROR envelope with this reason instead
|
// when set, the mint answers the ERROR envelope with this reason instead
|
||||||
// (a reason matching /spent/i surfaces the "already been spent" UI)
|
// (a reason matching /spent/i surfaces the "already been spent" UI)
|
||||||
spentReason?: string;
|
spentReason?: string;
|
||||||
// the mint's signing pubkey, advertised as mintPubkey in responses
|
// the mint's signing pubkey, advertised as mintPubkey in responses -
|
||||||
|
// defaults to the mock origin's real signing key (kit 0.8 refuses a
|
||||||
|
// withdrawRequest that publishes none, so every mock mint has one)
|
||||||
mintPubkey?: string;
|
mintPubkey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockTargetMintOptions {
|
interface MockTargetMintOptions {
|
||||||
// the mint's signing pubkey (66 hex chars), advertised in the payRequest
|
// the mint's signing pubkey (66 hex chars), advertised on the mint-address
|
||||||
// and note responses
|
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
|
||||||
mintPubkey: string;
|
// LUD-25 announces it on the withdraw side only, and a too-generous mock
|
||||||
|
// once masked a real bug. Defaults to the origin's real signing key.
|
||||||
|
mintPubkey?: string;
|
||||||
// the invoice this mint hands out (and reports back as settled) - keep it
|
// the invoice this mint hands out (and reports back as settled) - keep it
|
||||||
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
|
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
|
||||||
// amount cross-check
|
// amount cross-check
|
||||||
invoice: string;
|
invoice: string;
|
||||||
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
|
// the payment preimage a settled verify reveals - a receipt distinct from
|
||||||
// claimed note's secret)
|
// the wallet-chosen secret committed in the invoice comment
|
||||||
preimage: string;
|
preimage: string;
|
||||||
// value of the note a claim mints, in msat
|
// value of the note a claim mints, in msat
|
||||||
noteAmountMsat: number;
|
noteAmountMsat: number;
|
||||||
@@ -60,10 +90,69 @@ interface MockTargetMintOptions {
|
|||||||
export class MintMocker {
|
export class MintMocker {
|
||||||
constructor(private page: Page) {}
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
// when true, every minted output is signed with a foreign key: the
|
||||||
|
// mutation lands mint-side, but the signature verifies against no key
|
||||||
|
// this mint advertises - the wallet must keep the outputs unverified
|
||||||
|
invalidSignatures = false;
|
||||||
|
|
||||||
|
// every signature this mock issued, in order - lets a spec assert the
|
||||||
|
// mint actually signed (the kit's requireSignatures policy makes an
|
||||||
|
// unsigned success impossible to confuse with a signed one)
|
||||||
|
readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = [];
|
||||||
|
|
||||||
|
// the mint's advertised signing pubkey for an origin (compressed, hex)
|
||||||
|
signingPubkey(origin = MINT_ORIGIN): string {
|
||||||
|
return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// note id (sha256 of the secret) -> outstanding value in msat, per origin.
|
||||||
|
// A real mint keys its ledger by the hash; the mock does the same so a
|
||||||
|
// mutation can sign the output with the amount the inputs actually held.
|
||||||
|
private readonly outstanding = new Map<string, number>();
|
||||||
|
private readonly burned = new Set<string>();
|
||||||
|
// invoices this mint reports as settled on its verify endpoint, per origin
|
||||||
|
private readonly settledInvoices = new Map<string, string[]>();
|
||||||
|
|
||||||
|
private noteKey(origin: string, noteId: string): string {
|
||||||
|
return `${origin}|${noteId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(origin: string, h: string, amountMsat: number): string {
|
||||||
|
const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin);
|
||||||
|
// noble's 'recovered' layout is [recovery, r, s]; the wire layout is
|
||||||
|
// r || s || recovery (the kit accepts either, the trailing layout is
|
||||||
|
// what lnurl-mint emits)
|
||||||
|
const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, {
|
||||||
|
format: 'recovered',
|
||||||
|
prehash: false,
|
||||||
|
});
|
||||||
|
this.signaturesIssued.push({ origin, h, amountMsat });
|
||||||
|
return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// mint outputs at the given hashes, burning the inputs - the ledger
|
||||||
|
// transition every successful mutation makes
|
||||||
|
private remint(
|
||||||
|
origin: string,
|
||||||
|
inputIds: string[],
|
||||||
|
outputs: [h: string, amountMsat: number][],
|
||||||
|
): void {
|
||||||
|
for (const id of inputIds) {
|
||||||
|
this.outstanding.delete(this.noteKey(origin, id));
|
||||||
|
this.burned.add(this.noteKey(origin, id));
|
||||||
|
}
|
||||||
|
for (const [h, amountMsat] of outputs) {
|
||||||
|
this.outstanding.set(this.noteKey(origin, h), amountMsat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The note's informational GET: a spec-shaped withdrawRequest, or the
|
// The note's informational GET: a spec-shaped withdrawRequest, or the
|
||||||
// ERROR envelope for a note the mint considers spent. The kit validates
|
// ERROR envelope for a note the mint considers spent or never issued. The
|
||||||
// that the service echoes back the queried k1, so read it off the request.
|
// kit validates that the service echoes back the queried k1, so read it
|
||||||
|
// off the request. A first-seen k1 registers at the configured amount -
|
||||||
|
// the mock's way of "issuing" the note a spec hands to the wallet.
|
||||||
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
|
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
|
||||||
|
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
|
||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
@@ -72,54 +161,206 @@ export class MintMocker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(k1)) {
|
||||||
|
// a by-hash lookup (h=) or a malformed k1 - this mock answers
|
||||||
|
// secret-keyed info only
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = this.noteKey(origin, hashK1(k1));
|
||||||
|
if (this.burned.has(id)) {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.outstanding.has(id)) {
|
||||||
|
this.outstanding.set(id, options.amountMsat ?? 0);
|
||||||
|
}
|
||||||
|
const amountMsat = this.outstanding.get(id) ?? 0;
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
tag: 'withdrawRequest',
|
tag: 'withdrawRequest',
|
||||||
callback: `${origin}${CALLBACK_PATH}`,
|
callback: `${origin}${CALLBACK_PATH}`,
|
||||||
k1,
|
k1,
|
||||||
minWithdrawable: options.amountMsat ?? 0,
|
minWithdrawable: amountMsat,
|
||||||
maxWithdrawable: options.amountMsat ?? 0,
|
maxWithdrawable: amountMsat,
|
||||||
defaultDescription: 'mock mint note',
|
defaultDescription: 'mock mint note',
|
||||||
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
|
mintPubkey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
|
// The settle proof a melt's verify URL serves: settled once the melt
|
||||||
// params) both expect a plain {status: "OK"} confirmation. No sig is
|
// landed, carrying the paid invoice and its preimage receipt.
|
||||||
// returned - a plain LUD-03-style service that does not sign notes.
|
private async mockVerify(origin: string): Promise<void> {
|
||||||
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
|
|
||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
|
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
await fulfillJson(route, { status: 'OK' });
|
const pr = new URL(route.request().url()).searchParams.get('pr') ?? '';
|
||||||
|
const settled = (this.settledInvoices.get(origin) ?? []).includes(pr);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
settled,
|
||||||
|
...(settled ? { preimage: preimageFor(pr) } : {}),
|
||||||
|
pr,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A mint that never answers its mint-address discovery endpoint with
|
// The mutating callback GET, signed per LUD-25:
|
||||||
// anything usable - prepareMint treats that as "no mint-address support"
|
// - melt (k1 + pr): burns the input, answers OK with a settle-proof URL
|
||||||
// and falls back to the plain LNURL-pay guess.
|
// - rotate (k1 + h): burns the input, mints at h for its full value
|
||||||
private async mockNoMintAddress(origin: string): Promise<void> {
|
// - split (k1... + amount + h + h2): carves amount off the inputs' total,
|
||||||
|
// minting h for it and h2 for the change
|
||||||
|
// - merge (k1... + h): burns all inputs, mints h for their total
|
||||||
|
// Every minted output's signature rides in the response (sig / sig2), and
|
||||||
|
// a mutation naming an unknown or burned input is refused like a real
|
||||||
|
// mint's. A byte-identical replay gets the same success - the mutations
|
||||||
|
// are deterministic in their inputs, so the replayed answer re-derives
|
||||||
|
// the same signatures.
|
||||||
|
async mockMutationEndpoint(origin = MINT_ORIGIN): Promise<void> {
|
||||||
|
await this.mockVerify(origin);
|
||||||
|
await this.page.route(
|
||||||
|
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
|
||||||
|
async (route: Route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const k1s = url.searchParams.getAll('k1');
|
||||||
|
if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pr = url.searchParams.get('pr');
|
||||||
|
const h = url.searchParams.get('h');
|
||||||
|
const h2 = url.searchParams.get('h2');
|
||||||
|
const amountRaw = url.searchParams.get('amount');
|
||||||
|
const inputIds = k1s.map((k1) => hashK1(k1));
|
||||||
|
const lookup = (id: string): number | 'unknown' | 'burned' => {
|
||||||
|
const key = this.noteKey(origin, id);
|
||||||
|
if (this.burned.has(key)) return 'burned';
|
||||||
|
return this.outstanding.get(key) ?? 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
// melt: the note burns once the payment settles; the mock answers
|
||||||
|
// OK (payment in flight) and the verify endpoint reports it settled
|
||||||
|
if (pr !== null) {
|
||||||
|
for (const id of inputIds) {
|
||||||
|
const state = lookup(id);
|
||||||
|
if (state === 'burned') {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === 'unknown') {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.remint(origin, inputIds, []);
|
||||||
|
const paid = this.settledInvoices.get(origin) ?? [];
|
||||||
|
paid.push(pr);
|
||||||
|
this.settledInvoices.set(origin, paid);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
status: 'OK',
|
||||||
|
verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// every other mutation mints outputs whose signatures the wallet
|
||||||
|
// must be able to verify offline
|
||||||
|
const total = inputIds.reduce((sum, id) => {
|
||||||
|
const state = lookup(id);
|
||||||
|
return typeof state === 'number' ? sum + state : sum;
|
||||||
|
}, 0);
|
||||||
|
for (const id of inputIds) {
|
||||||
|
const state = lookup(id);
|
||||||
|
if (state === 'burned') {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === 'unknown') {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h !== null && h2 !== null && amountRaw !== null) {
|
||||||
|
// split: amount carved off, the rest is change
|
||||||
|
const amountMsat = Number(amountRaw);
|
||||||
|
this.remint(origin, inputIds, [
|
||||||
|
[h, amountMsat],
|
||||||
|
[h2, total - amountMsat],
|
||||||
|
]);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
status: 'OK',
|
||||||
|
sig: this.sign(origin, h, amountMsat),
|
||||||
|
sig2: this.sign(origin, h2, total - amountMsat),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (h !== null) {
|
||||||
|
// rotate (one input) or merge (several): full value at h
|
||||||
|
this.remint(origin, inputIds, [[h, total]]);
|
||||||
|
await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mint-address discovery endpoint (LUD-25): announces the signing key
|
||||||
|
// and the node stats a real lnurl-mint advertises. `nodeCapacity` is msat
|
||||||
|
// under its WIRE name (no suffix) - the kit has to map it onto
|
||||||
|
// nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises.
|
||||||
|
// The payLink points back at the lnurlp route below, as prepareMint treats
|
||||||
|
// it as the authoritative place to read the payRequest from.
|
||||||
|
private async mockMintAddress(origin: string, mintPubkey: string): Promise<void> {
|
||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
|
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
await fulfillJson(route, { status: 'ERROR', reason: 'not supported' });
|
await fulfillJson(route, {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: `${origin}${CALLBACK_PATH}`,
|
||||||
|
minWithdrawable: 1000,
|
||||||
|
maxWithdrawable: 100_000_000_000,
|
||||||
|
mintPubkey,
|
||||||
|
payLink: `${origin}/.well-known/lnurlp/mint`,
|
||||||
|
nodeCapacity: 500_000_000,
|
||||||
|
nodeNumChannels: 4,
|
||||||
|
nodeNumPeers: 6,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything the target side of a transfer (or a Lightning receive)
|
// Everything the target side of a transfer (or a Lightning receive)
|
||||||
// needs: the payRequest at the standard mint@ address, the invoice
|
// needs: the mint-address discovery endpoint carrying the mint metadata,
|
||||||
// callback, an immediately-settled verify endpoint revealing the
|
// the payRequest at the standard mint@ address, the invoice callback, an
|
||||||
// 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> {
|
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
|
||||||
await this.mockNoMintAddress(origin);
|
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
|
||||||
await this.mockNoteInfo(
|
let stagedHash: string | null = null;
|
||||||
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
|
let settled = false;
|
||||||
origin,
|
await this.mockMintAddress(origin, mintPubkey);
|
||||||
|
await this.mockMutationEndpoint(origin);
|
||||||
|
await this.page.route(
|
||||||
|
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
||||||
|
async (route: Route) => {
|
||||||
|
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: `${origin}${CALLBACK_PATH}`,
|
||||||
|
k1,
|
||||||
|
minWithdrawable: options.noteAmountMsat,
|
||||||
|
maxWithdrawable: options.noteAmountMsat,
|
||||||
|
defaultDescription: 'mock minted note',
|
||||||
|
mintPubkey,
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await this.mockRotateOk(origin);
|
|
||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
|
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
@@ -128,8 +369,8 @@ export class MintMocker {
|
|||||||
callback: `${origin}/pay`,
|
callback: `${origin}/pay`,
|
||||||
minSendable: 1000,
|
minSendable: 1000,
|
||||||
maxSendable: 100_000_000_000,
|
maxSendable: 100_000_000_000,
|
||||||
|
commentAllowed: 64,
|
||||||
withdrawLink: `${origin}${NOTE_PATH}`,
|
withdrawLink: `${origin}${NOTE_PATH}`,
|
||||||
mintPubkey: options.mintPubkey,
|
|
||||||
metadata: options.mintFeeMetadata ?? '[]',
|
metadata: options.mintFeeMetadata ?? '[]',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -137,18 +378,38 @@ export class MintMocker {
|
|||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
|
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
|
const request = new URL(route.request().url());
|
||||||
|
const comment = request.searchParams.get('comment');
|
||||||
|
const commitment = request.searchParams.get('h');
|
||||||
|
if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) {
|
||||||
|
await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stagedHash = comment.toLowerCase();
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
pr: options.invoice,
|
pr: options.invoice,
|
||||||
verify: `${origin}/verify`,
|
verify: `${origin}/verify`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await this.page.route(
|
||||||
|
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
|
||||||
|
async (route: Route) => {
|
||||||
|
const paidInvoice = new URL(route.request().url()).searchParams.get('pr');
|
||||||
|
if (paidInvoice !== options.invoice) {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
await fulfillJson(route, { status: 'OK' });
|
||||||
|
},
|
||||||
|
);
|
||||||
await this.page.route(
|
await this.page.route(
|
||||||
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
||||||
async (route: Route) => {
|
async (route: Route) => {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
settled: true,
|
settled,
|
||||||
preimage: options.preimage,
|
...(settled ? { preimage: options.preimage } : {}),
|
||||||
pr: options.invoice,
|
pr: options.invoice,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
||||||
|
import { MINT_ORIGIN, NOTE_PATH } from './MintMocker';
|
||||||
|
import type { MintMocker } from './MintMocker';
|
||||||
|
|
||||||
// Drives onboarding end to end on a fresh browser context: /#/welcome ->
|
// Drives onboarding end to end on a fresh browser context: /#/welcome ->
|
||||||
// "Create wallet" (empty password = stored unencrypted) -> confirm the
|
// "Create wallet" (empty password = stored unencrypted) -> confirm the
|
||||||
@@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise<void> => {
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.waitForURL(/#\/$/);
|
await page.waitForURL(/#\/$/);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Give a fresh wallet one verified, spendable note at the mock mint: the
|
||||||
|
// mock answers the note's informational GET and signs every mutation, and
|
||||||
|
// the receive's first-contact trust prompt is confirmed, pinning the mint's
|
||||||
|
// signing key so the wallet can verify what this mint mints.
|
||||||
|
export const fundMockMintWallet = async (
|
||||||
|
page: Page,
|
||||||
|
mint: MintMocker,
|
||||||
|
amountMsat: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
await mint.mockNoteInfo({ amountMsat });
|
||||||
|
await mint.mockMutationEndpoint();
|
||||||
|
await createFreshWallet(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Receive' }).click();
|
||||||
|
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
||||||
|
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||||
|
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||||
|
await dialog
|
||||||
|
.locator('textarea')
|
||||||
|
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat));
|
||||||
|
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
||||||
|
await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible();
|
||||||
|
// the note advertises the mint's key, so the first-contact trust prompt
|
||||||
|
// opens as its own dialog - trust it (that also pins the signing key)
|
||||||
|
await page
|
||||||
|
.locator('.q-dialog', { hasText: 'New mint' })
|
||||||
|
.getByRole('button', { name: 'Trust this mint' })
|
||||||
|
.click();
|
||||||
|
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { test, expect } from '../fixtures';
|
import { test, expect } from '../fixtures';
|
||||||
import { createFreshWallet } from '../helpers/wallet';
|
import { createFreshWallet } from '../helpers/wallet';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||||
|
|
||||||
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
|
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
|
||||||
// platform authenticator, so the security page is asserted in its honest
|
// platform authenticator, so the security page is asserted in its honest
|
||||||
@@ -12,6 +16,72 @@ const VALID_PHRASE =
|
|||||||
const readSettings = async (page: import('@playwright/test').Page) =>
|
const readSettings = async (page: import('@playwright/test').Page) =>
|
||||||
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
|
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
|
||||||
|
|
||||||
|
// A v2 encrypted wallet-material record built with the app's own wrap
|
||||||
|
// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that
|
||||||
|
// need a saved, locked wallet without driving the onboarding UI.
|
||||||
|
const PASSWORD = 'correct horse battery staple';
|
||||||
|
const PBKDF2_ITERATIONS = 210_000;
|
||||||
|
|
||||||
|
const encryptedWalletMaterialRecord = async (
|
||||||
|
password: string,
|
||||||
|
): Promise<{
|
||||||
|
enc: true;
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
materialHash: string;
|
||||||
|
ownerId: string;
|
||||||
|
version: 2;
|
||||||
|
}> => {
|
||||||
|
const linkingKeyHex = '07'.repeat(32);
|
||||||
|
const material = {
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex,
|
||||||
|
cashRootHex: '08'.repeat(64),
|
||||||
|
};
|
||||||
|
const serialized = JSON.stringify(material);
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(password),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey'],
|
||||||
|
);
|
||||||
|
const aesKey = await crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
|
baseKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
aesKey,
|
||||||
|
new TextEncoder().encode(serialized),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enc: true,
|
||||||
|
salt: bytesToHex(salt),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
ciphertext: bytesToHex(ciphertext),
|
||||||
|
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
|
||||||
|
ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)),
|
||||||
|
version: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToBytesLocal = (hex: string): Uint8Array => {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
test.describe('Backup page', () => {
|
test.describe('Backup page', () => {
|
||||||
test('renders all sections and exports a JSON backup file', async ({ page }) => {
|
test('renders all sections and exports a JSON backup file', async ({ page }) => {
|
||||||
await createFreshWallet(page);
|
await createFreshWallet(page);
|
||||||
@@ -79,6 +149,103 @@ test.describe('Security page', () => {
|
|||||||
// auto-lock: display-only for now
|
// auto-lock: display-only for now
|
||||||
await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible();
|
await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lists only passkeys owned by the current wallet', async ({ page }) => {
|
||||||
|
// Given a browser with a PRF-capable authenticator probe and an unlocked wallet
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true),
|
||||||
|
getClientCapabilities: () => Promise.resolve({ 'extension:prf': true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await createFreshWallet(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
|
||||||
|
if (
|
||||||
|
typeof saved !== 'object' ||
|
||||||
|
saved === null ||
|
||||||
|
!('ownerId' in saved) ||
|
||||||
|
typeof saved.ownerId !== 'string' ||
|
||||||
|
!('materialHash' in saved) ||
|
||||||
|
typeof saved.materialHash !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('expected saved wallet owner');
|
||||||
|
}
|
||||||
|
const wrap = {
|
||||||
|
hkdfSalt: '11'.repeat(16),
|
||||||
|
iv: '22'.repeat(12),
|
||||||
|
materialHash: saved.materialHash,
|
||||||
|
wrappedMaterial: '33'.repeat(48),
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
...wrap,
|
||||||
|
credentialId: '44'.repeat(16),
|
||||||
|
name: 'Current wallet passkey',
|
||||||
|
ownerId: saved.ownerId,
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...wrap,
|
||||||
|
credentialId: '55'.repeat(16),
|
||||||
|
name: 'Foreign wallet passkey',
|
||||||
|
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the security management surface reads passkey slots
|
||||||
|
await page.goto('/#/settings/security');
|
||||||
|
|
||||||
|
// Then only the current owner's slot is rendered
|
||||||
|
await expect(page.getByText('Current wallet passkey')).toBeVisible();
|
||||||
|
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Given an encrypted v2 saved wallet whose only passkey slot belongs to
|
||||||
|
// a DIFFERENT owner (a foreign wallet's residue on the same device)
|
||||||
|
const record = await encryptedWalletMaterialRecord(PASSWORD);
|
||||||
|
await page.addInitScript(
|
||||||
|
({ storedRecord }) => {
|
||||||
|
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
credentialId: '44'.repeat(16),
|
||||||
|
hkdfSalt: '55'.repeat(16),
|
||||||
|
iv: '66'.repeat(12),
|
||||||
|
materialHash: '77'.repeat(32),
|
||||||
|
wrappedMaterial: '88'.repeat(48),
|
||||||
|
createdAt: 1,
|
||||||
|
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ storedRecord: record },
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the locked wallet renders
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Then passkey-first unlock is unavailable - the one slot on this
|
||||||
|
// device is not this wallet's
|
||||||
|
await expect(page.getByText('Wallet locked')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Welcome: restore from nostr', () => {
|
test.describe('Welcome: restore from nostr', () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise<void> => {
|
|||||||
callback: 'https://mint.600.wtf/pay',
|
callback: 'https://mint.600.wtf/pay',
|
||||||
minSendable: 1000,
|
minSendable: 1000,
|
||||||
maxSendable: 100_000_000_000,
|
maxSendable: 100_000_000_000,
|
||||||
|
commentAllowed: 64,
|
||||||
withdrawLink: 'https://mint.600.wtf/note',
|
withdrawLink: 'https://mint.600.wtf/note',
|
||||||
metadata: '[]',
|
metadata: '[]',
|
||||||
}),
|
}),
|
||||||
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
|
|||||||
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
|
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
|
||||||
// hold a 21-sat note from the mock mint - holding funds locks the mint
|
// hold a 21-sat note from the mock mint - holding funds locks the mint
|
||||||
// against removal
|
// against removal
|
||||||
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
|
await mint.mockNoteInfo({ amountMsat: 21_000 });
|
||||||
await mint.mockRotateOk();
|
await mint.mockMutationEndpoint();
|
||||||
await createFreshWallet(page);
|
await createFreshWallet(page);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Receive' }).click();
|
await page.getByRole('button', { name: 'Receive' }).click();
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { test, expect } from '../fixtures';
|
import { test, expect } from '../fixtures';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
import { MINT2_ORIGIN } from '../helpers/MintMocker';
|
||||||
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
|
import { fundMockMintWallet } from '../helpers/wallet';
|
||||||
import { createFreshWallet } from '../helpers/wallet';
|
|
||||||
|
|
||||||
const AMOUNT_MSAT = 50_000; // 50 sats
|
const AMOUNT_MSAT = 50_000; // 50 sats
|
||||||
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
|
// 64 hex chars - a valid payment receipt, distinct from the claimed note secret
|
||||||
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
|
|
||||||
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
|
|
||||||
const PREIMAGE = 'ef'.repeat(32);
|
const PREIMAGE = 'ef'.repeat(32);
|
||||||
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
|
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
|
||||||
// amount cross-check against the requested value
|
// amount cross-check against the requested value
|
||||||
@@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer';
|
|||||||
|
|
||||||
// give the wallet a verified, spendable 50-sat note at the source mint
|
// give the wallet a verified, spendable 50-sat note at the source mint
|
||||||
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
|
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
|
||||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
|
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
|
||||||
await mint.mockRotateOk();
|
|
||||||
await createFreshWallet(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Receive' }).click();
|
|
||||||
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
|
||||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
|
||||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
|
||||||
await dialog
|
|
||||||
.locator('textarea')
|
|
||||||
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
|
|
||||||
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
|
||||||
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
|
|
||||||
// the note advertises a mint key, so the first-contact trust prompt opens
|
|
||||||
// as its own dialog - trust it (that also locks the mint)
|
|
||||||
await page
|
|
||||||
.locator('.q-dialog', { hasText: 'New mint' })
|
|
||||||
.getByRole('button', { name: 'Trust this mint' })
|
|
||||||
.click();
|
|
||||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// pick an option from a Quasar select identified by its label
|
// pick an option from a Quasar select identified by its label
|
||||||
@@ -78,7 +56,6 @@ test.describe('Move funds', () => {
|
|||||||
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
|
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
|
||||||
await mint.mockTargetMint(
|
await mint.mockTargetMint(
|
||||||
{
|
{
|
||||||
mintPubkey: TARGET_PUBKEY,
|
|
||||||
invoice: INVOICE,
|
invoice: INVOICE,
|
||||||
preimage: PREIMAGE,
|
preimage: PREIMAGE,
|
||||||
noteAmountMsat: AMOUNT_MSAT,
|
noteAmountMsat: AMOUNT_MSAT,
|
||||||
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
|
|||||||
|
|
||||||
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
|
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
|
||||||
await fundSourceMint(page, mint);
|
await fundSourceMint(page, mint);
|
||||||
// the target mint: hands out the invoice, reports it settled with the
|
// the target mint commits the wallet-chosen secret, hands out the invoice,
|
||||||
// preimage, and answers the claim's note info + rotation
|
// and exposes that note only after the source-mint melt pays it
|
||||||
await mint.mockTargetMint(
|
await mint.mockTargetMint(
|
||||||
{
|
{
|
||||||
mintPubkey: TARGET_PUBKEY,
|
|
||||||
invoice: INVOICE,
|
invoice: INVOICE,
|
||||||
preimage: PREIMAGE,
|
preimage: PREIMAGE,
|
||||||
noteAmountMsat: AMOUNT_MSAT,
|
noteAmountMsat: AMOUNT_MSAT,
|
||||||
|
|||||||
+128
-2
@@ -1,17 +1,34 @@
|
|||||||
import { test, expect } from '../fixtures';
|
import { test, expect } from '../fixtures';
|
||||||
import { createFreshWallet } from '../helpers/wallet';
|
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent } from 'nostr-tools/pure';
|
||||||
|
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04';
|
||||||
|
|
||||||
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
|
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
|
||||||
// test that runs the service injects a fake transport through the dev-only
|
// test that runs the service injects a fake transport through the dev-only
|
||||||
// window hook (stores/nwc.ts) before enabling - every other test stays
|
// window hook (stores/nwc.ts) before enabling - every other test stays
|
||||||
// UI-only with the service off.
|
// UI-only with the service off.
|
||||||
|
|
||||||
|
// a Nostr event as the fake transport carries it - the plain JSON shape
|
||||||
|
// NIP-47 requests and responses take on the wire
|
||||||
|
type WireEvent = {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
kind: number;
|
||||||
|
created_at: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
sig: string;
|
||||||
|
};
|
||||||
|
|
||||||
type FakeSub = { closed: boolean; close: () => void };
|
type FakeSub = { closed: boolean; close: () => void };
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__sattleNwcTest: { setTransport: (transport: unknown) => void };
|
__sattleNwcTest: { setTransport: (transport: unknown) => void };
|
||||||
__nwcSubs: FakeSub[];
|
__nwcSubs: FakeSub[];
|
||||||
|
__nwcPublished: WireEvent[];
|
||||||
|
__nwcOnEvent?: (event: WireEvent) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +80,10 @@ test.describe('NWC settings page', () => {
|
|||||||
await expect(page.getByText('shown only once', { exact: false })).toBeVisible();
|
await expect(page.getByText('shown only once', { exact: false })).toBeVisible();
|
||||||
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
|
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
|
||||||
expect(uri).toBeTruthy();
|
expect(uri).toBeTruthy();
|
||||||
|
if (!uri) throw new Error('Expected the one-time NWC connection string.');
|
||||||
|
|
||||||
// a well-formed NIP-47 connection string
|
// a well-formed NIP-47 connection string
|
||||||
const url = new URL(uri!);
|
const url = new URL(uri);
|
||||||
expect(url.protocol).toBe('nostr+walletconnect:');
|
expect(url.protocol).toBe('nostr+walletconnect:');
|
||||||
expect(url.host).toMatch(/^[0-9a-f]{64}$/);
|
expect(url.host).toMatch(/^[0-9a-f]{64}$/);
|
||||||
expect(url.searchParams.get('secret')).toMatch(/^[0-9a-f]{64}$/);
|
expect(url.searchParams.get('secret')).toMatch(/^[0-9a-f]{64}$/);
|
||||||
@@ -145,4 +163,112 @@ test.describe('NWC settings page', () => {
|
|||||||
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
|
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => {
|
||||||
|
// a funded wallet: 50 sats at the mock mint, its signing key pinned
|
||||||
|
await fundMockMintWallet(page, mint, 50_000);
|
||||||
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||||
|
|
||||||
|
// create a connection and read its one-time URI: the wallet service
|
||||||
|
// pubkey is the host, the client secret the `secret` param
|
||||||
|
await openNwcPage(page);
|
||||||
|
await page.getByRole('button', { name: 'Create connection' }).click();
|
||||||
|
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
|
||||||
|
expect(uri).toBeTruthy();
|
||||||
|
if (!uri) throw new Error('Expected the one-time NWC connection string.');
|
||||||
|
await page.getByRole('button', { name: "Done - I've saved it" }).click();
|
||||||
|
const connectionUrl = new URL(uri);
|
||||||
|
const walletServicePubkey = connectionUrl.host;
|
||||||
|
const clientSecret = connectionUrl.searchParams.get('secret') ?? '';
|
||||||
|
|
||||||
|
// a fake transport that captures the request handler and every
|
||||||
|
// published event, installed before the service starts
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__nwcSubs = [];
|
||||||
|
window.__nwcPublished = [];
|
||||||
|
window.__sattleNwcTest.setTransport({
|
||||||
|
publish: (_relays: string[], event: WireEvent) => {
|
||||||
|
window.__nwcPublished.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => {
|
||||||
|
window.__nwcOnEvent = onEvent;
|
||||||
|
const sub: FakeSub = {
|
||||||
|
closed: false,
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.__nwcSubs.push(sub);
|
||||||
|
return sub;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click();
|
||||||
|
await expect(page.getByText('Service running', { exact: false })).toBeVisible();
|
||||||
|
await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function');
|
||||||
|
|
||||||
|
// a 21-sat invoice the mint will settle; the request is exactly what a
|
||||||
|
// NIP-47 client sends: kind 23194, NIP-04 encrypted to the service,
|
||||||
|
// schnorr-signed by the connection's client secret
|
||||||
|
const invoice = 'lnbc210n1pjqrstuvwxyz';
|
||||||
|
const content = nip04Encrypt(
|
||||||
|
clientSecret,
|
||||||
|
walletServicePubkey,
|
||||||
|
JSON.stringify({ method: 'pay_invoice', params: { invoice } }),
|
||||||
|
);
|
||||||
|
const request = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 23194,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['p', walletServicePubkey],
|
||||||
|
['encryption', 'nip04'],
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
hexToBytes(clientSecret),
|
||||||
|
);
|
||||||
|
await page.evaluate((event) => window.__nwcOnEvent?.(event), request);
|
||||||
|
|
||||||
|
// the service pays and answers on the same scheme
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__nwcPublished.find(
|
||||||
|
(event) =>
|
||||||
|
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
|
||||||
|
) ?? null,
|
||||||
|
request.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
const responseEvent = await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__nwcPublished.find(
|
||||||
|
(event) =>
|
||||||
|
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
|
||||||
|
) ?? null,
|
||||||
|
request.id,
|
||||||
|
);
|
||||||
|
if (!responseEvent) throw new Error('the service published no response');
|
||||||
|
const response: unknown = JSON.parse(
|
||||||
|
nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content),
|
||||||
|
);
|
||||||
|
if (typeof response !== 'object' || response === null) {
|
||||||
|
throw new Error('the service response is not an object');
|
||||||
|
}
|
||||||
|
const result = response as { result_type?: unknown; error?: unknown; result?: unknown };
|
||||||
|
expect(result.result_type).toBe('pay_invoice');
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
// the receipt is the melt's payment preimage from the mint's own
|
||||||
|
// settle proof - 64 hex chars, never a note secret
|
||||||
|
const payload = result.result as { preimage?: unknown };
|
||||||
|
expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
// the wallet paid 21 of its 50 sats; the change stays in the wallet
|
||||||
|
await page.goto('/#/');
|
||||||
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('29');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise<void> => {
|
|||||||
test.describe('Receive bearer note', () => {
|
test.describe('Receive bearer note', () => {
|
||||||
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
|
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
|
||||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||||
await mint.mockRotateOk();
|
await mint.mockMutationEndpoint();
|
||||||
await createFreshWallet(page);
|
await createFreshWallet(page);
|
||||||
|
|
||||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||||
@@ -37,6 +37,18 @@ test.describe('Receive bearer note', () => {
|
|||||||
// were really consumed (the kit bug this guards against silently fell
|
// were really consumed (the kit bug this guards against silently fell
|
||||||
// back to the declared amount without any request)
|
// back to the declared amount without any request)
|
||||||
await expect(dialog.locator('.q-banner')).toHaveCount(0);
|
await expect(dialog.locator('.q-banner')).toHaveCount(0);
|
||||||
|
// the mint signed the rotation's output (LUD-25) - the wallet's
|
||||||
|
// requireSignatures policy accepted a signed answer, not a rescue
|
||||||
|
expect(
|
||||||
|
mint.signaturesIssued.some(
|
||||||
|
(entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// the note advertised the mint's key, so the first-contact trust prompt
|
||||||
|
// opened - dismiss it, then close the success screen
|
||||||
|
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
|
||||||
|
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
|
||||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
|
||||||
});
|
});
|
||||||
@@ -58,4 +70,54 @@ test.describe('Receive bearer note', () => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an already trusted current-owner mint bypasses the first-contact prompt', async ({
|
||||||
|
page,
|
||||||
|
mint,
|
||||||
|
}) => {
|
||||||
|
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||||
|
await mint.mockMutationEndpoint();
|
||||||
|
await createFreshWallet(page);
|
||||||
|
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||||
|
await redeemNote(page, freshNoteUrl());
|
||||||
|
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
|
||||||
|
await expect(trustDialog).toBeVisible();
|
||||||
|
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
|
||||||
|
await expect(trustDialog).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(receiveDialog).toHaveCount(0);
|
||||||
|
|
||||||
|
await redeemNote(page, freshNoteUrl());
|
||||||
|
|
||||||
|
await expect(receiveDialog.getByText('Received 21 sats')).toBeVisible();
|
||||||
|
await expect(trustDialog).toHaveCount(0);
|
||||||
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trust failure after commit keeps received funds and warns against retry', async ({
|
||||||
|
page,
|
||||||
|
mint,
|
||||||
|
}) => {
|
||||||
|
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||||
|
await mint.mockMutationEndpoint();
|
||||||
|
await createFreshWallet(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||||
|
await redeemNote(page, freshNoteUrl());
|
||||||
|
|
||||||
|
await expect(dialog.getByText('Received 21 sats')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Funds were saved.*receive succeeded.*do not retry/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(dialog.locator('.q-banner')).toHaveCount(0);
|
||||||
|
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
|
||||||
|
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type { Page, TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createFreshWallet } from '../helpers/wallet';
|
||||||
|
|
||||||
|
// the dev-only lifecycle hook (boot/wallet.ts) used to drive the forget
|
||||||
|
// transition, which has no UI surface
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINT_KEY_A = `02${'aa'.repeat(32)}`;
|
||||||
|
const MINT_KEY_B = `03${'bb'.repeat(32)}`;
|
||||||
|
const MINT_KEY_C = `02${'cc'.repeat(32)}`;
|
||||||
|
const VIEWPORT_WIDTHS = [375, 768, 1280] as const;
|
||||||
|
const CONCURRENT_ADD_ROUNDS = 10;
|
||||||
|
|
||||||
|
const mintsList = (page: Page) => page.locator('.q-list', { hasText: 'Your mints' });
|
||||||
|
|
||||||
|
const fillMintForm = async (page: Page, server: string, mintPubkey: string): Promise<void> => {
|
||||||
|
await page.getByLabel('Server').fill(server);
|
||||||
|
await page.getByLabel('Signing key (66 hex characters)').fill(mintPubkey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMint = async (page: Page, server: string, mintPubkey: string): Promise<void> => {
|
||||||
|
await fillMintForm(page, server, mintPubkey);
|
||||||
|
await page.getByRole('button', { name: 'Trust this mint' }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerId = async (page: Page): Promise<string | null> =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('sattle_wallet_material_v2');
|
||||||
|
if (raw === null) return null;
|
||||||
|
const saved: unknown = JSON.parse(raw);
|
||||||
|
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null;
|
||||||
|
return typeof saved.ownerId === 'string' ? saved.ownerId : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedMintServers = async (page: Page): Promise<string[]> =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('sattle_trusted_mints');
|
||||||
|
if (raw === null) return [];
|
||||||
|
const registry: unknown = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
typeof registry !== 'object' ||
|
||||||
|
registry === null ||
|
||||||
|
!('mints' in registry) ||
|
||||||
|
!Array.isArray(registry.mints)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return registry.mints
|
||||||
|
.map((mint) =>
|
||||||
|
typeof mint === 'object' &&
|
||||||
|
mint !== null &&
|
||||||
|
'server' in mint &&
|
||||||
|
typeof mint.server === 'string'
|
||||||
|
? mint.server
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const retainFailureEvidence = async (
|
||||||
|
testInfo: TestInfo,
|
||||||
|
pages: readonly Page[],
|
||||||
|
consoleMessages: readonly string[],
|
||||||
|
): Promise<void> => {
|
||||||
|
for (const [index, page] of pages.entries()) {
|
||||||
|
if (page.isClosed()) continue;
|
||||||
|
const path = testInfo.outputPath(`tab-${index + 1}-failure.png`);
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
await testInfo.attach(`tab-${index + 1}-failure`, { path, contentType: 'image/png' });
|
||||||
|
}
|
||||||
|
await testInfo.attach('browser-console', {
|
||||||
|
body: consoleMessages.join('\n'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('trusted mint tabs', () => {
|
||||||
|
test('remote updates converge and concurrent additions survive Web Locks', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await createFreshWallet(page);
|
||||||
|
test.skip(
|
||||||
|
!(await page.evaluate(() => 'locks' in navigator)),
|
||||||
|
'Web Locks are unavailable, so this browser provides no concurrent-write guarantee.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const remote = await page.context().newPage();
|
||||||
|
const consoleMessages: string[] = [];
|
||||||
|
for (const [name, current] of [
|
||||||
|
['first', page],
|
||||||
|
['second', remote],
|
||||||
|
] as const) {
|
||||||
|
current.on('console', (message) => {
|
||||||
|
consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([page.goto('/#/settings/mints'), remote.goto('/#/settings/mints')]);
|
||||||
|
await expect(mintsList(page).getByText('No mints yet', { exact: false })).toBeVisible();
|
||||||
|
await expect(mintsList(remote).getByText('No mints yet', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
// A real storage event from the second tab updates the first tab without reload.
|
||||||
|
await addMint(remote, 'remote.example', MINT_KEY_A);
|
||||||
|
await expect(mintsList(page).getByText('remote.example', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Repeated UI races from separate pages must all survive Web Locks.
|
||||||
|
const expectedServers = ['remote.example'];
|
||||||
|
for (let round = 1; round <= CONCURRENT_ADD_ROUNDS; round++) {
|
||||||
|
const firstServer = `first-${round}.example`;
|
||||||
|
const secondServer = `second-${round}.example`;
|
||||||
|
expectedServers.push(firstServer, secondServer);
|
||||||
|
await Promise.all([
|
||||||
|
fillMintForm(page, firstServer, MINT_KEY_B),
|
||||||
|
fillMintForm(remote, secondServer, MINT_KEY_C),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Trust this mint' }).click(),
|
||||||
|
remote.getByRole('button', { name: 'Trust this mint' }).click(),
|
||||||
|
]);
|
||||||
|
await expect.poll(() => storedMintServers(page)).toEqual([...expectedServers].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of VIEWPORT_WIDTHS) {
|
||||||
|
await Promise.all([
|
||||||
|
page.setViewportSize({ width, height: 900 }),
|
||||||
|
remote.setViewportSize({ width, height: 900 }),
|
||||||
|
]);
|
||||||
|
await expect(mintsList(page).getByText('first-10.example', { exact: true })).toBeVisible();
|
||||||
|
await expect(mintsList(page).getByText('second-10.example', { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
mintsList(remote).getByText('first-10.example', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
mintsList(remote).getByText('second-10.example', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
expect(await storedMintServers(page)).toEqual([...expectedServers].sort());
|
||||||
|
} catch (error) {
|
||||||
|
await retainFailureEvidence(testInfo, [page, remote], consoleMessages);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await remote.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('wallet ownership', () => {
|
||||||
|
test('a stale owner tab locks and cannot recreate trust or NWC state', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await createFreshWallet(page);
|
||||||
|
const oldOwner = await ownerId(page);
|
||||||
|
expect(oldOwner).not.toBeNull();
|
||||||
|
|
||||||
|
const stale = await page.context().newPage();
|
||||||
|
const consoleMessages: string[] = [];
|
||||||
|
for (const [name, current] of [
|
||||||
|
['successor', page],
|
||||||
|
['stale', stale],
|
||||||
|
] as const) {
|
||||||
|
current.on('console', (message) => {
|
||||||
|
consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stale.goto('/#/settings/mints');
|
||||||
|
await expect(mintsList(stale).getByText('No mints yet', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
// The active page forgets owner A, then creates owner B through the rendered onboarding flow.
|
||||||
|
await page.evaluate(() => window.__sattleWalletTest.forget());
|
||||||
|
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
|
||||||
|
await stale.goto('/#/');
|
||||||
|
await expect(stale.getByText('Wallet locked')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create wallet' }).click();
|
||||||
|
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
|
||||||
|
|
||||||
|
const successorOwner = await ownerId(page);
|
||||||
|
expect(successorOwner).not.toBe(oldOwner);
|
||||||
|
for (const width of VIEWPORT_WIDTHS) {
|
||||||
|
await stale.setViewportSize({ width, height: 900 });
|
||||||
|
await expect(stale.getByText('Wallet locked')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale settings route cannot call owner-bound mutations after invalidation.
|
||||||
|
await stale.goto('/#/settings/mints');
|
||||||
|
await addMint(stale, 'stale.example', MINT_KEY_A);
|
||||||
|
await expect(stale.locator('.q-banner', { hasText: 'Wallet is locked.' })).toBeVisible();
|
||||||
|
await stale.goto('/#/settings/nwc');
|
||||||
|
await expect(stale.locator('.q-page', { hasText: 'Unlock your wallet first' })).toBeVisible();
|
||||||
|
|
||||||
|
const residue = await page.evaluate(() => ({
|
||||||
|
trustedMints: localStorage.getItem('sattle_trusted_mints'),
|
||||||
|
nwcConnections: localStorage.getItem('sattle_nwc_connections'),
|
||||||
|
nwcEnabled: localStorage.getItem('sattle_nwc_enabled'),
|
||||||
|
}));
|
||||||
|
expect(residue).toEqual({ trustedMints: null, nwcConnections: null, nwcEnabled: null });
|
||||||
|
} catch (error) {
|
||||||
|
await retainFailureEvidence(testInfo, [page, stale], consoleMessages);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await stale.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
|
||||||
|
// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
|
||||||
|
// install - the linking-key-only record, the pre-v2 bearer store and their
|
||||||
|
// ownerless residue - is never migrated (the cash root is unrecoverable
|
||||||
|
// from a linking key), so boot resets the device to the uninstalled state
|
||||||
|
// before any route can expose it, and a successor wallet starts without
|
||||||
|
// any of the old residue.
|
||||||
|
|
||||||
|
// every namespace the alpha wallet could have touched
|
||||||
|
const LEGACY_KEYS = [
|
||||||
|
'sattle_linking_key',
|
||||||
|
'sattle_bearers',
|
||||||
|
'sattle_wallet_material_v2',
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
'sattle_nwc_enabled',
|
||||||
|
'sattle_trusted_mints',
|
||||||
|
'sattle_biometric_wrap',
|
||||||
|
'sattle_activity',
|
||||||
|
'sattle_settings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe('wallet lifecycle', () => {
|
||||||
|
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Given a legacy encrypted linking-key record plus ownerless residue
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_linking_key',
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
salt: '11'.repeat(16),
|
||||||
|
iv: '22'.repeat(12),
|
||||||
|
ciphertext: '33'.repeat(48),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_bearers',
|
||||||
|
JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
credentialId: '11'.repeat(16),
|
||||||
|
hkdfSalt: '22'.repeat(16),
|
||||||
|
iv: '33'.repeat(12),
|
||||||
|
wrappedKey: '44'.repeat(48),
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
clientPubkey: '55'.repeat(32),
|
||||||
|
relays: ['wss://relay.example'],
|
||||||
|
budget: { maxMsat: 1000, periodMs: 60_000 },
|
||||||
|
spent: { periodStart: 0, msat: 0 },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
localStorage.setItem('sattle_nwc_enabled', 'true');
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_trusted_mints',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
server: 'legacy.example',
|
||||||
|
mintPubkey: `02${'aa'.repeat(32)}`,
|
||||||
|
addedAt: 1,
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the app boots
|
||||||
|
await page.goto('/#/');
|
||||||
|
|
||||||
|
// Then the alpha reset landed on the uninstalled state - no unlock
|
||||||
|
// surface for a wallet that can never be derived again
|
||||||
|
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Wallet locked')).toHaveCount(0);
|
||||||
|
|
||||||
|
// ...and every namespace the alpha wallet could have touched is gone
|
||||||
|
const remaining = await page.evaluate(
|
||||||
|
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
|
||||||
|
[...LEGACY_KEYS],
|
||||||
|
);
|
||||||
|
expect(remaining).toEqual([]);
|
||||||
|
|
||||||
|
// When a successor wallet is created
|
||||||
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create wallet' }).click();
|
||||||
|
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Then it unlocks into a working wallet with zero adopted residue
|
||||||
|
// (settings/activity are the live app's own namespaces - the residue
|
||||||
|
// check covers only the stores a predecessor could have planted)
|
||||||
|
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
|
||||||
|
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
|
||||||
|
const residue = await page.evaluate(
|
||||||
|
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
|
||||||
|
[
|
||||||
|
'sattle_linking_key',
|
||||||
|
'sattle_bearers',
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
'sattle_nwc_enabled',
|
||||||
|
'sattle_trusted_mints',
|
||||||
|
'sattle_biometric_wrap',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(residue).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+10
-9
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.2",
|
"version": "0.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.2",
|
"version": "0.0.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agicash/qr-scanner": "^0.1.2",
|
"@agicash/qr-scanner": "^0.1.2",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@quasar/extras": "^2.0.4",
|
"@quasar/extras": "^2.0.4",
|
||||||
"@scure/bip32": "^2.3.0",
|
"@scure/bip32": "^2.3.0",
|
||||||
"@scure/bip39": "^2.3.0",
|
"@scure/bip39": "^2.3.0",
|
||||||
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
|
"lnurlcash-kit": "0.8.0",
|
||||||
"nostr-tools": "2.24.3",
|
"nostr-tools": "2.24.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-vue": "^10.8.0",
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
|
"lnurlcash-conformance": "0.7.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
@@ -5961,8 +5961,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lnurlcash-conformance": {
|
"node_modules/lnurlcash-conformance": {
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-conformance.git#3d63f8ac3f3b8b87f5b490ff4a55c310afe4e5e2",
|
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5986,9 +5987,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lnurlcash-kit": {
|
"node_modules/lnurlcash-kit": {
|
||||||
"version": "0.1.0",
|
"version": "0.8.0",
|
||||||
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-kit.git#392aeaf6682f23a0e66d28175a9df62802cd1d76",
|
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz",
|
||||||
"integrity": "sha512-xgO7ykIBD5SdGV7M6xyhDJcfgbkvlHxmBmXu2gBs/Cew/RAfH7GRZSCdeVexo6vRGDXhX6IjqqqzfFXrYqkM9A==",
|
"integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.3.0",
|
"@noble/curves": "^2.3.0",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.2",
|
"version": "0.0.5",
|
||||||
"description": "A Quasar Project",
|
"description": "A Quasar Project",
|
||||||
"productName": "sattle",
|
"productName": "sattle",
|
||||||
"author": "protom <office@protom.eu>",
|
"author": "protom <office@protom.eu>",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"@quasar/extras": "^2.0.4",
|
"@quasar/extras": "^2.0.4",
|
||||||
"@scure/bip32": "^2.3.0",
|
"@scure/bip32": "^2.3.0",
|
||||||
"@scure/bip39": "^2.3.0",
|
"@scure/bip39": "^2.3.0",
|
||||||
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
|
"lnurlcash-kit": "0.8.0",
|
||||||
"nostr-tools": "2.24.3",
|
"nostr-tools": "2.24.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-vue": "^10.8.0",
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
|
"lnurlcash-conformance": "0.7.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
@@ -21,7 +22,8 @@ export default defineConfig({
|
|||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:9333',
|
baseURL: 'http://localhost:9333',
|
||||||
trace: 'on-first-retry',
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
serviceWorkers: 'block',
|
serviceWorkers: 'block',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
executablePath: process.env.CHROMIUM_PATH || findSystemChromium(),
|
executablePath: process.env.CHROMIUM_PATH || findSystemChromium(),
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
"target": {
|
"target": {
|
||||||
"namespace": "android_app",
|
"namespace": "android_app",
|
||||||
"package_name": "eu.protom.sattle",
|
"package_name": "eu.protom.sattle",
|
||||||
"sha256_cert_fingerprints": [
|
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"]
|
||||||
"REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { register } from "register-service-worker";
|
import { register } from 'register-service-worker';
|
||||||
|
|
||||||
// The ready(), registered(), cached(), updatefound() and updated()
|
// The ready(), registered(), cached(), updatefound() and updated()
|
||||||
// events passes a ServiceWorkerRegistration instance in their arguments.
|
// events passes a ServiceWorkerRegistration instance in their arguments.
|
||||||
@@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, {
|
|||||||
|
|
||||||
error(/* err */) {
|
error(/* err */) {
|
||||||
// console.error('Error during service worker registration:', err)
|
// console.error('Error during service worker registration:', err)
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-13
@@ -4,13 +4,13 @@
|
|||||||
* quasar.config file > pwa > workboxMode is set to "InjectManifest"
|
* quasar.config file > pwa > workboxMode is set to "InjectManifest"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { clientsClaim } from "workbox-core";
|
import { clientsClaim } from 'workbox-core';
|
||||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||||
import {
|
import {
|
||||||
cleanupOutdatedCaches,
|
cleanupOutdatedCaches,
|
||||||
createHandlerBoundToURL,
|
createHandlerBoundToURL,
|
||||||
precacheAndRoute
|
precacheAndRoute,
|
||||||
} from "workbox-precaching";
|
} from 'workbox-precaching';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope & typeof globalThis;
|
declare const self: ServiceWorkerGlobalScope & typeof globalThis;
|
||||||
|
|
||||||
@@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) {
|
|||||||
// Non-SSR/SSG fallbacks to index.html
|
// Non-SSR/SSG fallbacks to index.html
|
||||||
// Production SSR/SSG fallbacks to offline.html (except for dev)
|
// Production SSR/SSG fallbacks to offline.html (except for dev)
|
||||||
registerRoute(
|
registerRoute(
|
||||||
new NavigationRoute(
|
new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), {
|
||||||
createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML),
|
denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
|
||||||
{
|
}),
|
||||||
denylist: [
|
|
||||||
new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX),
|
|
||||||
/workbox-(.)*\.js$/
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -3,10 +3,22 @@ import { useWalletStore } from '@/stores/wallet';
|
|||||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||||
import { useNwcStore } from '@/stores/nwc';
|
import { useNwcStore } from '@/stores/nwc';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sattleWalletTest?: {
|
||||||
|
readonly state: () => ReturnType<typeof useWalletStore>['state'];
|
||||||
|
readonly forget: () => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
// wallet store at app start - a plaintext-stored material record unlocks
|
||||||
// a password-encrypted one lands on 'locked' for the unlock screen, and no
|
// straight away, a password-encrypted one lands on 'locked' for the unlock
|
||||||
// key at all lands on 'none' for onboarding.
|
// screen, and no record at all lands on 'none' for onboarding. init() first
|
||||||
|
// runs the alpha reset: unsupported legacy state (the linking-key-only
|
||||||
|
// record or the pre-v2 bearer namespace) is wiped, never migrated, before
|
||||||
|
// any route can expose it.
|
||||||
export default defineBoot(async () => {
|
export default defineBoot(async () => {
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
await wallet.init();
|
await wallet.init();
|
||||||
@@ -17,3 +29,12 @@ export default defineBoot(async () => {
|
|||||||
// client requests; on lock it stops and drops the key-material closure
|
// client requests; on lock it stops and drops the key-material closure
|
||||||
useNwcStore();
|
useNwcStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dev-only e2e hook: lets a spec observe the wallet state and drive the
|
||||||
|
// forget transition, which has no UI surface. Never in production builds.
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
window.__sattleWalletTest = {
|
||||||
|
state: () => useWalletStore().state,
|
||||||
|
forget: () => useWalletStore().forgetWallet(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
|
type WalletMaterialV2,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
|
import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage';
|
||||||
|
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
|
||||||
|
const pluginMocks = vi.hoisted(() => ({
|
||||||
|
authenticate: vi.fn<() => Promise<void>>(),
|
||||||
|
checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(),
|
||||||
|
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
|
||||||
|
secureRemove: vi.fn<(key: string) => Promise<void>>(),
|
||||||
|
secureSet: vi.fn<(key: string, value: string) => Promise<void>>(),
|
||||||
|
secureValues: new Map<string, string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
|
||||||
|
AndroidBiometryStrength: { weak: 'weak' },
|
||||||
|
BiometricAuth: {
|
||||||
|
authenticate: pluginMocks.authenticate,
|
||||||
|
checkBiometry: pluginMocks.checkBiometry,
|
||||||
|
},
|
||||||
|
BiometryError: class BiometryError extends Error {},
|
||||||
|
BiometryErrorType: { userCancel: 'userCancel' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@aparajita/capacitor-secure-storage', () => ({
|
||||||
|
SecureStorage: {
|
||||||
|
get: pluginMocks.secureGet,
|
||||||
|
remove: pluginMocks.secureRemove,
|
||||||
|
set: pluginMocks.secureSet,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platform', () => ({ isNative: () => true }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
disableBiometricUnlock,
|
||||||
|
enableBiometricUnlock,
|
||||||
|
unlockWalletMaterialWithBiometrics,
|
||||||
|
} from './biometricUnlock';
|
||||||
|
|
||||||
|
const SEED =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||||
|
const MATERIAL = deriveWalletMaterial(SEED);
|
||||||
|
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED);
|
||||||
|
// same linking key (same owner) as MATERIAL, but a different, VALID cash root
|
||||||
|
const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = {
|
||||||
|
...MATERIAL,
|
||||||
|
cashRootHex: OTHER_MATERIAL.cashRootHex,
|
||||||
|
};
|
||||||
|
const WRAP_SECRET = new Uint8Array(32).fill(3);
|
||||||
|
const WRAP_RECORD_KEY = 'sattle_biometric_wrap';
|
||||||
|
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
||||||
|
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
|
||||||
|
const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2');
|
||||||
|
|
||||||
|
const persistedState = () => ({
|
||||||
|
legacyKey: localStorage.getItem('sattle_linking_key'),
|
||||||
|
legacyNwc: localStorage.getItem('sattle_nwc_connections'),
|
||||||
|
legacyPasskeys: localStorage.getItem('sattle_passkey_slots'),
|
||||||
|
legacyTrust: localStorage.getItem('sattle_trusted_mints'),
|
||||||
|
material: localStorage.getItem(MATERIAL_STORAGE_KEY),
|
||||||
|
secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null,
|
||||||
|
wrap: localStorage.getItem(WRAP_RECORD_KEY),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deriveFixtureWrapKey = async (
|
||||||
|
secret: Uint8Array,
|
||||||
|
hkdfSalt: Uint8Array,
|
||||||
|
): Promise<CryptoKey> => {
|
||||||
|
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
|
||||||
|
'deriveKey',
|
||||||
|
]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: new Uint8Array(hkdfSalt),
|
||||||
|
info: new Uint8Array(WRAP_KEY_HKDF_INFO),
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const storePayload = async (
|
||||||
|
plaintext: Uint8Array,
|
||||||
|
pubkey: string,
|
||||||
|
materialHash: string,
|
||||||
|
secret = WRAP_SECRET,
|
||||||
|
): Promise<void> => {
|
||||||
|
const hkdfSalt = new Uint8Array(16).fill(4);
|
||||||
|
const iv = new Uint8Array(12).fill(5);
|
||||||
|
const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)),
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
WRAP_RECORD_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
hkdfSalt: bytesToHex(hkdfSalt),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
wrappedKey: bytesToHex(ciphertext),
|
||||||
|
pubkey,
|
||||||
|
materialHash,
|
||||||
|
createdAt: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret));
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeMaterialPayload = async (
|
||||||
|
material: WalletMaterialV2,
|
||||||
|
pubkey = walletMaterialOwnerId(material),
|
||||||
|
secret = WRAP_SECRET,
|
||||||
|
materialHash = walletMaterialHash(material),
|
||||||
|
): Promise<void> =>
|
||||||
|
storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stubLocalStorage();
|
||||||
|
pluginMocks.secureValues.clear();
|
||||||
|
pluginMocks.authenticate.mockResolvedValue();
|
||||||
|
pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true });
|
||||||
|
pluginMocks.secureGet.mockImplementation((key) =>
|
||||||
|
Promise.resolve(pluginMocks.secureValues.get(key) ?? null),
|
||||||
|
);
|
||||||
|
pluginMocks.secureSet.mockImplementation((key, value) => {
|
||||||
|
pluginMocks.secureValues.set(key, value);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
pluginMocks.secureRemove.mockImplementation((key) => {
|
||||||
|
pluginMocks.secureValues.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('biometric wallet material', () => {
|
||||||
|
it('rejects foreign enrollment before writing either biometric store', async () => {
|
||||||
|
// Given one proven saved owner and complete material for another owner
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When enrollment is attempted with the foreign material
|
||||||
|
const attempt = enableBiometricUnlock(OTHER_MATERIAL);
|
||||||
|
|
||||||
|
// Then ownership fails before native or local persistence changes
|
||||||
|
await expect(attempt).rejects.toThrow('proven saved wallet owner');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a local wrap when secure-secret persistence fails', async () => {
|
||||||
|
// Given valid material and a rejecting native secure store
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed'));
|
||||||
|
|
||||||
|
// When enrollment reaches native secret persistence
|
||||||
|
const attempt = enableBiometricUnlock(MATERIAL);
|
||||||
|
|
||||||
|
// Then failure is reported without a misleading local enrollment record
|
||||||
|
await expect(attempt).rejects.toThrow('secure write failed');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips both key parts when enrollment belongs to the saved owner', async () => {
|
||||||
|
// Given complete canonical material persisted for its proven owner
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
|
||||||
|
// When the capability enrolls and unlocks it through the native adapter
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
const beforeUnlock = persistedState();
|
||||||
|
const unlocked = await unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then both key parts return together and unlock does not rewrite persistence
|
||||||
|
expect(unlocked).toEqual(MATERIAL);
|
||||||
|
expect(persistedState()).toEqual(beforeUnlock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['malformed JSON', utf8ToBytes('{')],
|
||||||
|
['legacy key-only payload', new Uint8Array(32).fill(7)],
|
||||||
|
[
|
||||||
|
'altered root',
|
||||||
|
utf8ToBytes(
|
||||||
|
JSON.stringify({
|
||||||
|
...MATERIAL,
|
||||||
|
cashRootHex: '00'.repeat(64),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
|
||||||
|
// Given an authenticated non-canonical payload bound to the saved owner
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL));
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock decrypts the payload
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then neither key part crosses the boundary and persistence is byte-identical
|
||||||
|
await expect(attempt).rejects.toThrow();
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no material for a wrong secure secret and preserves state', async () => {
|
||||||
|
// Given a valid wrap whose secure secret has been replaced
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storeMaterialPayload(MATERIAL);
|
||||||
|
pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32));
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock authenticates with the wrong wrapping secret
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then authenticated decryption fails without changing either store
|
||||||
|
await expect(attempt).rejects.toThrow();
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no material for altered ciphertext and preserves state', async () => {
|
||||||
|
// Given a valid wrap with one ciphertext nibble altered
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storeMaterialPayload(MATERIAL);
|
||||||
|
const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}');
|
||||||
|
const wrappedKey = record.wrappedKey;
|
||||||
|
if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext');
|
||||||
|
localStorage.setItem(
|
||||||
|
WRAP_RECORD_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...record,
|
||||||
|
wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock verifies the AES-GCM tag
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then neither key part returns and persistence remains untouched
|
||||||
|
await expect(attempt).rejects.toThrow();
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => {
|
||||||
|
// Given material wrapped for one owner and saved material replaced by another
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storeMaterialPayload(MATERIAL);
|
||||||
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When the stale biometric record is used
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then no old material returns and both stores remain byte-identical
|
||||||
|
await expect(attempt).rejects.toThrow('different wallet');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects authenticated material owned by another linking key', async () => {
|
||||||
|
// Given the saved owner and wrap claim owner A but authenticated material owner B
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
localStorage.setItem('sattle_linking_key', 'legacy-key-residue');
|
||||||
|
localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue');
|
||||||
|
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
|
||||||
|
localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue');
|
||||||
|
await storeMaterialPayload(
|
||||||
|
OTHER_MATERIAL,
|
||||||
|
walletMaterialOwnerId(MATERIAL),
|
||||||
|
WRAP_SECRET,
|
||||||
|
walletMaterialHash(MATERIAL),
|
||||||
|
);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock checks the decrypted linking-key owner
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then neither foreign key part returns and persistence is unchanged
|
||||||
|
await expect(attempt).rejects.toThrow('different wallet');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('biometric exact-material commitment', () => {
|
||||||
|
it('rejects same-owner altered-root enrollment before writing either biometric store', async () => {
|
||||||
|
// Given one proven saved owner and well-formed material with only the cash root swapped
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When enrollment is attempted with the same-owner altered-root material
|
||||||
|
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
|
||||||
|
|
||||||
|
// Then the exact-material commitment fails before native or local persistence changes
|
||||||
|
await expect(attempt).rejects.toThrow('saved wallet material');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects same-owner altered-root enrollment against encrypted saved material', async () => {
|
||||||
|
// Given the saved material envelope is password-encrypted (no plaintext available)
|
||||||
|
await saveWalletMaterial(MATERIAL, 'password');
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When enrollment is attempted with the same-owner altered-root material
|
||||||
|
const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL);
|
||||||
|
|
||||||
|
// Then the hash commitment alone rejects it before either store changes
|
||||||
|
await expect(attempt).rejects.toThrow('saved wallet material');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects enrollment when same-owner saved material changes during the prompt', async () => {
|
||||||
|
// Given valid enrollment input and a prompt-time swap of only the saved cash root
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
pluginMocks.authenticate.mockImplementationOnce(async () => {
|
||||||
|
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When enrollment runs the biometric prompt
|
||||||
|
const attempt = enableBiometricUnlock(MATERIAL);
|
||||||
|
|
||||||
|
// Then the post-prompt commitment recheck fails and no biometric store is written
|
||||||
|
await expect(attempt).rejects.toThrow('changed');
|
||||||
|
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
|
||||||
|
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
|
||||||
|
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an authenticated well-formed same-owner altered-root payload', async () => {
|
||||||
|
// Given an authenticated wrap that claims the saved commitment but whose
|
||||||
|
// decrypted material swaps only the cash root
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storeMaterialPayload(
|
||||||
|
ALTERED_ROOT_MATERIAL,
|
||||||
|
walletMaterialOwnerId(ALTERED_ROOT_MATERIAL),
|
||||||
|
WRAP_SECRET,
|
||||||
|
walletMaterialHash(MATERIAL),
|
||||||
|
);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock decrypts the well-formed same-owner payload
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then the material commitment rejects it and both stores stay byte-identical
|
||||||
|
await expect(attempt).rejects.toThrow('wallet material');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => {
|
||||||
|
// Given an authenticated wrap committed to same-owner altered-root material
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await storeMaterialPayload(ALTERED_ROOT_MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When biometric unlock inspects the record commitment
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then it rejects before any prompt and both stores stay byte-identical
|
||||||
|
await expect(attempt).rejects.toThrow('wallet material');
|
||||||
|
expect(pluginMocks.authenticate).not.toHaveBeenCalled();
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a stale wrap after same-owner saved material replacement', async () => {
|
||||||
|
// Given an enrolled wrap and a later saved-material swap of only the cash root
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
|
||||||
|
// When the now-stale biometric wrap is used
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then the stale commitment rejects and both stores stay byte-identical
|
||||||
|
await expect(attempt).rejects.toThrow('wallet material');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unlock when same-owner saved material changes during the prompt', async () => {
|
||||||
|
// Given an enrolled wrap and a prompt-time swap of only the saved cash root
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
let swapped: ReturnType<typeof persistedState> | null = null;
|
||||||
|
pluginMocks.authenticate.mockImplementationOnce(async () => {
|
||||||
|
await saveWalletMaterial(ALTERED_ROOT_MATERIAL);
|
||||||
|
swapped = persistedState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When biometric unlock runs the prompt
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then the post-prompt commitment recheck rejects and the ceremony writes nothing
|
||||||
|
await expect(attempt).rejects.toThrow('changed');
|
||||||
|
expect(swapped).not.toBeNull();
|
||||||
|
expect(persistedState()).toEqual(swapped);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unlock when the wrap record is replaced during the prompt', async () => {
|
||||||
|
// Given an enrolled wrap replaced mid-prompt by another valid same-owner record
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
let swapped: ReturnType<typeof persistedState> | null = null;
|
||||||
|
pluginMocks.authenticate.mockImplementationOnce(async () => {
|
||||||
|
const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY);
|
||||||
|
if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret');
|
||||||
|
await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret));
|
||||||
|
swapped = persistedState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When biometric unlock runs the prompt
|
||||||
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
|
|
||||||
|
// Then the record recheck rejects and the ceremony writes nothing itself
|
||||||
|
await expect(attempt).rejects.toThrow('changed');
|
||||||
|
expect(swapped).not.toBeNull();
|
||||||
|
expect(persistedState()).toEqual(swapped);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('biometric deletion ordering', () => {
|
||||||
|
it('keeps native and local state retryable when secure-secret deletion fails', async () => {
|
||||||
|
// Given a complete enrolled biometric wrap
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed'));
|
||||||
|
|
||||||
|
// When native secure-secret deletion fails
|
||||||
|
const attempt = disableBiometricUnlock();
|
||||||
|
|
||||||
|
// Then failure is reported before either persisted part is removed
|
||||||
|
await expect(attempt).rejects.toThrow('native deletion failed');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports repeated delete interruption followed by a successful retry', async () => {
|
||||||
|
// Given two interrupted deletion attempts for an enrolled wrap
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await enableBiometricUnlock(MATERIAL);
|
||||||
|
const before = persistedState();
|
||||||
|
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption'));
|
||||||
|
pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption'));
|
||||||
|
|
||||||
|
// When deletion is retried until native removal succeeds
|
||||||
|
await expect(disableBiometricUnlock()).rejects.toThrow('first interruption');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
await expect(disableBiometricUnlock()).rejects.toThrow('second interruption');
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
|
await disableBiometricUnlock();
|
||||||
|
|
||||||
|
// Then native state is gone before the local wrap is removed
|
||||||
|
expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false);
|
||||||
|
expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
// Native biometric unlock: a THIRD wrap of the same linking key, alongside
|
// Native biometric unlock: a THIRD wrap of the same canonical wallet material,
|
||||||
// the password wrap (keys.ts) and the passkey slots (passkeys.ts).
|
// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts).
|
||||||
//
|
//
|
||||||
// Why native: Android WebView has no usable WebAuthn platform authenticator
|
// Why native: Android WebView has no usable WebAuthn platform authenticator
|
||||||
// for this app (passkeys need Play Services / Digital Asset Links wiring we
|
// for this app (passkeys need Play Services / Digital Asset Links wiring we
|
||||||
// cannot rely on), so the biometric path on native is a device-credential
|
// cannot rely on), so the biometric path on native is a device-credential
|
||||||
// prompt instead of a passkey ceremony.
|
// prompt instead of a passkey ceremony.
|
||||||
//
|
//
|
||||||
// Design (mirrors the passkey-slot construction, reusing its wrap
|
// Design (mirrors the passkey-slot construction with a biometric-specific
|
||||||
// primitives verbatim): on enrollment a random 32-byte wrap secret is
|
// HKDF domain): on enrollment a random 32-byte wrap secret is generated,
|
||||||
// generated, the linking key is AES-GCM-wrapped under an HKDF of that
|
// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret,
|
||||||
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32
|
// and only the SECRET goes into biometric-gated secure
|
||||||
// bytes of IKM), and only the SECRET goes into biometric-gated secure
|
|
||||||
// storage (Android Keystore-backed AES-GCM via
|
// storage (Android Keystore-backed AES-GCM via
|
||||||
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
|
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
|
||||||
// localStorage as a record shaped like a passkey slot, plus the linking
|
// localStorage as a versioned AES-GCM record, plus the linking
|
||||||
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale
|
// pubkey as an identity check AND the SHA-256 commitment of the canonical
|
||||||
// wrap behind, and unlocking with it must fail loudly (never activate the
|
// saved material: restoring a DIFFERENT seed leaves a stale wrap behind,
|
||||||
// old wallet silently), so unlock verifies the unwrapped key against the
|
// and so does replacing only the saved cash root with same-owner material.
|
||||||
// recorded pubkey and tells the holder to re-enroll.
|
// Unlocking with either must fail loudly (never activate the old wallet
|
||||||
|
// silently), so unlock verifies the record commitment and the unwrapped
|
||||||
|
// material against the saved wallet material and tells the holder to
|
||||||
|
// re-enroll.
|
||||||
//
|
//
|
||||||
// The biometric gate is app-level (a BiometricPrompt before the secure
|
// The biometric gate is app-level (a BiometricPrompt before the secure
|
||||||
// read), not a keystore key invalidated on biometric re-enrollment - 04
|
// read), not a keystore key invalidated on biometric re-enrollment - 04
|
||||||
@@ -36,39 +38,90 @@ import {
|
|||||||
BiometryErrorType,
|
BiometryErrorType,
|
||||||
} from '@aparajita/capacitor-biometric-auth';
|
} from '@aparajita/capacitor-biometric-auth';
|
||||||
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
|
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
|
||||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
import {
|
||||||
import type { PasskeyWrap } from '@/lnurlcash/passkeys';
|
getPlainWalletMaterial,
|
||||||
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
|
parseWalletMaterial,
|
||||||
|
savedWalletMaterialHash,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
|
type WalletMaterialV2,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
|
|
||||||
import { isNative } from './platform';
|
import { isNative } from './platform';
|
||||||
|
|
||||||
type BiometricWrapRecord = PasskeyWrap & {
|
type BiometricWrapRecord = {
|
||||||
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps
|
readonly version: 2;
|
||||||
createdAt: number;
|
readonly hkdfSalt: string;
|
||||||
|
readonly iv: string;
|
||||||
|
readonly wrappedKey: string;
|
||||||
|
readonly pubkey: string;
|
||||||
|
readonly materialHash: string;
|
||||||
|
readonly createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// the wrapped blob is useless without the secure-storage secret, so (like
|
// the wrapped blob is useless without the secure-storage secret, so (like
|
||||||
// the passkey slots) this record sits in plain localStorage
|
// the passkey slots) this record sits in plain localStorage
|
||||||
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
|
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
|
||||||
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
||||||
|
const BIOMETRIC_WRAP_VERSION = 2 as const;
|
||||||
|
const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2';
|
||||||
|
const WRAP_RECORD_KEYS = [
|
||||||
|
'version',
|
||||||
|
'hkdfSalt',
|
||||||
|
'iv',
|
||||||
|
'wrappedKey',
|
||||||
|
'pubkey',
|
||||||
|
'materialHash',
|
||||||
|
'createdAt',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const deriveBiometricWrapKey = async (
|
||||||
|
secret: Uint8Array,
|
||||||
|
hkdfSalt: Uint8Array,
|
||||||
|
): Promise<CryptoKey> => {
|
||||||
|
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [
|
||||||
|
'deriveKey',
|
||||||
|
]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: new Uint8Array(hkdfSalt),
|
||||||
|
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)),
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
|
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
|
||||||
if (typeof record !== 'object' || record === null) return false;
|
if (!isJsonObject(record)) return false;
|
||||||
const r = record as Record<string, unknown>;
|
|
||||||
return (
|
return (
|
||||||
typeof r.hkdfSalt === 'string' &&
|
Object.keys(record).length === WRAP_RECORD_KEYS.length &&
|
||||||
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) &&
|
Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) &&
|
||||||
typeof r.iv === 'string' &&
|
record.version === BIOMETRIC_WRAP_VERSION &&
|
||||||
/^[0-9a-f]{24}$/i.test(r.iv) &&
|
typeof record.hkdfSalt === 'string' &&
|
||||||
typeof r.wrappedKey === 'string' &&
|
/^[0-9a-f]{32}$/.test(record.hkdfSalt) &&
|
||||||
r.wrappedKey.length > 0 &&
|
typeof record.iv === 'string' &&
|
||||||
r.wrappedKey.length % 2 === 0 &&
|
/^[0-9a-f]{24}$/.test(record.iv) &&
|
||||||
/^[0-9a-f]+$/i.test(r.wrappedKey) &&
|
typeof record.wrappedKey === 'string' &&
|
||||||
typeof r.pubkey === 'string' &&
|
record.wrappedKey.length > 0 &&
|
||||||
/^[0-9a-f]{66}$/i.test(r.pubkey) &&
|
record.wrappedKey.length % 2 === 0 &&
|
||||||
typeof r.createdAt === 'number'
|
/^[0-9a-f]+$/.test(record.wrappedKey) &&
|
||||||
|
typeof record.pubkey === 'string' &&
|
||||||
|
/^[0-9a-f]{66}$/.test(record.pubkey) &&
|
||||||
|
typeof record.materialHash === 'string' &&
|
||||||
|
/^[0-9a-f]{64}$/.test(record.materialHash) &&
|
||||||
|
typeof record.createdAt === 'number' &&
|
||||||
|
Number.isSafeInteger(record.createdAt) &&
|
||||||
|
record.createdAt >= 0
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +136,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const biometricWrapRecordsEqual = (
|
||||||
|
left: BiometricWrapRecord,
|
||||||
|
right: BiometricWrapRecord,
|
||||||
|
): boolean =>
|
||||||
|
left.version === right.version &&
|
||||||
|
left.hkdfSalt === right.hkdfSalt &&
|
||||||
|
left.iv === right.iv &&
|
||||||
|
left.wrappedKey === right.wrappedKey &&
|
||||||
|
left.pubkey === right.pubkey &&
|
||||||
|
left.materialHash === right.materialHash &&
|
||||||
|
left.createdAt === right.createdAt;
|
||||||
|
|
||||||
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
|
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
|
||||||
// the security page ask this during render/setup
|
// the security page ask this during render/setup
|
||||||
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
|
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
|
||||||
@@ -121,52 +186,125 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<void> => {
|
export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise<void> => {
|
||||||
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
const canonicalMaterial = parseWalletMaterial(serialized);
|
||||||
|
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
|
||||||
|
const ownerId = walletMaterialOwnerId(canonicalMaterial);
|
||||||
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Biometric enrollment requires the proven saved wallet owner.');
|
||||||
|
}
|
||||||
|
// exact-material commitment (same check as passkey slots): the enrolled
|
||||||
|
// bytes must equal the canonical saved material, not just share its owner
|
||||||
|
const materialHash = savedWalletMaterialHash();
|
||||||
|
const plaintext = getPlainWalletMaterial();
|
||||||
|
if (
|
||||||
|
materialHash === null ||
|
||||||
|
walletMaterialHash(canonicalMaterial) !== materialHash ||
|
||||||
|
(plaintext !== null && serializeWalletMaterial(plaintext) !== serialized)
|
||||||
|
) {
|
||||||
|
throw new Error('Biometric enrollment requires the exact saved wallet material.');
|
||||||
|
}
|
||||||
const biometry = await BiometricAuth.checkBiometry();
|
const biometry = await BiometricAuth.checkBiometry();
|
||||||
if (!biometry.isAvailable) {
|
if (!biometry.isAvailable) {
|
||||||
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
|
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
|
||||||
}
|
}
|
||||||
// prove presence before storing anything under the biometric gate
|
// prove presence before storing anything under the biometric gate
|
||||||
await authenticateOrThrow('Set up biometric unlock for your wallet');
|
await authenticateOrThrow('Set up biometric unlock for your wallet');
|
||||||
|
// the prompt yields time: re-verify the commitment before writing anything
|
||||||
|
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
|
||||||
|
throw new Error('Saved wallet material changed during biometric enrollment.');
|
||||||
|
}
|
||||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||||
const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey);
|
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const wrapKey = await deriveBiometricWrapKey(secret, hkdfSalt);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
wrapKey,
|
||||||
|
new Uint8Array(utf8ToBytes(serialized)),
|
||||||
|
),
|
||||||
|
);
|
||||||
// secure storage first: if it fails, no record is written and the wallet
|
// secure storage first: if it fails, no record is written and the wallet
|
||||||
// simply stays unenrolled instead of carrying an unwrap-able-nothing
|
// simply stays unenrolled instead of carrying an unwrap-able-nothing
|
||||||
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
|
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
|
||||||
const record: BiometricWrapRecord = {
|
const record: BiometricWrapRecord = {
|
||||||
...wrap,
|
version: BIOMETRIC_WRAP_VERSION,
|
||||||
pubkey: linkingPubKeyHex(linkingKey),
|
hkdfSalt: bytesToHex(hkdfSalt),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
wrappedKey: bytesToHex(ciphertext),
|
||||||
|
pubkey: ownerId,
|
||||||
|
materialHash,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unlockWithBiometrics = async (): Promise<Uint8Array> => {
|
export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMaterialV2> => {
|
||||||
const record = readWrapRecord();
|
const record = readWrapRecord();
|
||||||
if (!isNative() || !record) {
|
if (!isNative() || !record) {
|
||||||
throw new Error('Biometric unlock is not set up on this device.');
|
throw new Error('Biometric unlock is not set up on this device.');
|
||||||
}
|
}
|
||||||
await authenticateOrThrow('Unlock your sattle wallet');
|
const ownerId = savedWalletMaterialOwnerId();
|
||||||
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
|
if (ownerId === null || ownerId !== record.pubkey) {
|
||||||
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
|
|
||||||
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
|
|
||||||
}
|
|
||||||
const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record);
|
|
||||||
if (linkingPubKeyHex(linkingKey) !== record.pubkey) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
|
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return linkingKey;
|
// the stored commitment must match the canonical saved material, or the
|
||||||
|
// wrap is stale (same-owner cash-root replacement) and must not unlock
|
||||||
|
const materialHash = savedWalletMaterialHash();
|
||||||
|
if (materialHash === null || record.materialHash !== materialHash) {
|
||||||
|
throw new Error(
|
||||||
|
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await authenticateOrThrow('Unlock your sattle wallet');
|
||||||
|
// the prompt yields time: re-read the record and the saved commitment
|
||||||
|
// before any decrypted plaintext can reach the caller
|
||||||
|
const currentRecord = readWrapRecord();
|
||||||
|
if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) {
|
||||||
|
throw new Error('The biometric wrap changed during the unlock ceremony.');
|
||||||
|
}
|
||||||
|
if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) {
|
||||||
|
throw new Error('Saved wallet material changed during the unlock ceremony.');
|
||||||
|
}
|
||||||
|
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
|
||||||
|
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
|
||||||
|
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
|
||||||
|
}
|
||||||
|
const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt));
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) },
|
||||||
|
wrapKey,
|
||||||
|
new Uint8Array(hexToBytes(record.wrappedKey)),
|
||||||
|
);
|
||||||
|
const material = parseWalletMaterial(new TextDecoder().decode(plaintext));
|
||||||
|
if (
|
||||||
|
material === null ||
|
||||||
|
savedWalletMaterialOwnerId() !== ownerId ||
|
||||||
|
walletMaterialOwnerId(material) !== ownerId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// the decrypted material itself must carry the committed cash root
|
||||||
|
if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) {
|
||||||
|
throw new Error(
|
||||||
|
'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return material;
|
||||||
};
|
};
|
||||||
|
|
||||||
// the localStorage record goes first and synchronously: even if the caller
|
// Native deletion goes first so a failure leaves the complete enrollment
|
||||||
// fire-and-forgets this (forgetWallet), a later unlock attempt can never
|
// available for the locked lifecycle to retry safely.
|
||||||
// reach the secure secret with a stale record
|
|
||||||
export const disableBiometricUnlock = async (): Promise<void> => {
|
export const disableBiometricUnlock = async (): Promise<void> => {
|
||||||
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
|
|
||||||
if (isNative()) {
|
if (isNative()) {
|
||||||
await SecureStorage.remove(SECURE_SECRET_KEY);
|
await SecureStorage.remove(SECURE_SECRET_KEY);
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,19 +19,14 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!videoEl.value) return;
|
if (!videoEl.value) return;
|
||||||
scanner = new QrScanner(
|
scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), {
|
||||||
videoEl.value,
|
highlightScanRegion: true,
|
||||||
(result) => emit('decode', result.data),
|
});
|
||||||
{ highlightScanRegion: true },
|
|
||||||
);
|
|
||||||
await scanner.start();
|
await scanner.start();
|
||||||
starting.value = false;
|
starting.value = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
starting.value = false;
|
starting.value = false;
|
||||||
emit(
|
emit('error', err instanceof Error ? err.message : 'Camera access failed.');
|
||||||
'error',
|
|
||||||
err instanceof Error ? err.message : 'Camera access failed.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col q-ml-md">
|
<div class="col q-ml-md">
|
||||||
<div class="text-body1 text-weight-medium">Bearer note</div>
|
<div class="text-body1 text-weight-medium">Bearer note</div>
|
||||||
<div class="text-caption text-grey-5">
|
<div class="text-caption text-grey-5">Paste or scan a note someone sent you</div>
|
||||||
Paste or scan a note someone sent you
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<q-icon name="chevron_right" color="grey-5" size="24px" />
|
<q-icon name="chevron_right" color="grey-5" size="24px" />
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +106,9 @@ const onReceived = () => {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row:hover,
|
.action-row:hover,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
{{ claimError }}
|
{{ claimError }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
v-if="claimRetryable"
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="dark"
|
text-color="dark"
|
||||||
@@ -202,19 +203,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { Notify } from 'quasar';
|
|
||||||
|
|
||||||
import QrCode from '../QrCode.vue';
|
import QrCode from '../QrCode.vue';
|
||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { useReceiveLightningDialog } from '@/composables/useReceiveLightningDialog';
|
||||||
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
|
||||||
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
|
||||||
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
import { useMintsStore } from '@/stores/mints';
|
|
||||||
import { useActivityStore } from '@/stores/activity';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>();
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -222,232 +212,37 @@ const emit = defineEmits<{
|
|||||||
received: [];
|
received: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const wallet = useWalletStore();
|
const {
|
||||||
const mints = useMintsStore();
|
CUSTOM_MINT,
|
||||||
const activity = useActivityStore();
|
amountSats,
|
||||||
|
claimError,
|
||||||
const CUSTOM_MINT = '__custom__';
|
claimRetryable,
|
||||||
|
copyInvoice,
|
||||||
// whole-sat display for received amounts (msat remainder rounded down, per
|
createInvoice,
|
||||||
// units.ts's floorMsatToSat)
|
customMint,
|
||||||
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
feeSats,
|
||||||
|
formError,
|
||||||
const errorMessage = (err: unknown): string =>
|
formValid,
|
||||||
err instanceof Error ? err.message : 'Something went wrong.';
|
grossSats,
|
||||||
|
mintChoice,
|
||||||
type Step = 'form' | 'invoice' | 'success';
|
mintOptions,
|
||||||
const step = ref<Step>('form');
|
netSats,
|
||||||
|
prepared,
|
||||||
// ---- form ----
|
preparing,
|
||||||
const amountSats = ref<number | null>(null);
|
receivedSats,
|
||||||
const mintChoice = ref('');
|
receivedServer,
|
||||||
const customMint = ref('');
|
resumeWaiting,
|
||||||
const preparing = ref(false);
|
retryClaim,
|
||||||
const formError = ref('');
|
rotationWarning,
|
||||||
|
showTrust,
|
||||||
type MintOption = { label: string; value: string };
|
skipTrust,
|
||||||
|
step,
|
||||||
const mintOptions = computed<MintOption[]>(() => {
|
stopWaiting,
|
||||||
const options: MintOption[] = [];
|
trustMint,
|
||||||
const seen = new Set<string>();
|
trustNodeAlias,
|
||||||
for (const mint of mints.mints) {
|
trustServer,
|
||||||
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
|
waiting,
|
||||||
if (seen.has(address)) continue;
|
} = useReceiveLightningDialog(props, emit);
|
||||||
seen.add(address);
|
|
||||||
const label = mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address;
|
|
||||||
options.push({ label, value: address });
|
|
||||||
}
|
|
||||||
for (const publicMint of mints.PUBLIC_MINTS) {
|
|
||||||
if (seen.has(publicMint)) continue;
|
|
||||||
seen.add(publicMint);
|
|
||||||
options.push({ label: publicMint, value: publicMint });
|
|
||||||
}
|
|
||||||
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
|
|
||||||
return options;
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultChoice = (): string => {
|
|
||||||
const options = mintOptions.value;
|
|
||||||
if (mints.defaultMint) {
|
|
||||||
const match = options.find((o) => o.value.endsWith(`@${mints.defaultMint}`));
|
|
||||||
if (match) return match.value;
|
|
||||||
}
|
|
||||||
const first = options[0];
|
|
||||||
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formValid = computed(() => {
|
|
||||||
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
|
|
||||||
return mintChoice.value === CUSTOM_MINT
|
|
||||||
? customMint.value.trim() !== ''
|
|
||||||
: mintChoice.value !== '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const createInvoice = async () => {
|
|
||||||
const sats = amountSats.value;
|
|
||||||
if (!sats || preparing.value) return;
|
|
||||||
preparing.value = true;
|
|
||||||
formError.value = '';
|
|
||||||
try {
|
|
||||||
const mintInput = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
|
|
||||||
const preparedMint = await prepareMint(mintInput, satsToMsat(sats));
|
|
||||||
if (!preparedMint.verifyUrl) {
|
|
||||||
// without a verify URL the payment can never be auto-claimed - showing
|
|
||||||
// a payable invoice here would strand the sats at the mint
|
|
||||||
formError.value =
|
|
||||||
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prepared.value = preparedMint;
|
|
||||||
claimRun = null;
|
|
||||||
claimError.value = '';
|
|
||||||
step.value = 'invoice';
|
|
||||||
beginClaim();
|
|
||||||
} catch (err) {
|
|
||||||
formError.value = errorMessage(err);
|
|
||||||
Notify.create({ type: 'negative', message: formError.value });
|
|
||||||
} finally {
|
|
||||||
preparing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- invoice ----
|
|
||||||
const prepared = ref<PreparedMint | null>(null);
|
|
||||||
const waiting = ref(false);
|
|
||||||
const claimError = ref('');
|
|
||||||
// single in-flight claim; "stop waiting" only detaches the UI from it - the
|
|
||||||
// claim itself always runs to completion so a settled payment is never
|
|
||||||
// abandoned unclaimed
|
|
||||||
let claimRun: Promise<void> | null = null;
|
|
||||||
|
|
||||||
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
|
|
||||||
const netSats = computed(() =>
|
|
||||||
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
|
|
||||||
);
|
|
||||||
const feeSats = computed(() => grossSats.value - netSats.value);
|
|
||||||
|
|
||||||
const copyInvoice = async () => {
|
|
||||||
if (!prepared.value) return;
|
|
||||||
try {
|
|
||||||
await writeClipboard(prepared.value.invoice);
|
|
||||||
Notify.create({ type: 'positive', message: 'Invoice copied.' });
|
|
||||||
} catch (err) {
|
|
||||||
Notify.create({ type: 'negative', message: errorMessage(err) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const beginClaim = () => {
|
|
||||||
if (!prepared.value || claimRun) return;
|
|
||||||
waiting.value = true;
|
|
||||||
claimError.value = '';
|
|
||||||
const current = prepared.value;
|
|
||||||
claimRun = (async () => {
|
|
||||||
try {
|
|
||||||
const claimed = await claimMintedNote(current);
|
|
||||||
await onClaimed(claimed, current);
|
|
||||||
} catch (err) {
|
|
||||||
claimError.value = `${errorMessage(err)} The invoice stays valid — you can try again.`;
|
|
||||||
Notify.create({ type: 'negative', message: errorMessage(err) });
|
|
||||||
} finally {
|
|
||||||
waiting.value = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
// after a failed claim the run is over - allow a fresh attempt
|
|
||||||
const retryClaim = () => {
|
|
||||||
claimRun = null;
|
|
||||||
beginClaim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopWaiting = () => {
|
|
||||||
waiting.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumeWaiting = () => {
|
|
||||||
if (claimRun) waiting.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- success ----
|
|
||||||
const receivedSats = ref(0);
|
|
||||||
const receivedServer = ref('');
|
|
||||||
const rotationWarning = ref('');
|
|
||||||
|
|
||||||
const onClaimed = async (claimed: ClaimedNote, from: PreparedMint) => {
|
|
||||||
const server = from.server;
|
|
||||||
const wasTrusted = mints.isTrusted(server);
|
|
||||||
const notes: NewBearer[] = claimed.possibleCopy
|
|
||||||
? [claimed.note, claimed.possibleCopy]
|
|
||||||
: [claimed.note];
|
|
||||||
await wallet.addBearers(notes);
|
|
||||||
receivedSats.value = displaySats(claimed.note.amount);
|
|
||||||
receivedServer.value = server;
|
|
||||||
rotationWarning.value = claimed.rotationError ?? '';
|
|
||||||
activity.log(
|
|
||||||
'mint',
|
|
||||||
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
|
|
||||||
);
|
|
||||||
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
|
|
||||||
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
|
||||||
});
|
|
||||||
emit('received');
|
|
||||||
if (props.modelValue) step.value = 'success';
|
|
||||||
if (!wasTrusted && claimed.note.mintPubkey) {
|
|
||||||
trustServer.value = server;
|
|
||||||
trustPubkey.value = claimed.note.mintPubkey;
|
|
||||||
trustNodeAlias.value = from.nodeInfo?.nodeAlias ?? '';
|
|
||||||
showTrust.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- trust prompt ----
|
|
||||||
const showTrust = ref(false);
|
|
||||||
const trustServer = ref('');
|
|
||||||
const trustPubkey = ref('');
|
|
||||||
const trustNodeAlias = ref('');
|
|
||||||
|
|
||||||
const trustMint = () => {
|
|
||||||
try {
|
|
||||||
mints.trust(trustServer.value, trustPubkey.value, {
|
|
||||||
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
|
|
||||||
});
|
|
||||||
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
|
||||||
} catch (err) {
|
|
||||||
Notify.create({ type: 'negative', message: errorMessage(err) });
|
|
||||||
} finally {
|
|
||||||
showTrust.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipTrust = () => {
|
|
||||||
showTrust.value = false;
|
|
||||||
Notify.create({
|
|
||||||
type: 'warning',
|
|
||||||
message:
|
|
||||||
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// fresh form every time the dialog opens; a claim already in flight keeps
|
|
||||||
// running in the background regardless
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (!open) return;
|
|
||||||
step.value = 'form';
|
|
||||||
amountSats.value = null;
|
|
||||||
customMint.value = '';
|
|
||||||
mintChoice.value = defaultChoice();
|
|
||||||
preparing.value = false;
|
|
||||||
formError.value = '';
|
|
||||||
prepared.value = null;
|
|
||||||
waiting.value = false;
|
|
||||||
rotationWarning.value = '';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -14,12 +14,7 @@
|
|||||||
|
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
<q-card-section v-if="step === 'input'" class="q-pt-sm">
|
<q-card-section v-if="step === 'input'" class="q-pt-sm">
|
||||||
<qr-scanner
|
<qr-scanner v-if="scanning" class="q-mb-md" @decode="onScan" @error="onScanError" />
|
||||||
v-if="scanning"
|
|
||||||
class="q-mb-md"
|
|
||||||
@decode="onScan"
|
|
||||||
@error="onScanError"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="input"
|
v-model="input"
|
||||||
@@ -82,16 +77,16 @@
|
|||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="info" color="info" />
|
<q-icon name="info" color="info" />
|
||||||
</template>
|
</template>
|
||||||
The mint could not be reached, so the note is stored unconfirmed at the
|
The mint could not be reached, so the note is stored unconfirmed at the sender's declared
|
||||||
sender's declared amount. Refresh it later to confirm.
|
amount. Refresh it later to confirm.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="warning" color="warning" />
|
<q-icon name="warning" color="warning" />
|
||||||
</template>
|
</template>
|
||||||
The note is in your wallet, but it could not be fully secured yet — the
|
The note is in your wallet, but it could not be fully secured yet — the sender may still
|
||||||
sender may still hold a copy. You can secure it later.
|
hold a copy. You can secure it later.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -114,8 +109,8 @@
|
|||||||
<strong>{{ trustServer }}</strong>
|
<strong>{{ trustServer }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-5 q-mb-md">
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
Trusting saves the mint so it is offered next time. You can manage trusted
|
Trusting saves the mint so it is offered next time. You can manage trusted mints in
|
||||||
mints in Settings.
|
Settings.
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-gutter-sm justify-end">
|
<div class="row q-gutter-sm justify-end">
|
||||||
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
||||||
@@ -134,22 +129,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { Notify } from 'quasar';
|
|
||||||
import {
|
|
||||||
NoteSpentError,
|
|
||||||
NoteUnknownError,
|
|
||||||
PendingNoteError,
|
|
||||||
isValidNoteInput,
|
|
||||||
} from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import QrScanner from '../QrScanner.vue';
|
import QrScanner from '../QrScanner.vue';
|
||||||
import { receiveBearer } from '@/lnurlcash/ops';
|
import { useReceiveTokenDialog } from '@/composables/useReceiveTokenDialog';
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
import { useMintsStore } from '@/stores/mints';
|
|
||||||
import { useActivityStore } from '@/stores/activity';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -157,193 +138,27 @@ const emit = defineEmits<{
|
|||||||
received: [];
|
received: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const wallet = useWalletStore();
|
const {
|
||||||
const mints = useMintsStore();
|
busy,
|
||||||
const activity = useActivityStore();
|
errorIcon,
|
||||||
|
errorKind,
|
||||||
// whole-sat display for received amounts (msat remainder rounded down, per
|
errorText,
|
||||||
// units.ts's floorMsatToSat)
|
input,
|
||||||
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
inputValid,
|
||||||
|
onScan,
|
||||||
const errMsg = (err: unknown): string =>
|
onScanError,
|
||||||
err instanceof Error ? err.message : 'Something went wrong.';
|
receive,
|
||||||
|
receivedSats,
|
||||||
// distinct, jargon-free error states for the definitive service answers
|
receivedServer,
|
||||||
type ReceiveErrorKind =
|
rotationWarning,
|
||||||
| 'spent'
|
scanning,
|
||||||
| 'unknown'
|
showTrust,
|
||||||
| 'pending'
|
skipTrust,
|
||||||
| 'duplicate'
|
step,
|
||||||
| 'invalid'
|
trustMint,
|
||||||
| 'generic'
|
trustServer,
|
||||||
| '';
|
unverifiedNote,
|
||||||
|
} = useReceiveTokenDialog(props, emit);
|
||||||
const ERROR_TEXT: Record<Exclude<ReceiveErrorKind, ''>, string> = {
|
|
||||||
spent: 'This note has already been spent.',
|
|
||||||
unknown: "The mint doesn't know this note.",
|
|
||||||
pending: 'This note is locked mid-payment — try again shortly.',
|
|
||||||
duplicate: 'This note is already in your wallet.',
|
|
||||||
invalid: 'Not a valid bearer note.',
|
|
||||||
generic: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ERROR_ICON: Record<Exclude<ReceiveErrorKind, ''>, string> = {
|
|
||||||
spent: 'money_off',
|
|
||||||
unknown: 'help_outline',
|
|
||||||
pending: 'hourglass_top',
|
|
||||||
duplicate: 'content_copy',
|
|
||||||
invalid: 'error_outline',
|
|
||||||
generic: 'error_outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorText = computed(() =>
|
|
||||||
errorKind.value === 'generic'
|
|
||||||
? errorMessageText.value
|
|
||||||
: errorKind.value === ''
|
|
||||||
? ''
|
|
||||||
: ERROR_TEXT[errorKind.value],
|
|
||||||
);
|
|
||||||
const errorIcon = computed(() =>
|
|
||||||
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
|
|
||||||
);
|
|
||||||
|
|
||||||
type Step = 'input' | 'success';
|
|
||||||
const step = ref<Step>('input');
|
|
||||||
|
|
||||||
const input = ref('');
|
|
||||||
const scanning = ref(false);
|
|
||||||
const busy = ref(false);
|
|
||||||
const errorKind = ref<ReceiveErrorKind>('');
|
|
||||||
const errorMessageText = ref('');
|
|
||||||
|
|
||||||
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
|
|
||||||
|
|
||||||
const clearError = () => {
|
|
||||||
errorKind.value = '';
|
|
||||||
errorMessageText.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const classifyError = (err: unknown): void => {
|
|
||||||
let kind: Exclude<ReceiveErrorKind, ''>;
|
|
||||||
if (err instanceof NoteSpentError) {
|
|
||||||
kind = 'spent';
|
|
||||||
} else if (err instanceof NoteUnknownError) {
|
|
||||||
kind = 'unknown';
|
|
||||||
} else if (err instanceof PendingNoteError) {
|
|
||||||
kind = 'pending';
|
|
||||||
} else if (err instanceof Error && err.message.includes('already in your wallet')) {
|
|
||||||
kind = 'duplicate';
|
|
||||||
} else if (err instanceof Error && err.message.includes('Not an LNURLcash bearer note')) {
|
|
||||||
kind = 'invalid';
|
|
||||||
} else {
|
|
||||||
kind = 'generic';
|
|
||||||
errorMessageText.value = errMsg(err);
|
|
||||||
}
|
|
||||||
errorKind.value = kind;
|
|
||||||
Notify.create({
|
|
||||||
type: 'negative',
|
|
||||||
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// re-entrancy guard: a scanner double-fire or Enter+click landing together
|
|
||||||
// must not run two receives for the same note - both would pass the
|
|
||||||
// duplicate check before either addBearers landed
|
|
||||||
const receive = async () => {
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (busy.value || value === '') return;
|
|
||||||
busy.value = true;
|
|
||||||
clearError();
|
|
||||||
try {
|
|
||||||
const claimed = await receiveBearer(value, wallet.bearers);
|
|
||||||
const note = claimed.note;
|
|
||||||
const server = new URL(note.url).host;
|
|
||||||
const wasTrusted = mints.isTrusted(server);
|
|
||||||
const notes: NewBearer[] = claimed.possibleCopy
|
|
||||||
? [note, claimed.possibleCopy]
|
|
||||||
: [note];
|
|
||||||
await wallet.addBearers(notes);
|
|
||||||
receivedSats.value = displaySats(note.amount);
|
|
||||||
receivedServer.value = server;
|
|
||||||
unverifiedNote.value = !note.verified;
|
|
||||||
rotationWarning.value = claimed.rotationError ?? '';
|
|
||||||
activity.log(
|
|
||||||
'receive',
|
|
||||||
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
|
|
||||||
);
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
|
||||||
});
|
|
||||||
emit('received');
|
|
||||||
scanning.value = false;
|
|
||||||
step.value = 'success';
|
|
||||||
if (!wasTrusted && note.mintPubkey) {
|
|
||||||
trustServer.value = server;
|
|
||||||
trustPubkey.value = note.mintPubkey;
|
|
||||||
showTrust.value = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
classifyError(err);
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScan = (text: string) => {
|
|
||||||
input.value = text;
|
|
||||||
scanning.value = false;
|
|
||||||
void receive();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanError = (message: string) => {
|
|
||||||
scanning.value = false;
|
|
||||||
Notify.create({ type: 'negative', message });
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- success ----
|
|
||||||
const receivedSats = ref(0);
|
|
||||||
const receivedServer = ref('');
|
|
||||||
const unverifiedNote = ref(false);
|
|
||||||
const rotationWarning = ref('');
|
|
||||||
|
|
||||||
// ---- trust prompt ----
|
|
||||||
const showTrust = ref(false);
|
|
||||||
const trustServer = ref('');
|
|
||||||
const trustPubkey = ref('');
|
|
||||||
|
|
||||||
const trustMint = () => {
|
|
||||||
try {
|
|
||||||
mints.trust(trustServer.value, trustPubkey.value);
|
|
||||||
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
|
||||||
} catch (err) {
|
|
||||||
Notify.create({ type: 'negative', message: errMsg(err) });
|
|
||||||
} finally {
|
|
||||||
showTrust.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipTrust = () => {
|
|
||||||
showTrust.value = false;
|
|
||||||
Notify.create({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (!open) return;
|
|
||||||
step.value = 'input';
|
|
||||||
input.value = props.initialInput ?? '';
|
|
||||||
scanning.value = false;
|
|
||||||
busy.value = false;
|
|
||||||
clearError();
|
|
||||||
unverifiedNote.value = false;
|
|
||||||
rotationWarning.value = '';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -7,18 +7,8 @@
|
|||||||
//
|
//
|
||||||
// Fund-safety order when applying the carve: fresh notes are added to the
|
// Fund-safety order when applying the carve: fresh notes are added to the
|
||||||
// wallet BEFORE consumed inputs are marked spent.
|
// wallet BEFORE consumed inputs are marked spent.
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import QrScanner from '@/components/QrScanner.vue';
|
import QrScanner from '@/components/QrScanner.vue';
|
||||||
import { readClipboard } from '@/capabilities/clipboard';
|
import { usePayInvoiceDialog } from '@/composables/usePayInvoiceDialog';
|
||||||
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
|
||||||
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
|
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
import { useActivityStore } from '@/stores/activity';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -26,231 +16,28 @@ const emit = defineEmits<{
|
|||||||
sent: [];
|
sent: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $q = useQuasar();
|
const {
|
||||||
const wallet = useWalletStore();
|
addressAmountSats,
|
||||||
const activity = useActivityStore();
|
closeResult,
|
||||||
|
formatSats,
|
||||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
inlineError,
|
||||||
// guarded: the Notify plugin registration lives in quasar.config, outside
|
input,
|
||||||
// this component's control - a missing registration must not break a flow
|
msatToSats,
|
||||||
if (typeof $q.notify === 'function') {
|
onScan,
|
||||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
onScanError,
|
||||||
}
|
paste,
|
||||||
};
|
pay,
|
||||||
|
pendingPayment,
|
||||||
const show = computed({
|
proceed,
|
||||||
get: () => props.modelValue,
|
result,
|
||||||
set: (value: boolean) => emit('update:modelValue', value),
|
resultAmountSats,
|
||||||
});
|
show,
|
||||||
|
showScanner,
|
||||||
type Step = 'input' | 'confirm' | 'working' | 'result';
|
stage,
|
||||||
type TargetKind = 'invoice' | 'address';
|
step,
|
||||||
|
targetKind,
|
||||||
type PendingPayment = {
|
truncatedInput,
|
||||||
kind: TargetKind;
|
} = usePayInvoiceDialog(props, emit);
|
||||||
input: string;
|
|
||||||
amountMsat: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Result = {
|
|
||||||
outcome: PayOutcome;
|
|
||||||
amountMsat: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const step = ref<Step>('input');
|
|
||||||
const input = ref('');
|
|
||||||
const addressAmountSats = ref('');
|
|
||||||
const showScanner = ref(false);
|
|
||||||
const inlineError = ref<string | null>(null);
|
|
||||||
const stage = ref('');
|
|
||||||
const pendingPayment = ref<PendingPayment | null>(null);
|
|
||||||
const result = ref<Result | null>(null);
|
|
||||||
|
|
||||||
const formatSats = (sats: number): string =>
|
|
||||||
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
||||||
|
|
||||||
const classify = (value: string): TargetKind | null => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (isBolt11Invoice(trimmed)) return 'invoice';
|
|
||||||
// resolveLnurlInput also accepts Lightning Addresses, bech32 LNURLs and
|
|
||||||
// lightning= deep links - anything it resolves can be paid
|
|
||||||
if (resolveLnurlInput(trimmed) !== null) return 'address';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetKind = computed<TargetKind | null>(() => classify(input.value));
|
|
||||||
|
|
||||||
const truncatedInput = computed(() => {
|
|
||||||
const p = pendingPayment.value;
|
|
||||||
if (!p) return '';
|
|
||||||
if (p.kind === 'address') return p.input;
|
|
||||||
return p.input.length > 30 ? `${p.input.slice(0, 18)}…${p.input.slice(-8)}` : p.input;
|
|
||||||
});
|
|
||||||
|
|
||||||
const resultAmountSats = computed(() =>
|
|
||||||
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
step.value = 'input';
|
|
||||||
input.value = props.initialInput ?? '';
|
|
||||||
addressAmountSats.value = '';
|
|
||||||
showScanner.value = false;
|
|
||||||
inlineError.value = null;
|
|
||||||
stage.value = '';
|
|
||||||
pendingPayment.value = null;
|
|
||||||
result.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (open) reset();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onScan = (text: string) => {
|
|
||||||
input.value = text.replace(/^lightning:/i, '').trim();
|
|
||||||
showScanner.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanError = (message: string) => {
|
|
||||||
showScanner.value = false;
|
|
||||||
toast('negative', message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const paste = async () => {
|
|
||||||
try {
|
|
||||||
const text = await readClipboard();
|
|
||||||
if (text) input.value = text.trim();
|
|
||||||
} catch {
|
|
||||||
toast('negative', "Couldn't read the clipboard - paste manually.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// input -> confirm: classify and validate everything that can be checked
|
|
||||||
// before any network call (amount present, within balance)
|
|
||||||
const proceed = () => {
|
|
||||||
inlineError.value = null;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
inlineError.value = 'Paste an invoice or a Lightning Address first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = classify(value);
|
|
||||||
if (kind === null) {
|
|
||||||
inlineError.value = "That doesn't look like a Lightning invoice or address.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let amountMsat: number;
|
|
||||||
if (kind === 'invoice') {
|
|
||||||
const decoded = decodeBolt11AmountMsat(value);
|
|
||||||
if (decoded === null || decoded <= 0) {
|
|
||||||
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
amountMsat = decoded;
|
|
||||||
} else {
|
|
||||||
const sats = Number(addressAmountSats.value);
|
|
||||||
if (!Number.isInteger(sats) || sats <= 0) {
|
|
||||||
inlineError.value = 'Enter how many sats to send to this address.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
amountMsat = satsToMsat(sats);
|
|
||||||
}
|
|
||||||
if (amountMsat > wallet.balanceMsat) {
|
|
||||||
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingPayment.value = { kind, input: value, amountMsat };
|
|
||||||
step.value = 'confirm';
|
|
||||||
};
|
|
||||||
|
|
||||||
const friendlyError = (err: unknown): string => {
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
if (message.startsWith('No mint holds enough')) {
|
|
||||||
return 'Not enough spendable balance to cover that payment.';
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Applies a carve to the wallet in the only safe order: add the fresh
|
|
||||||
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
|
||||||
// id of the carved note (for the exact-match path the note already exists
|
|
||||||
// in the wallet, so it is found by url instead of re-added).
|
|
||||||
const applyCarve = async (carve: CarveResult): Promise<string> => {
|
|
||||||
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
|
||||||
const toAdd: NewBearer[] = [];
|
|
||||||
if (!existing) toAdd.push(carve.note);
|
|
||||||
if (carve.change) toAdd.push(carve.change);
|
|
||||||
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
|
||||||
for (const consumed of carve.consumed) {
|
|
||||||
await wallet.markSpent(consumed.id);
|
|
||||||
}
|
|
||||||
return existing ? existing.id : added[0].id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pay = async () => {
|
|
||||||
const p = pendingPayment.value;
|
|
||||||
if (!p) return;
|
|
||||||
step.value = 'working';
|
|
||||||
stage.value = 'Preparing the exact amount and sending the payment…';
|
|
||||||
try {
|
|
||||||
const payResult = await payWithBearers(
|
|
||||||
wallet.bearers,
|
|
||||||
p.input,
|
|
||||||
p.kind === 'address' ? { amountMsat: p.amountMsat } : {},
|
|
||||||
);
|
|
||||||
stage.value = 'Confirming the result…';
|
|
||||||
const noteId = await applyCarve(payResult.carve);
|
|
||||||
if (payResult.rescuedNote) {
|
|
||||||
await wallet.addBearers([payResult.rescuedNote]);
|
|
||||||
}
|
|
||||||
const sats = formatSats(msatToSats(payResult.amountMsat));
|
|
||||||
if (payResult.outcome === 'settled') {
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('melt', `Paid ${sats} sats over Lightning.`);
|
|
||||||
toast('positive', `Paid ${sats} sats.`);
|
|
||||||
emit('sent');
|
|
||||||
} else if (payResult.outcome === 'failed-funds-returned') {
|
|
||||||
// the payment never happened and the note is spendable again - it
|
|
||||||
// stays in the wallet, deliberately NOT marked spent
|
|
||||||
activity.log('transfer', `A ${sats} sat payment failed - funds are back in your wallet.`);
|
|
||||||
toast('warning', 'Payment failed - funds are back in your wallet.');
|
|
||||||
} else if (payResult.outcome === 'unknown-still-pending') {
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('melt', `Payment of ${sats} sats is still in flight - the note is locked.`);
|
|
||||||
emit('sent');
|
|
||||||
} else {
|
|
||||||
// note-already-spent: the mint says the note is gone; nothing was
|
|
||||||
// paid. Lock it locally so it can't be tried again.
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('spent', `A ${sats} sat note was already spent at the mint.`);
|
|
||||||
}
|
|
||||||
result.value = { outcome: payResult.outcome, amountMsat: payResult.amountMsat };
|
|
||||||
step.value = 'result';
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof UncertainOutcomeError) {
|
|
||||||
await wallet.addBearers(err.possibleOutputs);
|
|
||||||
activity.log(
|
|
||||||
'transfer',
|
|
||||||
'A payment preparation could not be confirmed - possible notes stored unverified.',
|
|
||||||
);
|
|
||||||
inlineError.value =
|
|
||||||
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
|
||||||
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
|
|
||||||
} else {
|
|
||||||
inlineError.value = friendlyError(err);
|
|
||||||
toast('negative', inlineError.value);
|
|
||||||
}
|
|
||||||
step.value = 'input';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeResult = () => {
|
|
||||||
show.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ const onSent = () => emit('sent');
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-dialog v-model="show" position="bottom" transition-show="slide-up" transition-hide="slide-down">
|
<q-dialog
|
||||||
|
v-model="show"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
>
|
||||||
<q-card class="sattle-card drawer-card full-width">
|
<q-card class="sattle-card drawer-card full-width">
|
||||||
<q-card-section class="row items-center q-pb-sm">
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
|
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
|
||||||
|
|||||||
@@ -7,19 +7,8 @@
|
|||||||
// Fund-safety order when applying the carve: the replacement notes are
|
// Fund-safety order when applying the carve: the replacement notes are
|
||||||
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
|
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
|
||||||
// mid-way strands a duplicate, never a secret.
|
// mid-way strands a duplicate, never a secret.
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import { toBech32Lnurl } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import QrCode from '@/components/QrCode.vue';
|
import QrCode from '@/components/QrCode.vue';
|
||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { useSendTokenDialog } from '@/composables/useSendTokenDialog';
|
||||||
import { canShareText, shareText } from '@/capabilities/share';
|
|
||||||
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
|
||||||
import type { CarveResult } from '@/lnurlcash/ops';
|
|
||||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
import { useActivityStore } from '@/stores/activity';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>();
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -27,170 +16,28 @@ const emit = defineEmits<{
|
|||||||
sent: [];
|
sent: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $q = useQuasar();
|
const {
|
||||||
const wallet = useWalletStore();
|
amountError,
|
||||||
const activity = useActivityStore();
|
amountSats,
|
||||||
|
canPrepare,
|
||||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
canShare,
|
||||||
// guarded: the Notify plugin registration lives in quasar.config, outside
|
copyNote,
|
||||||
// this component's control - a missing registration must not break a flow
|
errorMessage,
|
||||||
if (typeof $q.notify === 'function') {
|
finishKeep,
|
||||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
finishRemove,
|
||||||
}
|
formatSats,
|
||||||
};
|
msatToSats,
|
||||||
|
noteDisplayValue,
|
||||||
const show = computed({
|
prepare,
|
||||||
get: () => props.modelValue,
|
prepared,
|
||||||
set: (value: boolean) => emit('update:modelValue', value),
|
preparing,
|
||||||
});
|
removing,
|
||||||
|
revealed,
|
||||||
type Step = 'amount' | 'ready';
|
shareNote,
|
||||||
const step = ref<Step>('amount');
|
show,
|
||||||
const amountSats = ref('');
|
step,
|
||||||
const preparing = ref(false);
|
wallet,
|
||||||
const removing = ref(false);
|
} = useSendTokenDialog(props, emit);
|
||||||
const errorMessage = ref<string | null>(null);
|
|
||||||
const prepared = ref<Bearer | null>(null);
|
|
||||||
const revealed = ref(false);
|
|
||||||
|
|
||||||
const formatSats = (sats: number): string =>
|
|
||||||
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
||||||
|
|
||||||
const parsedAmount = computed<number | null>(() => {
|
|
||||||
const n = Number(amountSats.value);
|
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const amountError = computed<string | null>(() => {
|
|
||||||
if (parsedAmount.value === null) return null;
|
|
||||||
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
|
|
||||||
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const canPrepare = computed(
|
|
||||||
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
const noteDisplayValue = computed(() => (prepared.value ? toBech32Lnurl(prepared.value.url) : ''));
|
|
||||||
|
|
||||||
const canShare = canShareText();
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
step.value = 'amount';
|
|
||||||
amountSats.value = '';
|
|
||||||
preparing.value = false;
|
|
||||||
removing.value = false;
|
|
||||||
errorMessage.value = null;
|
|
||||||
prepared.value = null;
|
|
||||||
revealed.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (open) reset();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Applies a carve to the wallet in the only safe order: add the fresh
|
|
||||||
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
|
||||||
// id of the carved note (for the exact-match path the note already exists
|
|
||||||
// in the wallet, so it is found by url instead of re-added).
|
|
||||||
const applyCarve = async (carve: CarveResult): Promise<Bearer> => {
|
|
||||||
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
|
||||||
const toAdd: NewBearer[] = [];
|
|
||||||
if (!existing) toAdd.push(carve.note);
|
|
||||||
if (carve.change) toAdd.push(carve.change);
|
|
||||||
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
|
||||||
for (const consumed of carve.consumed) {
|
|
||||||
await wallet.markSpent(consumed.id);
|
|
||||||
}
|
|
||||||
return existing ?? added[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepare = async () => {
|
|
||||||
const sats = parsedAmount.value;
|
|
||||||
if (sats === null || amountError.value !== null) return;
|
|
||||||
preparing.value = true;
|
|
||||||
errorMessage.value = null;
|
|
||||||
try {
|
|
||||||
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats));
|
|
||||||
const note = await applyCarve(carve);
|
|
||||||
if (carve.change) {
|
|
||||||
activity.log('split', `Prepared a ${formatSats(sats)} sat note to hand over.`);
|
|
||||||
} else if (carve.consumed.length > 1) {
|
|
||||||
activity.log('combine', `Combined notes into a ${formatSats(sats)} sat note.`);
|
|
||||||
}
|
|
||||||
prepared.value = note;
|
|
||||||
revealed.value = false;
|
|
||||||
step.value = 'ready';
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof UncertainOutcomeError) {
|
|
||||||
// the mutation may have landed - the possible outputs carry fresh
|
|
||||||
// secrets and must be tracked alongside the (kept) originals
|
|
||||||
await wallet.addBearers(err.possibleOutputs);
|
|
||||||
activity.log(
|
|
||||||
'transfer',
|
|
||||||
'A note preparation could not be confirmed - possible notes stored unverified.',
|
|
||||||
);
|
|
||||||
errorMessage.value =
|
|
||||||
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
|
||||||
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
errorMessage.value = message.startsWith('No mint holds enough')
|
|
||||||
? 'Not enough spendable balance to cover that amount.'
|
|
||||||
: message;
|
|
||||||
toast('negative', errorMessage.value);
|
|
||||||
} finally {
|
|
||||||
preparing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyNote = async () => {
|
|
||||||
try {
|
|
||||||
await writeClipboard(noteDisplayValue.value);
|
|
||||||
toast('positive', 'Note copied to clipboard.');
|
|
||||||
} catch {
|
|
||||||
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareNote = async () => {
|
|
||||||
try {
|
|
||||||
await shareText('sattle bearer note', noteDisplayValue.value);
|
|
||||||
} catch (err) {
|
|
||||||
// the user dismissing the share sheet is not an error
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
||||||
await copyNote();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishRemove = async () => {
|
|
||||||
const note = prepared.value;
|
|
||||||
if (!note) return;
|
|
||||||
removing.value = true;
|
|
||||||
try {
|
|
||||||
await wallet.markSpent(note.id);
|
|
||||||
activity.log('spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`);
|
|
||||||
toast('positive', 'Removed from your balance.');
|
|
||||||
emit('sent');
|
|
||||||
show.value = false;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
toast('negative', message);
|
|
||||||
} finally {
|
|
||||||
removing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishKeep = () => {
|
|
||||||
toast('info', 'Note kept in your wallet.');
|
|
||||||
show.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-body1 q-mb-md">
|
||||||
|
Sets this device up from a downloaded backup file — no recovery phrase needed, as long as you
|
||||||
|
still know the password the backup was encrypted with. Notes from the file are merged into
|
||||||
|
storage either way.
|
||||||
|
</div>
|
||||||
|
<q-banner v-if="keySkipped" class="sattle-card text-warning q-mb-md" rounded>
|
||||||
|
<template #avatar><q-icon name="warning" color="warning" /></template>
|
||||||
|
This device already has a wallet, so the backup's own key was <strong>not</strong> installed.
|
||||||
|
Its notes were merged and will appear if the existing wallet is the one this backup belongs to.
|
||||||
|
</q-banner>
|
||||||
|
<template v-if="keyRestored">
|
||||||
|
<q-banner class="sattle-card text-warning q-mb-md" rounded>
|
||||||
|
<template #avatar><q-icon name="warning" color="warning" /></template>
|
||||||
|
The backup's key was installed. Whoever wrote that file may know it — only continue if you
|
||||||
|
trust the file's source completely. Otherwise set up a fresh wallet from your own recovery
|
||||||
|
phrase instead.
|
||||||
|
</q-banner>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="I trust this file — continue"
|
||||||
|
class="full-width"
|
||||||
|
@click="proceed"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="result" class="text-positive q-mb-md">
|
||||||
|
Backup restored: {{ result.added }} note(s) added, {{ result.skipped }} already present.
|
||||||
|
<span v-if="!result.linkingKeyRestored">
|
||||||
|
The file carried no usable key of its own — restore its recovery phrase to unlock the notes.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="text-negative q-mb-md">{{ error }}</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="restoreFile"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Choose backup file"
|
||||||
|
icon="upload_file"
|
||||||
|
class="full-width"
|
||||||
|
:loading="busy"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import { MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
||||||
|
import type { RestoreResult } from '@/lnurlcash/storage';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const result = ref<RestoreResult | null>(null);
|
||||||
|
const keySkipped = ref(false);
|
||||||
|
const keyRestored = ref(false);
|
||||||
|
const restoreFile = async (event: Event): Promise<void> => {
|
||||||
|
if (!(event.currentTarget instanceof HTMLInputElement)) return;
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
result.value = null;
|
||||||
|
keySkipped.value = false;
|
||||||
|
try {
|
||||||
|
if (file.size > MAX_BACKUP_FILE_BYTES) {
|
||||||
|
throw new Error('That file is far too large to be a wallet backup.');
|
||||||
|
}
|
||||||
|
const data: unknown = JSON.parse(await file.text());
|
||||||
|
const restored = await wallet.restoreFromBackup(data);
|
||||||
|
if (restored.linkingKeyRestored) {
|
||||||
|
keyRestored.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (restored.linkingKeySkipped) {
|
||||||
|
keySkipped.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.value = restored;
|
||||||
|
Notify.create({ type: 'positive', message: 'Backup restored.' });
|
||||||
|
} catch (caught) {
|
||||||
|
error.value = caught instanceof Error ? caught.message : 'Something went wrong.';
|
||||||
|
Notify.create({ type: 'negative', message: error.value });
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const proceed = async (): Promise<void> => {
|
||||||
|
await wallet.init();
|
||||||
|
await router.push('/');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="!createdPhrase">
|
||||||
|
<div class="text-body1 q-mb-md">
|
||||||
|
A fresh recovery phrase is generated in your browser. It is the master key to your wallet —
|
||||||
|
and the only way to recover your notes on another device.
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-sm">
|
||||||
|
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
|
||||||
|
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-if="password !== ''"
|
||||||
|
v-model="confirmation"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Confirm password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
At least {{ MIN_PASSWORD_LENGTH }} characters — this password is the only thing standing
|
||||||
|
between an offline brute-force and your notes.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="confirmation !== '' && password !== confirmation"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
Passwords do not match.
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Create wallet"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="!passwordValid(password, confirmation)"
|
||||||
|
@click="createWallet"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-body1 text-weight-medium q-mb-sm">
|
||||||
|
Your recovery phrase — shown once, never stored:
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-xs q-mb-md">
|
||||||
|
<div v-for="(word, index) in createdPhrase.split(' ')" :key="index" class="word-chip">
|
||||||
|
<span class="text-grey-5 q-mr-xs">{{ index + 1 }}.</span>{{ word }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-banner class="sattle-card text-warning q-mb-md" rounded>
|
||||||
|
<template #avatar><q-icon name="warning" color="warning" /></template>
|
||||||
|
Write these 12 words down and keep them somewhere safe. Anyone who knows them can spend your
|
||||||
|
notes; if you lose them, your notes are gone forever.
|
||||||
|
</q-banner>
|
||||||
|
<q-checkbox v-model="phraseConfirmed" color="primary" label="I wrote it down" class="q-mb-md" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Continue"
|
||||||
|
class="full-width"
|
||||||
|
:disable="!phraseConfirmed"
|
||||||
|
@click="router.push('/')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const password = ref('');
|
||||||
|
const confirmation = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const createdPhrase = ref<string | null>(null);
|
||||||
|
const phraseConfirmed = ref(false);
|
||||||
|
const createWallet = async (): Promise<void> => {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
createdPhrase.value = await wallet.create(password.value || undefined);
|
||||||
|
phraseConfirmed.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: error instanceof Error ? error.message : 'Something went wrong.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.word-chip {
|
||||||
|
background: rgba(85, 255, 204, 0.08);
|
||||||
|
border: 1px solid rgba(85, 255, 204, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-body1 q-mb-md">
|
||||||
|
If this wallet used nostr backup before, your notes, mints and settings are waiting on your
|
||||||
|
relays - encrypted so only your recovery phrase can read them.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="phrase"
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Your 12-word recovery phrase"
|
||||||
|
placeholder="twelve words separated by spaces"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-xs">Relays to look on</div>
|
||||||
|
<RelaysEditor v-model="relays" />
|
||||||
|
<div v-if="error" class="text-negative q-my-sm">{{ error }}</div>
|
||||||
|
<template v-if="!found">
|
||||||
|
<div v-if="looked" class="text-warning q-my-sm">
|
||||||
|
No backup found for this phrase on those relays. Check the phrase and the relay list - or
|
||||||
|
restore from a backup file instead.
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Look for a backup"
|
||||||
|
class="full-width q-mt-md"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="!phraseValid || relays.length === 0"
|
||||||
|
@click="lookForBackup"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-body2 text-grey-4 q-my-md">
|
||||||
|
Found a backup: {{ found.notes }} note(s), {{ found.mints }} mint(s)<template
|
||||||
|
v-if="found.settings"
|
||||||
|
>, settings</template
|
||||||
|
>.
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-sm">
|
||||||
|
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
|
||||||
|
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-if="password !== ''"
|
||||||
|
v-model="confirmation"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Confirm password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
At least {{ MIN_PASSWORD_LENGTH }} characters.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="confirmation !== '' && password !== confirmation"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
Passwords do not match.
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Restore this backup"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="!passwordValid(password, confirmation)"
|
||||||
|
@click="restoreBackup"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
|
||||||
|
import { backupPubkey, deriveBackupKey, fetchBackup } from '@/lnurlcash/nostrBackup';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
|
||||||
|
import RelaysEditor from '@/components/RelaysEditor.vue';
|
||||||
|
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const phrase = ref('');
|
||||||
|
const relays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
|
||||||
|
const password = ref('');
|
||||||
|
const confirmation = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const looked = ref(false);
|
||||||
|
const found = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
|
||||||
|
const phraseValid = computed(() => isValidSeedPhrase(phrase.value));
|
||||||
|
const linkingKey = (): Uint8Array => deriveWalletLinkingKey(phrase.value.trim().toLowerCase());
|
||||||
|
const errorMessage = (caught: unknown): string =>
|
||||||
|
caught instanceof Error ? caught.message : 'Something went wrong.';
|
||||||
|
const lookForBackup = async (): Promise<void> => {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
found.value = null;
|
||||||
|
looked.value = false;
|
||||||
|
try {
|
||||||
|
const secretKey = deriveBackupKey(linkingKey());
|
||||||
|
const parts = await fetchBackup(backupPubkey(secretKey), relays.value, { secretKey });
|
||||||
|
looked.value = true;
|
||||||
|
if (parts.notes || parts.mints || parts.settings) {
|
||||||
|
found.value = {
|
||||||
|
notes: parts.notes?.bearers.length ?? 0,
|
||||||
|
mints: parts.mints?.length ?? 0,
|
||||||
|
settings: parts.settings !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
error.value = errorMessage(caught);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const restoreBackup = async (): Promise<void> => {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await wallet.restoreFromNostr(
|
||||||
|
phrase.value.trim().toLowerCase(),
|
||||||
|
relays.value,
|
||||||
|
password.value || undefined,
|
||||||
|
);
|
||||||
|
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
|
||||||
|
void router.push('/');
|
||||||
|
} catch (caught) {
|
||||||
|
error.value = errorMessage(caught);
|
||||||
|
Notify.create({ type: 'negative', message: error.value });
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<q-input
|
||||||
|
v-model="phrase"
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Your 12-word recovery phrase"
|
||||||
|
placeholder="twelve words separated by spaces"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-sm">
|
||||||
|
Password (optional) — encrypts your wallet on this device and enables locking. Minimum
|
||||||
|
{{ MIN_PASSWORD_LENGTH }} characters. Leave empty to store unencrypted.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-if="password !== ''"
|
||||||
|
v-model="confirmation"
|
||||||
|
type="password"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Confirm password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
At least {{ MIN_PASSWORD_LENGTH }} characters.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="confirmation !== '' && password !== confirmation"
|
||||||
|
class="text-warning text-caption q-mb-sm"
|
||||||
|
>
|
||||||
|
Passwords do not match.
|
||||||
|
</div>
|
||||||
|
<div v-if="restoreError" class="text-negative q-mt-sm">{{ restoreError }}</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Restore wallet"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="!phrase.trim() || !passwordValid(password, confirmation)"
|
||||||
|
@click="restoreWallet"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const phrase = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmation = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const restoreError = ref('');
|
||||||
|
const restoreWallet = async (): Promise<void> => {
|
||||||
|
busy.value = true;
|
||||||
|
restoreError.value = '';
|
||||||
|
try {
|
||||||
|
await wallet.restoreFromSeed(phrase.value.trim().toLowerCase(), password.value || undefined);
|
||||||
|
Notify.create({ type: 'positive', message: 'Wallet restored.' });
|
||||||
|
void router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
restoreError.value = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
Notify.create({ type: 'negative', message: restoreError.value });
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import {
|
||||||
|
fetchMintAddress,
|
||||||
|
fetchPayRequest,
|
||||||
|
lightningAddressUsername,
|
||||||
|
mintAddressUrl,
|
||||||
|
resolveMintInput,
|
||||||
|
serverOf,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
|
||||||
|
export const useManageMintsPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fingerprint = (pubkey: string): string =>
|
||||||
|
pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey;
|
||||||
|
const balanceAt = (server: string): string =>
|
||||||
|
(wallet.balanceByMintSats.get(server) ?? 0).toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
const suggestions = computed(() =>
|
||||||
|
mints.PUBLIC_MINTS.filter(
|
||||||
|
(address) => !mints.mints.some((mint) => mint.server === address.replace(/^@/, '')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const banner = ref('');
|
||||||
|
const addServer = ref('');
|
||||||
|
const addPubkey = ref('');
|
||||||
|
const discovering = ref('');
|
||||||
|
const confirmingRemove = ref(false);
|
||||||
|
const removeTarget = ref('');
|
||||||
|
const trustResult = (result: string, server: string): void => {
|
||||||
|
if (result === 'rekey-pending') {
|
||||||
|
toast('warning', `${server} advertised a different signing key - review it above.`);
|
||||||
|
} else if (result === 'unchanged') {
|
||||||
|
toast('info', `${server} is already trusted.`);
|
||||||
|
} else {
|
||||||
|
toast('positive', `${server} is now trusted.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const trustManual = async (): Promise<void> => {
|
||||||
|
banner.value = '';
|
||||||
|
try {
|
||||||
|
const result = await mints.trust(addServer.value, addPubkey.value);
|
||||||
|
trustResult(result, addServer.value.trim());
|
||||||
|
addServer.value = '';
|
||||||
|
addPubkey.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
banner.value = error instanceof Error ? error.message : 'Could not trust that mint.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const trustSuggestion = async (address: string): Promise<void> => {
|
||||||
|
if (discovering.value) return;
|
||||||
|
banner.value = '';
|
||||||
|
discovering.value = address;
|
||||||
|
try {
|
||||||
|
const url = resolveMintInput(address);
|
||||||
|
if (!url) throw new Error('That mint address cannot be resolved.');
|
||||||
|
let nodeInfo = null;
|
||||||
|
let payUrl = url;
|
||||||
|
const addressUrl = mintAddressUrl(url);
|
||||||
|
if (addressUrl) {
|
||||||
|
try {
|
||||||
|
nodeInfo = await fetchMintAddress(addressUrl);
|
||||||
|
payUrl = nodeInfo.payLink;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const info = await fetchPayRequest(payUrl);
|
||||||
|
const announcedKey = nodeInfo?.nodePubkey ?? info.mintPubkey;
|
||||||
|
if (!announcedKey) {
|
||||||
|
throw new Error("This mint didn't announce its signing key - add it manually instead.");
|
||||||
|
}
|
||||||
|
const server = serverOf(payUrl);
|
||||||
|
const result = await mints.trust(
|
||||||
|
server,
|
||||||
|
announcedKey,
|
||||||
|
mintAddressCacheInfo(nodeInfo, lightningAddressUsername(payUrl)),
|
||||||
|
);
|
||||||
|
trustResult(result, server);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Could not reach that mint.';
|
||||||
|
banner.value = `Could not add ${address}: ${message}`;
|
||||||
|
} finally {
|
||||||
|
discovering.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const askRemove = (server: string): void => {
|
||||||
|
banner.value = '';
|
||||||
|
removeTarget.value = server;
|
||||||
|
confirmingRemove.value = true;
|
||||||
|
};
|
||||||
|
const doRemove = async (): Promise<void> => {
|
||||||
|
confirmingRemove.value = false;
|
||||||
|
try {
|
||||||
|
await mints.remove(removeTarget.value);
|
||||||
|
if (mints.defaultMint === removeTarget.value) mints.setDefaultMint(null);
|
||||||
|
toast('positive', `${removeTarget.value} removed.`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
banner.value = `${removeTarget.value} can't be removed while you hold notes from it - move or spend them first.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const confirmRekey = async (server: string): Promise<void> => {
|
||||||
|
banner.value = '';
|
||||||
|
try {
|
||||||
|
await mints.confirmRekey(server);
|
||||||
|
} catch (error) {
|
||||||
|
banner.value = error instanceof Error ? error.message : 'Could not confirm that signing key.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dismissRekey = async (server: string): Promise<void> => {
|
||||||
|
banner.value = '';
|
||||||
|
try {
|
||||||
|
await mints.dismissRekey(server);
|
||||||
|
} catch (error) {
|
||||||
|
banner.value = error instanceof Error ? error.message : 'Could not dismiss that signing key.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
addPubkey,
|
||||||
|
addServer,
|
||||||
|
askRemove,
|
||||||
|
balanceAt,
|
||||||
|
banner,
|
||||||
|
confirmingRemove,
|
||||||
|
confirmRekey,
|
||||||
|
discovering,
|
||||||
|
dismissRekey,
|
||||||
|
doRemove,
|
||||||
|
fingerprint,
|
||||||
|
mints,
|
||||||
|
removeTarget,
|
||||||
|
router,
|
||||||
|
suggestions,
|
||||||
|
trustManual,
|
||||||
|
trustSuggestion,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
|
||||||
|
export const useMintTrustPrompt = () => {
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const showTrust = ref(false);
|
||||||
|
const trustServer = ref('');
|
||||||
|
const trustPubkey = ref('');
|
||||||
|
const trustNodeAlias = ref('');
|
||||||
|
const openTrust = (server: string, pubkey: string, nodeAlias = ''): void => {
|
||||||
|
trustServer.value = server;
|
||||||
|
trustPubkey.value = pubkey;
|
||||||
|
trustNodeAlias.value = nodeAlias;
|
||||||
|
showTrust.value = true;
|
||||||
|
};
|
||||||
|
const trustMint = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await mints.trust(trustServer.value, trustPubkey.value, {
|
||||||
|
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
|
||||||
|
});
|
||||||
|
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
||||||
|
} catch (error) {
|
||||||
|
const caught = error instanceof Error ? error : new Error(String(error));
|
||||||
|
Notify.create({ type: 'negative', message: caught.message });
|
||||||
|
} finally {
|
||||||
|
showTrust.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const skipTrust = (): void => {
|
||||||
|
showTrust.value = false;
|
||||||
|
Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message:
|
||||||
|
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
openTrust,
|
||||||
|
showTrust,
|
||||||
|
skipTrust,
|
||||||
|
trustMint,
|
||||||
|
trustNodeAlias,
|
||||||
|
trustServer,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
|
||||||
|
import type { MintFee } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { transferBetweenMints } from '@/lnurlcash/ops';
|
||||||
|
import type { TransferOutcome } from '@/lnurlcash/ops';
|
||||||
|
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type Option = Readonly<{ label: string; value: string }>;
|
||||||
|
type TransferResult = Readonly<{
|
||||||
|
outcome: TransferOutcome;
|
||||||
|
requestedSats: number;
|
||||||
|
feeSats: number;
|
||||||
|
sourceServer: string;
|
||||||
|
targetServer: string;
|
||||||
|
claimNoteValueSats?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const useMoveFundsPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
watch(
|
||||||
|
() => wallet.state,
|
||||||
|
(state) => {
|
||||||
|
if (state !== 'unlocked') void router.replace('/');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
const CUSTOM_TARGET = '__custom__';
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
const spendableByServerMsat = computed(() => {
|
||||||
|
const byServer = new Map<string, number>();
|
||||||
|
for (const bearer of wallet.bearers) {
|
||||||
|
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
|
||||||
|
continue;
|
||||||
|
const server = serverOf(bearer.url);
|
||||||
|
byServer.set(server, (byServer.get(server) ?? 0) + bearer.amount);
|
||||||
|
}
|
||||||
|
return byServer;
|
||||||
|
});
|
||||||
|
const sourceOptions = computed<Option[]>(() =>
|
||||||
|
[...spendableByServerMsat.value.entries()].map(([server, msat]) => ({
|
||||||
|
label: `${server} - ${displaySats(msat).toLocaleString()} sats available`,
|
||||||
|
value: server,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const step = ref<'form' | 'confirm' | 'working' | 'result'>('form');
|
||||||
|
const sourceServer = ref('');
|
||||||
|
const targetChoice = ref('');
|
||||||
|
const customTarget = ref('');
|
||||||
|
const amountSats = ref<number | null>(null);
|
||||||
|
const inlineError = ref('');
|
||||||
|
const stage = ref('');
|
||||||
|
const result = ref<TransferResult | null>(null);
|
||||||
|
const targetOptions = computed<Option[]>(() => {
|
||||||
|
const options: Option[] = [];
|
||||||
|
for (const mint of mints.mints) {
|
||||||
|
if (mint.server === sourceServer.value) continue;
|
||||||
|
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
|
||||||
|
options.push({
|
||||||
|
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
|
||||||
|
value: address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.push({ label: 'Another mint…', value: CUSTOM_TARGET });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
const targetInput = computed(() =>
|
||||||
|
targetChoice.value === CUSTOM_TARGET ? customTarget.value.trim() : targetChoice.value,
|
||||||
|
);
|
||||||
|
const formFilled = computed(
|
||||||
|
() =>
|
||||||
|
sourceServer.value !== '' &&
|
||||||
|
targetInput.value !== '' &&
|
||||||
|
Number.isInteger(amountSats.value) &&
|
||||||
|
(amountSats.value ?? 0) >= 1,
|
||||||
|
);
|
||||||
|
const targetFee = ref<MintFee | null>(null);
|
||||||
|
let quoteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
watch(targetInput, (input) => {
|
||||||
|
targetFee.value = null;
|
||||||
|
if (quoteTimer) clearTimeout(quoteTimer);
|
||||||
|
if (input === '') return;
|
||||||
|
quoteTimer = setTimeout(() => {
|
||||||
|
void quoteMintFee(input).then((fee) => {
|
||||||
|
if (targetInput.value === input) targetFee.value = fee;
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
const targetFeeText = computed(() =>
|
||||||
|
targetFee.value
|
||||||
|
? `This mint charges a receive fee (${describeMintFee(targetFee.value)}) - Max already accounts for it.`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
const setMax = (): void => {
|
||||||
|
const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
|
||||||
|
amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value));
|
||||||
|
};
|
||||||
|
const proceed = (): void => {
|
||||||
|
inlineError.value = '';
|
||||||
|
const sats = amountSats.value;
|
||||||
|
if (!sats || !Number.isInteger(sats) || sats < 1) {
|
||||||
|
inlineError.value = 'Enter how many sats to move.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceMsat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
|
||||||
|
if (satsToMsat(sats) > sourceMsat) {
|
||||||
|
inlineError.value = `That's more than the ${displaySats(sourceMsat).toLocaleString()} sats spendable at ${sourceServer.value}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
step.value = 'confirm';
|
||||||
|
};
|
||||||
|
const move = async (): Promise<void> => {
|
||||||
|
const sats = amountSats.value;
|
||||||
|
if (!sats) return;
|
||||||
|
step.value = 'working';
|
||||||
|
stage.value = 'Asking the target mint for an invoice…';
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = fundOperation.ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
|
const commitContext = { ownerFence, warn: warnCommitted };
|
||||||
|
let stagedOutput: Bearer | undefined;
|
||||||
|
let carvedOutput: Bearer | undefined;
|
||||||
|
// the carve commits the moment it lands server-side (onCarve), not
|
||||||
|
// after the melt + settlement wait - an abort mid-wait can then
|
||||||
|
// never leave burned inputs looking spendable or strand the outputs
|
||||||
|
const carveState: { committed?: Bearer } = {};
|
||||||
|
const transfer = await transferBetweenMints(
|
||||||
|
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
|
||||||
|
satsToMsat(sats),
|
||||||
|
targetInput.value,
|
||||||
|
{
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
[stagedOutput] = await addCommittedBearers(wallet, [note], commitContext);
|
||||||
|
},
|
||||||
|
onCarve: async (carve) => {
|
||||||
|
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
||||||
|
},
|
||||||
|
onMeltReady: async (carve, sourceRecoverySecret) => {
|
||||||
|
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
|
||||||
|
carvedOutput =
|
||||||
|
carveState.committed ??
|
||||||
|
wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!carvedOutput) throw new Error('The carved note was not tracked.');
|
||||||
|
await wallet.reserveStagedMintSource(
|
||||||
|
stagedOutput.id,
|
||||||
|
carvedOutput.id,
|
||||||
|
sourceRecoverySecret,
|
||||||
|
ownerFence,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
|
||||||
|
stage.value = 'Confirming the result…';
|
||||||
|
// a carve that mutated nothing fires no hook - the note is already
|
||||||
|
// tracked; look it up
|
||||||
|
const carved =
|
||||||
|
carvedOutput ??
|
||||||
|
carveState.committed ??
|
||||||
|
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
|
||||||
|
if (!carved) throw new Error('The carved note was not tracked.');
|
||||||
|
if (transfer.rescuedNote) {
|
||||||
|
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
|
||||||
|
}
|
||||||
|
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
|
||||||
|
if (transfer.outcome === 'settled') {
|
||||||
|
const claimed = transfer.mintedAtTarget;
|
||||||
|
if (claimed) {
|
||||||
|
try {
|
||||||
|
await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
warnCommitted(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
|
||||||
|
} else if (transfer.outcome === 'failed-funds-returned') {
|
||||||
|
if (transfer.rotatedNote) {
|
||||||
|
await wallet.restoreStagedMintSource(stagedOutput.id, transfer.rotatedNote, ownerFence);
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (transfer.outcome === 'unknown-still-pending') {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (transfer.outcome === 'settled-claim-failed') {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'spent',
|
||||||
|
`A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
|
||||||
|
result.value = {
|
||||||
|
outcome: transfer.outcome,
|
||||||
|
requestedSats: sats,
|
||||||
|
feeSats,
|
||||||
|
sourceServer: transfer.sourceServer,
|
||||||
|
targetServer: transfer.targetServer,
|
||||||
|
...(claimNote ? { claimNoteValueSats: displaySats(claimNote.amount) } : {}),
|
||||||
|
};
|
||||||
|
step.value = 'result';
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
inlineError.value = message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance at the source mint to cover that move.'
|
||||||
|
: message;
|
||||||
|
toast('negative', inlineError.value);
|
||||||
|
step.value = 'form';
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
CUSTOM_TARGET,
|
||||||
|
amountSats,
|
||||||
|
customTarget,
|
||||||
|
formFilled,
|
||||||
|
inlineError,
|
||||||
|
move,
|
||||||
|
proceed,
|
||||||
|
result,
|
||||||
|
router,
|
||||||
|
setMax,
|
||||||
|
sourceOptions,
|
||||||
|
sourceServer,
|
||||||
|
stage,
|
||||||
|
step,
|
||||||
|
targetChoice,
|
||||||
|
targetFeeText,
|
||||||
|
targetInput,
|
||||||
|
targetOptions,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import {
|
||||||
|
decodeBolt11AmountMsat,
|
||||||
|
isBolt11Invoice,
|
||||||
|
resolveLnurlInput,
|
||||||
|
withNewK1,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { readClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import type { PayOutcome } from '@/lnurlcash/ops';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type PayInvoiceProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
|
||||||
|
type PayInvoiceEmit = {
|
||||||
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
|
(event: 'sent'): void;
|
||||||
|
};
|
||||||
|
type TargetKind = 'invoice' | 'address';
|
||||||
|
type PendingPayment = Readonly<{ kind: TargetKind; input: string; amountMsat: number }>;
|
||||||
|
type PaymentResult = Readonly<{ outcome: PayOutcome; amountMsat: number }>;
|
||||||
|
|
||||||
|
export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit) => {
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
const step = ref<'input' | 'confirm' | 'working' | 'result'>('input');
|
||||||
|
const input = ref('');
|
||||||
|
const addressAmountSats = ref('');
|
||||||
|
const showScanner = ref(false);
|
||||||
|
const inlineError = ref<string | null>(null);
|
||||||
|
const stage = ref('');
|
||||||
|
const pendingPayment = ref<PendingPayment | null>(null);
|
||||||
|
const result = ref<PaymentResult | null>(null);
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
const classify = (value: string): TargetKind | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (isBolt11Invoice(trimmed)) return 'invoice';
|
||||||
|
return resolveLnurlInput(trimmed) === null ? null : 'address';
|
||||||
|
};
|
||||||
|
const targetKind = computed<TargetKind | null>(() => classify(input.value));
|
||||||
|
const truncatedInput = computed(() => {
|
||||||
|
const payment = pendingPayment.value;
|
||||||
|
if (!payment) return '';
|
||||||
|
if (payment.kind === 'address') return payment.input;
|
||||||
|
return payment.input.length > 30
|
||||||
|
? `${payment.input.slice(0, 18)}…${payment.input.slice(-8)}`
|
||||||
|
: payment.input;
|
||||||
|
});
|
||||||
|
const resultAmountSats = computed(() =>
|
||||||
|
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
|
||||||
|
);
|
||||||
|
const reset = (): void => {
|
||||||
|
step.value = 'input';
|
||||||
|
input.value = props.initialInput ?? '';
|
||||||
|
addressAmountSats.value = '';
|
||||||
|
showScanner.value = false;
|
||||||
|
inlineError.value = null;
|
||||||
|
stage.value = '';
|
||||||
|
pendingPayment.value = null;
|
||||||
|
result.value = null;
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const onScan = (text: string): void => {
|
||||||
|
input.value = text.replace(/^lightning:/i, '').trim();
|
||||||
|
showScanner.value = false;
|
||||||
|
};
|
||||||
|
const onScanError = (message: string): void => {
|
||||||
|
showScanner.value = false;
|
||||||
|
toast('negative', message);
|
||||||
|
};
|
||||||
|
const paste = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const text = await readClipboard();
|
||||||
|
if (text) input.value = text.trim();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
toast('negative', "Couldn't read the clipboard - paste manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const proceed = (): void => {
|
||||||
|
inlineError.value = null;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
inlineError.value = 'Paste an invoice or a Lightning Address first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = classify(value);
|
||||||
|
if (kind === null) {
|
||||||
|
inlineError.value = "That doesn't look like a Lightning invoice or address.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amountMsat: number;
|
||||||
|
if (kind === 'invoice') {
|
||||||
|
const decoded = decodeBolt11AmountMsat(value);
|
||||||
|
if (decoded === null || decoded <= 0) {
|
||||||
|
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = decoded;
|
||||||
|
} else {
|
||||||
|
const sats = Number(addressAmountSats.value);
|
||||||
|
if (!Number.isInteger(sats) || sats <= 0) {
|
||||||
|
inlineError.value = 'Enter how many sats to send to this address.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = satsToMsat(sats);
|
||||||
|
}
|
||||||
|
if (amountMsat > wallet.balanceMsat) {
|
||||||
|
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingPayment.value = { kind, input: value, amountMsat };
|
||||||
|
step.value = 'confirm';
|
||||||
|
};
|
||||||
|
const friendlyError = (error: unknown): string => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
return message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance to cover that payment.'
|
||||||
|
: message;
|
||||||
|
};
|
||||||
|
const pay = async (): Promise<void> => {
|
||||||
|
const payment = pendingPayment.value;
|
||||||
|
if (!payment) return;
|
||||||
|
step.value = 'working';
|
||||||
|
stage.value = 'Preparing the exact amount and sending the payment…';
|
||||||
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
ownerFence = fundOperation.ownerFence;
|
||||||
|
const fence = ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
|
const commitContext = { ownerFence: fence, warn: warnCommitted };
|
||||||
|
// the carve commits the moment it lands server-side (onCarve), not
|
||||||
|
// after the settlement wait - an abort mid-wait can then never leave
|
||||||
|
// burned inputs looking spendable or strand the fresh outputs
|
||||||
|
const carveState: { committed?: Bearer } = {};
|
||||||
|
let returnStage: Bearer | undefined;
|
||||||
|
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
|
||||||
|
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
||||||
|
};
|
||||||
|
const allocateOutputSecrets = (server: string, count: number) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, fence);
|
||||||
|
const mintSignatureKeys = (server: string) => getTrustedMintVerificationKeys(server, ownerId);
|
||||||
|
// the pay-return recovery output: staged durably (allocated secret,
|
||||||
|
// zero value, pendingMint) and linked to the carved source BEFORE the
|
||||||
|
// classification rotate can burn it
|
||||||
|
const onReturnReady = async (
|
||||||
|
carve: Parameters<typeof commitCarve>[1],
|
||||||
|
recoverySecret: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const source =
|
||||||
|
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!source) throw new Error('The carved note was not tracked.');
|
||||||
|
[returnStage] = await addCommittedBearers(
|
||||||
|
wallet,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
commitContext,
|
||||||
|
);
|
||||||
|
if (!returnStage) throw new Error('The payment recovery output was not persisted.');
|
||||||
|
await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence);
|
||||||
|
};
|
||||||
|
const paid = await payWithBearers(wallet.bearers, payment.input, {
|
||||||
|
...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}),
|
||||||
|
assertOwner: fence,
|
||||||
|
onCarve,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
mintSignatureKeys,
|
||||||
|
onReturnReady,
|
||||||
|
});
|
||||||
|
stage.value = 'Confirming the result…';
|
||||||
|
// a carve that mutated nothing (one note already held the exact
|
||||||
|
// amount) fires no hook - the note is already tracked; look it up
|
||||||
|
const committed =
|
||||||
|
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
|
||||||
|
if (!committed) throw new Error('The carved note was not tracked.');
|
||||||
|
if (returnStage) {
|
||||||
|
const returned = paid.rotatedNote ?? paid.rescuedNote;
|
||||||
|
if (returned) {
|
||||||
|
await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence);
|
||||||
|
} else if (paid.outcome !== 'unknown-still-pending') {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sats = formatSats(msatToSats(paid.amountMsat));
|
||||||
|
if (paid.outcome === 'settled') {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log('melt', `Paid ${sats} sats over Lightning.`, (error) =>
|
||||||
|
warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', `Paid ${sats} sats.`);
|
||||||
|
emit('sent');
|
||||||
|
} else if (paid.outcome === 'failed-funds-returned') {
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A ${sats} sat payment failed - funds are back in your wallet.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('warning', 'Payment failed - funds are back in your wallet.');
|
||||||
|
} else if (paid.outcome === 'unknown-still-pending') {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'melt',
|
||||||
|
`Payment of ${sats} sats is still in flight - the note is locked.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
emit('sent');
|
||||||
|
} else {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log('spent', `A ${sats} sat note was already spent at the mint.`, (error) =>
|
||||||
|
warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.value = { outcome: paid.outcome, amountMsat: paid.amountMsat };
|
||||||
|
step.value = 'result';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UncertainOutcomeError) {
|
||||||
|
if (!ownerFence) throw error;
|
||||||
|
await addCommittedBearers(wallet, error.possibleOutputs, {
|
||||||
|
ownerFence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A payment preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
(activityError) => warnCommitted(activityError.message),
|
||||||
|
);
|
||||||
|
inlineError.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
|
||||||
|
} else {
|
||||||
|
inlineError.value = friendlyError(error);
|
||||||
|
toast('negative', inlineError.value);
|
||||||
|
}
|
||||||
|
step.value = 'input';
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeResult = (): void => {
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
addressAmountSats,
|
||||||
|
closeResult,
|
||||||
|
formatSats,
|
||||||
|
inlineError,
|
||||||
|
input,
|
||||||
|
msatToSats,
|
||||||
|
onScan,
|
||||||
|
onScanError,
|
||||||
|
paste,
|
||||||
|
pay,
|
||||||
|
pendingPayment,
|
||||||
|
proceed,
|
||||||
|
result,
|
||||||
|
resultAmountSats,
|
||||||
|
show,
|
||||||
|
showScanner,
|
||||||
|
stage,
|
||||||
|
step,
|
||||||
|
targetKind,
|
||||||
|
truncatedInput,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
||||||
|
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
|
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
||||||
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
import { useMintTrustPrompt } from './useMintTrustPrompt';
|
||||||
|
|
||||||
|
type ReceiveLightningProps = Readonly<{ modelValue: boolean }>;
|
||||||
|
type ReceiveLightningEmit = (event: 'received') => void;
|
||||||
|
type MintOption = Readonly<{ label: string; value: string }>;
|
||||||
|
|
||||||
|
export const useReceiveLightningDialog = (
|
||||||
|
props: ReceiveLightningProps,
|
||||||
|
emit: ReceiveLightningEmit,
|
||||||
|
) => {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const CUSTOM_MINT = '__custom__';
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
const errorMessage = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
const step = ref<'form' | 'invoice' | 'success'>('form');
|
||||||
|
const amountSats = ref<number | null>(null);
|
||||||
|
const mintChoice = ref('');
|
||||||
|
const customMint = ref('');
|
||||||
|
const preparing = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
const prepared = ref<PreparedMint | null>(null);
|
||||||
|
const stagedOutput = ref<Bearer | null>(null);
|
||||||
|
const waiting = ref(false);
|
||||||
|
const claimError = ref('');
|
||||||
|
const claimRetryable = ref(true);
|
||||||
|
const receivedSats = ref(0);
|
||||||
|
const receivedServer = ref('');
|
||||||
|
const rotationWarning = ref('');
|
||||||
|
const trustPrompt = useMintTrustPrompt();
|
||||||
|
let claimRun: Promise<void> | null = null;
|
||||||
|
const mintOptions = computed<MintOption[]>(() => {
|
||||||
|
const options: MintOption[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const mint of mints.mints) {
|
||||||
|
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
|
||||||
|
if (seen.has(address)) continue;
|
||||||
|
seen.add(address);
|
||||||
|
options.push({
|
||||||
|
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
|
||||||
|
value: address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const publicMint of mints.PUBLIC_MINTS) {
|
||||||
|
if (seen.has(publicMint)) continue;
|
||||||
|
seen.add(publicMint);
|
||||||
|
options.push({ label: publicMint, value: publicMint });
|
||||||
|
}
|
||||||
|
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
const defaultChoice = (): string => {
|
||||||
|
const options = mintOptions.value;
|
||||||
|
if (mints.defaultMint) {
|
||||||
|
const match = options.find((option) => option.value.endsWith(`@${mints.defaultMint}`));
|
||||||
|
if (match) return match.value;
|
||||||
|
}
|
||||||
|
const first = options[0];
|
||||||
|
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
|
||||||
|
};
|
||||||
|
const formValid = computed(() => {
|
||||||
|
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
|
||||||
|
return mintChoice.value === CUSTOM_MINT
|
||||||
|
? customMint.value.trim() !== ''
|
||||||
|
: mintChoice.value !== '';
|
||||||
|
});
|
||||||
|
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
|
||||||
|
const netSats = computed(() =>
|
||||||
|
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
|
||||||
|
);
|
||||||
|
const feeSats = computed(() => grossSats.value - netSats.value);
|
||||||
|
const onClaimed = async (
|
||||||
|
claimed: ClaimedNote,
|
||||||
|
from: PreparedMint,
|
||||||
|
staged: Bearer,
|
||||||
|
ownerFence: WalletOwnerFence,
|
||||||
|
): Promise<void> => {
|
||||||
|
const server = from.server;
|
||||||
|
const wasTrusted = mints.isTrusted(server);
|
||||||
|
let trustWarning = '';
|
||||||
|
try {
|
||||||
|
await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
trustWarning = error.message;
|
||||||
|
}
|
||||||
|
receivedSats.value = displaySats(claimed.note.amount);
|
||||||
|
receivedServer.value = server;
|
||||||
|
rotationWarning.value = '';
|
||||||
|
await activity.log(
|
||||||
|
'mint',
|
||||||
|
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
|
||||||
|
(error) => {
|
||||||
|
trustWarning = error.message;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
|
||||||
|
if (nodeInfo) {
|
||||||
|
try {
|
||||||
|
await mints.cacheNodeInfo(server, nodeInfo);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
trustWarning = `Funds were saved, but mint details could not be updated: ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
||||||
|
});
|
||||||
|
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
|
||||||
|
emit('received');
|
||||||
|
if (props.modelValue) step.value = 'success';
|
||||||
|
if (!wasTrusted && claimed.note.mintPubkey) {
|
||||||
|
trustPrompt.openTrust(server, claimed.note.mintPubkey, from.nodeInfo?.nodeAlias ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const beginClaim = (): void => {
|
||||||
|
if (!prepared.value || !stagedOutput.value || claimRun) return;
|
||||||
|
waiting.value = true;
|
||||||
|
claimError.value = '';
|
||||||
|
claimRetryable.value = true;
|
||||||
|
const current = prepared.value;
|
||||||
|
const staged = stagedOutput.value;
|
||||||
|
claimRun = (async () => {
|
||||||
|
const fundOperation = wallet.beginFundOperation();
|
||||||
|
try {
|
||||||
|
const ownerFence = fundOperation.ownerFence;
|
||||||
|
await onClaimed(
|
||||||
|
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
|
||||||
|
current,
|
||||||
|
staged,
|
||||||
|
ownerFence,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
if (error instanceof MintedNoteSpentError) {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence);
|
||||||
|
claimRetryable.value = false;
|
||||||
|
claimError.value = errorMessage(error);
|
||||||
|
Notify.create({ type: 'negative', message: claimError.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
fundOperation.complete();
|
||||||
|
waiting.value = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
const createInvoice = async (): Promise<void> => {
|
||||||
|
const sats = amountSats.value;
|
||||||
|
if (!sats || preparing.value) return;
|
||||||
|
preparing.value = true;
|
||||||
|
formError.value = '';
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = fundOperation.ownerFence;
|
||||||
|
let persisted: Bearer | undefined;
|
||||||
|
const next = await prepareMint(input, satsToMsat(sats), {
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
[persisted] = await wallet.addBearers([note], ownerFence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!persisted) throw new Error('The pending mint output was not persisted.');
|
||||||
|
stagedOutput.value = persisted;
|
||||||
|
prepared.value = next;
|
||||||
|
claimRun = null;
|
||||||
|
claimError.value = '';
|
||||||
|
claimRetryable.value = true;
|
||||||
|
step.value = 'invoice';
|
||||||
|
beginClaim();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
formError.value = errorMessage(error);
|
||||||
|
Notify.create({ type: 'negative', message: formError.value });
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
preparing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copyInvoice = async (): Promise<void> => {
|
||||||
|
if (!prepared.value) return;
|
||||||
|
try {
|
||||||
|
await writeClipboard(prepared.value.invoice);
|
||||||
|
Notify.create({ type: 'positive', message: 'Invoice copied.' });
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const retryClaim = (): void => {
|
||||||
|
claimRun = null;
|
||||||
|
beginClaim();
|
||||||
|
};
|
||||||
|
const stopWaiting = (): void => {
|
||||||
|
waiting.value = false;
|
||||||
|
};
|
||||||
|
const resumeWaiting = (): void => {
|
||||||
|
if (claimRun) waiting.value = true;
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
step.value = 'form';
|
||||||
|
amountSats.value = null;
|
||||||
|
customMint.value = '';
|
||||||
|
mintChoice.value = defaultChoice();
|
||||||
|
preparing.value = false;
|
||||||
|
formError.value = '';
|
||||||
|
prepared.value = null;
|
||||||
|
stagedOutput.value = null;
|
||||||
|
waiting.value = false;
|
||||||
|
rotationWarning.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
CUSTOM_MINT,
|
||||||
|
amountSats,
|
||||||
|
claimError,
|
||||||
|
claimRetryable,
|
||||||
|
copyInvoice,
|
||||||
|
createInvoice,
|
||||||
|
customMint,
|
||||||
|
feeSats,
|
||||||
|
formError,
|
||||||
|
formValid,
|
||||||
|
grossSats,
|
||||||
|
mintChoice,
|
||||||
|
mintOptions,
|
||||||
|
netSats,
|
||||||
|
prepared,
|
||||||
|
preparing,
|
||||||
|
receivedSats,
|
||||||
|
receivedServer,
|
||||||
|
resumeWaiting,
|
||||||
|
retryClaim,
|
||||||
|
rotationWarning,
|
||||||
|
showTrust: trustPrompt.showTrust,
|
||||||
|
skipTrust: trustPrompt.skipTrust,
|
||||||
|
step,
|
||||||
|
stopWaiting,
|
||||||
|
trustMint: trustPrompt.trustMint,
|
||||||
|
trustNodeAlias: trustPrompt.trustNodeAlias,
|
||||||
|
trustServer: trustPrompt.trustServer,
|
||||||
|
waiting,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import {
|
||||||
|
NoteSpentError,
|
||||||
|
NoteUnknownError,
|
||||||
|
PendingNoteError,
|
||||||
|
isValidNoteInput,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { receiveBearer } from '@/lnurlcash/ops';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import { useMintTrustPrompt } from './useMintTrustPrompt';
|
||||||
|
|
||||||
|
type ReceiveTokenProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
|
||||||
|
type ReceiveTokenEmit = {
|
||||||
|
(event: 'received'): void;
|
||||||
|
};
|
||||||
|
type ReceiveErrorKind = 'spent' | 'unknown' | 'pending' | 'duplicate' | 'invalid' | 'generic' | '';
|
||||||
|
|
||||||
|
const ERROR_TEXT: Readonly<Record<Exclude<ReceiveErrorKind, ''>, string>> = {
|
||||||
|
spent: 'This note has already been spent.',
|
||||||
|
unknown: "The mint doesn't know this note.",
|
||||||
|
pending: 'This note is locked mid-payment — try again shortly.',
|
||||||
|
duplicate: 'This note is already in your wallet.',
|
||||||
|
invalid: 'Not a valid bearer note.',
|
||||||
|
generic: '',
|
||||||
|
};
|
||||||
|
const ERROR_ICON: Readonly<Record<Exclude<ReceiveErrorKind, ''>, string>> = {
|
||||||
|
spent: 'money_off',
|
||||||
|
unknown: 'help_outline',
|
||||||
|
pending: 'hourglass_top',
|
||||||
|
duplicate: 'content_copy',
|
||||||
|
invalid: 'error_outline',
|
||||||
|
generic: 'error_outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTokenEmit) => {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
const errorMessage = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
const step = ref<'input' | 'success'>('input');
|
||||||
|
const input = ref('');
|
||||||
|
const scanning = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorKind = ref<ReceiveErrorKind>('');
|
||||||
|
const errorMessageText = ref('');
|
||||||
|
const receivedSats = ref(0);
|
||||||
|
const receivedServer = ref('');
|
||||||
|
const unverifiedNote = ref(false);
|
||||||
|
const rotationWarning = ref('');
|
||||||
|
const trustPrompt = useMintTrustPrompt();
|
||||||
|
const errorText = computed(() =>
|
||||||
|
errorKind.value === 'generic'
|
||||||
|
? errorMessageText.value
|
||||||
|
: errorKind.value === ''
|
||||||
|
? ''
|
||||||
|
: ERROR_TEXT[errorKind.value],
|
||||||
|
);
|
||||||
|
const errorIcon = computed(() =>
|
||||||
|
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
|
||||||
|
);
|
||||||
|
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
|
||||||
|
const clearError = (): void => {
|
||||||
|
errorKind.value = '';
|
||||||
|
errorMessageText.value = '';
|
||||||
|
};
|
||||||
|
const classifyError = (error: unknown): void => {
|
||||||
|
let kind: Exclude<ReceiveErrorKind, ''>;
|
||||||
|
if (error instanceof NoteSpentError) kind = 'spent';
|
||||||
|
else if (error instanceof NoteUnknownError) kind = 'unknown';
|
||||||
|
else if (error instanceof PendingNoteError) kind = 'pending';
|
||||||
|
else if (error instanceof Error && error.message.includes('already in your wallet')) {
|
||||||
|
kind = 'duplicate';
|
||||||
|
} else if (error instanceof Error && error.message.includes('Not an LNURLcash bearer note')) {
|
||||||
|
kind = 'invalid';
|
||||||
|
} else {
|
||||||
|
kind = 'generic';
|
||||||
|
errorMessageText.value = errorMessage(error);
|
||||||
|
}
|
||||||
|
errorKind.value = kind;
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const receive = async (): Promise<void> => {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (busy.value || value === '') return;
|
||||||
|
busy.value = true;
|
||||||
|
clearError();
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
let staged: Bearer | undefined;
|
||||||
|
try {
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = fundOperation.ownerFence;
|
||||||
|
// the rotate's fresh secret is reserved and its future note staged
|
||||||
|
// before the mutation can land - a crash mid-rotate never loses the
|
||||||
|
// money (wallet recovery reconciles the staged record)
|
||||||
|
const claimed = await receiveBearer(value, wallet.bearers, {
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
stageRotation: async (note) => {
|
||||||
|
[staged] = await wallet.addBearers([note], ownerFence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const note = claimed.note;
|
||||||
|
const server = new URL(note.url).host;
|
||||||
|
const wasTrusted = mints.isTrusted(server);
|
||||||
|
let trustWarning = '';
|
||||||
|
try {
|
||||||
|
if (claimed.stage === 'finalize') {
|
||||||
|
// the rotate landed: the staged record becomes the confirmed note
|
||||||
|
if (!staged) throw new Error('The rotated note was not staged.');
|
||||||
|
await wallet.finalizeStagedMintOutput(staged.id, note, ownerFence);
|
||||||
|
} else if (claimed.stage === 'discard') {
|
||||||
|
// the rotate provably never landed: drop the worthless stage and
|
||||||
|
// keep the original (exposed - see rotationWarning)
|
||||||
|
if (staged) await wallet.removeNote(staged.id, ownerFence);
|
||||||
|
await wallet.addBearers([note], ownerFence);
|
||||||
|
} else {
|
||||||
|
// 'keep': the staged record is the possible rotated copy; the
|
||||||
|
// original is tracked unverified alongside it. 'none': no rotate
|
||||||
|
// was attempted - the note goes in as received.
|
||||||
|
await wallet.addBearers([note], ownerFence);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
trustWarning = error.message;
|
||||||
|
}
|
||||||
|
receivedSats.value = displaySats(note.amount);
|
||||||
|
receivedServer.value = server;
|
||||||
|
unverifiedNote.value = !note.verified;
|
||||||
|
rotationWarning.value = claimed.rotationError ?? '';
|
||||||
|
await activity.log(
|
||||||
|
'receive',
|
||||||
|
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
|
||||||
|
(error) => Notify.create({ type: 'warning', message: error.message }),
|
||||||
|
);
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
||||||
|
});
|
||||||
|
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
|
||||||
|
emit('received');
|
||||||
|
scanning.value = false;
|
||||||
|
step.value = 'success';
|
||||||
|
if (!wasTrusted && note.mintPubkey) {
|
||||||
|
trustPrompt.openTrust(server, note.mintPubkey);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// a definitive failure thrown after staging leaves the staged record
|
||||||
|
// behind - drop it best-effort (wallet recovery restores the original
|
||||||
|
// note from it if this discard itself is impossible right now)
|
||||||
|
if (staged && fundOperation) {
|
||||||
|
await wallet.removeNote(staged.id, fundOperation.ownerFence).catch(() => undefined);
|
||||||
|
}
|
||||||
|
classifyError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onScan = (text: string): void => {
|
||||||
|
input.value = text;
|
||||||
|
scanning.value = false;
|
||||||
|
void receive();
|
||||||
|
};
|
||||||
|
const onScanError = (message: string): void => {
|
||||||
|
scanning.value = false;
|
||||||
|
Notify.create({ type: 'negative', message });
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
step.value = 'input';
|
||||||
|
input.value = props.initialInput ?? '';
|
||||||
|
scanning.value = false;
|
||||||
|
busy.value = false;
|
||||||
|
clearError();
|
||||||
|
unverifiedNote.value = false;
|
||||||
|
rotationWarning.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
errorIcon,
|
||||||
|
errorKind,
|
||||||
|
errorText,
|
||||||
|
input,
|
||||||
|
inputValid,
|
||||||
|
onScan,
|
||||||
|
onScanError,
|
||||||
|
receive,
|
||||||
|
receivedSats,
|
||||||
|
receivedServer,
|
||||||
|
rotationWarning,
|
||||||
|
scanning,
|
||||||
|
showTrust: trustPrompt.showTrust,
|
||||||
|
skipTrust: trustPrompt.skipTrust,
|
||||||
|
step,
|
||||||
|
trustMint: trustPrompt.trustMint,
|
||||||
|
trustServer: trustPrompt.trustServer,
|
||||||
|
unverifiedNote,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { toBech32Lnurl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { canShareText, shareText } from '@/capabilities/share';
|
||||||
|
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type SendTokenProps = Readonly<{ modelValue: boolean }>;
|
||||||
|
type SendTokenEmit = {
|
||||||
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
|
(event: 'sent'): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) => {
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
const step = ref<'amount' | 'ready'>('amount');
|
||||||
|
const amountSats = ref('');
|
||||||
|
const preparing = ref(false);
|
||||||
|
const removing = ref(false);
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
const prepared = ref<Bearer | null>(null);
|
||||||
|
const revealed = ref(false);
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
const parsedAmount = computed<number | null>(() => {
|
||||||
|
const amount = Number(amountSats.value);
|
||||||
|
return Number.isInteger(amount) && amount > 0 ? amount : null;
|
||||||
|
});
|
||||||
|
const amountError = computed<string | null>(() => {
|
||||||
|
if (parsedAmount.value === null) return null;
|
||||||
|
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
|
||||||
|
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const canPrepare = computed(
|
||||||
|
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
||||||
|
);
|
||||||
|
const noteDisplayValue = computed(() =>
|
||||||
|
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
|
||||||
|
);
|
||||||
|
const canShare = canShareText();
|
||||||
|
const reset = (): void => {
|
||||||
|
step.value = 'amount';
|
||||||
|
amountSats.value = '';
|
||||||
|
preparing.value = false;
|
||||||
|
removing.value = false;
|
||||||
|
errorMessage.value = null;
|
||||||
|
prepared.value = null;
|
||||||
|
revealed.value = false;
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const prepare = async (): Promise<void> => {
|
||||||
|
const sats = parsedAmount.value;
|
||||||
|
if (sats === null || amountError.value !== null) return;
|
||||||
|
preparing.value = true;
|
||||||
|
errorMessage.value = null;
|
||||||
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
ownerFence = fundOperation.ownerFence;
|
||||||
|
const fence = ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
|
// the carve commits the moment it lands server-side (onCarve), so
|
||||||
|
// even a crash right after can never strand the fresh outputs or
|
||||||
|
// leave burned inputs looking spendable
|
||||||
|
const carveState: { committed?: Bearer } = {};
|
||||||
|
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
|
||||||
|
assertOwner: fence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, fence),
|
||||||
|
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
|
||||||
|
onCarve: async (landed) => {
|
||||||
|
carveState.committed = await commitCarve(wallet, landed, {
|
||||||
|
ownerFence: fence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// a carve that mutated nothing (one note already held the exact
|
||||||
|
// amount) fires no hook - the note is already tracked; look it up
|
||||||
|
const committed =
|
||||||
|
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!committed) throw new Error('The carved note was not tracked.');
|
||||||
|
const note = committed;
|
||||||
|
if (carve.change) {
|
||||||
|
await activity.log(
|
||||||
|
'split',
|
||||||
|
`Prepared a ${formatSats(sats)} sat note to hand over.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (carve.consumed.length > 1) {
|
||||||
|
await activity.log(
|
||||||
|
'combine',
|
||||||
|
`Combined notes into a ${formatSats(sats)} sat note.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prepared.value = note;
|
||||||
|
revealed.value = false;
|
||||||
|
step.value = 'ready';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UncertainOutcomeError) {
|
||||||
|
if (!ownerFence) throw error;
|
||||||
|
await addCommittedBearers(wallet, error.possibleOutputs, {
|
||||||
|
ownerFence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A note preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
(activityError) => warnCommitted(activityError.message),
|
||||||
|
);
|
||||||
|
errorMessage.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
errorMessage.value = message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance to cover that amount.'
|
||||||
|
: message;
|
||||||
|
toast('negative', errorMessage.value);
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
preparing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copyNote = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await writeClipboard(noteDisplayValue.value);
|
||||||
|
toast('positive', 'Note copied to clipboard.');
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shareNote = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await shareText('sattle bearer note', noteDisplayValue.value);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
await copyNote();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finishRemove = async (): Promise<void> => {
|
||||||
|
const note = prepared.value;
|
||||||
|
if (!note) return;
|
||||||
|
removing.value = true;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
await wallet.markSpent(note.id, fundOperation.ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'spent',
|
||||||
|
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', 'Removed from your balance.');
|
||||||
|
emit('sent');
|
||||||
|
show.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
|
removing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finishKeep = (): void => {
|
||||||
|
toast('info', 'Note kept in your wallet.');
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
amountError,
|
||||||
|
amountSats,
|
||||||
|
canPrepare,
|
||||||
|
canShare,
|
||||||
|
copyNote,
|
||||||
|
errorMessage,
|
||||||
|
finishKeep,
|
||||||
|
finishRemove,
|
||||||
|
formatSats,
|
||||||
|
msatToSats,
|
||||||
|
noteDisplayValue,
|
||||||
|
prepare,
|
||||||
|
prepared,
|
||||||
|
preparing,
|
||||||
|
removing,
|
||||||
|
revealed,
|
||||||
|
shareNote,
|
||||||
|
show,
|
||||||
|
step,
|
||||||
|
wallet,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey } from '@/lnurlcash/keys';
|
||||||
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
|
import { loadBearers } from '@/lnurlcash/storage';
|
||||||
|
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||||
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { commitCarve } from './walletCarveCommit';
|
||||||
|
import { useWalletStore } from '../stores/wallet';
|
||||||
|
|
||||||
|
const note = (secret: string): NewBearer => ({
|
||||||
|
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const failOnBearerWrite = (occurrence: number): void => {
|
||||||
|
const setItem = localStorage.setItem.bind(localStorage);
|
||||||
|
let writes = 0;
|
||||||
|
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
|
||||||
|
if (key === FUNDS_STORAGE_KEY) {
|
||||||
|
writes += 1;
|
||||||
|
if (writes === occurrence) throw new Error('bearer storage unavailable');
|
||||||
|
}
|
||||||
|
setItem(key, value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// a present-but-non-serializing LockManager fake: walletFunds serializes its
|
||||||
|
// own mutations, so single-store tests only need locks to EXIST
|
||||||
|
const stubPassthroughLocks = (): void => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
stubPassthroughLocks();
|
||||||
|
stubLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commitCarve', () => {
|
||||||
|
it('commits the carve additions and spent marks in one bearer write', async () => {
|
||||||
|
// Given a wallet holding the carve's input note
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
const carve: CarveResult = {
|
||||||
|
note: note('bb'),
|
||||||
|
change: note('cc'),
|
||||||
|
consumed: [input],
|
||||||
|
};
|
||||||
|
const writes = vi.spyOn(localStorage, 'setItem');
|
||||||
|
|
||||||
|
// When the carve is committed
|
||||||
|
const committed = await commitCarve(wallet, carve, {
|
||||||
|
ownerFence,
|
||||||
|
warn: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then the whole rotation landed as ONE durable write
|
||||||
|
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
|
||||||
|
expect(committed.url).toBe(carve.note.url);
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(3);
|
||||||
|
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a failure that would have hit the old split commit second write', async () => {
|
||||||
|
// Given a wallet holding the carve's input note, with the second
|
||||||
|
// funds-document write poisoned (the old add-then-markSpent split wrote
|
||||||
|
// twice; the single-write commit never reaches a second write)
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
failOnBearerWrite(2);
|
||||||
|
const carve: CarveResult = {
|
||||||
|
note: note('bb'),
|
||||||
|
change: note('cc'),
|
||||||
|
consumed: [input],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the carve is committed
|
||||||
|
const committed = await commitCarve(wallet, carve, {
|
||||||
|
ownerFence,
|
||||||
|
warn: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then the rotation committed completely: additions tracked, input spent
|
||||||
|
expect(committed.url).toBe(carve.note.url);
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(3);
|
||||||
|
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no partial carve behind when the commit write itself fails', async () => {
|
||||||
|
// Given bearer storage that fails the very next write
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
failOnBearerWrite(1);
|
||||||
|
|
||||||
|
// When the carve commit fails
|
||||||
|
await expect(
|
||||||
|
commitCarve(
|
||||||
|
wallet,
|
||||||
|
{ note: note('bb'), change: note('cc'), consumed: [input] },
|
||||||
|
{ ownerFence, warn: () => undefined },
|
||||||
|
),
|
||||||
|
).rejects.toThrow('bearer storage unavailable');
|
||||||
|
|
||||||
|
// Then nothing moved: not in storage, not in the reactive list
|
||||||
|
expect(wallet.bearers).toHaveLength(1);
|
||||||
|
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(1);
|
||||||
|
expect(persisted[0]?.spent).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the staged and landed phases of the same output as one record', async () => {
|
||||||
|
// Given a wallet holding the carve's input note
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
const context = { ownerFence, warn: () => undefined };
|
||||||
|
|
||||||
|
// When the pre-wire phase stages the outputs (sig-less URLs), then the
|
||||||
|
// landed phase re-reports them with the mint's signature in the URL
|
||||||
|
await commitCarve(wallet, { note: note('bb'), change: note('cc'), consumed: [] }, context);
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
const stagedNote = note('bb');
|
||||||
|
const committed = await commitCarve(
|
||||||
|
wallet,
|
||||||
|
{
|
||||||
|
note: { ...stagedNote, url: `${stagedNote.url}&sig=${'ab'.repeat(64)}`, verified: true },
|
||||||
|
consumed: [input],
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then the staged record was refreshed in place: still one record per
|
||||||
|
// output, the input retired, no duplicate from the URL difference
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
expect(committed.verified).toBe(true);
|
||||||
|
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
expect(wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32)))).toHaveLength(1);
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
expect(await loadBearers(key)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
|
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { TrustedMintPostCommitError } from '@/stores/wallet';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
|
||||||
|
export type CarveWallet = {
|
||||||
|
readonly bearers: readonly Bearer[];
|
||||||
|
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
|
||||||
|
readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarveCommitContext = Readonly<{
|
||||||
|
ownerFence: WalletOwnerFence;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const addCommittedBearers = async (
|
||||||
|
wallet: CarveWallet,
|
||||||
|
notes: NewBearer[],
|
||||||
|
context: CarveCommitContext,
|
||||||
|
): Promise<Bearer[]> => {
|
||||||
|
try {
|
||||||
|
return await wallet.addBearers(notes, context.ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
context.warn(error.message);
|
||||||
|
return error.committedBearers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A carve is ONE logical rotation: the fresh notes (target + change) and the
|
||||||
|
// spent marks of the burned inputs must land together or not at all - the
|
||||||
|
// mint already destroyed the inputs server-side, so a partial commit (added
|
||||||
|
// but not spent, or vice versa) would strand or double-show money. Both
|
||||||
|
// checkpoint phases (pre-wire staging, landed retirement) go through the
|
||||||
|
// wallet's k1-aware one-write boundary: the landed phase's URLs carry the
|
||||||
|
// mint's signature the staged phase's could not have, so the store matches
|
||||||
|
// checkpoints by output SECRET and refreshes the staged record in place -
|
||||||
|
// a replayed checkpoint is a no-op, never a duplicate.
|
||||||
|
export const commitCarve = async (
|
||||||
|
wallet: CarveWallet,
|
||||||
|
carve: CarveResult,
|
||||||
|
context: CarveCommitContext,
|
||||||
|
): Promise<Bearer> => {
|
||||||
|
try {
|
||||||
|
return await wallet.commitCarve(carve, context.ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
context.warn(error.message);
|
||||||
|
const committed = error.committedBearers[0];
|
||||||
|
if (!committed) throw error;
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
export const passwordValid = (password: string, confirmation: string): boolean =>
|
||||||
|
password === '' || (password.length >= MIN_PASSWORD_LENGTH && password === confirmation);
|
||||||
+26
-6
@@ -6,7 +6,11 @@ lnurlcash-kit directly.
|
|||||||
|
|
||||||
## FUND-CRITICAL INVARIANTS (non-negotiable)
|
## FUND-CRITICAL INVARIANTS (non-negotiable)
|
||||||
|
|
||||||
- Rotate on every receive, and immediately after claiming a fresh mint.
|
- Rotate every externally received bearer. Minted notes are different:
|
||||||
|
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
|
- A note's declared amount is a claim; the service's `maxWithdrawable` is
|
||||||
authoritative.
|
authoritative.
|
||||||
- A melt's "OK" only means the payment is in flight — the verify URL (or
|
- A melt's "OK" only means the payment is in flight — the verify URL (or
|
||||||
@@ -22,8 +26,9 @@ lnurlcash-kit directly.
|
|||||||
```
|
```
|
||||||
ops.ts / ops/ # flows: carve (exact-amount), mint, pay, receiveBearer,
|
ops.ts / ops/ # flows: carve (exact-amount), mint, pay, receiveBearer,
|
||||||
# transfer (inter-mint); ops.ts is the façade
|
# transfer (inter-mint); ops.ts is the façade
|
||||||
storage/ # encrypted bearers + activity log, settings, backup,
|
storage/ # encrypted bearers + activity log, owner-bound NWC,
|
||||||
# nwcConnections, passkeySlots; storage.ts is the façade
|
# passkey and trusted-mint records, settings, backup;
|
||||||
|
# storage.ts is the façade
|
||||||
keys.ts # BIP39, LUD-05 linking key derivation, password wrap
|
keys.ts # BIP39, LUD-05 linking key derivation, password wrap
|
||||||
passkeys.ts + passkeyWrap.ts # WebAuthn PRF wrap (same linking key)
|
passkeys.ts + passkeyWrap.ts # WebAuthn PRF wrap (same linking key)
|
||||||
nostrBackup.ts + nostr/ # kind-30078 backup, NIP-44 self-encryption
|
nostrBackup.ts + nostr/ # kind-30078 backup, NIP-44 self-encryption
|
||||||
@@ -38,8 +43,21 @@ test-utils.ts # mock mint harness used by *.test.ts
|
|||||||
- Style: NO semicolons, 2-space indent, single quotes, `{braced}` imports
|
- Style: NO semicolons, 2-space indent, single quotes, `{braced}` imports
|
||||||
without inner spaces — deliberately different from the rest of the app
|
without inner spaces — deliberately different from the rest of the app
|
||||||
(eslint override); keep the tested core diffable against its lineage.
|
(eslint override); keep the tested core diffable against its lineage.
|
||||||
- Storage: localStorage keys `sattle_*`; strict shape validation on read,
|
- Storage: localStorage keys `sattle_*`; strict shape validation on read.
|
||||||
malformed entries dropped; read-modify-write under `withStorageLock`.
|
Credential, NWC, passkey, and trusted-mint records belong to the canonical
|
||||||
|
saved-key owner. Normal writes require that exact persisted owner; migration
|
||||||
|
of ownerless legacy records has its own proof-gated API.
|
||||||
|
- Concurrency: read-modify-write uses `withStorageLock` where Web Locks are
|
||||||
|
available, but lock handoff is not a localStorage visibility barrier. The
|
||||||
|
trusted-mint repository reconciles from a durable IndexedDB commit mirror
|
||||||
|
before one successful localStorage write and before resolving. Its fallback
|
||||||
|
is local execution only, with no cross-tab serialization guarantee. Storage
|
||||||
|
events are wakeups, so listeners re-read current storage instead of trusting
|
||||||
|
`event.newValue`, including on clears.
|
||||||
|
- Lifecycle: wallet transitions serialize create, restore, unlock, lock, and
|
||||||
|
forget. Activation completes proven-owner migration before exposing unlocked
|
||||||
|
state. Forget locks, drains NWC, clears runtime and owner-bound state, then
|
||||||
|
removes the saved key after biometric deletion succeeds.
|
||||||
- Network: kit calls only; injectable transport/options so tests never
|
- Network: kit calls only; injectable transport/options so tests never
|
||||||
touch the network. No WebSocket at import time (lazy `import()`).
|
touch the network. No WebSocket at import time (lazy `import()`).
|
||||||
- Every module header comment explains the WHY, including failure models.
|
- Every module header comment explains the WHY, including failure models.
|
||||||
@@ -47,4 +65,6 @@ test-utils.ts # mock mint harness used by *.test.ts
|
|||||||
## TESTS
|
## TESTS
|
||||||
|
|
||||||
`npx vitest run` — node environment, `*.test.ts` next to the modules.
|
`npx vitest run` — node environment, `*.test.ts` next to the modules.
|
||||||
Adversarial mock mint from lnurlcash-conformance via test-utils.
|
The adversarial mock mint from lnurlcash-conformance is wrapped by
|
||||||
|
`ops.testHarness.ts`; its default contract is mandatory comment-bound
|
||||||
|
minting.
|
||||||
|
|||||||
+29
-28
@@ -22,30 +22,30 @@ import {
|
|||||||
grossUpForMintFee,
|
grossUpForMintFee,
|
||||||
mintAddressUrl,
|
mintAddressUrl,
|
||||||
resolveMintInput,
|
resolveMintInput,
|
||||||
serverOf
|
serverOf,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit';
|
||||||
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
|
import type { LnurlcashOptions, MintFee } from 'lnurlcash-kit';
|
||||||
import {ceilMsatToSat, floorMsatToSat} from './units'
|
import { ceilMsatToSat, floorMsatToSat } from './units';
|
||||||
|
|
||||||
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
|
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
|
||||||
applyMintFee(grossMsat, fee)
|
applyMintFee(grossMsat, fee);
|
||||||
|
|
||||||
export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
|
export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
|
||||||
ceilMsatToSat(grossUpForMintFee(netMsat, fee))
|
ceilMsatToSat(grossUpForMintFee(netMsat, fee));
|
||||||
|
|
||||||
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
|
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
|
||||||
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat
|
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat;
|
||||||
|
|
||||||
// Fee quotes are per-mint and change rarely; cache briefly by server.
|
// Fee quotes are per-mint and change rarely; cache briefly by server.
|
||||||
// nulls are cached too - an unreachable mint shouldn't be retried on
|
// nulls are cached too - an unreachable mint shouldn't be retried on
|
||||||
// every render either.
|
// every render either.
|
||||||
const QUOTE_TTL_MS = 60_000
|
const QUOTE_TTL_MS = 60_000;
|
||||||
const QUOTE_TIMEOUT_MS = 5_000
|
const QUOTE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
const quoteCache = new Map<string, {at: number; fee: MintFee | null}>()
|
const quoteCache = new Map<string, { at: number; fee: MintFee | null }>();
|
||||||
|
|
||||||
// tests can reset the cache between cases
|
// tests can reset the cache between cases
|
||||||
export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
|
export const clearMintFeeQuoteCache = (): void => quoteCache.clear();
|
||||||
|
|
||||||
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting
|
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting
|
||||||
// an invoice - the same resolution chain as prepareMint (mint-address
|
// an invoice - the same resolution chain as prepareMint (mint-address
|
||||||
@@ -55,30 +55,31 @@ export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
|
|||||||
// reached right now.
|
// reached right now.
|
||||||
export const quoteMintFee = async (
|
export const quoteMintFee = async (
|
||||||
mintInput: string,
|
mintInput: string,
|
||||||
options: LnurlcashOptions = {}
|
options: LnurlcashOptions = {},
|
||||||
): Promise<MintFee | null> => {
|
): Promise<MintFee | null> => {
|
||||||
const url = resolveMintInput(mintInput)
|
const url = resolveMintInput(mintInput);
|
||||||
if (!url) return null
|
if (!url) return null;
|
||||||
const server = serverOf(url)
|
const server = serverOf(url);
|
||||||
const cached = quoteCache.get(server)
|
const cached = quoteCache.get(server);
|
||||||
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee
|
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee;
|
||||||
|
|
||||||
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options}
|
const opts: LnurlcashOptions = { timeoutMs: QUOTE_TIMEOUT_MS, ...options };
|
||||||
let payUrl = url
|
let payUrl = url;
|
||||||
const addressUrl = mintAddressUrl(url)
|
const addressUrl = mintAddressUrl(url);
|
||||||
if (addressUrl) {
|
if (addressUrl) {
|
||||||
try {
|
try {
|
||||||
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink
|
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
|
||||||
} catch {
|
} catch (error) {
|
||||||
// no mint-address support - the plain payRequest guess still works
|
// no mint-address support - the plain payRequest guess still works
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let fee: MintFee | null
|
let fee: MintFee | null;
|
||||||
try {
|
try {
|
||||||
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null
|
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
fee = null
|
fee = null;
|
||||||
}
|
}
|
||||||
quoteCache.set(server, {at: Date.now(), fee})
|
quoteCache.set(server, { at: Date.now(), fee });
|
||||||
return fee
|
return fee;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const parseJsonObject = (source: string): Record<string, unknown> => {
|
||||||
|
const parsed: unknown = JSON.parse(source);
|
||||||
|
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.');
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseJsonArray = (source: string): unknown[] => {
|
||||||
|
const parsed: unknown = JSON.parse(source);
|
||||||
|
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.');
|
||||||
|
return Array.from(parsed, (value: unknown) => value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
|
||||||
|
parseJsonArray(source).map((value) => {
|
||||||
|
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.');
|
||||||
|
return value;
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptSavedWalletMaterial,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
encryptSecretParts,
|
||||||
|
ensureSavedWalletMaterialOwner,
|
||||||
|
getPlainWalletMaterial,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
restoreWalletMaterialStored,
|
||||||
|
saveWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
} from './keys';
|
||||||
|
import { walletMaterialOwnerId } from './storage/storedSecret';
|
||||||
|
import { parseJsonObject, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const MNEMONIC =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
const OTHER_MNEMONIC =
|
||||||
|
'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||||
|
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
|
||||||
|
const PASSWORD = 'correct horse battery staple';
|
||||||
|
|
||||||
|
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
|
||||||
|
|
||||||
|
const readRawMaterialRecord = (): Record<string, unknown> => {
|
||||||
|
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
|
||||||
|
if (raw === null) throw new Error('expected a saved wallet-material record');
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('baseline: wallet material identity', () => {
|
||||||
|
it('derives identity only from the linking key when the cash root changes', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const other = deriveWalletMaterial(OTHER_MNEMONIC);
|
||||||
|
const alteredRoot = { ...material, cashRootHex: other.cashRootHex };
|
||||||
|
|
||||||
|
expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material));
|
||||||
|
expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saved wallet material commitment', () => {
|
||||||
|
it('stores the canonical material commitment on plaintext and encrypted saves', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const expectedHash = materialHash(serializeWalletMaterial(material));
|
||||||
|
|
||||||
|
await saveWalletMaterial(material);
|
||||||
|
const plaintext = readRawMaterialRecord();
|
||||||
|
await saveWalletMaterial(material, PASSWORD);
|
||||||
|
const encrypted = readRawMaterialRecord();
|
||||||
|
|
||||||
|
expect(plaintext.materialHash).toBe(expectedHash);
|
||||||
|
expect(encrypted.materialHash).toBe(expectedHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects authenticated encrypted material when its commitment differs', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
await saveWalletMaterial(material, PASSWORD);
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects plaintext material when its commitment differs', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
await saveWalletMaterial(material);
|
||||||
|
const before = JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) });
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, before);
|
||||||
|
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull();
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to stamp same-owner material with an altered cash root', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const other = deriveWalletMaterial(OTHER_MNEMONIC);
|
||||||
|
await saveWalletMaterial(material, PASSWORD);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ensureSavedWalletMaterialOwner({ ...material, cashRootHex: other.cashRootHex }),
|
||||||
|
).toThrow(WalletMaterialProofMismatchError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: walletMaterialOwnerId(material),
|
||||||
|
version: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isValidStoredWalletMaterial(record)).toBe(true);
|
||||||
|
expect(
|
||||||
|
isValidStoredWalletMaterial({ ...record, materialHash: record.materialHash.toUpperCase() }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isValidStoredWalletMaterial({
|
||||||
|
enc: record.enc,
|
||||||
|
value: record.value,
|
||||||
|
ownerId: record.ownerId,
|
||||||
|
version: record.version,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed authenticated material after its correct commitment matches', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const malformed = JSON.stringify({ ...material, cashRootHex: '00'.repeat(64) });
|
||||||
|
const parts = await encryptSecretParts(malformed, PASSWORD);
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: materialHash(malformed),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow('valid v2 wallet material');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a verified restored commitment while discarding its owner claim', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readRawMaterialRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
});
|
||||||
|
expect(getPlainWalletMaterial()).toEqual(material);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proves an encrypted restored commitment before owner stamping', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
const parts = await encryptSecretParts(serialized, PASSWORD);
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBeNull();
|
||||||
|
const proven = await decryptSavedWalletMaterial(PASSWORD);
|
||||||
|
ensureSavedWalletMaterialOwner(proven);
|
||||||
|
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unproven restored hash that contradicts encrypted material', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC);
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
const parts = await encryptSecretParts(serialized, PASSWORD);
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: '00'.repeat(32),
|
||||||
|
ownerId: walletMaterialOwnerId(material),
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
);
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptSavedWalletMaterial,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
encryptSecretParts,
|
||||||
|
ensureSavedWalletMaterialOwner,
|
||||||
|
getPlainWalletMaterial,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
restoreWalletMaterialStored,
|
||||||
|
savedWalletMaterialExists,
|
||||||
|
savedWalletMaterialIsEncrypted,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
saveWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
} from './keys';
|
||||||
|
import { parseJsonObject, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const FIXED_MNEMONIC =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
const PASSWORD = 'correct horse battery staple';
|
||||||
|
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
|
||||||
|
const LEGACY_STORAGE_KEY = 'sattle_linking_key';
|
||||||
|
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
|
||||||
|
const CASH_ROOT_HEX =
|
||||||
|
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
|
||||||
|
|
||||||
|
const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
|
||||||
|
|
||||||
|
const readRawMaterialRecord = (): Record<string, unknown> => {
|
||||||
|
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
|
||||||
|
if (raw === null) throw new Error('expected a saved wallet-material record');
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WalletMaterialV2 persistence', () => {
|
||||||
|
it('round-trips canonical plaintext material in the new namespace', async () => {
|
||||||
|
// Given complete wallet material and an unrelated legacy record
|
||||||
|
stubLocalStorage();
|
||||||
|
localStorage.setItem(
|
||||||
|
LEGACY_STORAGE_KEY,
|
||||||
|
JSON.stringify({ enc: false, value: '07'.repeat(32) }),
|
||||||
|
);
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
|
||||||
|
// When the material is saved without a password
|
||||||
|
await saveWalletMaterial(material);
|
||||||
|
|
||||||
|
// Then only the new namespace carries the exact complete material
|
||||||
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
|
expect(savedWalletMaterialIsEncrypted()).toBe(false);
|
||||||
|
expect(getPlainWalletMaterial()).toEqual(material);
|
||||||
|
expect(readRawMaterialRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull();
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC);
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips canonical password-encrypted material without plaintext fields', async () => {
|
||||||
|
// Given complete wallet material
|
||||||
|
stubLocalStorage();
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
|
||||||
|
// When the material is password protected
|
||||||
|
await saveWalletMaterial(material, PASSWORD);
|
||||||
|
|
||||||
|
// Then only authenticated decryption returns both parts
|
||||||
|
expect(savedWalletMaterialIsEncrypted()).toBe(true);
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull();
|
||||||
|
expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material);
|
||||||
|
await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow();
|
||||||
|
const raw = JSON.stringify(readRawMaterialRecord());
|
||||||
|
expect(raw).not.toContain(LINKING_KEY_HEX);
|
||||||
|
expect(raw).not.toContain(CASH_ROOT_HEX);
|
||||||
|
expect(raw).not.toContain(FIXED_MNEMONIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips restored owner claims until the material proves its real owner', () => {
|
||||||
|
// Given plaintext material carrying a foreign owner claim
|
||||||
|
stubLocalStorage();
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9));
|
||||||
|
|
||||||
|
// When the record is restored and subsequently proven by plaintext unlock
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
|
ownerId: foreignOwner,
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const beforeProof = readRawMaterialRecord();
|
||||||
|
const unlocked = getPlainWalletMaterial();
|
||||||
|
if (unlocked === null) throw new Error('expected restored plaintext material');
|
||||||
|
ensureSavedWalletMaterialOwner(unlocked);
|
||||||
|
|
||||||
|
// Then the claim was absent before proof and only the derived owner was stamped
|
||||||
|
expect(beforeProof).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
|
});
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a plaintext owner mismatch without returning or stamping material', () => {
|
||||||
|
// Given canonical material stamped with a foreign current owner
|
||||||
|
stubLocalStorage();
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
|
||||||
|
|
||||||
|
// When it is read or offered for proof
|
||||||
|
const unlocked = getPlainWalletMaterial();
|
||||||
|
const stamp = () => ensureSavedWalletMaterialOwner(material);
|
||||||
|
|
||||||
|
// Then neither operation trusts or rewrites the foreign record
|
||||||
|
expect(unlocked).toBeNull();
|
||||||
|
expect(stamp).toThrow('different wallet');
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an encrypted owner mismatch after authentication', async () => {
|
||||||
|
// Given valid encrypted material whose owner marker was replaced
|
||||||
|
stubLocalStorage();
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
await saveWalletMaterial(material, PASSWORD);
|
||||||
|
const foreign = {
|
||||||
|
...readRawMaterialRecord(),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
};
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign));
|
||||||
|
const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
|
||||||
|
|
||||||
|
// When the authenticated material contradicts the current owner marker
|
||||||
|
const unlock = decryptSavedWalletMaterial(PASSWORD);
|
||||||
|
|
||||||
|
// Then no material is returned or marker changed
|
||||||
|
await expect(unlock).rejects.toThrow('different wallet');
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['legacy key-only plaintext', LINKING_KEY_HEX, 2],
|
||||||
|
['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1],
|
||||||
|
[
|
||||||
|
'malformed material plaintext',
|
||||||
|
JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00' }),
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
])('rejects %s records at the storage boundary', (_name, value, version) => {
|
||||||
|
// Given an invalid record under the v2 material namespace
|
||||||
|
stubLocalStorage();
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value,
|
||||||
|
materialHash: materialHash(value),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record));
|
||||||
|
|
||||||
|
// When storage helpers parse it
|
||||||
|
// Then the record is unusable as wallet material
|
||||||
|
expect(isValidStoredWalletMaterial(record)).toBe(false);
|
||||||
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects authenticated ciphertext containing malformed material', async () => {
|
||||||
|
// Given valid AES-GCM parts whose plaintext is not a v2 material envelope
|
||||||
|
stubLocalStorage();
|
||||||
|
const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD);
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC);
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: materialHash(LINKING_KEY_HEX),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the password successfully authenticates the malformed plaintext
|
||||||
|
const unlock = decryptSavedWalletMaterial(PASSWORD);
|
||||||
|
|
||||||
|
// Then it still fails closed as invalid material
|
||||||
|
await expect(unlock).rejects.toThrow('valid v2 wallet material');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Saved linking-key record tests. The baseline describes pin the observable
|
||||||
|
// behavior of saveLinkingKey/decryptSavedLinkingKey/getPlainLinkingKey/
|
||||||
|
// restoreLinkingKeyStored as it existed before the owner marker (they must
|
||||||
|
// keep passing unchanged); the owner-marker describes cover the ownerId
|
||||||
|
// field that binds the saved key to its one proven wallet identity.
|
||||||
|
// Node env: in-memory localStorage stub, native WebCrypto.
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptSavedLinkingKey,
|
||||||
|
encryptSecretParts,
|
||||||
|
ensureSavedKeyOwner,
|
||||||
|
getPlainLinkingKey,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
restoreLinkingKeyStored,
|
||||||
|
savedKeyExists,
|
||||||
|
savedKeyIsEncrypted,
|
||||||
|
savedKeyOwnerMatches,
|
||||||
|
savedKeyOwnerId,
|
||||||
|
saveLinkingKey,
|
||||||
|
} from './keys';
|
||||||
|
import { isWalletOwnerId } from './storage/walletOwner';
|
||||||
|
import { parseJsonObject, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import './keys.version.cases';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const PASSWORD = 'hunter2';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sattle_linking_key';
|
||||||
|
|
||||||
|
const readRawRecord = (): Record<string, unknown> => {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) throw new Error('expected a saved linking-key record');
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// hand-written records in the pre-owner-marker shape - what every wallet
|
||||||
|
// created before this change has on disk
|
||||||
|
const saveLegacyPlaintext = (key: Uint8Array): void => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ enc: false, value: bytesToHex(key) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLegacyEncrypted = async (
|
||||||
|
key: Uint8Array,
|
||||||
|
password: string,
|
||||||
|
): Promise<Record<string, unknown>> => {
|
||||||
|
const parts = await encryptSecretParts(bytesToHex(key), password);
|
||||||
|
const record: Record<string, unknown> = { enc: true, ...parts };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('baseline: saved-key record behavior', () => {
|
||||||
|
it('saves a plaintext key and reads it back', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
|
||||||
|
expect(savedKeyExists()).toBe(true);
|
||||||
|
expect(savedKeyIsEncrypted()).toBe(false);
|
||||||
|
expect(readRawRecord()).toMatchObject({ enc: false, value: bytesToHex(LINKING_KEY) });
|
||||||
|
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a password-encrypted key and decrypts it with the password', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY, PASSWORD);
|
||||||
|
|
||||||
|
expect(savedKeyIsEncrypted()).toBe(true);
|
||||||
|
const record = readRawRecord();
|
||||||
|
expect(record.enc).toBe(true);
|
||||||
|
expect(record.value).toBeUndefined();
|
||||||
|
// an encrypted record never reads through the plaintext path
|
||||||
|
expect(getPlainLinkingKey()).toBeNull();
|
||||||
|
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the wrong password via the GCM auth tag', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY, PASSWORD);
|
||||||
|
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when asked to decrypt a plaintext record', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
|
||||||
|
'No encrypted linking key saved.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores an ownerless record verbatim and reads it back', async () => {
|
||||||
|
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
|
||||||
|
const record = { enc: true as const, ...parts };
|
||||||
|
restoreLinkingKeyStored(record);
|
||||||
|
|
||||||
|
expect(readRawRecord()).toEqual(record);
|
||||||
|
expect(savedKeyIsEncrypted()).toBe(true);
|
||||||
|
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a malformed stored record instead of trusting it', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }),
|
||||||
|
);
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'not json');
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
expect(getPlainLinkingKey()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('owner marker on new writes', () => {
|
||||||
|
it('matches the canonical owner derived from the saved linking key', async () => {
|
||||||
|
// Given a newly saved owner-bearing key
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
|
||||||
|
// When its freshly derived linking key is compared
|
||||||
|
const matches = savedKeyOwnerMatches(LINKING_KEY);
|
||||||
|
|
||||||
|
// Then the saved owner matches
|
||||||
|
expect(matches).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a different linking key', async () => {
|
||||||
|
// Given a key owned by this wallet
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
|
||||||
|
// When a foreign freshly derived linking key is compared
|
||||||
|
const matches = savedKeyOwnerMatches(OTHER_KEY);
|
||||||
|
|
||||||
|
// Then the foreign key is rejected
|
||||||
|
expect(matches).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
const plainOwner = savedKeyOwnerId();
|
||||||
|
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
|
||||||
|
|
||||||
|
await saveLinkingKey(LINKING_KEY, PASSWORD);
|
||||||
|
expect(savedKeyOwnerId()).toBe(plainOwner);
|
||||||
|
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('owner marker on legacy records', () => {
|
||||||
|
it('reads a legacy record without ownerId as ownerless', async () => {
|
||||||
|
saveLegacyPlaintext(LINKING_KEY);
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
// the record itself stays a fully valid saved key
|
||||||
|
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
|
||||||
|
|
||||||
|
await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed owner-bearing records instead of treating them as legacy', () => {
|
||||||
|
const real = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
const junk: unknown[] = [
|
||||||
|
real.slice(1), // wrong length (65)
|
||||||
|
real + '00', // wrong length (68)
|
||||||
|
real.toUpperCase(), // uppercase hex
|
||||||
|
'zz' + real.slice(2), // non-hex
|
||||||
|
'04' + real.slice(2), // not a compressed-pubkey prefix
|
||||||
|
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point
|
||||||
|
42, // wrong type
|
||||||
|
null,
|
||||||
|
{ pubkey: real },
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
for (const ownerId of junk) {
|
||||||
|
saveLegacyPlaintext(LINKING_KEY);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
|
||||||
|
// the junk marker never reads as an owner...
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
// ...or downgrades to an adoptable ownerless legacy record
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
expect(getPlainLinkingKey()).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the owner after a password unlock without touching the ciphertext', async () => {
|
||||||
|
// Given a legacy encrypted record with no owner marker
|
||||||
|
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
|
||||||
|
|
||||||
|
// When ownership is proven by a successful unlock and then stamped
|
||||||
|
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
|
||||||
|
ensureSavedKeyOwner(linkingKey);
|
||||||
|
|
||||||
|
// Then the marker names the proven key and every ciphertext byte is
|
||||||
|
// preserved
|
||||||
|
const after = readRawRecord();
|
||||||
|
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
|
||||||
|
expect(after.ciphertext).toBe(before.ciphertext);
|
||||||
|
expect(after.salt).toBe(before.salt);
|
||||||
|
expect(after.iv).toBe(before.iv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps a plaintext legacy record after a plaintext unlock', () => {
|
||||||
|
saveLegacyPlaintext(LINKING_KEY);
|
||||||
|
const linkingKey = getPlainLinkingKey();
|
||||||
|
if (linkingKey === null) throw new Error('expected a plaintext key');
|
||||||
|
ensureSavedKeyOwner(linkingKey);
|
||||||
|
|
||||||
|
expect(readRawRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(LINKING_KEY),
|
||||||
|
version: 1,
|
||||||
|
ownerId: linkingPubKeyHex(LINKING_KEY),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
|
||||||
|
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
|
||||||
|
const linkingKey = await decryptSavedLinkingKey(PASSWORD);
|
||||||
|
|
||||||
|
ensureSavedKeyOwner(linkingKey);
|
||||||
|
const afterFirst = localStorage.getItem(STORAGE_KEY);
|
||||||
|
ensureSavedKeyOwner(linkingKey);
|
||||||
|
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst);
|
||||||
|
expect(readRawRecord().ciphertext).toBe(before.ciphertext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing when a new-format record is already correctly stamped', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY, PASSWORD);
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to restamp a record owned by a different wallet', async () => {
|
||||||
|
await saveLinkingKey(OTHER_KEY, PASSWORD);
|
||||||
|
const before = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
|
||||||
|
// the failed stamp leaves the record - marker included - untouched
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
|
||||||
|
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to stamp a key that contradicts a plaintext record', () => {
|
||||||
|
saveLegacyPlaintext(OTHER_KEY);
|
||||||
|
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not adopt a record carrying a junk owner marker', () => {
|
||||||
|
saveLegacyPlaintext(LINKING_KEY);
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }),
|
||||||
|
);
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no record is saved at all', () => {
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('owner marker on restore', () => {
|
||||||
|
it('strips the unproven ownerId a restored record arrives with', async () => {
|
||||||
|
// a backup file can claim any marker - only a freshly derived key may
|
||||||
|
// establish ownership, so restore installs the secret parts alone
|
||||||
|
restoreLinkingKeyStored({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(LINKING_KEY),
|
||||||
|
ownerId: linkingPubKeyHex(OTHER_KEY),
|
||||||
|
});
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
|
||||||
|
|
||||||
|
// the first proven unlock then establishes the true owner
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a junk ownerId from a restored encrypted record', async () => {
|
||||||
|
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
|
||||||
|
restoreLinkingKeyStored({ enc: true, ...parts, ownerId: 42 });
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWalletOwnerId', () => {
|
||||||
|
it('accepts exactly what linkingPubKeyHex produces', () => {
|
||||||
|
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true);
|
||||||
|
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects everything else', () => {
|
||||||
|
const real = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
expect(isWalletOwnerId(real.toUpperCase())).toBe(false);
|
||||||
|
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false);
|
||||||
|
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false);
|
||||||
|
expect(isWalletOwnerId(66)).toBe(false);
|
||||||
|
expect(isWalletOwnerId(undefined)).toBe(false);
|
||||||
|
expect(isWalletOwnerId(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+187
-212
@@ -1,288 +1,263 @@
|
|||||||
|
import { mnemonicToSeedSync, generateMnemonic, validateMnemonic } from '@scure/bip39';
|
||||||
|
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||||
|
import { HDKey, HARDENED_OFFSET } from '@scure/bip32';
|
||||||
|
import { hmac } from '@noble/hashes/hmac.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||||
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode } from 'lnurlcash-kit';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mnemonicToSeedSync,
|
parseStoredSecret,
|
||||||
generateMnemonic,
|
stampStoredSecretOwner,
|
||||||
validateMnemonic
|
storedSecretClaimedOwnerId,
|
||||||
} from '@scure/bip39'
|
storedSecretOwnerId,
|
||||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
stripStoredSecretOwner,
|
||||||
import {HDKey, HARDENED_OFFSET} from '@scure/bip32'
|
STORED_SECRET_VERSION,
|
||||||
import {hmac} from '@noble/hashes/hmac.js'
|
type StoredSecret,
|
||||||
import {sha256} from '@noble/hashes/sha2.js'
|
type WalletMaterialV2,
|
||||||
import {secp256k1} from '@noble/curves/secp256k1.js'
|
} from './storage/storedSecret';
|
||||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
|
||||||
|
import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidStoredSecret,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
|
} from './storage/storedSecret';
|
||||||
|
export type { StoredSecret, StoredWalletMaterial, WalletMaterialV2 } from './storage/storedSecret';
|
||||||
|
export { decryptSecretParts, encryptSecretParts } from './passwordWrap';
|
||||||
|
export type { EncryptedSecretParts } from './passwordWrap';
|
||||||
|
export * from './walletMaterialStorage';
|
||||||
|
|
||||||
// The wallet's identity is derived against this fixed domain rather than
|
// The wallet's identity is derived against this fixed domain rather than
|
||||||
// window.location.hostname, so the same seed phrase always yields the same
|
// window.location.hostname, so the same seed phrase always yields the same
|
||||||
// linking key (and thus decrypts the same bearer tokens) no matter where
|
// linking key (and thus decrypts the same bearer tokens) no matter where
|
||||||
// this static build happens to be hosted - github.io, a mirror, file://.
|
// this static build happens to be hosted - github.io, a mirror, file://.
|
||||||
export const WALLET_DOMAIN = 'sattle'
|
export const WALLET_DOMAIN = 'sattle';
|
||||||
|
|
||||||
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128)
|
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128);
|
||||||
|
|
||||||
export const isValidSeedPhrase = (phrase: string): boolean =>
|
export const isValidSeedPhrase = (phrase: string): boolean =>
|
||||||
validateMnemonic(phrase.trim().toLowerCase(), wordlist)
|
validateMnemonic(phrase.trim().toLowerCase(), wordlist);
|
||||||
|
|
||||||
const readUint32BE = (bytes: Uint8Array, offset: number): number =>
|
const readUint32BE = (bytes: Uint8Array, offset: number): number =>
|
||||||
((bytes[offset] << 24) |
|
((bytes[offset] << 24) |
|
||||||
(bytes[offset + 1] << 16) |
|
(bytes[offset + 1] << 16) |
|
||||||
(bytes[offset + 2] << 8) |
|
(bytes[offset + 2] << 8) |
|
||||||
bytes[offset + 3]) >>>
|
bytes[offset + 3]) >>>
|
||||||
0
|
0;
|
||||||
|
|
||||||
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
|
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
|
||||||
// a seed restored there or here produces the same identity for a given domain
|
// a seed restored there or here produces the same identity for a given domain
|
||||||
export const deriveLud05LinkingKey = (
|
const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
|
||||||
seedPhrase: string,
|
const master = HDKey.fromMasterSeed(seed);
|
||||||
domain: string
|
|
||||||
): Uint8Array => {
|
|
||||||
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
|
|
||||||
const master = HDKey.fromMasterSeed(seed)
|
|
||||||
|
|
||||||
const hashingKeyNode = master.derive("m/138'/0")
|
const hashingKeyNode = master.derive("m/138'/0");
|
||||||
if (!hashingKeyNode.privateKey)
|
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key');
|
||||||
throw new Error('Could not derive hashing key')
|
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain);
|
||||||
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
|
|
||||||
|
|
||||||
// path suffix longs are raw BIP32 child indices: whether each level ends up
|
// path suffix longs are raw BIP32 child indices: whether each level ends up
|
||||||
// hardened depends solely on its own magnitude (>= 2^31), never forced
|
// hardened depends solely on its own magnitude (>= 2^31), never forced
|
||||||
let node = master.deriveChild(138 + HARDENED_OFFSET)
|
let node = master.deriveChild(138 + HARDENED_OFFSET);
|
||||||
for (const index of suffix) {
|
for (const index of suffix) {
|
||||||
node = node.deriveChild(index)
|
node = node.deriveChild(index);
|
||||||
}
|
}
|
||||||
if (!node.privateKey) throw new Error('Could not derive linking key')
|
if (!node.privateKey) throw new Error('Could not derive linking key');
|
||||||
return node.privateKey
|
return node.privateKey;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array =>
|
||||||
|
deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain);
|
||||||
|
|
||||||
// the HMAC half of the derivation, split out so the LUD-05 test vector
|
// the HMAC half of the derivation, split out so the LUD-05 test vector
|
||||||
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
|
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
|
||||||
// directly - see keys.test.ts
|
// directly - see keys.test.ts
|
||||||
export const lud05PathSuffix = (
|
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
|
||||||
hashingKey: Uint8Array,
|
const material = hmac(sha256, hashingKey, utf8ToBytes(domain));
|
||||||
domain: string
|
return [0, 4, 8, 12].map((i) => readUint32BE(material, i));
|
||||||
): number[] => {
|
};
|
||||||
const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
|
|
||||||
return [0, 4, 8, 12].map(i => readUint32BE(material, i))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
|
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
|
||||||
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
|
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN);
|
||||||
|
|
||||||
|
export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => {
|
||||||
|
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase());
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)),
|
||||||
|
cashRootHex: cashNodeToHex(deriveCashRoot(seed)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
seed.fill(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array =>
|
||||||
|
hexToBytes(material.linkingKeyHex);
|
||||||
|
|
||||||
|
export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode =>
|
||||||
|
cashNodeFromHex(material.cashRootHex);
|
||||||
|
|
||||||
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
|
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
|
||||||
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true))
|
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true));
|
||||||
|
|
||||||
// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the
|
|
||||||
// stored value is either plaintext or, if the holder opted in with a
|
|
||||||
// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password -
|
|
||||||
// GCM's auth tag doubles as the "wrong password" check on decrypt.
|
|
||||||
const PBKDF2_ITERATIONS = 210_000
|
|
||||||
|
|
||||||
export type StoredSecret =
|
|
||||||
| {enc: false; value: string}
|
|
||||||
| {enc: true; salt: string; iv: string; ciphertext: string}
|
|
||||||
|
|
||||||
// strict shape check on a StoredSecret - a plaintext form must be exactly a
|
|
||||||
// 32-byte hex key, an encrypted form must carry hex salt/iv/ciphertext of
|
|
||||||
// the sizes encryptSecretParts produces. Guards the backup-restore path
|
|
||||||
// (storage.ts's applyBackup), where a crafted file would otherwise get an
|
|
||||||
// arbitrary "linking key" installed verbatim.
|
|
||||||
export const isValidStoredSecret = (
|
|
||||||
stored: unknown
|
|
||||||
): stored is StoredSecret => {
|
|
||||||
if (typeof stored !== 'object' || stored === null) return false
|
|
||||||
const s = stored as Record<string, unknown>
|
|
||||||
if (s.enc === false) {
|
|
||||||
return typeof s.value === 'string' && /^[0-9a-f]{64}$/i.test(s.value)
|
|
||||||
}
|
|
||||||
if (s.enc === true) {
|
|
||||||
return (
|
|
||||||
typeof s.salt === 'string' &&
|
|
||||||
/^[0-9a-f]{32}$/i.test(s.salt) &&
|
|
||||||
typeof s.iv === 'string' &&
|
|
||||||
/^[0-9a-f]{24}$/i.test(s.iv) &&
|
|
||||||
typeof s.ciphertext === 'string' &&
|
|
||||||
s.ciphertext.length > 0 &&
|
|
||||||
s.ciphertext.length % 2 === 0 &&
|
|
||||||
/^[0-9a-f]+$/i.test(s.ciphertext)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const readSecret = (storageKey: string): StoredSecret | null => {
|
const readSecret = (storageKey: string): StoredSecret | null => {
|
||||||
const raw = localStorage.getItem(storageKey)
|
const raw = localStorage.getItem(storageKey);
|
||||||
if (!raw) return null
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(raw)
|
const parsed: unknown = JSON.parse(raw);
|
||||||
return isValidStoredSecret(parsed) ? parsed : null
|
return parseStoredSecret(parsed)?.secret ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deriveAesKeyFromPassword = (
|
// The legacy alpha lifecycle below persists only the linking key under its old
|
||||||
password: string,
|
// namespace. Task 5 replaces that lifecycle with the v2 material repository;
|
||||||
salt: Uint8Array
|
// these APIs stay byte-compatible until the serialized reset can switch every
|
||||||
): Promise<CryptoKey> =>
|
// caller atomically rather than creating a split-key wallet.
|
||||||
crypto.subtle
|
//
|
||||||
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
|
// The record also carries an ownerId marker: the lowercase compressed
|
||||||
.then(baseKey =>
|
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
|
||||||
crypto.subtle.deriveKey(
|
// other wallet-owned record (passkeys, NWC, trusted mints) to this exact
|
||||||
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
|
// identity. Failure modes of the marker API:
|
||||||
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
|
// - new writes always carry the marker derived from the key being saved;
|
||||||
{
|
// - a record restored from a backup/relay is installed OWNERLESS - its
|
||||||
name: 'PBKDF2',
|
// file-carried marker is an unproven claim and is stripped on restore;
|
||||||
salt: new Uint8Array(salt),
|
// - an ownerless legacy record stays usable but cannot establish ownership;
|
||||||
iterations: PBKDF2_ITERATIONS,
|
// malformed or unsupported owner-bearing metadata rejects the whole record;
|
||||||
hash: 'SHA-256'
|
// - ensureSavedKeyOwner stamps the marker after the caller proved the key
|
||||||
},
|
// (successful password/plaintext unlock or matching biometric unwrap),
|
||||||
baseKey,
|
// preserving ciphertext byte-for-byte; it refuses to restamp a record
|
||||||
{name: 'AES-GCM', length: 256},
|
// already owned by a different valid owner, and refuses a key that
|
||||||
false,
|
// contradicts a plaintext record.
|
||||||
['encrypt', 'decrypt']
|
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null;
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export type EncryptedSecretParts = {
|
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true;
|
||||||
salt: string
|
|
||||||
iv: string
|
|
||||||
ciphertext: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const encryptSecretParts = async (
|
|
||||||
value: string,
|
|
||||||
password: string
|
|
||||||
): Promise<EncryptedSecretParts> => {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
|
||||||
const ciphertext = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt(
|
|
||||||
{name: 'AES-GCM', iv},
|
|
||||||
aesKey,
|
|
||||||
utf8ToBytes(value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
salt: bytesToHex(salt),
|
|
||||||
iv: bytesToHex(iv),
|
|
||||||
ciphertext: bytesToHex(ciphertext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejects (WebCrypto's own auth-tag check) if the password is wrong
|
|
||||||
export const decryptSecretParts = async (
|
|
||||||
parts: EncryptedSecretParts,
|
|
||||||
password: string
|
|
||||||
): Promise<string> => {
|
|
||||||
const salt = hexToBytes(parts.salt)
|
|
||||||
const iv = hexToBytes(parts.iv)
|
|
||||||
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
|
||||||
{name: 'AES-GCM', iv},
|
|
||||||
aesKey,
|
|
||||||
hexToBytes(parts.ciphertext)
|
|
||||||
)
|
|
||||||
return new TextDecoder().decode(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The linking key is the only secret this wallet persists - the seed phrase
|
|
||||||
// it was derived from is shown once at setup and never stored. Everything
|
|
||||||
// else at rest (the bearer tokens) is encrypted with a key derived from it,
|
|
||||||
// so protecting this one record with a password protects the whole wallet.
|
|
||||||
const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
|
|
||||||
|
|
||||||
export const savedKeyExists = (): boolean =>
|
|
||||||
readSecret(LINKING_KEY_STORAGE_KEY) !== null
|
|
||||||
|
|
||||||
export const savedKeyIsEncrypted = (): boolean =>
|
|
||||||
readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
|
|
||||||
|
|
||||||
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
|
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
|
||||||
readSecret(LINKING_KEY_STORAGE_KEY)
|
readSecret(LINKING_KEY_STORAGE_KEY);
|
||||||
|
|
||||||
|
// the proven owner of the saved key, or null when there is no record or it
|
||||||
|
// carries no current version-1 marker (ownerless or compatible unversioned)
|
||||||
|
export const savedKeyOwnerId = (): string | null => {
|
||||||
|
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
|
||||||
|
return stored === null ? null : storedSecretOwnerId(stored);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compares only against an owner freshly derived from a linking key. An
|
||||||
|
// ownerless or unversioned saved marker never matches.
|
||||||
|
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
|
||||||
|
savedKeyOwnerId() === linkingPubKeyHex(linkingKey);
|
||||||
|
|
||||||
|
// Stamps the owner marker onto the existing record. Call ONLY with the key
|
||||||
|
// just proven against this record (decryptSavedLinkingKey / a plaintext
|
||||||
|
// read / a biometric unwrap whose stored pubkey matched) - the marker is
|
||||||
|
// derived from that key, never from a stored claim. No saved record or an
|
||||||
|
// already-correct marker: no write. A DIFFERENT valid owner, or a key that
|
||||||
|
// contradicts a plaintext record, throws and leaves storage untouched.
|
||||||
|
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
|
||||||
|
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
|
||||||
|
if (stored === null) return;
|
||||||
|
const ownerId = linkingPubKeyHex(linkingKey);
|
||||||
|
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) {
|
||||||
|
throw new Error('Proven key does not match the saved wallet key.');
|
||||||
|
}
|
||||||
|
const claimedOwnerId = storedSecretClaimedOwnerId(stored);
|
||||||
|
if (storedSecretOwnerId(stored) === ownerId) return;
|
||||||
|
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
|
||||||
|
throw new Error('Saved wallet key is owned by a different wallet.');
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
LINKING_KEY_STORAGE_KEY,
|
||||||
|
JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getPlainLinkingKey = (): Uint8Array | null => {
|
export const getPlainLinkingKey = (): Uint8Array | null => {
|
||||||
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
|
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
|
||||||
if (stored === null || stored.enc === true) return null
|
if (stored === null || stored.enc === true) return null;
|
||||||
return hexToBytes(stored.value)
|
return hexToBytes(stored.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const saveLinkingKey = async (
|
export const saveLinkingKey = async (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
password?: string
|
password?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const hex = bytesToHex(linkingPrivKey)
|
const hex = bytesToHex(linkingPrivKey);
|
||||||
|
const ownerId = linkingPubKeyHex(linkingPrivKey);
|
||||||
if (!password) {
|
if (!password) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
LINKING_KEY_STORAGE_KEY,
|
LINKING_KEY_STORAGE_KEY,
|
||||||
JSON.stringify({enc: false, value: hex})
|
JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }),
|
||||||
)
|
);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const parts = await encryptSecretParts(hex, password)
|
const parts = await encryptSecretParts(hex, password);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
LINKING_KEY_STORAGE_KEY,
|
LINKING_KEY_STORAGE_KEY,
|
||||||
JSON.stringify({enc: true, ...parts})
|
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 => {
|
export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
|
||||||
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored))
|
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const decryptSavedLinkingKey = async (
|
export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
|
||||||
password: string
|
const stored = readSecret(LINKING_KEY_STORAGE_KEY);
|
||||||
): Promise<Uint8Array> => {
|
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.');
|
||||||
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
|
return hexToBytes(await decryptSecretParts(stored, password));
|
||||||
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
|
};
|
||||||
return hexToBytes(await decryptSecretParts(stored, password))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearSavedLinkingKey = (): void => {
|
export const clearSavedLinkingKey = (): void => {
|
||||||
localStorage.removeItem(LINKING_KEY_STORAGE_KEY)
|
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||||
}
|
};
|
||||||
|
|
||||||
// The bearer-encryption key is derived (not random): sha256 over the linking
|
// The bearer-encryption key is derived (not random): sha256 over the linking
|
||||||
// key plus a fixed context string. Deterministic derivation is what makes
|
// key plus a fixed context string. Deterministic derivation is what makes
|
||||||
// backup/restore work with nothing but the seed phrase - restore the seed on
|
// backup/restore work with nothing but the seed phrase - restore the seed on
|
||||||
// a fresh device and every previously exported ciphertext decrypts again.
|
// a fresh device and every previously exported ciphertext decrypts again.
|
||||||
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
|
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1';
|
||||||
|
|
||||||
export const deriveBearerAesKey = (
|
export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
|
||||||
linkingPrivKey: Uint8Array
|
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]));
|
||||||
): Promise<CryptoKey> => {
|
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 (
|
export const encryptRecord = async (
|
||||||
aesKey: CryptoKey,
|
aesKey: CryptoKey,
|
||||||
value: object
|
value: object,
|
||||||
): Promise<EncryptedRecordParts> => {
|
): Promise<EncryptedRecordParts> => {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ciphertext = new Uint8Array(
|
const ciphertext = new Uint8Array(
|
||||||
await crypto.subtle.encrypt(
|
await crypto.subtle.encrypt(
|
||||||
{name: 'AES-GCM', iv},
|
{ name: 'AES-GCM', iv },
|
||||||
aesKey,
|
aesKey,
|
||||||
utf8ToBytes(JSON.stringify(value))
|
utf8ToBytes(JSON.stringify(value)),
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
|
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
|
||||||
}
|
};
|
||||||
|
|
||||||
export const decryptRecord = async <T>(
|
export const decryptRecord = async (
|
||||||
aesKey: CryptoKey,
|
aesKey: CryptoKey,
|
||||||
parts: EncryptedRecordParts
|
parts: EncryptedRecordParts,
|
||||||
): Promise<T> => {
|
): Promise<unknown> => {
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
{name: 'AES-GCM', iv: hexToBytes(parts.iv)},
|
{ name: 'AES-GCM', iv: hexToBytes(parts.iv) },
|
||||||
aesKey,
|
aesKey,
|
||||||
hexToBytes(parts.ciphertext)
|
hexToBytes(parts.ciphertext),
|
||||||
)
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plaintext)) as T
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureSavedKeyOwner,
|
||||||
|
getPlainLinkingKey,
|
||||||
|
isValidStoredSecret,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
savedKeyExists,
|
||||||
|
savedKeyOwnerId,
|
||||||
|
saveLinkingKey,
|
||||||
|
} from './keys';
|
||||||
|
import { parseJsonObject, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import './keys.material.cases';
|
||||||
|
import './keys.material-commitment.cases';
|
||||||
|
import './keys.material-storage.cases';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const STORAGE_KEY = 'sattle_linking_key';
|
||||||
|
|
||||||
|
const readRawRecord = (): Record<string, unknown> => {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) throw new Error('expected a saved linking-key record');
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saved-key schema version', () => {
|
||||||
|
it('writes version 1 on current plaintext and encrypted records', async () => {
|
||||||
|
// Given a linking key saved through each current persistence path
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
const plaintext = readRawRecord();
|
||||||
|
await saveLinkingKey(LINKING_KEY, 'correct horse');
|
||||||
|
const encrypted = readRawRecord();
|
||||||
|
|
||||||
|
// When the persisted schema metadata is inspected
|
||||||
|
// Then both owner-bearing records carry the recognized discriminator
|
||||||
|
expect(plaintext.version).toBe(1);
|
||||||
|
expect(encrypted.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades an unversioned same-owner record only after key proof', () => {
|
||||||
|
// Given the valid owner-bearing shape written before schema versioning
|
||||||
|
const ownerId = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// When it is read before and then stamped after the plaintext key proves ownership
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
const provenKey = getPlainLinkingKey();
|
||||||
|
if (provenKey === null) throw new Error('expected the compatible plaintext key');
|
||||||
|
ensureSavedKeyOwner(provenKey);
|
||||||
|
|
||||||
|
// Then it becomes an explicitly versioned current record
|
||||||
|
expect(readRawRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(LINKING_KEY),
|
||||||
|
ownerId,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
|
||||||
|
// Given an otherwise valid owner-bearing record with unrecognized metadata
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(LINKING_KEY),
|
||||||
|
ownerId: linkingPubKeyHex(LINKING_KEY),
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
||||||
|
|
||||||
|
// When the saved-key boundary parses it
|
||||||
|
// Then the record is neither usable nor eligible for legacy adoption
|
||||||
|
expect(isValidStoredSecret(record)).toBe(false);
|
||||||
|
expect(savedKeyExists()).toBe(false);
|
||||||
|
expect(savedKeyOwnerId()).toBeNull();
|
||||||
|
expect(getPlainLinkingKey()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a foreign current owner at the proven-key stamping boundary', () => {
|
||||||
|
// Given a versioned record whose owner conflicts with its plaintext key
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(LINKING_KEY),
|
||||||
|
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
||||||
|
const before = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
// When the actual key is proven
|
||||||
|
const stamp = () => ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
|
||||||
|
// Then the foreign claim fails closed without rewriting storage
|
||||||
|
expect(stamp).toThrow('different wallet');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// Wire-contract coverage for the pinned lnurlcash-kit 0.8.0 /
|
||||||
|
// lnurlcash-conformance 0.7.0 artifacts. Contracts the app's mint flows
|
||||||
|
// rely on:
|
||||||
|
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
|
||||||
|
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
|
||||||
|
// the typed field read undefined forever); a payRequest withdraw link is
|
||||||
|
// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
|
||||||
|
// mock emits either, selected by its withdrawLinkForm option, so do NOT
|
||||||
|
// assume a default anywhere in the receive path); and the LUD-06 quote
|
||||||
|
// request must carry the output-naming hash (as BOTH `comment` and `h`)
|
||||||
|
// only when the payRequest advertises commentAllowed >= 64. The wallet's
|
||||||
|
// output secret must be durably staged before that quote request is sent;
|
||||||
|
// mintToHash alone is only an additive receipt extension, not permission
|
||||||
|
// to mint.
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
import { buildNoteUrl, fetchMintAddress, hashK1 } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { claimMintedNote, prepareMint } from './ops';
|
||||||
|
import { mintAddressCacheInfo } from './trustedMints';
|
||||||
|
import './mintContract.v08.cases';
|
||||||
|
|
||||||
|
type Mint = Awaited<ReturnType<typeof createMockMint>>;
|
||||||
|
|
||||||
|
const mints: Mint[] = [];
|
||||||
|
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
|
||||||
|
const m = await createMockMint(options);
|
||||||
|
mints.push(m);
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(mints.splice(0).map((m) => m.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// paying a mint invoice is what brings its note into existence - the mock
|
||||||
|
// exposes that through its test hook (settle + credit in one step)
|
||||||
|
const settleLastInvoice = async (m: Mint): Promise<string> => {
|
||||||
|
const paymentHash = [...m.state.invoices.keys()].at(-1);
|
||||||
|
if (!paymentHash) throw new Error('no invoice requested yet');
|
||||||
|
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`);
|
||||||
|
const invoice = m.state.invoices.get(paymentHash);
|
||||||
|
if (!invoice) throw new Error('settled invoice vanished from the mock');
|
||||||
|
return invoice.preimage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
|
||||||
|
const persistOutput = async (): Promise<void> => undefined;
|
||||||
|
|
||||||
|
// deterministic stand-in for the wallet's BIP-32 allocation path
|
||||||
|
let allocatedCounter = 0;
|
||||||
|
const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => {
|
||||||
|
const secrets = Array.from({ length: count }, () => {
|
||||||
|
allocatedCounter += 1;
|
||||||
|
return `03${allocatedCounter.toString(16).padStart(62, '0')}`;
|
||||||
|
});
|
||||||
|
return Promise.resolve(secrets);
|
||||||
|
};
|
||||||
|
|
||||||
|
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
|
||||||
|
// node stats under their WIRE names - `nodeCapacity` is msat like every
|
||||||
|
// other amount, named without the suffix on the wire
|
||||||
|
const mintAddressFixture = {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
minWithdrawable: 1_000,
|
||||||
|
maxWithdrawable: 100_000_000,
|
||||||
|
defaultDescription: 'fixture mint',
|
||||||
|
payLink: 'https://mint.example/.well-known/lnurlp/mint',
|
||||||
|
mintPubkey: MINT_PUBKEY,
|
||||||
|
nodeAlias: 'fixture-mint',
|
||||||
|
nodeCapacity: 500_000_000,
|
||||||
|
nodeNumChannels: 4,
|
||||||
|
nodeNumPeers: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// a fetch that serves fixture bodies by URL prefix and 404s everything else,
|
||||||
|
// so a test drives the real kit HTTP boundary without any network
|
||||||
|
const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => {
|
||||||
|
const impl: typeof fetch = (input, _init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
for (const [prefix, body] of routes) {
|
||||||
|
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response('not found', { status: 404 }));
|
||||||
|
};
|
||||||
|
return impl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// fixtureFetch plus a record of every URL it saw, for canaries about the
|
||||||
|
// exact query the kit puts on the wire
|
||||||
|
const capturingFetch = (
|
||||||
|
routes: ReadonlyArray<readonly [string, unknown]>,
|
||||||
|
seen: string[],
|
||||||
|
): typeof fetch => {
|
||||||
|
const impl: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
seen.push(url);
|
||||||
|
return fixtureFetch(routes)(input, init);
|
||||||
|
};
|
||||||
|
return impl;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mint-address wire contract', () => {
|
||||||
|
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
|
||||||
|
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
|
||||||
|
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
|
||||||
|
});
|
||||||
|
// renamed fields have to be mapped, not spread: the spread carries the
|
||||||
|
// wire name through and the typed one reads undefined forever
|
||||||
|
expect(info.nodePubkey).toBe(MINT_PUBKEY);
|
||||||
|
expect(info.nodeCapacityMsat).toBe(500_000_000);
|
||||||
|
expect(info.nodeNumChannels).toBe(4);
|
||||||
|
expect(info.nodeNumPeers).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries node stats into the cached trusted-mint display metadata', async () => {
|
||||||
|
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
|
||||||
|
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
|
||||||
|
});
|
||||||
|
const cached = mintAddressCacheInfo(info, 'mint');
|
||||||
|
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
|
||||||
|
expect(cached?.nodeNumChannels).toBe(4);
|
||||||
|
expect(cached?.nodeNumPeers).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
// the metadata is advertised at the mint-address endpoint itself -
|
||||||
|
// the payRequest never carried it
|
||||||
|
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey);
|
||||||
|
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000);
|
||||||
|
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4);
|
||||||
|
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6);
|
||||||
|
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username);
|
||||||
|
expect(cached?.nodeCapacityMsat).toBe(500_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withdraw-link forms', () => {
|
||||||
|
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
|
||||||
|
// conformance 0.7.0 defaults to plain https links; lnurlw:// is an
|
||||||
|
// explicit LUD-17 transport form
|
||||||
|
const m = await mint({ testHooks: true, withdrawLinkForm: 'lnurlw' });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//);
|
||||||
|
|
||||||
|
// and the link is fully usable: settle the invoice, claim the note
|
||||||
|
const preimage = await settleLastInvoice(m);
|
||||||
|
const claimed = await claimMintedNote(prepared, {
|
||||||
|
intervalMs: 10,
|
||||||
|
intervalCapMs: 50,
|
||||||
|
maxWaitMs: 5_000,
|
||||||
|
});
|
||||||
|
expect(claimed.note.amount).toBe(21_000);
|
||||||
|
expect(m.state.noteState(preimage)).toBeNull();
|
||||||
|
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an HTTPS withdraw link', async () => {
|
||||||
|
const fetch = fixtureFetch([
|
||||||
|
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
|
||||||
|
[
|
||||||
|
'https://mint.example/.well-known/lnurlp/mint',
|
||||||
|
{
|
||||||
|
tag: 'payRequest',
|
||||||
|
callback: 'https://mint.example/pay',
|
||||||
|
minSendable: 1_000,
|
||||||
|
maxSendable: 100_000_000_000,
|
||||||
|
withdrawLink: 'https://mint.example/note',
|
||||||
|
commentAllowed: 64,
|
||||||
|
metadata: '[]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// amount-less invoice: the kit skips its amount cross-check
|
||||||
|
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
|
||||||
|
]);
|
||||||
|
const prepared = await prepareMint('mint@mint.example', 21_000, {
|
||||||
|
fetch,
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(prepared.withdrawLink).toBe('https://mint.example/note');
|
||||||
|
// the mint-address payLink is authoritative - the payRequest came from it
|
||||||
|
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the same note URL from both withdraw-link forms', () => {
|
||||||
|
const k1 = 'ab'.repeat(32);
|
||||||
|
expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe(
|
||||||
|
buildNoteUrl('https://mint.example/note', k1, 21_000),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quote output-naming wire contract', () => {
|
||||||
|
const payRequestFixture = (extra: Record<string, unknown>) => ({
|
||||||
|
tag: 'payRequest',
|
||||||
|
callback: 'https://mint.example/pay',
|
||||||
|
minSendable: 1_000,
|
||||||
|
maxSendable: 100_000_000_000,
|
||||||
|
withdrawLink: 'https://mint.example/note',
|
||||||
|
metadata: '[]',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const routesFor = (payRequest: Record<string, unknown>) =>
|
||||||
|
[
|
||||||
|
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
|
||||||
|
['https://mint.example/.well-known/lnurlp/mint', payRequest],
|
||||||
|
// amount-less invoice: the kit skips its amount cross-check
|
||||||
|
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const callbackUrl = (seen: string[]): URL => {
|
||||||
|
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'));
|
||||||
|
if (!hit) throw new Error('the quote request never hit the wire');
|
||||||
|
return new URL(hit);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('rejects a mint that cannot carry the mandatory output commitment', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await expect(
|
||||||
|
prepareMint('mint@mint.example', 21_000, {
|
||||||
|
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/64-character output commitment/i);
|
||||||
|
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the wallet output before sending its hash as comment and h', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const events: string[] = [];
|
||||||
|
const prepared = await prepareMint('mint@mint.example', 21_000, {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
fetch: (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery');
|
||||||
|
return capturingFetch(
|
||||||
|
routesFor(payRequestFixture({ commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}` })),
|
||||||
|
seen,
|
||||||
|
)(input, init);
|
||||||
|
},
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
events.push('persist');
|
||||||
|
expect(note.verified).toBe(false);
|
||||||
|
expect(note.callback).toBe('');
|
||||||
|
expect(note.amount).toBe(0);
|
||||||
|
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const noteSecret = prepared.noteSecret;
|
||||||
|
const url = callbackUrl(seen);
|
||||||
|
// both spellings go out together: `comment` is the LUD-25 form
|
||||||
|
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
|
||||||
|
const comment = url.searchParams.get('comment');
|
||||||
|
const h = url.searchParams.get('h');
|
||||||
|
expect(comment).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(h).toBe(comment);
|
||||||
|
expect(comment).toBe(hashK1(noteSecret));
|
||||||
|
expect(prepared.mintPubkey).toBe(MINT_PUBKEY);
|
||||||
|
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not request an invoice when output persistence fails', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await expect(
|
||||||
|
prepareMint('mint@mint.example', 21_000, {
|
||||||
|
fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen),
|
||||||
|
persistOutput: () => Promise.reject(new Error('storage full')),
|
||||||
|
allocateOutputSecrets,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/storage full/);
|
||||||
|
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mintToHash without the mandatory comment capacity', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await expect(
|
||||||
|
prepareMint('mint@mint.example', 21_000, {
|
||||||
|
fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/64-character output commitment/i);
|
||||||
|
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// The wallet's deterministic-secret and signed-output work depends on this
|
||||||
|
// exact installed API surface, so package drift must fail before funds code runs.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
cashNodeFromHex,
|
||||||
|
cashNodeToHex,
|
||||||
|
cashSecretSource,
|
||||||
|
deriveCashRoot,
|
||||||
|
restoreFromSeed,
|
||||||
|
verifyNoteSignature,
|
||||||
|
verifyNoteSignatureAgainst,
|
||||||
|
verifyNoteSignatureHash,
|
||||||
|
verifyNoteSignatureHashAgainst,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
import type { LnurlcashOptions } from 'lnurlcash-kit';
|
||||||
|
import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json';
|
||||||
|
import kitPackage from '../../node_modules/lnurlcash-kit/package.json';
|
||||||
|
|
||||||
|
const retryOptions = { mutationRetries: 1 } satisfies LnurlcashOptions;
|
||||||
|
const requiredExports = [
|
||||||
|
deriveCashRoot,
|
||||||
|
cashNodeToHex,
|
||||||
|
cashNodeFromHex,
|
||||||
|
cashSecretSource,
|
||||||
|
restoreFromSeed,
|
||||||
|
verifyNoteSignature,
|
||||||
|
verifyNoteSignatureAgainst,
|
||||||
|
verifyNoteSignatureHash,
|
||||||
|
verifyNoteSignatureHashAgainst,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('lnurlcash 0.8 dependency contract', () => {
|
||||||
|
it('exposes the BIP-32, recovery, signature, and retry contracts', () => {
|
||||||
|
expect(requiredExports.every((value) => typeof value === 'function')).toBe(true);
|
||||||
|
expect(retryOptions.mutationRetries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs against the exact installed package versions', () => {
|
||||||
|
expect(kitPackage.version).toBe('0.8.0');
|
||||||
|
expect(conformancePackage.version).toBe('0.7.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
+117
-102
@@ -9,33 +9,36 @@
|
|||||||
//
|
//
|
||||||
// Three d-tags carry three payloads in separate replaceable slots, so a
|
// Three d-tags carry three payloads in separate replaceable slots, so a
|
||||||
// notes publish never clobbers settings:
|
// notes publish never clobbers settings:
|
||||||
// notes - the encrypted bearer records exactly as they sit in
|
// notes - the funds projection: encrypted bearer records exactly as
|
||||||
// localStorage (each note is already an AES-GCM ciphertext
|
// they sit in the funds document (each note is already an
|
||||||
// under the seed-derived bearer key, so the blob goes up
|
// AES-GCM ciphertext under the seed-derived bearer key, so the
|
||||||
// as-is; the linking key itself is NEVER part of a payload -
|
// blob goes up as-is; the linking key itself is NEVER part of a
|
||||||
// the seed phrase is its recovery path)
|
// payload - the seed phrase is its recovery path) plus the
|
||||||
|
// per-host BIP-32 next-unused counters, so a restored device
|
||||||
|
// never reissues a burned index. Pending journal state is
|
||||||
|
// device-local recovery data and is never published.
|
||||||
// mints - the trusted-mint registry
|
// mints - the trusted-mint registry
|
||||||
// settings - plaintext wallet settings
|
// settings - plaintext wallet settings
|
||||||
|
|
||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
import {utf8ToBytes} from '@noble/hashes/utils.js'
|
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
import type {Event as NostrEvent} from 'nostr-tools/core'
|
import type { Event as NostrEvent } from 'nostr-tools/core';
|
||||||
import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure'
|
import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
|
||||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
|
||||||
import type {EncryptedBearerRecord} from '../storage/bearers'
|
import type { EncryptedBearerRecord } from '../storage/bearers';
|
||||||
import type {TrustedMint} from '../trustedMints'
|
import type { TrustedMint } from '../trustedMints';
|
||||||
import type {WalletSettings} from '../storage/settings'
|
import type { WalletSettings } from '../storage/settings';
|
||||||
|
|
||||||
export type {NostrEvent}
|
export type { NostrEvent };
|
||||||
|
|
||||||
// addressable (parametrized-replaceable) app-data event - relays keep only
|
// addressable (parametrized-replaceable) app-data event - relays keep only
|
||||||
// the newest event per (pubkey, kind, d-tag)
|
// the newest event per (pubkey, kind, d-tag)
|
||||||
export const BACKUP_EVENT_KIND = 30078
|
export const BACKUP_EVENT_KIND = 30078;
|
||||||
|
|
||||||
export type BackupPart = 'notes' | 'mints' | 'settings'
|
export type BackupPart = 'notes' | 'mints' | 'settings';
|
||||||
|
|
||||||
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings']
|
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'];
|
||||||
|
|
||||||
// the backup key is dedicated to this wallet (derived with a
|
// the backup key is dedicated to this wallet (derived with a
|
||||||
// sattle-specific context, see below), so its pubkey is ours alone and the
|
// sattle-specific context, see below), so its pubkey is ours alone and the
|
||||||
@@ -43,153 +46,165 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'
|
|||||||
export const BACKUP_D_TAGS: Record<BackupPart, string> = {
|
export const BACKUP_D_TAGS: Record<BackupPart, string> = {
|
||||||
notes: 'notes',
|
notes: 'notes',
|
||||||
mints: 'mints',
|
mints: 'mints',
|
||||||
settings: 'settings'
|
settings: 'settings',
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// the notes payload: bearers plus the counter map, from one consistent
|
||||||
|
// funds-document read
|
||||||
|
export type NotesBackupPayload = {
|
||||||
|
bearers: EncryptedBearerRecord[];
|
||||||
|
nextByHost: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
// the decrypted payload of each part, without its envelope
|
// the decrypted payload of each part, without its envelope
|
||||||
export type BackupPartPayload = {
|
export type BackupPartPayload = {
|
||||||
notes: EncryptedBearerRecord[]
|
notes: NotesBackupPayload;
|
||||||
mints: TrustedMint[]
|
mints: TrustedMint[];
|
||||||
settings: WalletSettings
|
settings: WalletSettings;
|
||||||
}
|
};
|
||||||
|
|
||||||
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1'
|
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1';
|
||||||
|
|
||||||
// Deterministic: sha256(linking key || context), mirroring keys.ts's
|
// Deterministic: sha256(linking key || context), mirroring keys.ts's
|
||||||
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
|
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
|
||||||
// backup - it signs and decrypts backup events, nothing else.
|
// backup - it signs and decrypts backup events, nothing else.
|
||||||
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
|
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
|
||||||
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]))
|
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]));
|
||||||
|
|
||||||
// the x-only nostr pubkey identifying this wallet's backup events
|
// the x-only nostr pubkey identifying this wallet's backup events
|
||||||
export const backupPubkey = (secretKey: Uint8Array): string =>
|
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey);
|
||||||
getPublicKey(secretKey)
|
|
||||||
|
|
||||||
// NIP-44 "self-DM": the conversation key between the backup key and its own
|
// NIP-44 "self-DM": the conversation key between the backup key and its own
|
||||||
// pubkey - decryptable by the seed holder and nobody else
|
// pubkey - decryptable by the seed holder and nobody else
|
||||||
const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
|
const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
|
||||||
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
|
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
|
||||||
|
|
||||||
// a relay could serve a multi-megabyte content string; JSON.parse and
|
// a relay could serve a multi-megabyte content string; JSON.parse and
|
||||||
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of
|
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of
|
||||||
// kilobytes per part - this is far beyond generous (applyBackup's own
|
// kilobytes per part - this is far beyond generous (applyBackup's own
|
||||||
// per-record bounds still apply on top after decrypt)
|
// per-record bounds still apply on top after decrypt)
|
||||||
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
|
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024;
|
||||||
|
|
||||||
export const dTagOf = (event: NostrEvent): string =>
|
export const dTagOf = (event: NostrEvent): string =>
|
||||||
event.tags.find(t => t[0] === 'd')?.[1] ?? ''
|
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
|
||||||
|
|
||||||
const envelopeFor = (
|
// per-part envelope versions: the notes part went v2 with the funds
|
||||||
part: BackupPart,
|
// document (counters joined the payload - a breaking change); mints and
|
||||||
payload: BackupPartPayload[BackupPart]
|
// settings envelopes are unchanged
|
||||||
): string => {
|
const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
|
||||||
|
notes: 2,
|
||||||
|
mints: 1,
|
||||||
|
settings: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
|
||||||
switch (part) {
|
switch (part) {
|
||||||
case 'notes':
|
case 'notes': {
|
||||||
return JSON.stringify({version: 1, bearers: payload})
|
const notes = payload as NotesBackupPayload;
|
||||||
|
return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
|
||||||
|
}
|
||||||
case 'mints':
|
case 'mints':
|
||||||
return JSON.stringify({version: 1, trustedMints: payload})
|
return JSON.stringify({ version: 1, trustedMints: payload });
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return JSON.stringify({version: 1, settings: payload})
|
return JSON.stringify({ version: 1, settings: payload });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// signs one addressable backup event for a single part
|
// signs one addressable backup event for a single part
|
||||||
export const buildBackupEvent = <P extends BackupPart>(
|
export const buildBackupEvent = <P extends BackupPart>(
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
part: P,
|
part: P,
|
||||||
payload: BackupPartPayload[P],
|
payload: BackupPartPayload[P],
|
||||||
createdAt: number = Math.floor(Date.now() / 1000)
|
createdAt: number = Math.floor(Date.now() / 1000),
|
||||||
): NostrEvent =>
|
): NostrEvent =>
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
kind: BACKUP_EVENT_KIND,
|
kind: BACKUP_EVENT_KIND,
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
tags: [['d', BACKUP_D_TAGS[part]]],
|
tags: [['d', BACKUP_D_TAGS[part]]],
|
||||||
content: nip44v2.encrypt(
|
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
|
||||||
envelopeFor(part, payload),
|
|
||||||
selfConversationKey(secretKey)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
secretKey
|
secretKey,
|
||||||
)
|
);
|
||||||
|
|
||||||
// one event per part present in `parts`, all sharing one timestamp
|
// one event per part present in `parts`, all sharing one timestamp
|
||||||
export const buildBackupEvents = (
|
export const buildBackupEvents = (
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
parts: Partial<BackupPartPayload>,
|
parts: Partial<BackupPartPayload>,
|
||||||
createdAt?: number
|
createdAt?: number,
|
||||||
): NostrEvent[] => {
|
): NostrEvent[] => {
|
||||||
const at = createdAt ?? Math.floor(Date.now() / 1000)
|
const at = createdAt ?? Math.floor(Date.now() / 1000);
|
||||||
const events: NostrEvent[] = []
|
const events: NostrEvent[] = [];
|
||||||
for (const part of BACKUP_PARTS) {
|
for (const part of BACKUP_PARTS) {
|
||||||
const payload = parts[part]
|
const payload = parts[part];
|
||||||
if (payload === undefined) continue
|
if (payload === undefined) continue;
|
||||||
events.push(buildBackupEvent(secretKey, part, payload, at))
|
events.push(buildBackupEvent(secretKey, part, payload, at));
|
||||||
}
|
}
|
||||||
return events
|
return events;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ParsedBackupEvent =
|
export type ParsedBackupEvent =
|
||||||
| {part: 'notes'; bearers: EncryptedBearerRecord[]}
|
| { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
|
||||||
| {part: 'mints'; trustedMints: TrustedMint[]}
|
| { part: 'mints'; trustedMints: TrustedMint[] }
|
||||||
| {part: 'settings'; settings: WalletSettings}
|
| { part: 'settings'; settings: WalletSettings };
|
||||||
|
|
||||||
// Light shape checks so parse returns typed values; the strict bounds
|
// Light shape checks so parse returns typed values; the strict bounds
|
||||||
// (record counts, field lengths, pubkey patterns) are enforced by
|
// (record counts, field lengths, counter ranges and caps) are enforced by
|
||||||
// applyBackup / mergeTrustedMints on the restore path, same as file
|
// applyBackup / commitFundsRestore on the restore path, same as file
|
||||||
// backups.
|
// backups.
|
||||||
const parsePayload = (
|
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
|
||||||
dTag: BackupPart,
|
if (typeof data !== 'object' || data === null) return null;
|
||||||
data: unknown
|
const envelope = data as Record<string, unknown>;
|
||||||
): ParsedBackupEvent | null => {
|
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
|
||||||
if (typeof data !== 'object' || data === null) return null
|
|
||||||
const envelope = data as Record<string, unknown>
|
|
||||||
if (envelope.version !== 1) return null
|
|
||||||
switch (dTag) {
|
switch (dTag) {
|
||||||
case 'notes': {
|
case 'notes': {
|
||||||
if (!Array.isArray(envelope.bearers)) return null
|
if (!Array.isArray(envelope.bearers)) return null;
|
||||||
const bearers = envelope.bearers as unknown[]
|
const bearers = envelope.bearers as unknown[];
|
||||||
if (
|
if (
|
||||||
!bearers.every(
|
!bearers.every(
|
||||||
r =>
|
(r) =>
|
||||||
typeof (r as EncryptedBearerRecord)?.id === 'string' &&
|
typeof (r as EncryptedBearerRecord)?.id === 'string' &&
|
||||||
typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
|
typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
|
||||||
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string'
|
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]}
|
if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
|
||||||
|
const counters = envelope.nextByHost as Record<string, unknown>;
|
||||||
|
if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
|
||||||
|
return {
|
||||||
|
part: 'notes',
|
||||||
|
bearers: bearers as EncryptedBearerRecord[],
|
||||||
|
nextByHost: counters as Record<string, number>,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case 'mints': {
|
case 'mints': {
|
||||||
if (!Array.isArray(envelope.trustedMints)) return null
|
if (!Array.isArray(envelope.trustedMints)) return null;
|
||||||
const mints = envelope.trustedMints as unknown[]
|
const mints = envelope.trustedMints as unknown[];
|
||||||
if (
|
if (
|
||||||
!mints.every(
|
!mints.every(
|
||||||
m =>
|
(m) =>
|
||||||
typeof (m as TrustedMint)?.server === 'string' &&
|
typeof (m as TrustedMint)?.server === 'string' &&
|
||||||
typeof (m as TrustedMint)?.mintPubkey === 'string'
|
typeof (m as TrustedMint)?.mintPubkey === 'string',
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return {part: 'mints', trustedMints: mints as TrustedMint[]}
|
return { part: 'mints', trustedMints: mints as TrustedMint[] };
|
||||||
}
|
}
|
||||||
case 'settings': {
|
case 'settings': {
|
||||||
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
|
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
const settings = envelope.settings as Record<string, unknown>
|
const settings = envelope.settings as Record<string, unknown>;
|
||||||
if (
|
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
|
||||||
settings.defaultMint !== undefined &&
|
return null;
|
||||||
typeof settings.defaultMint !== 'string'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
return {part: 'settings', settings: settings as WalletSettings}
|
return { part: 'settings', settings: settings as WalletSettings };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Validates and decrypts one backup event. Returns null for anything that
|
// Validates and decrypts one backup event. Returns null for anything that
|
||||||
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
|
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
|
||||||
@@ -197,25 +212,25 @@ const parsePayload = (
|
|||||||
// skip nulls - one junk event must never sink a restore.
|
// skip nulls - one junk event must never sink a restore.
|
||||||
export const parseBackupEvent = (
|
export const parseBackupEvent = (
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
event: NostrEvent
|
event: NostrEvent,
|
||||||
): ParsedBackupEvent | null => {
|
): ParsedBackupEvent | null => {
|
||||||
if (event.kind !== BACKUP_EVENT_KIND) return null
|
if (event.kind !== BACKUP_EVENT_KIND) return null;
|
||||||
if (event.pubkey !== getPublicKey(secretKey)) return null
|
if (event.pubkey !== getPublicKey(secretKey)) return null;
|
||||||
const dTag = dTagOf(event)
|
const dTag = dTagOf(event);
|
||||||
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null
|
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
|
||||||
if (!verifyEvent(event)) return null
|
if (!verifyEvent(event)) return null;
|
||||||
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null
|
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
|
||||||
let plaintext: string
|
let plaintext: string;
|
||||||
try {
|
try {
|
||||||
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey))
|
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
let data: unknown
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(plaintext)
|
data = JSON.parse(plaintext);
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return parsePayload(dTag, data)
|
return parsePayload(dTag, data);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,74 +2,76 @@
|
|||||||
// edits (a mint plus a receive plus a reorder) collapse into one publish
|
// edits (a mint plus a receive plus a reorder) collapse into one publish
|
||||||
// of the final state. Trailing-edge debounce with in-flight coalescing.
|
// of the final state. Trailing-edge debounce with in-flight coalescing.
|
||||||
|
|
||||||
import type {BackupPartPayload} from './events'
|
import type { BackupPartPayload } from './events';
|
||||||
|
|
||||||
export type BackupPublisher = {
|
export type BackupPublisher = {
|
||||||
// record that local state changed - the latest snapshot wins, and only
|
// record that local state changed - the latest snapshot wins, and only
|
||||||
// one publish fires per quiet window no matter how many changes landed
|
// one publish fires per quiet window no matter how many changes landed
|
||||||
schedule: (parts: Partial<BackupPartPayload>) => void
|
schedule: (parts: Partial<BackupPartPayload>) => void;
|
||||||
// publish any pending snapshot immediately (app backgrounding, logout)
|
// publish any pending snapshot immediately (app backgrounding, logout)
|
||||||
flush: () => Promise<void>
|
flush: () => Promise<void>;
|
||||||
// drop any pending snapshot without publishing
|
// drop any pending snapshot without publishing
|
||||||
cancel: () => void
|
cancel: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type BackupPublisherOptions = {
|
export type BackupPublisherOptions = {
|
||||||
publish: (parts: Partial<BackupPartPayload>) => Promise<void>
|
publish: (parts: Partial<BackupPartPayload>) => Promise<void>;
|
||||||
delayMs: number
|
delayMs: number;
|
||||||
// a debounced publish has no caller to throw to - errors surface here;
|
// a debounced publish has no caller to throw to - errors surface here;
|
||||||
// the next scheduled change retries
|
// the next scheduled change retries
|
||||||
onError?: (error: unknown) => void
|
onError?: (error: unknown) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createBackupPublisher = (
|
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
|
||||||
options: BackupPublisherOptions
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
): BackupPublisher => {
|
let pending: Partial<BackupPartPayload> | null = null;
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let running: Promise<void> | null = null;
|
||||||
let pending: Partial<BackupPartPayload> | null = null
|
|
||||||
let running: Promise<void> | null = null
|
|
||||||
|
|
||||||
const clearTimer = (): void => {
|
const clearTimer = (): void => {
|
||||||
if (timer !== null) {
|
if (timer !== null) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
timer = null
|
timer = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const drain = async (): Promise<void> => {
|
const drain = async (): Promise<void> => {
|
||||||
// a change can land mid-publish - keep looping until the newest
|
// a change can land mid-publish - keep looping until the newest
|
||||||
// snapshot is the one that's out
|
// snapshot is the one that's out
|
||||||
while (pending !== null) {
|
while (pending !== null) {
|
||||||
const snapshot = pending
|
const snapshot = pending;
|
||||||
pending = null
|
pending = null;
|
||||||
try {
|
try {
|
||||||
await options.publish(snapshot)
|
await options.publish(snapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.onError?.(error)
|
options.onError?.(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('Backup publication failed.', { cause: error }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const fire = (): Promise<void> => {
|
const fire = (): Promise<void> => {
|
||||||
clearTimer()
|
clearTimer();
|
||||||
// an in-flight drain picks up anything pending itself - a second drain
|
// an in-flight drain picks up anything pending itself - a second drain
|
||||||
// would double-publish the same snapshot
|
// would double-publish the same snapshot
|
||||||
running ??= drain().finally(() => {
|
running ??= drain().finally(() => {
|
||||||
running = null
|
running = null;
|
||||||
})
|
});
|
||||||
return running
|
return running;
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schedule: parts => {
|
schedule: (parts) => {
|
||||||
pending = parts
|
pending = parts;
|
||||||
clearTimer()
|
clearTimer();
|
||||||
timer = setTimeout(() => void fire(), options.delayMs)
|
timer = setTimeout(() => void fire(), options.delayMs);
|
||||||
},
|
},
|
||||||
flush: fire,
|
flush: fire,
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
clearTimer()
|
clearTimer();
|
||||||
pending = null
|
pending = null;
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
+77
-79
@@ -2,20 +2,17 @@
|
|||||||
//
|
//
|
||||||
// v1 is single-device last-writer-wins at the relay (addressable events
|
// v1 is single-device last-writer-wins at the relay (addressable events
|
||||||
// replace). Restore merges locally through storage/backup.ts's applyBackup
|
// replace). Restore merges locally through storage/backup.ts's applyBackup
|
||||||
// - the SAME entry point as file restore (union by record id, mints merged
|
// - the SAME entry point as file restore (union by record id, counters
|
||||||
// unconfirmed, settings fill-only) - so the two restore paths can't drift
|
// merged upward-only, mints merged unconfirmed, settings fill-only) - so the
|
||||||
// apart. The note-level dedupe (same note under different record ids,
|
// two restore paths can't drift apart. The note-level dedupe (same note
|
||||||
// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly
|
// under different record ids, spent-wins) happens after decrypt in
|
||||||
// as with a file backup.
|
// bearers.ts's mergeBearers, exactly as with a file backup.
|
||||||
|
|
||||||
import type {RestoreResult} from '../storage/backup'
|
import type { RestoreResult } from '../storage/backup';
|
||||||
import {applyBackup} from '../storage/backup'
|
import { applyBackup } from '../storage/backup';
|
||||||
|
import { linkingPubKeyHex } from '../keys';
|
||||||
|
|
||||||
import type {
|
import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
|
||||||
BackupPart,
|
|
||||||
BackupPartPayload,
|
|
||||||
NostrEvent
|
|
||||||
} from './events'
|
|
||||||
import {
|
import {
|
||||||
BACKUP_EVENT_KIND,
|
BACKUP_EVENT_KIND,
|
||||||
BACKUP_PARTS,
|
BACKUP_PARTS,
|
||||||
@@ -23,50 +20,47 @@ import {
|
|||||||
buildBackupEvent,
|
buildBackupEvent,
|
||||||
deriveBackupKey,
|
deriveBackupKey,
|
||||||
dTagOf,
|
dTagOf,
|
||||||
parseBackupEvent
|
parseBackupEvent,
|
||||||
} from './events'
|
} from './events';
|
||||||
import type {BackupTransport} from './transport'
|
import type { BackupTransport } from './transport';
|
||||||
import {defaultTransport} from './transport'
|
import { defaultTransport } from './transport';
|
||||||
|
|
||||||
export type PublishBackupOptions = {
|
export type PublishBackupOptions = {
|
||||||
transport?: BackupTransport
|
transport?: BackupTransport;
|
||||||
createdAt?: number
|
createdAt?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type PublishBackupResult = {
|
export type PublishBackupResult = {
|
||||||
published: BackupPart[]
|
published: BackupPart[];
|
||||||
}
|
};
|
||||||
|
|
||||||
// builds and publishes one event per part present in `parts`
|
// builds and publishes one event per part present in `parts`
|
||||||
export const publishBackup = async (
|
export const publishBackup = async (
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
parts: Partial<BackupPartPayload>,
|
parts: Partial<BackupPartPayload>,
|
||||||
relays: string[],
|
relays: string[],
|
||||||
options: PublishBackupOptions = {}
|
options: PublishBackupOptions = {},
|
||||||
): Promise<PublishBackupResult> => {
|
): Promise<PublishBackupResult> => {
|
||||||
const at = options.createdAt ?? Math.floor(Date.now() / 1000)
|
const at = options.createdAt ?? Math.floor(Date.now() / 1000);
|
||||||
const present = BACKUP_PARTS.filter(part => parts[part] !== undefined)
|
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
|
||||||
if (present.length === 0) return {published: []}
|
if (present.length === 0) return { published: [] };
|
||||||
const transport = options.transport ?? (await defaultTransport())
|
const transport = options.transport ?? (await defaultTransport());
|
||||||
const published: BackupPart[] = []
|
const published: BackupPart[] = [];
|
||||||
for (const part of present) {
|
for (const part of present) {
|
||||||
const payload = parts[part]
|
const payload = parts[part];
|
||||||
if (payload === undefined) continue
|
if (payload === undefined) continue;
|
||||||
await transport.publish(
|
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
|
||||||
relays,
|
published.push(part);
|
||||||
buildBackupEvent(secretKey, part, payload, at)
|
|
||||||
)
|
|
||||||
published.push(part)
|
|
||||||
}
|
}
|
||||||
return {published}
|
return { published };
|
||||||
}
|
};
|
||||||
|
|
||||||
export type FetchBackupOptions = {
|
export type FetchBackupOptions = {
|
||||||
// the pubkey alone can fetch the ciphertext, but only the key holder
|
// the pubkey alone can fetch the ciphertext, but only the key holder
|
||||||
// reads it - decryption is part of fetching
|
// reads it - decryption is part of fetching
|
||||||
secretKey: Uint8Array
|
secretKey: Uint8Array;
|
||||||
transport?: BackupTransport
|
transport?: BackupTransport;
|
||||||
}
|
};
|
||||||
|
|
||||||
// fetches the newest valid event per d-tag and decrypts it. Honest relays
|
// fetches the newest valid event per d-tag and decrypts it. Honest relays
|
||||||
// already replace addressable events, but a stale or misbehaving relay may
|
// already replace addressable events, but a stale or misbehaving relay may
|
||||||
@@ -75,51 +69,51 @@ export type FetchBackupOptions = {
|
|||||||
export const fetchBackup = async (
|
export const fetchBackup = async (
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
relays: string[],
|
relays: string[],
|
||||||
options: FetchBackupOptions
|
options: FetchBackupOptions,
|
||||||
): Promise<Partial<BackupPartPayload>> => {
|
): Promise<Partial<BackupPartPayload>> => {
|
||||||
const transport = options.transport ?? (await defaultTransport())
|
const transport = options.transport ?? (await defaultTransport());
|
||||||
const events = await transport.fetch(relays, {
|
const events = await transport.fetch(relays, {
|
||||||
kinds: [BACKUP_EVENT_KIND],
|
kinds: [BACKUP_EVENT_KIND],
|
||||||
authors: [pubkey]
|
authors: [pubkey],
|
||||||
})
|
});
|
||||||
const byTag = new Map<string, NostrEvent[]>()
|
const byTag = new Map<string, NostrEvent[]>();
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const d = dTagOf(event)
|
const d = dTagOf(event);
|
||||||
if (!d) continue
|
if (!d) continue;
|
||||||
const candidates = byTag.get(d)
|
const candidates = byTag.get(d);
|
||||||
if (candidates) candidates.push(event)
|
if (candidates) candidates.push(event);
|
||||||
else byTag.set(d, [event])
|
else byTag.set(d, [event]);
|
||||||
}
|
}
|
||||||
const parts: Partial<BackupPartPayload> = {}
|
const parts: Partial<BackupPartPayload> = {};
|
||||||
for (const candidates of byTag.values()) {
|
for (const candidates of byTag.values()) {
|
||||||
// a hostile relay may serve a tampered "newest" copy - walk
|
// a hostile relay may serve a tampered "newest" copy - walk
|
||||||
// newest-first and take the first that validates and decrypts
|
// newest-first and take the first that validates and decrypts
|
||||||
candidates.sort((a, b) => b.created_at - a.created_at)
|
candidates.sort((a, b) => b.created_at - a.created_at);
|
||||||
for (const event of candidates) {
|
for (const event of candidates) {
|
||||||
const parsed = parseBackupEvent(options.secretKey, event)
|
const parsed = parseBackupEvent(options.secretKey, event);
|
||||||
if (!parsed) continue
|
if (!parsed) continue;
|
||||||
switch (parsed.part) {
|
switch (parsed.part) {
|
||||||
case 'notes':
|
case 'notes':
|
||||||
parts.notes = parsed.bearers
|
parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
|
||||||
break
|
break;
|
||||||
case 'mints':
|
case 'mints':
|
||||||
parts.mints = parsed.trustedMints
|
parts.mints = parsed.trustedMints;
|
||||||
break
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
parts.settings = parsed.settings
|
parts.settings = parsed.settings;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parts
|
return parts;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type NostrRestoreResult = RestoreResult & {
|
export type NostrRestoreResult = RestoreResult & {
|
||||||
// which d-tags carried a valid payload at all - lets the caller
|
// which d-tags carried a valid payload at all - lets the caller
|
||||||
// distinguish "nothing backed up yet" from "restored an empty wallet"
|
// distinguish "nothing backed up yet" from "restored an empty wallet"
|
||||||
found: BackupPart[]
|
found: BackupPart[];
|
||||||
}
|
};
|
||||||
|
|
||||||
// The restore path end to end: re-derive the backup key from the linking
|
// The restore path end to end: re-derive the backup key from the linking
|
||||||
// key (the holder already re-entered the seed phrase to get that far),
|
// key (the holder already re-entered the seed phrase to get that far),
|
||||||
@@ -130,23 +124,27 @@ export type NostrRestoreResult = RestoreResult & {
|
|||||||
export const restoreFromNostr = async (
|
export const restoreFromNostr = async (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
relays: string[],
|
relays: string[],
|
||||||
options: {transport?: BackupTransport} = {}
|
options: { transport?: BackupTransport } = {},
|
||||||
): Promise<NostrRestoreResult> => {
|
): Promise<NostrRestoreResult> => {
|
||||||
const secretKey = deriveBackupKey(linkingPrivKey)
|
const secretKey = deriveBackupKey(linkingPrivKey);
|
||||||
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
|
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
|
||||||
secretKey,
|
secretKey,
|
||||||
transport: options.transport
|
transport: options.transport,
|
||||||
})
|
});
|
||||||
const result = applyBackup({
|
const result = await applyBackup(
|
||||||
type: 'sattle-backup',
|
{
|
||||||
version: 1,
|
type: 'sattle-backup',
|
||||||
createdAt: Date.now(),
|
version: 2,
|
||||||
bearers: parts.notes ?? [],
|
createdAt: Date.now(),
|
||||||
trustedMints: parts.mints,
|
bearers: parts.notes?.bearers ?? [],
|
||||||
settings: parts.settings
|
nextByHost: parts.notes?.nextByHost ?? {},
|
||||||
})
|
trustedMints: parts.mints,
|
||||||
|
settings: parts.settings,
|
||||||
|
},
|
||||||
|
linkingPubKeyHex(linkingPrivKey),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
found: BACKUP_PARTS.filter(part => parts[part] !== undefined)
|
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -3,30 +3,30 @@
|
|||||||
// lazily: merely importing the backup module must never open (or even
|
// lazily: merely importing the backup module must never open (or even
|
||||||
// reference) a WebSocket.
|
// reference) a WebSocket.
|
||||||
|
|
||||||
import type {Filter as NostrFilter} from 'nostr-tools/filter'
|
import type { Filter as NostrFilter } from 'nostr-tools/filter';
|
||||||
|
|
||||||
import type {NostrEvent} from './events'
|
import type { NostrEvent } from './events';
|
||||||
|
|
||||||
export type {NostrFilter}
|
export type { NostrFilter };
|
||||||
|
|
||||||
// publish throws when the event was accepted NOWHERE
|
// publish throws when the event was accepted NOWHERE
|
||||||
export type BackupTransport = {
|
export type BackupTransport = {
|
||||||
publish: (relays: string[], event: NostrEvent) => Promise<void>
|
publish: (relays: string[], event: NostrEvent) => Promise<void>;
|
||||||
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>
|
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const defaultTransport = async (): Promise<BackupTransport> => {
|
export const defaultTransport = async (): Promise<BackupTransport> => {
|
||||||
const {SimplePool} = await import('nostr-tools/pool')
|
const { SimplePool } = await import('nostr-tools/pool');
|
||||||
const pool = new SimplePool()
|
const pool = new SimplePool();
|
||||||
return {
|
return {
|
||||||
publish: async (relays, event) => {
|
publish: async (relays, event) => {
|
||||||
const results = await Promise.allSettled(pool.publish(relays, event))
|
const results = await Promise.allSettled(pool.publish(relays, event));
|
||||||
// one honest relay keeping the event is enough - addressable events
|
// one honest relay keeping the event is enough - addressable events
|
||||||
// are re-publishable, and the next debounced publish retries anyway
|
// are re-publishable, and the next debounced publish retries anyway
|
||||||
if (!results.some(r => r.status === 'fulfilled')) {
|
if (!results.some((r) => r.status === 'fulfilled')) {
|
||||||
throw new Error('No relay accepted the backup event.')
|
throw new Error('No relay accepted the backup event.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetch: (relays, filter) => pool.querySync(relays, filter)
|
fetch: (relays, filter) => pool.querySync(relays, filter),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Nostr backup: key derivation stability, event build/parse round-trips,
|
||||||
|
// tamper rejection, publish/fetch and restore against an in-memory relay
|
||||||
|
// (the transport is injected - no network), and the debounced publisher.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import type { NostrEvent } from 'nostr-tools/core';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import {
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
backupPubkey,
|
||||||
|
buildBackupEvent,
|
||||||
|
buildBackupEvents,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
parseBackupEvent,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from './nostrBackup';
|
||||||
|
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
|
||||||
|
import {
|
||||||
|
loadBearers,
|
||||||
|
loadSettings,
|
||||||
|
mergeBearers,
|
||||||
|
persistBearer,
|
||||||
|
persistSettings,
|
||||||
|
readEncryptedBearers,
|
||||||
|
} from './storage';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
|
||||||
|
const K1_A = 'aa'.repeat(32);
|
||||||
|
const K1_B = 'bb'.repeat(32);
|
||||||
|
const MINT_PUBKEY = 'ab'.repeat(33);
|
||||||
|
|
||||||
|
// never connected - the recording transport below stands in for the relays
|
||||||
|
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
|
||||||
|
|
||||||
|
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||||
|
id: 'fixture',
|
||||||
|
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
||||||
|
// addressable copies included - like a relay that never replaces - which
|
||||||
|
// is exactly the case fetchBackup's client-side latest-pick exists for
|
||||||
|
const createRecordingTransport = (): {
|
||||||
|
transport: BackupTransport;
|
||||||
|
events: NostrEvent[];
|
||||||
|
} => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const transport: BackupTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
fetch: (_relays, filter) =>
|
||||||
|
Promise.resolve(
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
||||||
|
(!filter.authors || filter.authors.includes(e.pubkey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { transport, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
// flips the end of a base64 payload to different-but-valid characters
|
||||||
|
const tamperContent = (content: string): string =>
|
||||||
|
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildBackupEvent / parseBackupEvent', () => {
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
|
||||||
|
const records = [
|
||||||
|
// long unique sentinel id: a short id like 'r1' randomly appears in
|
||||||
|
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
|
||||||
|
// assertion below
|
||||||
|
{ id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
|
||||||
|
];
|
||||||
|
const notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
|
||||||
|
const mints = [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true }];
|
||||||
|
const settings = { defaultMint: 'mint.example' };
|
||||||
|
|
||||||
|
it('round-trips all three parts through build and parse', () => {
|
||||||
|
const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
expect(events.map((e) => e.kind)).toEqual([
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
]);
|
||||||
|
expect(events.map((e) => e.tags)).toEqual([
|
||||||
|
[['d', 'notes']],
|
||||||
|
[['d', 'mints']],
|
||||||
|
[['d', 'settings']],
|
||||||
|
]);
|
||||||
|
expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true);
|
||||||
|
|
||||||
|
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
|
||||||
|
part: 'notes',
|
||||||
|
bearers: records,
|
||||||
|
nextByHost: { 'mint.example': 3 },
|
||||||
|
});
|
||||||
|
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
|
||||||
|
part: 'mints',
|
||||||
|
trustedMints: mints,
|
||||||
|
});
|
||||||
|
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
|
||||||
|
part: 'settings',
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no plaintext in the payload', () => {
|
||||||
|
const event = buildBackupEvent(secretKey, 'notes', notes);
|
||||||
|
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
|
||||||
|
expect(event.content).not.toContain('ciphertext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds events only for the parts present', () => {
|
||||||
|
const events = buildBackupEvents(secretKey, { settings }, 1000);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a payload encrypted for a different key', () => {
|
||||||
|
const event = buildBackupEvent(secretKey, 'settings', settings);
|
||||||
|
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the wrong kind', () => {
|
||||||
|
const event = buildBackupEvent(secretKey, 'settings', settings);
|
||||||
|
expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown d-tag', () => {
|
||||||
|
const event = buildBackupEvent(secretKey, 'settings', settings);
|
||||||
|
expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a modified ciphertext - the signature no longer matches', () => {
|
||||||
|
const event = buildBackupEvent(secretKey, 'settings', settings);
|
||||||
|
const tampered = { ...event, content: tamperContent(event.content) };
|
||||||
|
expect(parseBackupEvent(secretKey, tampered)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event signed by a different key', () => {
|
||||||
|
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
|
||||||
|
expect(parseBackupEvent(secretKey, foreign)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a validly signed event whose payload is not a backup envelope', () => {
|
||||||
|
// a same-key event of the right kind and d-tag, but its decrypted
|
||||||
|
// content is not a version-1 envelope
|
||||||
|
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: BACKUP_EVENT_KIND,
|
||||||
|
created_at: 1000,
|
||||||
|
tags: [['d', 'settings']],
|
||||||
|
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
|
||||||
|
},
|
||||||
|
secretKey,
|
||||||
|
);
|
||||||
|
expect(parseBackupEvent(secretKey, event)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
|
||||||
|
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
|
||||||
|
const build = (payload: unknown): NostrEvent =>
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: BACKUP_EVENT_KIND,
|
||||||
|
created_at: 1000,
|
||||||
|
tags: [['d', 'notes']],
|
||||||
|
content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
|
||||||
|
},
|
||||||
|
secretKey,
|
||||||
|
);
|
||||||
|
// v1 notes carried bare bearer arrays and no counters - pre-funds-document
|
||||||
|
expect(parseBackupEvent(secretKey, build({ version: 1, bearers: records }))).toBeNull();
|
||||||
|
expect(parseBackupEvent(secretKey, build({ version: 2, bearers: records }))).toBeNull();
|
||||||
|
// a v2 envelope whose counters are malformed is rejected, not coerced
|
||||||
|
expect(
|
||||||
|
parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Nostr backup: key derivation stability, event build/parse round-trips,
|
||||||
|
// tamper rejection, publish/fetch and restore against an in-memory relay
|
||||||
|
// (the transport is injected - no network), and the debounced publisher.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import type { NostrEvent } from 'nostr-tools/core';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import {
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
backupPubkey,
|
||||||
|
buildBackupEvent,
|
||||||
|
buildBackupEvents,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
parseBackupEvent,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from './nostrBackup';
|
||||||
|
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
|
||||||
|
import {
|
||||||
|
loadBearers,
|
||||||
|
loadSettings,
|
||||||
|
mergeBearers,
|
||||||
|
persistBearer,
|
||||||
|
persistSettings,
|
||||||
|
readEncryptedBearers,
|
||||||
|
} from './storage';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
|
||||||
|
const K1_A = 'aa'.repeat(32);
|
||||||
|
const K1_B = 'bb'.repeat(32);
|
||||||
|
const MINT_PUBKEY = 'ab'.repeat(33);
|
||||||
|
|
||||||
|
// never connected - the recording transport below stands in for the relays
|
||||||
|
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
|
||||||
|
|
||||||
|
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||||
|
id: 'fixture',
|
||||||
|
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
||||||
|
// addressable copies included - like a relay that never replaces - which
|
||||||
|
// is exactly the case fetchBackup's client-side latest-pick exists for
|
||||||
|
const createRecordingTransport = (): {
|
||||||
|
transport: BackupTransport;
|
||||||
|
events: NostrEvent[];
|
||||||
|
} => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const transport: BackupTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
fetch: (_relays, filter) =>
|
||||||
|
Promise.resolve(
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
||||||
|
(!filter.authors || filter.authors.includes(e.pubkey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { transport, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
// flips the end of a base64 payload to different-but-valid characters
|
||||||
|
const tamperContent = (content: string): string =>
|
||||||
|
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveBackupKey', () => {
|
||||||
|
it('derives a stable key from the linking key', () => {
|
||||||
|
// pinned: changing the context string or the construction would
|
||||||
|
// silently orphan every backup ever published - the wallet would
|
||||||
|
// derive a different pubkey and find nothing to restore
|
||||||
|
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
|
||||||
|
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a different key from a different linking key', () => {
|
||||||
|
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
|
||||||
|
bytesToHex(deriveBackupKey(LINKING_KEY)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Nostr backup: key derivation stability, event build/parse round-trips,
|
||||||
|
// tamper rejection, publish/fetch and restore against an in-memory relay
|
||||||
|
// (the transport is injected - no network), and the debounced publisher.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import type { NostrEvent } from 'nostr-tools/core';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import {
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
backupPubkey,
|
||||||
|
buildBackupEvent,
|
||||||
|
buildBackupEvents,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
parseBackupEvent,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from './nostrBackup';
|
||||||
|
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
|
||||||
|
import {
|
||||||
|
loadBearers,
|
||||||
|
loadSettings,
|
||||||
|
mergeBearers,
|
||||||
|
persistBearer,
|
||||||
|
persistSettings,
|
||||||
|
readEncryptedBearers,
|
||||||
|
} from './storage';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
|
||||||
|
const K1_A = 'aa'.repeat(32);
|
||||||
|
const K1_B = 'bb'.repeat(32);
|
||||||
|
const MINT_PUBKEY = 'ab'.repeat(33);
|
||||||
|
|
||||||
|
// never connected - the recording transport below stands in for the relays
|
||||||
|
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
|
||||||
|
|
||||||
|
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||||
|
id: 'fixture',
|
||||||
|
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
||||||
|
// addressable copies included - like a relay that never replaces - which
|
||||||
|
// is exactly the case fetchBackup's client-side latest-pick exists for
|
||||||
|
const createRecordingTransport = (): {
|
||||||
|
transport: BackupTransport;
|
||||||
|
events: NostrEvent[];
|
||||||
|
} => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const transport: BackupTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
fetch: (_relays, filter) =>
|
||||||
|
Promise.resolve(
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
||||||
|
(!filter.authors || filter.authors.includes(e.pubkey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { transport, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
// flips the end of a base64 payload to different-but-valid characters
|
||||||
|
const tamperContent = (content: string): string =>
|
||||||
|
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBackupPublisher', () => {
|
||||||
|
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const published: Partial<BackupPartPayload>[] = [];
|
||||||
|
const publisher = createBackupPublisher({
|
||||||
|
publish: (p) => {
|
||||||
|
published.push(p);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
delayMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'a' } });
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'b' } });
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'c' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(999);
|
||||||
|
expect(published).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes again when a change lands after the quiet window', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const published: Partial<BackupPartPayload>[] = [];
|
||||||
|
const publisher = createBackupPublisher({
|
||||||
|
publish: (p) => {
|
||||||
|
published.push(p);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
delayMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'a' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'b' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(published).toEqual([
|
||||||
|
{ settings: { defaultMint: 'a' } },
|
||||||
|
{ settings: { defaultMint: 'b' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const published: Partial<BackupPartPayload>[] = [];
|
||||||
|
// the publish callback re-schedules on the publisher being created -
|
||||||
|
// a holder indirection keeps both const
|
||||||
|
const holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
|
||||||
|
const publisher = createBackupPublisher({
|
||||||
|
publish: (p) => {
|
||||||
|
published.push(p);
|
||||||
|
// a local change lands while the first publish is in flight
|
||||||
|
if (published.length === 1) {
|
||||||
|
holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
delayMs: 1000,
|
||||||
|
});
|
||||||
|
holder.publisher = publisher;
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'first' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(published).toEqual([
|
||||||
|
{ settings: { defaultMint: 'first' } },
|
||||||
|
{ settings: { defaultMint: 'mid-flight' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const published: Partial<BackupPartPayload>[] = [];
|
||||||
|
const publisher = createBackupPublisher({
|
||||||
|
publish: (p) => {
|
||||||
|
published.push(p);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
delayMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'a' } });
|
||||||
|
await publisher.flush();
|
||||||
|
expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'b' } });
|
||||||
|
publisher.cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(published).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a failed publish via onError and retries on the next change', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const published: Partial<BackupPartPayload>[] = [];
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
let failing = true;
|
||||||
|
const publisher = createBackupPublisher({
|
||||||
|
publish: (p) => {
|
||||||
|
if (failing) return Promise.reject(new Error('relay down'));
|
||||||
|
published.push(p);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
delayMs: 1000,
|
||||||
|
onError: (e) => {
|
||||||
|
errors.push(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'a' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(published).toEqual([]);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
|
||||||
|
failing = false;
|
||||||
|
publisher.schedule({ settings: { defaultMint: 'b' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// Nostr backup: key derivation stability, event build/parse round-trips,
|
||||||
|
// tamper rejection, publish/fetch and restore against an in-memory relay
|
||||||
|
// (the transport is injected - no network), and the debounced publisher.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import type { NostrEvent } from 'nostr-tools/core';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveBearerAesKey,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
saveWalletMaterial,
|
||||||
|
} from './keys';
|
||||||
|
import {
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
backupPubkey,
|
||||||
|
buildBackupEvent,
|
||||||
|
buildBackupEvents,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
parseBackupEvent,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from './nostrBackup';
|
||||||
|
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
|
||||||
|
import {
|
||||||
|
loadBearers,
|
||||||
|
loadSettings,
|
||||||
|
mergeBearers,
|
||||||
|
persistBearer,
|
||||||
|
persistSettings,
|
||||||
|
readEncryptedBearers,
|
||||||
|
readNextByHost,
|
||||||
|
} from './storage';
|
||||||
|
import { readFundsDocument, writeFundsDocument } from './storage/bearers';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
// full v2 material for the fixed harness linking key (real BIP-32 cash root)
|
||||||
|
const MATERIAL = {
|
||||||
|
...deriveWalletMaterial(
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||||
|
),
|
||||||
|
linkingKeyHex: bytesToHex(LINKING_KEY),
|
||||||
|
};
|
||||||
|
|
||||||
|
const K1_A = 'aa'.repeat(32);
|
||||||
|
const K1_B = 'bb'.repeat(32);
|
||||||
|
const MINT_PUBKEY = 'ab'.repeat(33);
|
||||||
|
|
||||||
|
// never connected - the recording transport below stands in for the relays
|
||||||
|
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
|
||||||
|
|
||||||
|
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||||
|
id: 'fixture',
|
||||||
|
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
||||||
|
// addressable copies included - like a relay that never replaces - which
|
||||||
|
// is exactly the case fetchBackup's client-side latest-pick exists for
|
||||||
|
const createRecordingTransport = (): {
|
||||||
|
transport: BackupTransport;
|
||||||
|
events: NostrEvent[];
|
||||||
|
} => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const transport: BackupTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
fetch: (_relays, filter) =>
|
||||||
|
Promise.resolve(
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
||||||
|
(!filter.authors || filter.authors.includes(e.pubkey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { transport, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
// flips the end of a base64 payload to different-but-valid characters
|
||||||
|
const tamperContent = (content: string): string =>
|
||||||
|
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreFromNostr', () => {
|
||||||
|
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
|
||||||
|
const aesKey = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
|
||||||
|
// device A: one note, one trusted mint, one setting - all published
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
|
||||||
|
await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
|
||||||
|
persistSettings({ defaultMint: 'mint.example' });
|
||||||
|
await publishBackup(
|
||||||
|
secretKey,
|
||||||
|
{
|
||||||
|
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
|
||||||
|
mints: readTrustedMints(OWNER_ID),
|
||||||
|
settings: loadSettings(),
|
||||||
|
},
|
||||||
|
RELAYS,
|
||||||
|
{ transport },
|
||||||
|
);
|
||||||
|
|
||||||
|
// device B: the same seed on empty storage
|
||||||
|
stubLocalStorage();
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
|
||||||
|
expect(result.found).toEqual(['notes', 'mints', 'settings']);
|
||||||
|
expect(result.added).toBe(1);
|
||||||
|
expect(result.trustedMintsAdded).toBe(1);
|
||||||
|
expect(result.settingsRestored).toBe(true);
|
||||||
|
// the linking key is never part of a nostr backup - the seed phrase
|
||||||
|
// the holder entered is its recovery path
|
||||||
|
expect(result.linkingKeyRestored).toBe(false);
|
||||||
|
|
||||||
|
// the note decrypts under this device's bearer key - same seed
|
||||||
|
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
|
||||||
|
expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
|
||||||
|
// a file/backup-sourced mint pin stays unconfirmed until a live
|
||||||
|
// response corroborates it - nostr restore inherits that rule from
|
||||||
|
// applyBackup unchanged
|
||||||
|
expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unions records by id and lets a spent copy win after decrypt', async () => {
|
||||||
|
const aesKey = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
|
||||||
|
// device A publishes its store holding the spendable note
|
||||||
|
await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
|
||||||
|
await publishBackup(
|
||||||
|
secretKey,
|
||||||
|
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
|
||||||
|
RELAYS,
|
||||||
|
{
|
||||||
|
transport,
|
||||||
|
createdAt: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// device B restores, then marks the same note spent under its OWN
|
||||||
|
// record id, and republishes its full store
|
||||||
|
stubLocalStorage();
|
||||||
|
await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
|
||||||
|
await publishBackup(
|
||||||
|
secretKey,
|
||||||
|
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
|
||||||
|
RELAYS,
|
||||||
|
{
|
||||||
|
transport,
|
||||||
|
createdAt: 2000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// device C restores from the final published state
|
||||||
|
stubLocalStorage();
|
||||||
|
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
|
||||||
|
// union by record id: both copies landed
|
||||||
|
expect(result.added).toBe(2);
|
||||||
|
expect(
|
||||||
|
readEncryptedBearers()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(['rec-a', 'rec-b']);
|
||||||
|
|
||||||
|
// after decrypt, the note-level merge (same server + k1) collapses
|
||||||
|
// them, and the spent copy wins even though its record is the newer
|
||||||
|
// arrival - a restored backup must never resurrect spendable money
|
||||||
|
const merged = mergeBearers([], await loadBearers(aesKey));
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(requiredValue(merged[0]).id).toBe('rec-b');
|
||||||
|
expect(requiredValue(merged[0]).spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never overwrites local state: records union, settings keep local values', async () => {
|
||||||
|
const aesKey = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
|
||||||
|
await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
|
||||||
|
persistSettings({ defaultMint: 'remote.example' });
|
||||||
|
await publishBackup(
|
||||||
|
secretKey,
|
||||||
|
{
|
||||||
|
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
|
||||||
|
settings: loadSettings(),
|
||||||
|
},
|
||||||
|
RELAYS,
|
||||||
|
{ transport, createdAt: 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// this device already has its own wallet state
|
||||||
|
stubLocalStorage();
|
||||||
|
await persistBearer(
|
||||||
|
aesKey,
|
||||||
|
bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
|
||||||
|
);
|
||||||
|
persistSettings({ defaultMint: 'local.example' });
|
||||||
|
|
||||||
|
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
expect(result.added).toBe(1);
|
||||||
|
expect(
|
||||||
|
readEncryptedBearers()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(['local', 'remote']);
|
||||||
|
expect(result.settingsRestored).toBe(false);
|
||||||
|
expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing found when the relays hold no backup', async () => {
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
expect(result.found).toEqual([]);
|
||||||
|
expect(result.added).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges relayed counters upward-only and never carries pending state', async () => {
|
||||||
|
const aesKey = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
|
||||||
|
// device A: counter at 5 for mint.example, one note, all published
|
||||||
|
await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
|
||||||
|
writeFundsDocument({
|
||||||
|
version: 2,
|
||||||
|
bearers: readEncryptedBearers(),
|
||||||
|
pending: [
|
||||||
|
{
|
||||||
|
id: 'device-local-pending',
|
||||||
|
kind: 'cash-allocation',
|
||||||
|
phase: 'reserved',
|
||||||
|
iv: '00',
|
||||||
|
ciphertext: '00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextByHost: { 'mint.example': 5 },
|
||||||
|
revision: 3,
|
||||||
|
});
|
||||||
|
await publishBackup(
|
||||||
|
secretKey,
|
||||||
|
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
|
||||||
|
RELAYS,
|
||||||
|
{ transport, createdAt: 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// device B: same seed, a HIGHER local counter for one host and a lower
|
||||||
|
// incoming one for the other; the pending record must not cross over
|
||||||
|
stubLocalStorage();
|
||||||
|
writeFundsDocument({
|
||||||
|
version: 2,
|
||||||
|
bearers: [],
|
||||||
|
pending: [],
|
||||||
|
nextByHost: { 'mint.example': 2, 'local-only.example': 7 },
|
||||||
|
revision: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
|
||||||
|
|
||||||
|
expect(result.found).toEqual(['notes']);
|
||||||
|
expect(result.added).toBe(1);
|
||||||
|
expect(readFundsDocument().nextByHost).toEqual({
|
||||||
|
'mint.example': 5,
|
||||||
|
'local-only.example': 7,
|
||||||
|
});
|
||||||
|
expect(readFundsDocument().pending).toEqual([]);
|
||||||
|
expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,530 +1,5 @@
|
|||||||
// Nostr backup: key derivation stability, event build/parse round-trips,
|
import './nostrBackup.keys.cases';
|
||||||
// tamper rejection, publish/fetch and restore against an in-memory relay
|
import './nostrBackup.codec.cases';
|
||||||
// (the transport is injected - no network), and the debounced publisher.
|
import './nostrBackup.transport.cases';
|
||||||
|
import './nostrBackup.restore.cases';
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
import './nostrBackup.publisher.cases';
|
||||||
import {bytesToHex} from '@noble/hashes/utils.js'
|
|
||||||
import type {NostrEvent} from 'nostr-tools/core'
|
|
||||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
|
||||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
|
||||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
|
||||||
|
|
||||||
import {deriveBearerAesKey} from './keys'
|
|
||||||
import {
|
|
||||||
BACKUP_EVENT_KIND,
|
|
||||||
backupPubkey,
|
|
||||||
buildBackupEvent,
|
|
||||||
buildBackupEvents,
|
|
||||||
createBackupPublisher,
|
|
||||||
deriveBackupKey,
|
|
||||||
fetchBackup,
|
|
||||||
parseBackupEvent,
|
|
||||||
publishBackup,
|
|
||||||
restoreFromNostr
|
|
||||||
} from './nostrBackup'
|
|
||||||
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
|
|
||||||
import {
|
|
||||||
loadBearers,
|
|
||||||
loadSettings,
|
|
||||||
mergeBearers,
|
|
||||||
persistBearer,
|
|
||||||
persistSettings,
|
|
||||||
readEncryptedBearers
|
|
||||||
} from './storage'
|
|
||||||
import type {Bearer} from './types'
|
|
||||||
import {
|
|
||||||
addTrustedMint,
|
|
||||||
clearTrustedMints,
|
|
||||||
isMintUnconfirmed,
|
|
||||||
readTrustedMints
|
|
||||||
} from './trustedMints'
|
|
||||||
import {stubLocalStorage} from './test-utils'
|
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
|
||||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
|
||||||
|
|
||||||
const K1_A = 'aa'.repeat(32)
|
|
||||||
const K1_B = 'bb'.repeat(32)
|
|
||||||
const MINT_PUBKEY = 'ab'.repeat(33)
|
|
||||||
|
|
||||||
// never connected - the recording transport below stands in for the relays
|
|
||||||
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
|
|
||||||
|
|
||||||
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
|
||||||
id: 'fixture',
|
|
||||||
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
|
||||||
callback: 'https://mint.example/w/cb',
|
|
||||||
amount: 21_000,
|
|
||||||
verified: true,
|
|
||||||
createdAt: 1000,
|
|
||||||
updatedAt: 1000,
|
|
||||||
...overrides
|
|
||||||
})
|
|
||||||
|
|
||||||
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
|
||||||
// addressable copies included - like a relay that never replaces - which
|
|
||||||
// is exactly the case fetchBackup's client-side latest-pick exists for
|
|
||||||
const createRecordingTransport = (): {
|
|
||||||
transport: BackupTransport
|
|
||||||
events: NostrEvent[]
|
|
||||||
} => {
|
|
||||||
const events: NostrEvent[] = []
|
|
||||||
const transport: BackupTransport = {
|
|
||||||
publish: (_relays, event) => {
|
|
||||||
events.push(event)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
fetch: (_relays, filter) =>
|
|
||||||
Promise.resolve(
|
|
||||||
events.filter(
|
|
||||||
e =>
|
|
||||||
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
|
||||||
(!filter.authors || filter.authors.includes(e.pubkey))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return {transport, events}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flips the end of a base64 payload to different-but-valid characters
|
|
||||||
const tamperContent = (content: string): string =>
|
|
||||||
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
stubLocalStorage()
|
|
||||||
// the trusted-mint registry caches module-level - reset it alongside
|
|
||||||
// the storage stub
|
|
||||||
clearTrustedMints()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('deriveBackupKey', () => {
|
|
||||||
it('derives a stable key from the linking key', () => {
|
|
||||||
// pinned: changing the context string or the construction would
|
|
||||||
// silently orphan every backup ever published - the wallet would
|
|
||||||
// derive a different pubkey and find nothing to restore
|
|
||||||
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
|
|
||||||
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives a different key from a different linking key', () => {
|
|
||||||
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
|
|
||||||
bytesToHex(deriveBackupKey(LINKING_KEY))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('buildBackupEvent / parseBackupEvent', () => {
|
|
||||||
const secretKey = deriveBackupKey(LINKING_KEY)
|
|
||||||
|
|
||||||
const records = [
|
|
||||||
// long unique sentinel id: a short id like 'r1' randomly appears in
|
|
||||||
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
|
|
||||||
// assertion below
|
|
||||||
{id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}
|
|
||||||
]
|
|
||||||
const mints = [
|
|
||||||
{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}
|
|
||||||
]
|
|
||||||
const settings = {defaultMint: 'mint.example'}
|
|
||||||
|
|
||||||
it('round-trips all three parts through build and parse', () => {
|
|
||||||
const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000)
|
|
||||||
expect(events).toHaveLength(3)
|
|
||||||
expect(events.map(e => e.kind)).toEqual([
|
|
||||||
BACKUP_EVENT_KIND,
|
|
||||||
BACKUP_EVENT_KIND,
|
|
||||||
BACKUP_EVENT_KIND
|
|
||||||
])
|
|
||||||
expect(events.map(e => e.tags)).toEqual([
|
|
||||||
[['d', 'notes']],
|
|
||||||
[['d', 'mints']],
|
|
||||||
[['d', 'settings']]
|
|
||||||
])
|
|
||||||
expect(events.every(e => e.pubkey === backupPubkey(secretKey))).toBe(true)
|
|
||||||
|
|
||||||
expect(parseBackupEvent(secretKey, events[0]!)).toEqual({
|
|
||||||
part: 'notes',
|
|
||||||
bearers: records
|
|
||||||
})
|
|
||||||
expect(parseBackupEvent(secretKey, events[1]!)).toEqual({
|
|
||||||
part: 'mints',
|
|
||||||
trustedMints: mints
|
|
||||||
})
|
|
||||||
expect(parseBackupEvent(secretKey, events[2]!)).toEqual({
|
|
||||||
part: 'settings',
|
|
||||||
settings
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves no plaintext in the payload', () => {
|
|
||||||
const event = buildBackupEvent(secretKey, 'notes', records)
|
|
||||||
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
|
|
||||||
expect(event.content).not.toContain('ciphertext')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('builds events only for the parts present', () => {
|
|
||||||
const events = buildBackupEvents(secretKey, {settings}, 1000)
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
expect(events[0]!.tags).toEqual([['d', 'settings']])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a payload encrypted for a different key', () => {
|
|
||||||
const event = buildBackupEvent(secretKey, 'settings', settings)
|
|
||||||
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects the wrong kind', () => {
|
|
||||||
const event = buildBackupEvent(secretKey, 'settings', settings)
|
|
||||||
expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an unknown d-tag', () => {
|
|
||||||
const event = buildBackupEvent(secretKey, 'settings', settings)
|
|
||||||
expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a modified ciphertext - the signature no longer matches', () => {
|
|
||||||
const event = buildBackupEvent(secretKey, 'settings', settings)
|
|
||||||
const tampered = {...event, content: tamperContent(event.content)}
|
|
||||||
expect(parseBackupEvent(secretKey, tampered)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an event signed by a different key', () => {
|
|
||||||
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings)
|
|
||||||
expect(parseBackupEvent(secretKey, foreign)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a validly signed event whose payload is not a backup envelope', () => {
|
|
||||||
// a same-key event of the right kind and d-tag, but its decrypted
|
|
||||||
// content is not a version-1 envelope
|
|
||||||
const conversationKey = nip44v2.utils.getConversationKey(
|
|
||||||
secretKey,
|
|
||||||
getPublicKey(secretKey)
|
|
||||||
)
|
|
||||||
const event = finalizeEvent(
|
|
||||||
{
|
|
||||||
kind: BACKUP_EVENT_KIND,
|
|
||||||
created_at: 1000,
|
|
||||||
tags: [['d', 'settings']],
|
|
||||||
content: nip44v2.encrypt(
|
|
||||||
JSON.stringify({version: 2, settings: {}}),
|
|
||||||
conversationKey
|
|
||||||
)
|
|
||||||
},
|
|
||||||
secretKey
|
|
||||||
)
|
|
||||||
expect(parseBackupEvent(secretKey, event)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('publishBackup / fetchBackup', () => {
|
|
||||||
const secretKey = deriveBackupKey(LINKING_KEY)
|
|
||||||
|
|
||||||
it('publishes every part and fetches them back decrypted', async () => {
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
const parts = {
|
|
||||||
notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}],
|
|
||||||
mints: [
|
|
||||||
{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}
|
|
||||||
],
|
|
||||||
settings: {defaultMint: 'mint.example'}
|
|
||||||
}
|
|
||||||
const published = await publishBackup(secretKey, parts, RELAYS, {transport})
|
|
||||||
expect(published.published).toEqual(['notes', 'mints', 'settings'])
|
|
||||||
|
|
||||||
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
|
||||||
secretKey,
|
|
||||||
transport
|
|
||||||
})
|
|
||||||
expect(fetched).toEqual(parts)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('publishes nothing when no parts are given', async () => {
|
|
||||||
const {transport, events} = createRecordingTransport()
|
|
||||||
const result = await publishBackup(secretKey, {}, RELAYS, {transport})
|
|
||||||
expect(result.published).toEqual([])
|
|
||||||
expect(events).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, {
|
|
||||||
transport,
|
|
||||||
createdAt: 1000
|
|
||||||
})
|
|
||||||
await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, {
|
|
||||||
transport,
|
|
||||||
createdAt: 2000
|
|
||||||
})
|
|
||||||
// the recording transport serves BOTH - the newer must win
|
|
||||||
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
|
||||||
secretKey,
|
|
||||||
transport
|
|
||||||
})
|
|
||||||
expect(parts.settings).toEqual({defaultMint: 'new.example'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to an older valid copy when the newest event is tampered', async () => {
|
|
||||||
const {transport, events} = createRecordingTransport()
|
|
||||||
await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, {
|
|
||||||
transport,
|
|
||||||
createdAt: 1000
|
|
||||||
})
|
|
||||||
events.push({
|
|
||||||
...events[0]!,
|
|
||||||
content: tamperContent(events[0]!.content),
|
|
||||||
created_at: 3000
|
|
||||||
})
|
|
||||||
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
|
||||||
secretKey,
|
|
||||||
transport
|
|
||||||
})
|
|
||||||
expect(parts.settings).toEqual({defaultMint: 'mint.example'})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('restoreFromNostr', () => {
|
|
||||||
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
|
|
||||||
const aesKey = await deriveBearerAesKey(LINKING_KEY)
|
|
||||||
const secretKey = deriveBackupKey(LINKING_KEY)
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
|
|
||||||
// device A: one note, one trusted mint, one setting - all published
|
|
||||||
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
|
|
||||||
addTrustedMint('mint.example', MINT_PUBKEY)
|
|
||||||
persistSettings({defaultMint: 'mint.example'})
|
|
||||||
await publishBackup(
|
|
||||||
secretKey,
|
|
||||||
{
|
|
||||||
notes: readEncryptedBearers(),
|
|
||||||
mints: readTrustedMints(),
|
|
||||||
settings: loadSettings()
|
|
||||||
},
|
|
||||||
RELAYS,
|
|
||||||
{transport}
|
|
||||||
)
|
|
||||||
|
|
||||||
// device B: the same seed on empty storage
|
|
||||||
stubLocalStorage()
|
|
||||||
clearTrustedMints()
|
|
||||||
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
|
||||||
|
|
||||||
expect(result.found).toEqual(['notes', 'mints', 'settings'])
|
|
||||||
expect(result.added).toBe(1)
|
|
||||||
expect(result.trustedMintsAdded).toBe(1)
|
|
||||||
expect(result.settingsRestored).toBe(true)
|
|
||||||
// the linking key is never part of a nostr backup - the seed phrase
|
|
||||||
// the holder entered is its recovery path
|
|
||||||
expect(result.linkingKeyRestored).toBe(false)
|
|
||||||
|
|
||||||
// the note decrypts under this device's bearer key - same seed
|
|
||||||
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})])
|
|
||||||
expect(loadSettings()).toEqual({defaultMint: 'mint.example'})
|
|
||||||
// a file/backup-sourced mint pin stays unconfirmed until a live
|
|
||||||
// response corroborates it - nostr restore inherits that rule from
|
|
||||||
// applyBackup unchanged
|
|
||||||
expect(isMintUnconfirmed('mint.example')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('unions records by id and lets a spent copy win after decrypt', async () => {
|
|
||||||
const aesKey = await deriveBearerAesKey(LINKING_KEY)
|
|
||||||
const secretKey = deriveBackupKey(LINKING_KEY)
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
|
|
||||||
// device A publishes its store holding the spendable note
|
|
||||||
await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
|
|
||||||
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
|
|
||||||
transport,
|
|
||||||
createdAt: 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
// device B restores, then marks the same note spent under its OWN
|
|
||||||
// record id, and republishes its full store
|
|
||||||
stubLocalStorage()
|
|
||||||
clearTrustedMints()
|
|
||||||
await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
|
||||||
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000}))
|
|
||||||
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
|
|
||||||
transport,
|
|
||||||
createdAt: 2000
|
|
||||||
})
|
|
||||||
|
|
||||||
// device C restores from the final published state
|
|
||||||
stubLocalStorage()
|
|
||||||
clearTrustedMints()
|
|
||||||
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
|
||||||
|
|
||||||
// union by record id: both copies landed
|
|
||||||
expect(result.added).toBe(2)
|
|
||||||
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['rec-a', 'rec-b'])
|
|
||||||
|
|
||||||
// after decrypt, the note-level merge (same server + k1) collapses
|
|
||||||
// them, and the spent copy wins even though its record is the newer
|
|
||||||
// arrival - a restored backup must never resurrect spendable money
|
|
||||||
const merged = mergeBearers([], await loadBearers(aesKey))
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.id).toBe('rec-b')
|
|
||||||
expect(merged[0]!.spent).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('never overwrites local state: records union, settings keep local values', async () => {
|
|
||||||
const aesKey = await deriveBearerAesKey(LINKING_KEY)
|
|
||||||
const secretKey = deriveBackupKey(LINKING_KEY)
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
|
|
||||||
await persistBearer(aesKey, bearerFixture({id: 'remote'}))
|
|
||||||
persistSettings({defaultMint: 'remote.example'})
|
|
||||||
await publishBackup(
|
|
||||||
secretKey,
|
|
||||||
{notes: readEncryptedBearers(), settings: loadSettings()},
|
|
||||||
RELAYS,
|
|
||||||
{transport, createdAt: 1000}
|
|
||||||
)
|
|
||||||
|
|
||||||
// this device already has its own wallet state
|
|
||||||
stubLocalStorage()
|
|
||||||
clearTrustedMints()
|
|
||||||
await persistBearer(
|
|
||||||
aesKey,
|
|
||||||
bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
|
||||||
)
|
|
||||||
persistSettings({defaultMint: 'local.example'})
|
|
||||||
|
|
||||||
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
|
||||||
expect(result.added).toBe(1)
|
|
||||||
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['local', 'remote'])
|
|
||||||
expect(result.settingsRestored).toBe(false)
|
|
||||||
expect(loadSettings()).toEqual({defaultMint: 'local.example'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports nothing found when the relays hold no backup', async () => {
|
|
||||||
const {transport} = createRecordingTransport()
|
|
||||||
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
|
||||||
expect(result.found).toEqual([])
|
|
||||||
expect(result.added).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createBackupPublisher', () => {
|
|
||||||
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const published: Partial<BackupPartPayload>[] = []
|
|
||||||
const publisher = createBackupPublisher({
|
|
||||||
publish: p => {
|
|
||||||
published.push(p)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delayMs: 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'a'}})
|
|
||||||
publisher.schedule({settings: {defaultMint: 'b'}})
|
|
||||||
publisher.schedule({settings: {defaultMint: 'c'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(999)
|
|
||||||
expect(published).toEqual([])
|
|
||||||
await vi.advanceTimersByTimeAsync(1)
|
|
||||||
expect(published).toEqual([{settings: {defaultMint: 'c'}}])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('publishes again when a change lands after the quiet window', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const published: Partial<BackupPartPayload>[] = []
|
|
||||||
const publisher = createBackupPublisher({
|
|
||||||
publish: p => {
|
|
||||||
published.push(p)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delayMs: 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'a'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
|
||||||
publisher.schedule({settings: {defaultMint: 'b'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
|
||||||
expect(published).toEqual([
|
|
||||||
{settings: {defaultMint: 'a'}},
|
|
||||||
{settings: {defaultMint: 'b'}}
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const published: Partial<BackupPartPayload>[] = []
|
|
||||||
// the publish callback re-schedules on the publisher being created -
|
|
||||||
// a holder indirection keeps both const
|
|
||||||
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {}
|
|
||||||
const publisher = createBackupPublisher({
|
|
||||||
publish: p => {
|
|
||||||
published.push(p)
|
|
||||||
// a local change lands while the first publish is in flight
|
|
||||||
if (published.length === 1) {
|
|
||||||
holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}})
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delayMs: 1000
|
|
||||||
})
|
|
||||||
holder.publisher = publisher
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'first'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
|
||||||
expect(published).toEqual([
|
|
||||||
{settings: {defaultMint: 'first'}},
|
|
||||||
{settings: {defaultMint: 'mid-flight'}}
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const published: Partial<BackupPartPayload>[] = []
|
|
||||||
const publisher = createBackupPublisher({
|
|
||||||
publish: p => {
|
|
||||||
published.push(p)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delayMs: 60_000
|
|
||||||
})
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'a'}})
|
|
||||||
await publisher.flush()
|
|
||||||
expect(published).toEqual([{settings: {defaultMint: 'a'}}])
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'b'}})
|
|
||||||
publisher.cancel()
|
|
||||||
await vi.advanceTimersByTimeAsync(60_000)
|
|
||||||
expect(published).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports a failed publish via onError and retries on the next change', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const published: Partial<BackupPartPayload>[] = []
|
|
||||||
const errors: unknown[] = []
|
|
||||||
let failing = true
|
|
||||||
const publisher = createBackupPublisher({
|
|
||||||
publish: p => {
|
|
||||||
if (failing) return Promise.reject(new Error('relay down'))
|
|
||||||
published.push(p)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delayMs: 1000,
|
|
||||||
onError: e => {
|
|
||||||
errors.push(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
publisher.schedule({settings: {defaultMint: 'a'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
|
||||||
expect(published).toEqual([])
|
|
||||||
expect(errors).toHaveLength(1)
|
|
||||||
|
|
||||||
failing = false
|
|
||||||
publisher.schedule({settings: {defaultMint: 'b'}})
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
|
||||||
expect(published).toEqual([{settings: {defaultMint: 'b'}}])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Nostr backup: key derivation stability, event build/parse round-trips,
|
||||||
|
// tamper rejection, publish/fetch and restore against an in-memory relay
|
||||||
|
// (the transport is injected - no network), and the debounced publisher.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
import type { NostrEvent } from 'nostr-tools/core';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import {
|
||||||
|
BACKUP_EVENT_KIND,
|
||||||
|
backupPubkey,
|
||||||
|
buildBackupEvent,
|
||||||
|
buildBackupEvents,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
parseBackupEvent,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from './nostrBackup';
|
||||||
|
import type { BackupPartPayload, BackupTransport } from './nostrBackup';
|
||||||
|
import {
|
||||||
|
loadBearers,
|
||||||
|
loadSettings,
|
||||||
|
mergeBearers,
|
||||||
|
persistBearer,
|
||||||
|
persistSettings,
|
||||||
|
readEncryptedBearers,
|
||||||
|
} from './storage';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
|
||||||
|
const K1_A = 'aa'.repeat(32);
|
||||||
|
const K1_B = 'bb'.repeat(32);
|
||||||
|
const MINT_PUBKEY = 'ab'.repeat(33);
|
||||||
|
|
||||||
|
// never connected - the recording transport below stands in for the relays
|
||||||
|
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
|
||||||
|
|
||||||
|
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||||
|
id: 'fixture',
|
||||||
|
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// an in-memory relay set. It serves EVERY event it ever accepted, older
|
||||||
|
// addressable copies included - like a relay that never replaces - which
|
||||||
|
// is exactly the case fetchBackup's client-side latest-pick exists for
|
||||||
|
const createRecordingTransport = (): {
|
||||||
|
transport: BackupTransport;
|
||||||
|
events: NostrEvent[];
|
||||||
|
} => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const transport: BackupTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
fetch: (_relays, filter) =>
|
||||||
|
Promise.resolve(
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
(!filter.kinds || filter.kinds.includes(e.kind)) &&
|
||||||
|
(!filter.authors || filter.authors.includes(e.pubkey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { transport, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
// flips the end of a base64 payload to different-but-valid characters
|
||||||
|
const tamperContent = (content: string): string =>
|
||||||
|
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publishBackup / fetchBackup', () => {
|
||||||
|
const secretKey = deriveBackupKey(LINKING_KEY);
|
||||||
|
|
||||||
|
it('publishes every part and fetches them back decrypted', async () => {
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
const parts = {
|
||||||
|
notes: {
|
||||||
|
bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
|
||||||
|
nextByHost: { 'mint.example': 2 },
|
||||||
|
},
|
||||||
|
mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
|
||||||
|
settings: { defaultMint: 'mint.example' },
|
||||||
|
};
|
||||||
|
const published = await publishBackup(secretKey, parts, RELAYS, { transport });
|
||||||
|
expect(published.published).toEqual(['notes', 'mints', 'settings']);
|
||||||
|
|
||||||
|
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
||||||
|
secretKey,
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
expect(fetched).toEqual(parts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes nothing when no parts are given', async () => {
|
||||||
|
const { transport, events } = createRecordingTransport();
|
||||||
|
const result = await publishBackup(secretKey, {}, RELAYS, { transport });
|
||||||
|
expect(result.published).toEqual([]);
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the newest event per d-tag when a relay serves stale copies', async () => {
|
||||||
|
const { transport } = createRecordingTransport();
|
||||||
|
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
|
||||||
|
transport,
|
||||||
|
createdAt: 1000,
|
||||||
|
});
|
||||||
|
await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
|
||||||
|
transport,
|
||||||
|
createdAt: 2000,
|
||||||
|
});
|
||||||
|
// the recording transport serves BOTH - the newer must win
|
||||||
|
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
||||||
|
secretKey,
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
expect(parts.settings).toEqual({ defaultMint: 'new.example' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an older valid copy when the newest event is tampered', async () => {
|
||||||
|
const { transport, events } = createRecordingTransport();
|
||||||
|
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
|
||||||
|
transport,
|
||||||
|
createdAt: 1000,
|
||||||
|
});
|
||||||
|
const latest = requiredValue(events[0]);
|
||||||
|
events.push({
|
||||||
|
...latest,
|
||||||
|
content: tamperContent(latest.content),
|
||||||
|
created_at: 3000,
|
||||||
|
});
|
||||||
|
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
|
||||||
|
secretKey,
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,24 +24,19 @@ export {
|
|||||||
backupPubkey,
|
backupPubkey,
|
||||||
buildBackupEvent,
|
buildBackupEvent,
|
||||||
buildBackupEvents,
|
buildBackupEvents,
|
||||||
parseBackupEvent
|
parseBackupEvent,
|
||||||
} from './nostr/events'
|
} from './nostr/events';
|
||||||
export type {
|
export type { NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent } from './nostr/events';
|
||||||
NostrEvent,
|
|
||||||
BackupPart,
|
|
||||||
BackupPartPayload,
|
|
||||||
ParsedBackupEvent
|
|
||||||
} from './nostr/events'
|
|
||||||
|
|
||||||
export type {BackupTransport, NostrFilter} from './nostr/transport'
|
export type { BackupTransport, NostrFilter } from './nostr/transport';
|
||||||
|
|
||||||
export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync'
|
export { publishBackup, fetchBackup, restoreFromNostr } from './nostr/sync';
|
||||||
export type {
|
export type {
|
||||||
PublishBackupOptions,
|
PublishBackupOptions,
|
||||||
PublishBackupResult,
|
PublishBackupResult,
|
||||||
FetchBackupOptions,
|
FetchBackupOptions,
|
||||||
NostrRestoreResult
|
NostrRestoreResult,
|
||||||
} from './nostr/sync'
|
} from './nostr/sync';
|
||||||
|
|
||||||
export {createBackupPublisher} from './nostr/publisher'
|
export { createBackupPublisher } from './nostr/publisher';
|
||||||
export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher'
|
export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('connection strings', () => {
|
||||||
|
it('round-trips build -> parse, including several relays', () => {
|
||||||
|
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
|
||||||
|
'wss://relay-a.example',
|
||||||
|
'wss://relay-b.example/path?q=1',
|
||||||
|
]);
|
||||||
|
expect(uri).toBe(
|
||||||
|
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
|
||||||
|
);
|
||||||
|
expect(parseConnectionString(uri)).toEqual({
|
||||||
|
walletServicePubkey: 'ab'.repeat(32),
|
||||||
|
clientSecret: 'cd'.repeat(32),
|
||||||
|
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects strings that are not connection strings', () => {
|
||||||
|
expect(parseConnectionString('not a uri')).toBeNull();
|
||||||
|
expect(parseConnectionString('https://example.com')).toBeNull();
|
||||||
|
// missing secret
|
||||||
|
expect(
|
||||||
|
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
|
||||||
|
).toBeNull();
|
||||||
|
// missing relay
|
||||||
|
expect(
|
||||||
|
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
|
||||||
|
).toBeNull();
|
||||||
|
// a non-hex pubkey
|
||||||
|
expect(
|
||||||
|
parseConnectionString(
|
||||||
|
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createConnection returns a string that parses back to the same connection', () => {
|
||||||
|
const connection = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
const parsed = parseConnectionString(connection.connectionString);
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
walletServicePubkey: connection.walletServicePubkey,
|
||||||
|
clientSecret: '11'.repeat(32),
|
||||||
|
relays: RELAYS,
|
||||||
|
});
|
||||||
|
// the record persisted WITHOUT the client secret - it is handed out
|
||||||
|
// once, in the connection string, and never stored
|
||||||
|
const records = readNwcConnections(OWNER_ID);
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
|
||||||
|
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveNwcWalletKey', () => {
|
||||||
|
it('is pinned: derivation changes would silently orphan every connection', () => {
|
||||||
|
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
|
||||||
|
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
|
||||||
|
);
|
||||||
|
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
|
||||||
|
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a distinct key per client and per linking key', () => {
|
||||||
|
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY));
|
||||||
|
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
|
||||||
|
base,
|
||||||
|
);
|
||||||
|
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
|
||||||
|
const first = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 100_000, periodMs: 86_400_000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
expect(first.walletServicePubkey).toBe(
|
||||||
|
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Stale-owner fencing end to end: a service accepted work while its owner
|
||||||
|
// was still installed, and a second tab replaced the saved key AFTER the
|
||||||
|
// fence's last safe point - mid-melt, past the boundary where aborting is
|
||||||
|
// no longer possible. What must not happen is any stale-owner WRITE:
|
||||||
|
// no bearer changeset, no budget debit, no success response.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
|
import { readNwcConnections } from './nwc';
|
||||||
|
import {
|
||||||
|
methodRequest,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
|
||||||
|
describe('service: post-boundary stale-owner fencing', () => {
|
||||||
|
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
|
||||||
|
// Given a running service whose next mint call coincides with a second
|
||||||
|
// tab installing its own wallet (the first fetch is the melt itself:
|
||||||
|
// the exact-match carve performs no mint call of its own)
|
||||||
|
const m = await mint();
|
||||||
|
let swapped = false;
|
||||||
|
const swappingFetch: typeof fetch = (input, init) => {
|
||||||
|
if (!swapped) {
|
||||||
|
swapped = true;
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_linking_key',
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(OTHER_LINKING_KEY),
|
||||||
|
ownerId: OTHER_OWNER_ID,
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
kit: { fetch: swappingFetch },
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the pay flows past the irreversible boundary and reaches the
|
||||||
|
// first owner-bound persistence (the conservative budget debit)
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => state.errors.length > 0);
|
||||||
|
|
||||||
|
// Then the melt genuinely happened (we are past the boundary) ...
|
||||||
|
expect(swapped).toBe(true);
|
||||||
|
expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
|
||||||
|
// ... but nothing under the stale owner moved: no budget debit, no
|
||||||
|
// bearer changeset, no success response (the failure surfaced through
|
||||||
|
// onError instead)
|
||||||
|
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
|
||||||
|
expect(state.changesets).toEqual([]);
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
import { invoiceRetireAfter } from './nwc/invoices';
|
||||||
|
|
||||||
|
export const SIGNED_BOLT11_FIXTURE =
|
||||||
|
'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz';
|
||||||
|
|
||||||
|
describe('service: make_invoice / lookup_invoice', () => {
|
||||||
|
it('derives a safe retirement time from a signed BOLT11 invoice', () => {
|
||||||
|
expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a retirement time outside the safe integer range', () => {
|
||||||
|
const timestamp = 'q'.repeat(7);
|
||||||
|
const expiryTag = `xqv${'l'.repeat(12)}`;
|
||||||
|
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stages an output, settles it in the background, and reports the preimage', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
description: 'nwc test',
|
||||||
|
expiry: 3600,
|
||||||
|
});
|
||||||
|
expect(made.error).toBeNull();
|
||||||
|
expect(made.result).toMatchObject({
|
||||||
|
type: 'incoming',
|
||||||
|
state: 'pending',
|
||||||
|
amount: 21_000,
|
||||||
|
description: 'nwc test',
|
||||||
|
created_at: nowSeconds(),
|
||||||
|
expires_at: nowSeconds() + 3600,
|
||||||
|
});
|
||||||
|
const invoice = requiredValue(made.result).invoice;
|
||||||
|
if (typeof invoice !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return an invoice');
|
||||||
|
}
|
||||||
|
const paymentHash = made.result?.payment_hash;
|
||||||
|
if (typeof paymentHash !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return a payment hash');
|
||||||
|
}
|
||||||
|
expect(invoice).toMatch(/^lnbc/);
|
||||||
|
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')));
|
||||||
|
expect(staged.verified).toBe(false);
|
||||||
|
expect(staged.callback).toBe('');
|
||||||
|
// before settlement the lookup reports the pending invoice
|
||||||
|
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
});
|
||||||
|
expect(pending.error).toBeNull();
|
||||||
|
expect(pending.result?.state).toBe('pending');
|
||||||
|
expect(pending.result?.preimage).toBeUndefined();
|
||||||
|
|
||||||
|
// the "payer" pays the invoice; the background claim settles and
|
||||||
|
// mints the note
|
||||||
|
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
expect(settleRes.ok).toBe(true);
|
||||||
|
await waitFor(() => state.bearers.some((bearer) => bearer.verified));
|
||||||
|
|
||||||
|
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
});
|
||||||
|
expect(settled.error).toBeNull();
|
||||||
|
expect(settled.result?.state).toBe('settled');
|
||||||
|
expect(settled.result?.settled_at).toBe(nowSeconds());
|
||||||
|
const preimage = requiredString(settled.result?.preimage);
|
||||||
|
expect(preimage).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
// The staged record was updated in place before settlement became
|
||||||
|
// visible. The payment preimage is only a receipt and keys no note.
|
||||||
|
expect(m.state.noteState(preimage)).toBeNull();
|
||||||
|
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
|
||||||
|
expect(minted.amount).toBe(21_000);
|
||||||
|
expect(minted.verified).toBe(true);
|
||||||
|
expect(noteK1(minted.url)).not.toBe(preimage);
|
||||||
|
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const commit = deferred();
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
const paymentHash = made.result?.payment_hash;
|
||||||
|
if (typeof paymentHash !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return a payment hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
expect(settleResponse.ok).toBe(true);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
|
||||||
|
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
});
|
||||||
|
expect(pending.result?.state).toBe('pending');
|
||||||
|
expect(pending.result?.settled_at).toBeUndefined();
|
||||||
|
expect(pending.result?.preimage).toBeUndefined();
|
||||||
|
|
||||||
|
commit.resolve();
|
||||||
|
await waitFor(() => state.changesets.length === 1);
|
||||||
|
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
});
|
||||||
|
expect(settled.result?.state).toBe('settled');
|
||||||
|
expect(settled.result?.settled_at).toBe(nowSeconds());
|
||||||
|
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const commit = deferred();
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
const paymentHash = made.result?.payment_hash;
|
||||||
|
if (typeof paymentHash !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return a payment hash');
|
||||||
|
}
|
||||||
|
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
expect(settleResponse.ok).toBe(true);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const firstStop = stop().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
return state.changesets.length;
|
||||||
|
});
|
||||||
|
const repeatedStop = stop();
|
||||||
|
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
|
||||||
|
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
expect(state.changesets).toHaveLength(0);
|
||||||
|
|
||||||
|
commit.resolve();
|
||||||
|
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
|
||||||
|
expect(changesetsAtStop).toBe(1);
|
||||||
|
expect(state.changesets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
import { SIGNED_BOLT11_FIXTURE } from './nwc.invoice-a.cases';
|
||||||
|
describe('service: make_invoice / lookup_invoice (continued)', () => {
|
||||||
|
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const commit = deferred();
|
||||||
|
const commitError = new Error('invoice commit rejected during stop');
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
const paymentHash = made.result?.payment_hash;
|
||||||
|
if (typeof paymentHash !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return a payment hash');
|
||||||
|
}
|
||||||
|
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
expect(settleResponse.ok).toBe(true);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stopping = stop().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
return state.errors.length;
|
||||||
|
});
|
||||||
|
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
|
||||||
|
commit.reject(commitError);
|
||||||
|
expect(await stopping).toBe(1);
|
||||||
|
expect(state.errors).toEqual([commitError]);
|
||||||
|
expect(state.changesets).toHaveLength(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block stop on an invoice whose claim is still polling', async () => {
|
||||||
|
// an unpaid invoice's claim poll can legally run for minutes (the
|
||||||
|
// client pays whenever it pays) - stop must interrupt the wait, not
|
||||||
|
// sit on it; a settlement that already REACHED the commit phase is
|
||||||
|
// still awaited (see the drain tests above)
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 5_000,
|
||||||
|
});
|
||||||
|
expect(made.error).toBeNull();
|
||||||
|
|
||||||
|
// nobody pays the invoice; the claim keeps polling. stop must resolve
|
||||||
|
// promptly regardless (an un-interrupted stop would wait out the
|
||||||
|
// whole 60s claim budget)
|
||||||
|
await stop();
|
||||||
|
expect(state.changesets).toHaveLength(0);
|
||||||
|
expect(state.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start invoice settlement after stop begins during preparation', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const prepare = deferred();
|
||||||
|
let prepareStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
kit: {
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
prepareStarted = true;
|
||||||
|
await prepare.promise;
|
||||||
|
return fetch(input, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = methodRequest(walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => prepareStarted);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stopping = stop().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
});
|
||||||
|
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
|
||||||
|
prepare.resolve();
|
||||||
|
await stopping;
|
||||||
|
|
||||||
|
expect(state.changesets).toHaveLength(0);
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a paid invoice failed when its bearer commit rejects', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const commit = deferred();
|
||||||
|
const commitError = new Error('invoice bearer commit failed');
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
const paymentHash = made.result?.payment_hash;
|
||||||
|
if (typeof paymentHash !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return a payment hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
|
||||||
|
expect(settleResponse.ok).toBe(true);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
commit.reject(commitError);
|
||||||
|
await waitFor(() => state.errors.length === 1);
|
||||||
|
|
||||||
|
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
});
|
||||||
|
expect(failed.result?.state).toBe('failed');
|
||||||
|
expect(failed.result?.settled_at).toBeUndefined();
|
||||||
|
expect(failed.result?.preimage).toBeUndefined();
|
||||||
|
expect(state.errors).toEqual([commitError]);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds an invoice by its invoice string too', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
|
||||||
|
});
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 5_000,
|
||||||
|
});
|
||||||
|
const invoice = made.result?.invoice;
|
||||||
|
if (typeof invoice !== 'string') {
|
||||||
|
throw new TypeError('make_invoice did not return an invoice');
|
||||||
|
}
|
||||||
|
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
invoice: invoice.toUpperCase(),
|
||||||
|
});
|
||||||
|
expect(found.error).toBeNull();
|
||||||
|
expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers an unknown invoice with NOT_FOUND', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
|
payment_hash: 'ab'.repeat(32),
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('NOT_FOUND');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers make_invoice without a default mint with INTERNAL', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({
|
||||||
|
defaultMint: null,
|
||||||
|
});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('INTERNAL');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invoice creation when the durable pending-output cap is reached', async () => {
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: 'mint@example.com',
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
state.bearers.push({
|
||||||
|
id: `pending-${index}`,
|
||||||
|
url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`,
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: {},
|
||||||
|
createdAt: index,
|
||||||
|
updatedAt: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
|
||||||
|
expect(state.bearers).toHaveLength(20);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes staged outputs when invoice preparation fails before publication', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
let rejectQuotes = true;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
kit: {
|
||||||
|
fetch: (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (rejectQuotes && url.includes('comment=')) {
|
||||||
|
return Promise.resolve(new Response('quote failed', { status: 500 }));
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const failed = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
|
||||||
|
expect(failed.error?.code).toBe('INTERNAL');
|
||||||
|
expect(state.bearers).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectQuotes = false;
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
|
||||||
|
expect(made.error).toBeNull();
|
||||||
|
expect(state.bearers).toHaveLength(1);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes an unpublished stage when retirement persistence fails', async () => {
|
||||||
|
const m = await mint({ testHooks: true });
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
|
||||||
|
kit: {
|
||||||
|
fetch: (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.includes('comment=')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', { amount: 25_000 });
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('INTERNAL');
|
||||||
|
expect(state.bearers).toHaveLength(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a make_invoice with a bad amount with OTHER', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: -5,
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('OTHER');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('service ownership', () => {
|
||||||
|
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 0,
|
||||||
|
});
|
||||||
|
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
|
||||||
|
storeForeignConnection(foreign.record);
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
|
||||||
|
const service = await startService(LINKING_KEY, {
|
||||||
|
assertCurrentOwner: () => undefined,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([]),
|
||||||
|
mintSignatureKeys: () => [],
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
|
reserveMintOutputSource: () => Promise.resolve(),
|
||||||
|
finalizePaymentReturn: () => Promise.resolve(),
|
||||||
|
getBearers: () => [],
|
||||||
|
getDefaultMint: () => null,
|
||||||
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
commitCarve: () => Promise.reject(new Error('unused')),
|
||||||
|
applyChangeset: () => Promise.resolve(),
|
||||||
|
transport: relay.transport,
|
||||||
|
nowSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
|
||||||
|
expect(relay.subscriptionCount()).toBe(1);
|
||||||
|
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
|
||||||
|
expect(service.connections).not.toContainEqual(foreign);
|
||||||
|
await service.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores foreign records handed in through the records snapshot', async () => {
|
||||||
|
// the injected-records path bypasses storage, so the service's own
|
||||||
|
// owner filter is the only boundary here (stale-ownership probe: a
|
||||||
|
// snapshot from a previous wallet must not be served)
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 0,
|
||||||
|
});
|
||||||
|
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
|
||||||
|
const service = await startService(
|
||||||
|
LINKING_KEY,
|
||||||
|
{
|
||||||
|
assertCurrentOwner: () => undefined,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([]),
|
||||||
|
mintSignatureKeys: () => [],
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
|
reserveMintOutputSource: () => Promise.resolve(),
|
||||||
|
finalizePaymentReturn: () => Promise.resolve(),
|
||||||
|
getBearers: () => [],
|
||||||
|
getDefaultMint: () => null,
|
||||||
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
commitCarve: () => Promise.reject(new Error('unused')),
|
||||||
|
applyChangeset: () => Promise.resolve(),
|
||||||
|
transport: relay.transport,
|
||||||
|
nowSeconds,
|
||||||
|
},
|
||||||
|
[current.record, foreign.record],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
|
||||||
|
expect(relay.subscriptionCount()).toBe(1);
|
||||||
|
// the foreign snapshot record must not be persisted for the new owner
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
|
||||||
|
await service.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('service: info and balance', () => {
|
||||||
|
it('publishes a kind-13194 info event on startup', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
|
||||||
|
expect(info.pubkey).toBe(walletServicePubkey);
|
||||||
|
expect(info.content).toContain('pay_invoice');
|
||||||
|
expect(info.content).toContain('make_invoice');
|
||||||
|
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers get_info with the connection identity and method list', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'get_info', {});
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(response.result_type).toBe('get_info');
|
||||||
|
expect(response.result).toMatchObject({
|
||||||
|
alias: 'sattle',
|
||||||
|
pubkey: walletServicePubkey,
|
||||||
|
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
|
||||||
|
});
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers get_balance with the spendable total only', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({});
|
||||||
|
state.bearers = [
|
||||||
|
await makeBearer(m, 'aa'.repeat(32), 21_000),
|
||||||
|
await makeBearer(m, 'bb'.repeat(32), 5_000),
|
||||||
|
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
|
||||||
|
];
|
||||||
|
const response = await call(relay, walletServicePubkey, 'get_balance', {});
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(response.result).toEqual({ balance: 26_000 });
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('service: pay_invoice', () => {
|
||||||
|
it('applies the settled changeset before publishing success', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({});
|
||||||
|
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(state.changesets).toHaveLength(1);
|
||||||
|
expect(state.bearers[0]?.spent).toBe(true);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
budgetMsat: 50_000,
|
||||||
|
});
|
||||||
|
const foreign = foreignConnectionFixture(
|
||||||
|
{ maxMsat: 99_000, periodMs: 86_400_000 },
|
||||||
|
nowSeconds() * 1000,
|
||||||
|
);
|
||||||
|
storeForeignConnection(foreign.record);
|
||||||
|
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(requiredString(response.result?.preimage)).toHaveLength(64);
|
||||||
|
|
||||||
|
// the note is gone (melted) and locked spent via the changeset
|
||||||
|
expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
|
||||||
|
expect(requiredValue(state.bearers[0]).spent).toBe(true);
|
||||||
|
|
||||||
|
// the spend was recorded against the budget, persisted
|
||||||
|
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
|
||||||
|
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for a deferred bearer commit before publishing payment success', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const commit = deferred();
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
|
||||||
|
// a non-awaiting engine publishes within milliseconds of the commit
|
||||||
|
// call (one local verify re-read + encrypt); this window is far wider
|
||||||
|
// than that, so an early response here can only mean a missing barrier
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
|
||||||
|
commit.resolve();
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a rejected bearer commit without publishing payment success', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const commit = deferred();
|
||||||
|
const commitError = new Error('bearer commit failed');
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)];
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
commit.reject(commitError);
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
|
||||||
|
expect(state.errors).toEqual([commitError]);
|
||||||
|
// the conservative budget debit is persisted separately from bearer
|
||||||
|
// storage and deliberately NOT rolled back: the budget was debited,
|
||||||
|
// the bearer was never locked spent
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
|
||||||
|
expect(state.bearers[0]?.spent).toBeUndefined();
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const commit = deferred();
|
||||||
|
let commitStarted = false;
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
commitChangeset: () => {
|
||||||
|
commitStarted = true;
|
||||||
|
return commit.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => commitStarted);
|
||||||
|
// stop closes subscriptions; in-flight handlers still finish (their
|
||||||
|
// changesets hold money) - a repeated stop interrupts nothing twice
|
||||||
|
let stopped = false;
|
||||||
|
const firstStop = stop().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
});
|
||||||
|
const repeatedStop = stop();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
// subscriptions close IMMEDIATELY, before the drain completes
|
||||||
|
expect(relay.subscriptionCount()).toBe(0);
|
||||||
|
|
||||||
|
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
|
||||||
|
relay.emitAfterClose(afterStop);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
|
||||||
|
|
||||||
|
commit.resolve();
|
||||||
|
await Promise.all([firstStop, repeatedStop]);
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
|
||||||
|
expect(state.bearers[0]?.spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a stale saved owner before touching the mint', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({});
|
||||||
|
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_linking_key',
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(OTHER_LINKING_KEY),
|
||||||
|
ownerId: OTHER_OWNER_ID,
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
|
||||||
|
expect(state.changesets).toEqual([]);
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('service: pay_invoice (continued)', () => {
|
||||||
|
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
budgetMsat: 20_000,
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('QUOTA_EXCEEDED');
|
||||||
|
// nothing moved: the note is untouched, no spend recorded
|
||||||
|
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
|
||||||
|
expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the allowance once the budget period has rolled over', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
budgetMsat: 21_000,
|
||||||
|
periodMs: 60_000,
|
||||||
|
});
|
||||||
|
// simulate a fully spent budget from a period that ended long ago
|
||||||
|
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]);
|
||||||
|
writeNwcConnections(OWNER_ID, [
|
||||||
|
{ ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
|
||||||
|
]);
|
||||||
|
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
|
||||||
|
const m = await mint();
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({});
|
||||||
|
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
|
||||||
|
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request amount that mismatches the invoice amount', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
amount: 5_000,
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('OTHER');
|
||||||
|
expect(response.error?.message).toMatch(/match/i);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an amount-less invoice instead of guessing', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('OTHER');
|
||||||
|
expect(response.error?.message).toMatch(/amount/i);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
|
||||||
|
const m = await mint({ meltAlwaysFails: true });
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
// a short verify budget: the failed melt is classified by the poll
|
||||||
|
// running out, and that wait is the test's own clock
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
});
|
||||||
|
const original = await makeBearer(m, '01'.repeat(32), 21_000);
|
||||||
|
state.bearers = [original];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
expect(response.error?.code).toBe('PAYMENT_FAILED');
|
||||||
|
|
||||||
|
expect(m.state.noteState('01'.repeat(32))).toBe('burned');
|
||||||
|
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
|
||||||
|
expect(
|
||||||
|
state.bearers.find(
|
||||||
|
(bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
|
||||||
|
),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
state.bearers
|
||||||
|
.filter((bearer) => !bearer.spent)
|
||||||
|
.reduce((sum, bearer) => sum + bearer.amount, 0),
|
||||||
|
).toBe(21_000);
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conserves balance when a split payment fails and returns its carved funds', async () => {
|
||||||
|
const m = await mint({ meltAlwaysFails: true });
|
||||||
|
const { relay, walletServicePubkey, state, stop } = await startTestService({
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
});
|
||||||
|
state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)];
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('PAYMENT_FAILED');
|
||||||
|
expect(
|
||||||
|
state.bearers
|
||||||
|
.filter((bearer) => !bearer.spent)
|
||||||
|
.reduce((sum, bearer) => sum + bearer.amount, 0),
|
||||||
|
).toBe(30_000);
|
||||||
|
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined();
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('storage validation', () => {
|
||||||
|
it('drops malformed records instead of throwing', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
{ clientPubkey: 'nope' },
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
ownerId: OWNER_ID,
|
||||||
|
clientPubkey: CLIENT_PUBKEY,
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
||||||
|
spent: { periodStart: 0, msat: 0 },
|
||||||
|
createdAt: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for garbage json', () => {
|
||||||
|
localStorage.setItem('sattle_nwc_connections', '{{{');
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only strictly parsed records belonging to the requested owner', () => {
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 10,
|
||||||
|
}).record;
|
||||||
|
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
|
||||||
|
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
|
||||||
|
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
...raw,
|
||||||
|
foreign,
|
||||||
|
{ ...current, ownerId: 'malformed' },
|
||||||
|
{
|
||||||
|
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 3000, periodMs: 3000 },
|
||||||
|
spent: { periodStart: 0, msat: 0 },
|
||||||
|
createdAt: 30,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([current]);
|
||||||
|
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts ownerless connections and enabled state only after owner proof', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
|
||||||
|
if (typeof saved !== 'object' || saved === null) {
|
||||||
|
throw new TypeError('Expected a saved linking-key record');
|
||||||
|
}
|
||||||
|
Reflect.deleteProperty(saved, 'ownerId');
|
||||||
|
Reflect.deleteProperty(saved, 'version');
|
||||||
|
localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
clientPubkey: CLIENT_PUBKEY,
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
||||||
|
spent: { periodStart: 0, msat: 0 },
|
||||||
|
createdAt: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
localStorage.setItem('sattle_nwc_enabled', 'true');
|
||||||
|
|
||||||
|
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([]);
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(false);
|
||||||
|
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY);
|
||||||
|
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||||
|
connections: 1,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(true);
|
||||||
|
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||||
|
connections: 0,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose one wallet enabled state to another owner', () => {
|
||||||
|
writeNwcEnabled(OWNER_ID, true);
|
||||||
|
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(true);
|
||||||
|
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats malformed owner-bearing enabled records as disabled', () => {
|
||||||
|
for (const value of [
|
||||||
|
{ version: 1, ownerId: 'malformed', enabled: true },
|
||||||
|
{ version: 2, ownerId: OWNER_ID, enabled: true },
|
||||||
|
{ version: 1, ownerId: OWNER_ID, enabled: 'true' },
|
||||||
|
]) {
|
||||||
|
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-816
@@ -1,816 +1,9 @@
|
|||||||
// The NWC wallet service end to end: connection strings and key
|
import './nwc.connection.cases';
|
||||||
// derivation, the request/response cycle over an in-memory relay (the
|
import './nwc.storage.cases';
|
||||||
// transport is injected - no network), every method against the
|
import './nwc.ownership.cases';
|
||||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
import './nwc.validation.cases';
|
||||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
import './nwc.pay-a.cases';
|
||||||
// requests never execute, and a settled preimage only ever reveals an
|
import './nwc.pay-b.cases';
|
||||||
// already-rotated (burned) note secret.
|
import './nwc.fencing.cases';
|
||||||
|
import './nwc.invoice-a.cases';
|
||||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
import './nwc.invoice-b.cases';
|
||||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
|
||||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
|
||||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
|
||||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
|
||||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
|
||||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
|
||||||
|
|
||||||
import {
|
|
||||||
NWC_INFO_KIND,
|
|
||||||
NWC_REQUEST_KIND,
|
|
||||||
NWC_RESPONSE_KIND,
|
|
||||||
buildConnectionString,
|
|
||||||
createConnection,
|
|
||||||
deriveNwcWalletKey,
|
|
||||||
parseConnectionString,
|
|
||||||
readNwcConnections,
|
|
||||||
startService,
|
|
||||||
writeNwcConnections
|
|
||||||
} from './nwc'
|
|
||||||
import type {NostrEvent, NwcConnectionRecord, NwcTransport} from './nwc'
|
|
||||||
import type {NostrFilter} from './nwc/transport'
|
|
||||||
import type {NwcChangeset} from './nwc'
|
|
||||||
import type {Bearer} from './types'
|
|
||||||
import {stubLocalStorage} from './test-utils'
|
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
|
||||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
|
||||||
const CLIENT_SECRET = hexToBytes('11'.repeat(32))
|
|
||||||
const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
|
|
||||||
const STRANGER_SECRET = hexToBytes('22'.repeat(32))
|
|
||||||
|
|
||||||
// never connected - the in-memory relay below stands in
|
|
||||||
const RELAYS = ['wss://relay-a.example']
|
|
||||||
|
|
||||||
let NOW = 1_800_000_000
|
|
||||||
const nowSeconds = (): number => NOW
|
|
||||||
|
|
||||||
const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
|
|
||||||
|
|
||||||
// an in-memory relay set: subscriptions register, emit delivers to every
|
|
||||||
// matching one, publish records
|
|
||||||
const createFakeRelay = (): {
|
|
||||||
transport: NwcTransport
|
|
||||||
published: NostrEvent[]
|
|
||||||
emit: (event: NostrEvent) => void
|
|
||||||
} => {
|
|
||||||
const published: NostrEvent[] = []
|
|
||||||
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
|
|
||||||
const transport: NwcTransport = {
|
|
||||||
publish: (_relays, event) => {
|
|
||||||
published.push(event)
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
subscribe: (_relays, filter, onEvent) => {
|
|
||||||
const sub = {filter, onEvent}
|
|
||||||
subs.push(sub)
|
|
||||||
return {
|
|
||||||
close: () => {
|
|
||||||
const index = subs.indexOf(sub)
|
|
||||||
if (index >= 0) subs.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const emit = (event: NostrEvent): void => {
|
|
||||||
for (const sub of [...subs]) {
|
|
||||||
const kindsMatch =
|
|
||||||
!sub.filter.kinds || sub.filter.kinds.includes(event.kind)
|
|
||||||
const wanted = sub.filter['#p']
|
|
||||||
const pMatch =
|
|
||||||
!wanted ||
|
|
||||||
event.tags.some(t => t[0] === 'p' && wanted.includes(t[1] ?? ''))
|
|
||||||
const sinceMatch =
|
|
||||||
sub.filter.since === undefined || event.created_at >= sub.filter.since
|
|
||||||
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {transport, published, emit}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Encryption = 'nip44_v2' | 'nip04' | 'none'
|
|
||||||
|
|
||||||
// a NIP-47 request exactly as a real client would build it, signed by the
|
|
||||||
// connection's client secret
|
|
||||||
const clientRequest = (
|
|
||||||
walletServicePubkey: string,
|
|
||||||
content: string,
|
|
||||||
scheme: Encryption = 'nip44_v2',
|
|
||||||
createdAt: number = NOW
|
|
||||||
): NostrEvent => {
|
|
||||||
const tags: string[][] = [['p', walletServicePubkey]]
|
|
||||||
if (scheme !== 'none') tags.push(['encryption', scheme])
|
|
||||||
return finalizeEvent(
|
|
||||||
{
|
|
||||||
kind: NWC_REQUEST_KIND,
|
|
||||||
created_at: createdAt,
|
|
||||||
tags,
|
|
||||||
content:
|
|
||||||
scheme === 'nip44_v2'
|
|
||||||
? nip44v2.encrypt(
|
|
||||||
content,
|
|
||||||
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey)
|
|
||||||
)
|
|
||||||
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content)
|
|
||||||
},
|
|
||||||
CLIENT_SECRET
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const methodRequest = (
|
|
||||||
walletServicePubkey: string,
|
|
||||||
method: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
scheme: Encryption = 'nip44_v2',
|
|
||||||
createdAt?: number
|
|
||||||
): NostrEvent =>
|
|
||||||
clientRequest(
|
|
||||||
walletServicePubkey,
|
|
||||||
JSON.stringify({method, params}),
|
|
||||||
scheme,
|
|
||||||
createdAt
|
|
||||||
)
|
|
||||||
|
|
||||||
// generous: a failed/never-settling melt is only classified after the
|
|
||||||
// verify-poll budget (seconds) runs out
|
|
||||||
const waitFor = async (cond: () => boolean): Promise<void> => {
|
|
||||||
for (let i = 0; i < 3000 && !cond(); i++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5))
|
|
||||||
}
|
|
||||||
expect(cond()).toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
type NwcResponsePayload = {
|
|
||||||
result_type: string
|
|
||||||
error: {code: string; message: string} | null
|
|
||||||
result: Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const readResponse = (
|
|
||||||
published: NostrEvent[],
|
|
||||||
requestId: string,
|
|
||||||
scheme: Encryption
|
|
||||||
): NwcResponsePayload | null => {
|
|
||||||
const event = published.find(
|
|
||||||
e =>
|
|
||||||
e.kind === NWC_RESPONSE_KIND &&
|
|
||||||
e.tags.some(t => t[0] === 'e' && t[1] === requestId)
|
|
||||||
)
|
|
||||||
if (!event) return null
|
|
||||||
const plaintext =
|
|
||||||
scheme === 'nip44_v2'
|
|
||||||
? nip44v2.decrypt(
|
|
||||||
event.content,
|
|
||||||
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey)
|
|
||||||
)
|
|
||||||
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content)
|
|
||||||
return JSON.parse(plaintext) as NwcResponsePayload
|
|
||||||
}
|
|
||||||
|
|
||||||
// drives one full request/response round trip over the fake relay
|
|
||||||
const call = async (
|
|
||||||
relay: ReturnType<typeof createFakeRelay>,
|
|
||||||
walletServicePubkey: string,
|
|
||||||
method: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
scheme: Encryption = 'nip44_v2'
|
|
||||||
): Promise<NwcResponsePayload> => {
|
|
||||||
const request = methodRequest(walletServicePubkey, method, params, scheme)
|
|
||||||
relay.emit(request)
|
|
||||||
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null)
|
|
||||||
return readResponse(relay.published, request.id, scheme)!
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mint = Awaited<ReturnType<typeof createMockMint>>
|
|
||||||
const mints: Mint[] = []
|
|
||||||
const mint = async (
|
|
||||||
options: Parameters<typeof createMockMint>[0] = {}
|
|
||||||
): Promise<Mint> => {
|
|
||||||
const m = await createMockMint(options)
|
|
||||||
mints.push(m)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(mints.splice(0).map(m => m.close()))
|
|
||||||
})
|
|
||||||
|
|
||||||
let bearerCounter = 0
|
|
||||||
const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
|
|
||||||
m.state.creditNote(k1, amountMsat)
|
|
||||||
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
|
|
||||||
const info = await fetchNoteInfo(url)
|
|
||||||
bearerCounter += 1
|
|
||||||
return {
|
|
||||||
id: `bearer-${bearerCounter}`,
|
|
||||||
url,
|
|
||||||
callback: info.callback,
|
|
||||||
amount: info.maxWithdrawable,
|
|
||||||
verified: true,
|
|
||||||
mintPubkey: m.state.pubkey,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the harness around startService: a fake relay, an in-memory "store"
|
|
||||||
// applying changesets the way the Pinia layer will, and a created
|
|
||||||
// connection with a pinned client secret
|
|
||||||
const startTestService = async (options: {
|
|
||||||
budgetMsat?: number
|
|
||||||
periodMs?: number
|
|
||||||
defaultMint?: string | null
|
|
||||||
linkingKey?: Uint8Array
|
|
||||||
poll?: typeof FAST_POLL
|
|
||||||
}): Promise<{
|
|
||||||
relay: ReturnType<typeof createFakeRelay>
|
|
||||||
walletServicePubkey: string
|
|
||||||
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]}
|
|
||||||
stop: () => void
|
|
||||||
}> => {
|
|
||||||
const budgetMsat = options.budgetMsat ?? 1_000_000_000
|
|
||||||
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
|
|
||||||
relays: RELAYS,
|
|
||||||
budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000},
|
|
||||||
clientSecret: CLIENT_SECRET,
|
|
||||||
now: NOW * 1000
|
|
||||||
})
|
|
||||||
const relay = createFakeRelay()
|
|
||||||
const state = {
|
|
||||||
bearers: [] as Bearer[],
|
|
||||||
changesets: [] as NwcChangeset[],
|
|
||||||
errors: [] as unknown[]
|
|
||||||
}
|
|
||||||
const service = await startService(options.linkingKey ?? LINKING_KEY, {
|
|
||||||
getBearers: () => state.bearers,
|
|
||||||
getDefaultMint: () => options.defaultMint ?? null,
|
|
||||||
applyChangeset: (changeset: NwcChangeset) => {
|
|
||||||
state.changesets.push(changeset)
|
|
||||||
for (const id of changeset.markSpent) {
|
|
||||||
const bearer = state.bearers.find(b => b.id === id)
|
|
||||||
if (bearer) bearer.spent = true
|
|
||||||
}
|
|
||||||
for (const note of changeset.add) {
|
|
||||||
bearerCounter += 1
|
|
||||||
state.bearers.push({
|
|
||||||
...note,
|
|
||||||
id: `added-${bearerCounter}`,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: err => {
|
|
||||||
state.errors.push(err)
|
|
||||||
},
|
|
||||||
transport: relay.transport,
|
|
||||||
poll: options.poll ?? FAST_POLL,
|
|
||||||
claimPoll: FAST_POLL,
|
|
||||||
nowSeconds
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
relay,
|
|
||||||
walletServicePubkey: connection.walletServicePubkey,
|
|
||||||
state,
|
|
||||||
stop: service.stop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
stubLocalStorage()
|
|
||||||
NOW = 1_800_000_000
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('connection strings', () => {
|
|
||||||
it('round-trips build -> parse, including several relays', () => {
|
|
||||||
const uri = buildConnectionString(
|
|
||||||
'ab'.repeat(32),
|
|
||||||
'cd'.repeat(32),
|
|
||||||
['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
|
|
||||||
)
|
|
||||||
expect(uri).toBe(
|
|
||||||
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`
|
|
||||||
)
|
|
||||||
expect(parseConnectionString(uri)).toEqual({
|
|
||||||
walletServicePubkey: 'ab'.repeat(32),
|
|
||||||
clientSecret: 'cd'.repeat(32),
|
|
||||||
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects strings that are not connection strings', () => {
|
|
||||||
expect(parseConnectionString('not a uri')).toBeNull()
|
|
||||||
expect(parseConnectionString('https://example.com')).toBeNull()
|
|
||||||
// missing secret
|
|
||||||
expect(
|
|
||||||
parseConnectionString(
|
|
||||||
`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`
|
|
||||||
)
|
|
||||||
).toBeNull()
|
|
||||||
// missing relay
|
|
||||||
expect(
|
|
||||||
parseConnectionString(
|
|
||||||
`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`
|
|
||||||
)
|
|
||||||
).toBeNull()
|
|
||||||
// a non-hex pubkey
|
|
||||||
expect(
|
|
||||||
parseConnectionString(
|
|
||||||
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32)
|
|
||||||
)
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createConnection returns a string that parses back to the same connection', () => {
|
|
||||||
const connection = createConnection(LINKING_KEY, {
|
|
||||||
relays: RELAYS,
|
|
||||||
budget: {maxMsat: 100_000, periodMs: 86_400_000},
|
|
||||||
clientSecret: CLIENT_SECRET
|
|
||||||
})
|
|
||||||
const parsed = parseConnectionString(connection.connectionString)
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
walletServicePubkey: connection.walletServicePubkey,
|
|
||||||
clientSecret: '11'.repeat(32),
|
|
||||||
relays: RELAYS
|
|
||||||
})
|
|
||||||
// the record persisted WITHOUT the client secret - it is handed out
|
|
||||||
// once, in the connection string, and never stored
|
|
||||||
const records = readNwcConnections()
|
|
||||||
expect(records).toHaveLength(1)
|
|
||||||
expect(records[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
|
|
||||||
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('deriveNwcWalletKey', () => {
|
|
||||||
it('is pinned: derivation changes would silently orphan every connection', () => {
|
|
||||||
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
|
|
||||||
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d'
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
|
|
||||||
).toBe('bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives a distinct key per client and per linking key', () => {
|
|
||||||
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
|
|
||||||
expect(
|
|
||||||
bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))
|
|
||||||
).not.toBe(base)
|
|
||||||
expect(
|
|
||||||
bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))
|
|
||||||
).not.toBe(base)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
|
|
||||||
const first = createConnection(LINKING_KEY, {
|
|
||||||
relays: RELAYS,
|
|
||||||
budget: {maxMsat: 100_000, periodMs: 86_400_000},
|
|
||||||
clientSecret: CLIENT_SECRET
|
|
||||||
})
|
|
||||||
expect(first.walletServicePubkey).toBe(
|
|
||||||
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('storage validation', () => {
|
|
||||||
it('drops malformed records instead of throwing', () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
'sattle_nwc_connections',
|
|
||||||
JSON.stringify([
|
|
||||||
{clientPubkey: 'nope'},
|
|
||||||
{
|
|
||||||
clientPubkey: CLIENT_PUBKEY,
|
|
||||||
relays: RELAYS,
|
|
||||||
budget: {maxMsat: 1000, periodMs: 1000},
|
|
||||||
spent: {periodStart: 0, msat: 0},
|
|
||||||
createdAt: 0
|
|
||||||
}
|
|
||||||
])
|
|
||||||
)
|
|
||||||
expect(readNwcConnections()).toHaveLength(1)
|
|
||||||
expect(readNwcConnections()[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns nothing for garbage json', () => {
|
|
||||||
localStorage.setItem('sattle_nwc_connections', '{{{')
|
|
||||||
expect(readNwcConnections()).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('service: info and balance', () => {
|
|
||||||
it('publishes a kind-13194 info event on startup', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const info = relay.published.find(e => e.kind === NWC_INFO_KIND)
|
|
||||||
expect(info).toBeDefined()
|
|
||||||
expect(info!.pubkey).toBe(walletServicePubkey)
|
|
||||||
expect(info!.content).toContain('pay_invoice')
|
|
||||||
expect(info!.content).toContain('make_invoice')
|
|
||||||
expect(info!.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers get_info with the connection identity and method list', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'get_info', {})
|
|
||||||
expect(response.error).toBeNull()
|
|
||||||
expect(response.result_type).toBe('get_info')
|
|
||||||
expect(response.result).toMatchObject({
|
|
||||||
alias: 'sattle',
|
|
||||||
pubkey: walletServicePubkey,
|
|
||||||
methods: [
|
|
||||||
'get_info',
|
|
||||||
'get_balance',
|
|
||||||
'make_invoice',
|
|
||||||
'pay_invoice',
|
|
||||||
'lookup_invoice'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers get_balance with the spendable total only', async () => {
|
|
||||||
const m = await mint()
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
|
||||||
state.bearers = [
|
|
||||||
await makeBearer(m, 'aa'.repeat(32), 21_000),
|
|
||||||
await makeBearer(m, 'bb'.repeat(32), 5_000),
|
|
||||||
{...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true}
|
|
||||||
]
|
|
||||||
const response = await call(relay, walletServicePubkey, 'get_balance', {})
|
|
||||||
expect(response.error).toBeNull()
|
|
||||||
expect(response.result).toEqual({balance: 26_000})
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('service: request validation', () => {
|
|
||||||
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'get_payments', {})
|
|
||||||
expect(response.result_type).toBe('get_payments')
|
|
||||||
expect(response.error?.code).toBe('NOT_IMPLEMENTED')
|
|
||||||
expect(response.result).toBeNull()
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const request = clientRequest(walletServicePubkey, 'this is not json')
|
|
||||||
relay.emit(request)
|
|
||||||
await waitFor(
|
|
||||||
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
|
|
||||||
)
|
|
||||||
const response = readResponse(relay.published, request.id, 'nip44_v2')!
|
|
||||||
expect(response.error?.code).toBe('OTHER')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers a JSON request without a method with an error', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}}))
|
|
||||||
relay.emit(request)
|
|
||||||
await waitFor(
|
|
||||||
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
|
|
||||||
)
|
|
||||||
expect(readResponse(relay.published, request.id, 'nip44_v2')!.error?.code).toBe(
|
|
||||||
'OTHER'
|
|
||||||
)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores a request signed by a stranger key - silently', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const content = nip44v2.encrypt(
|
|
||||||
JSON.stringify({method: 'get_balance', params: {}}),
|
|
||||||
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey)
|
|
||||||
)
|
|
||||||
const forged = finalizeEvent(
|
|
||||||
{
|
|
||||||
kind: NWC_REQUEST_KIND,
|
|
||||||
created_at: NOW,
|
|
||||||
tags: [['p', walletServicePubkey], ['encryption', 'nip44_v2']],
|
|
||||||
content
|
|
||||||
},
|
|
||||||
STRANGER_SECRET
|
|
||||||
)
|
|
||||||
relay.emit(forged)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
expect(
|
|
||||||
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
|
|
||||||
).toHaveLength(0)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const content = nip04Encrypt(
|
|
||||||
CLIENT_SECRET,
|
|
||||||
walletServicePubkey,
|
|
||||||
JSON.stringify({method: 'get_balance', params: {}})
|
|
||||||
)
|
|
||||||
const request = finalizeEvent(
|
|
||||||
{
|
|
||||||
kind: NWC_REQUEST_KIND,
|
|
||||||
created_at: NOW,
|
|
||||||
tags: [['p', walletServicePubkey], ['encryption', 'nip17']],
|
|
||||||
content
|
|
||||||
},
|
|
||||||
CLIENT_SECRET
|
|
||||||
)
|
|
||||||
relay.emit(request)
|
|
||||||
// the error answer goes out in the legacy scheme every client reads
|
|
||||||
await waitFor(
|
|
||||||
() => readResponse(relay.published, request.id, 'nip04') !== null
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
readResponse(relay.published, request.id, 'nip04')!.error?.code
|
|
||||||
).toBe('UNSUPPORTED_ENCRYPTION')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
for (const scheme of ['none', 'nip04'] as const) {
|
|
||||||
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme)
|
|
||||||
relay.emit(request)
|
|
||||||
await waitFor(
|
|
||||||
() => readResponse(relay.published, request.id, 'nip04') !== null
|
|
||||||
)
|
|
||||||
const response = readResponse(relay.published, request.id, 'nip04')!
|
|
||||||
expect(response.error).toBeNull()
|
|
||||||
expect(response.result).toEqual({balance: 0})
|
|
||||||
// the response mirrors the request's scheme
|
|
||||||
const event = relay.published.find(
|
|
||||||
e =>
|
|
||||||
e.kind === NWC_RESPONSE_KIND &&
|
|
||||||
e.tags.some(t => t[0] === 'e' && t[1] === request.id)
|
|
||||||
)!
|
|
||||||
expect(event.tags).toContainEqual(['encryption', 'nip04'])
|
|
||||||
}
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('drops requests older than the replay window unanswered', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const stale = methodRequest(
|
|
||||||
walletServicePubkey,
|
|
||||||
'get_balance',
|
|
||||||
{},
|
|
||||||
'nip44_v2',
|
|
||||||
NOW - 1200
|
|
||||||
)
|
|
||||||
relay.emit(stale)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
expect(
|
|
||||||
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
|
|
||||||
).toHaveLength(0)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('picks up no new requests after stop', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
stop()
|
|
||||||
const request = methodRequest(walletServicePubkey, 'get_balance', {})
|
|
||||||
relay.emit(request)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
expect(
|
|
||||||
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
|
|
||||||
).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('service: pay_invoice', () => {
|
|
||||||
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
|
|
||||||
const m = await mint()
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
budgetMsat: 50_000
|
|
||||||
})
|
|
||||||
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
|
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error).toBeNull()
|
|
||||||
expect(typeof response.result?.preimage).toBe('string')
|
|
||||||
expect((response.result?.preimage as string).length).toBe(64)
|
|
||||||
|
|
||||||
// the note is gone (melted) and locked spent via the changeset
|
|
||||||
expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
|
|
||||||
expect(state.bearers[0]!.spent).toBe(true)
|
|
||||||
|
|
||||||
// the spend was recorded against the budget, persisted
|
|
||||||
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
|
|
||||||
const m = await mint()
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
budgetMsat: 20_000
|
|
||||||
})
|
|
||||||
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
|
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
|
|
||||||
// nothing moved: the note is untouched, no spend recorded
|
|
||||||
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
|
|
||||||
expect(state.bearers[0]!.spent).toBeUndefined()
|
|
||||||
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resets the allowance once the budget period has rolled over', async () => {
|
|
||||||
const m = await mint()
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
budgetMsat: 21_000,
|
|
||||||
periodMs: 60_000
|
|
||||||
})
|
|
||||||
// simulate a fully spent budget from a period that ended long ago
|
|
||||||
const record: NwcConnectionRecord = readNwcConnections()[0]!
|
|
||||||
writeNwcConnections([
|
|
||||||
{...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}}
|
|
||||||
])
|
|
||||||
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
|
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error).toBeNull()
|
|
||||||
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
|
|
||||||
const m = await mint()
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
|
||||||
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
|
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
|
|
||||||
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a request amount that mismatches the invoice amount', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
|
||||||
amount: 5_000
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('OTHER')
|
|
||||||
expect(response.error?.message).toMatch(/match/i)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an amount-less invoice instead of guessing', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('OTHER')
|
|
||||||
expect(response.error?.message).toMatch(/amount/i)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
|
|
||||||
const m = await mint({meltAlwaysFails: true})
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
// a short verify budget: the failed melt is classified by the poll
|
|
||||||
// running out, and that wait is the test's own clock
|
|
||||||
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
|
|
||||||
})
|
|
||||||
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
|
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz'
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('PAYMENT_FAILED')
|
|
||||||
|
|
||||||
// the funds came back, re-secured: the old secret burned, a fresh one
|
|
||||||
// tracked unspent via the changeset - and no budget spend recorded
|
|
||||||
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
|
|
||||||
const returned = state.bearers.find(b => b.id.startsWith('added-'))
|
|
||||||
expect(returned).toBeDefined()
|
|
||||||
expect(returned!.spent).toBeUndefined()
|
|
||||||
expect(returned!.amount).toBe(21_000)
|
|
||||||
expect(m.state.noteState(noteK1(returned!.url)!)).toBe('outstanding')
|
|
||||||
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('service: make_invoice / lookup_invoice', () => {
|
|
||||||
it('issues an invoice, settles it in the background, and reports the preimage', async () => {
|
|
||||||
const m = await mint({testHooks: true})
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
|
||||||
amount: 21_000,
|
|
||||||
description: 'nwc test',
|
|
||||||
expiry: 3600
|
|
||||||
})
|
|
||||||
expect(made.error).toBeNull()
|
|
||||||
expect(made.result).toMatchObject({
|
|
||||||
type: 'incoming',
|
|
||||||
state: 'pending',
|
|
||||||
amount: 21_000,
|
|
||||||
description: 'nwc test',
|
|
||||||
created_at: NOW,
|
|
||||||
expires_at: NOW + 3600
|
|
||||||
})
|
|
||||||
const invoice = made.result!.invoice as string
|
|
||||||
const paymentHash = made.result!.payment_hash as string
|
|
||||||
expect(invoice).toMatch(/^lnbc/)
|
|
||||||
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
|
|
||||||
// before settlement the lookup reports the pending invoice
|
|
||||||
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: paymentHash
|
|
||||||
})
|
|
||||||
expect(pending.error).toBeNull()
|
|
||||||
expect(pending.result?.state).toBe('pending')
|
|
||||||
expect(pending.result?.preimage).toBeUndefined()
|
|
||||||
|
|
||||||
// the "payer" pays the invoice; the background claim settles and
|
|
||||||
// mints the note
|
|
||||||
const settleRes = await fetch(
|
|
||||||
`${m.url}/_test/settle?payment_hash=${paymentHash}`
|
|
||||||
)
|
|
||||||
expect(settleRes.ok).toBe(true)
|
|
||||||
await waitFor(() =>
|
|
||||||
state.changesets.some(c => c.add.length > 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: paymentHash
|
|
||||||
})
|
|
||||||
expect(settled.error).toBeNull()
|
|
||||||
expect(settled.result?.state).toBe('settled')
|
|
||||||
expect(settled.result?.settled_at).toBe(NOW)
|
|
||||||
const preimage = settled.result?.preimage as string
|
|
||||||
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
|
|
||||||
// the minted note was claimed AND rotated before settlement was
|
|
||||||
// recorded: the preimage the client just learned is a burned secret,
|
|
||||||
// and the wallet's fresh note is the only live one
|
|
||||||
expect(m.state.noteState(preimage)).toBe('burned')
|
|
||||||
const minted = state.bearers.find(b => b.id.startsWith('added-'))!
|
|
||||||
expect(minted.amount).toBe(21_000)
|
|
||||||
expect(minted.verified).toBe(true)
|
|
||||||
expect(noteK1(minted.url)).not.toBe(preimage)
|
|
||||||
expect(m.state.noteState(noteK1(minted.url)!)).toBe('outstanding')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('finds an invoice by its invoice string too', async () => {
|
|
||||||
const m = await mint({testHooks: true})
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({
|
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`
|
|
||||||
})
|
|
||||||
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
|
||||||
amount: 5_000
|
|
||||||
})
|
|
||||||
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
invoice: (made.result!.invoice as string).toUpperCase()
|
|
||||||
})
|
|
||||||
expect(found.error).toBeNull()
|
|
||||||
expect(found.result?.payment_hash).toBe(made.result!.payment_hash)
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers an unknown invoice with NOT_FOUND', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: 'ab'.repeat(32)
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('NOT_FOUND')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers make_invoice without a default mint with INTERNAL', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({
|
|
||||||
defaultMint: null
|
|
||||||
})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
|
||||||
amount: 21_000
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('INTERNAL')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('answers a make_invoice with a bad amount with OTHER', async () => {
|
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
|
||||||
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
|
||||||
amount: -5
|
|
||||||
})
|
|
||||||
expect(response.error?.code).toBe('OTHER')
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||||
|
export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
|
||||||
|
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
|
||||||
|
export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
|
||||||
|
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
||||||
|
|
||||||
|
// never connected - the in-memory relay below stands in
|
||||||
|
export const RELAYS = ['wss://relay-a.example'];
|
||||||
|
|
||||||
|
let NOW = 1_800_000_000;
|
||||||
|
export const nowSeconds = (): number => NOW;
|
||||||
|
|
||||||
|
export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
|
||||||
|
|
||||||
|
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
|
||||||
|
connectionInfoOf(OTHER_LINKING_KEY, {
|
||||||
|
version: 1,
|
||||||
|
ownerId: OTHER_OWNER_ID,
|
||||||
|
clientPubkey: getPublicKey(STRANGER_SECRET),
|
||||||
|
relays: RELAYS,
|
||||||
|
budget,
|
||||||
|
spent: { periodStart: now, msat: 0 },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const storeForeignConnection = (record: NwcConnectionRecord): void => {
|
||||||
|
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
|
||||||
|
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
|
||||||
|
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// an in-memory relay set: subscriptions register, emit delivers to every
|
||||||
|
// matching one, publish records
|
||||||
|
export const createFakeRelay = (): {
|
||||||
|
transport: NwcTransport;
|
||||||
|
published: NostrEvent[];
|
||||||
|
emit: (event: NostrEvent) => void;
|
||||||
|
emitAfterClose: (event: NostrEvent) => void;
|
||||||
|
subscriptionCount: () => number;
|
||||||
|
} => {
|
||||||
|
const published: NostrEvent[] = [];
|
||||||
|
const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
|
||||||
|
const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
|
||||||
|
const transport: NwcTransport = {
|
||||||
|
publish: (_relays, event) => {
|
||||||
|
published.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
subscribe: (_relays, filter, onEvent) => {
|
||||||
|
const sub = { filter, onEvent };
|
||||||
|
subs.push(sub);
|
||||||
|
allSubs.push(sub);
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
const index = subs.indexOf(sub);
|
||||||
|
if (index >= 0) subs.splice(index, 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const deliver = (
|
||||||
|
targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
|
||||||
|
event: NostrEvent,
|
||||||
|
): void => {
|
||||||
|
for (const sub of [...targets]) {
|
||||||
|
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
|
||||||
|
const wanted = sub.filter['#p'];
|
||||||
|
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''));
|
||||||
|
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
|
||||||
|
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const emit = (event: NostrEvent): void => deliver(subs, event);
|
||||||
|
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
published,
|
||||||
|
emit,
|
||||||
|
emitAfterClose,
|
||||||
|
subscriptionCount: () => subs.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Encryption = 'nip44_v2' | 'nip04' | 'none';
|
||||||
|
|
||||||
|
// a NIP-47 request exactly as a real client would build it, signed by the
|
||||||
|
// connection's client secret
|
||||||
|
export const clientRequest = (
|
||||||
|
walletServicePubkey: string,
|
||||||
|
content: string,
|
||||||
|
scheme: Encryption = 'nip44_v2',
|
||||||
|
createdAt: number = NOW,
|
||||||
|
): NostrEvent => {
|
||||||
|
const tags: string[][] = [['p', walletServicePubkey]];
|
||||||
|
if (scheme !== 'none') tags.push(['encryption', scheme]);
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: NWC_REQUEST_KIND,
|
||||||
|
created_at: createdAt,
|
||||||
|
tags,
|
||||||
|
content:
|
||||||
|
scheme === 'nip44_v2'
|
||||||
|
? nip44v2.encrypt(
|
||||||
|
content,
|
||||||
|
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey),
|
||||||
|
)
|
||||||
|
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
|
||||||
|
},
|
||||||
|
CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const methodRequest = (
|
||||||
|
walletServicePubkey: string,
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
scheme: Encryption = 'nip44_v2',
|
||||||
|
createdAt?: number,
|
||||||
|
): NostrEvent =>
|
||||||
|
clientRequest(walletServicePubkey, JSON.stringify({ method, params }), scheme, createdAt);
|
||||||
|
|
||||||
|
// generous: a failed/never-settling melt is only classified after the
|
||||||
|
// verify-poll budget (seconds) runs out
|
||||||
|
export const waitFor = async (cond: () => boolean): Promise<void> => {
|
||||||
|
for (let i = 0; i < 3000 && !cond(); i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
expect(cond()).toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deferred = (): {
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (reason: Error) => void;
|
||||||
|
} => {
|
||||||
|
let resolve = (): void => undefined;
|
||||||
|
let reject = (_reason: Error): void => undefined;
|
||||||
|
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetNow = (): void => {
|
||||||
|
NOW = 1_800_000_000;
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
import { isJsonObject } from './jsonParsing';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
createFakeRelay,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
resetNow,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import type { Encryption } from './nwc.testProtocol';
|
||||||
|
export type NwcResponsePayload = {
|
||||||
|
result_type: string;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
|
||||||
|
isJsonObject(value) &&
|
||||||
|
typeof value.result_type === 'string' &&
|
||||||
|
(value.error === null ||
|
||||||
|
(isJsonObject(value.error) &&
|
||||||
|
typeof value.error.code === 'string' &&
|
||||||
|
typeof value.error.message === 'string')) &&
|
||||||
|
(value.result === null || isJsonObject(value.result));
|
||||||
|
|
||||||
|
export const readResponse = (
|
||||||
|
published: NostrEvent[],
|
||||||
|
requestId: string,
|
||||||
|
scheme: Encryption,
|
||||||
|
): NwcResponsePayload | null => {
|
||||||
|
const event = published.find(
|
||||||
|
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId),
|
||||||
|
);
|
||||||
|
if (!event) return null;
|
||||||
|
const plaintext =
|
||||||
|
scheme === 'nip44_v2'
|
||||||
|
? nip44v2.decrypt(
|
||||||
|
event.content,
|
||||||
|
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
|
||||||
|
)
|
||||||
|
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
|
||||||
|
const parsed: unknown = JSON.parse(plaintext);
|
||||||
|
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// drives one full request/response round trip over the fake relay
|
||||||
|
export const call = async (
|
||||||
|
relay: ReturnType<typeof createFakeRelay>,
|
||||||
|
walletServicePubkey: string,
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
scheme: Encryption = 'nip44_v2',
|
||||||
|
): Promise<NwcResponsePayload> => {
|
||||||
|
const request = methodRequest(walletServicePubkey, method, params, scheme);
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
|
||||||
|
return requiredValue(readResponse(relay.published, request.id, scheme));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Mint = Awaited<ReturnType<typeof createMockMint>>;
|
||||||
|
const mints: Mint[] = [];
|
||||||
|
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
|
||||||
|
const m = await createMockMint(options);
|
||||||
|
mints.push(m);
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(mints.splice(0).map((m) => m.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let bearerCounter = 0;
|
||||||
|
let allocationCounter = 0;
|
||||||
|
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
|
||||||
|
m.state.creditNote(k1, amountMsat);
|
||||||
|
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
|
||||||
|
const info = await fetchNoteInfo(url);
|
||||||
|
bearerCounter += 1;
|
||||||
|
return {
|
||||||
|
id: `bearer-${bearerCounter}`,
|
||||||
|
url,
|
||||||
|
callback: info.callback,
|
||||||
|
amount: info.maxWithdrawable,
|
||||||
|
verified: true,
|
||||||
|
mintPubkey: m.state.pubkey,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// the harness around startService: a fake relay, an in-memory "store"
|
||||||
|
// applying changesets the way the Pinia layer will, and a created
|
||||||
|
// connection with a pinned client secret
|
||||||
|
export const startTestService = async (options: {
|
||||||
|
budgetMsat?: number;
|
||||||
|
periodMs?: number;
|
||||||
|
defaultMint?: string | null;
|
||||||
|
linkingKey?: Uint8Array;
|
||||||
|
poll?: typeof FAST_POLL;
|
||||||
|
claimPoll?: typeof FAST_POLL;
|
||||||
|
kit?: NwcServiceDeps['kit'];
|
||||||
|
commitChangeset?: (changeset: NwcChangeset) => Promise<void>;
|
||||||
|
setMintOutputRetirement?: () => Promise<void>;
|
||||||
|
}): Promise<{
|
||||||
|
relay: ReturnType<typeof createFakeRelay>;
|
||||||
|
walletServicePubkey: string;
|
||||||
|
state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}> => {
|
||||||
|
const budgetMsat = options.budgetMsat ?? 1_000_000_000;
|
||||||
|
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: nowSeconds() * 1000,
|
||||||
|
});
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
|
||||||
|
bearers: [],
|
||||||
|
changesets: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
const service = await startService(options.linkingKey ?? LINKING_KEY, {
|
||||||
|
assertCurrentOwner: () => undefined,
|
||||||
|
// deterministic stand-in for the wallet's BIP-32 allocation path
|
||||||
|
allocateOutputSecrets: (_server, count) => {
|
||||||
|
const secrets = Array.from({ length: count }, () => {
|
||||||
|
allocationCounter += 1;
|
||||||
|
return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)));
|
||||||
|
});
|
||||||
|
return Promise.resolve(secrets);
|
||||||
|
},
|
||||||
|
// the keys a production caller serves from the trusted-mint registry:
|
||||||
|
// each created mock mint's current key plus any it retired
|
||||||
|
mintSignatureKeys: (server) =>
|
||||||
|
mints.flatMap((m) =>
|
||||||
|
new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [],
|
||||||
|
),
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
|
getBearers: () => state.bearers,
|
||||||
|
getDefaultMint: () => options.defaultMint ?? null,
|
||||||
|
persistMintOutput: (note) => {
|
||||||
|
bearerCounter += 1;
|
||||||
|
const bearer: Bearer = {
|
||||||
|
...note,
|
||||||
|
id: `staged-${bearerCounter}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
state.bearers.push(bearer);
|
||||||
|
return Promise.resolve(bearer);
|
||||||
|
},
|
||||||
|
discardMintOutput: (staged) => {
|
||||||
|
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
setMintOutputRetirement: (staged, retireAfter) => {
|
||||||
|
const override = options.setMintOutputRetirement?.();
|
||||||
|
if (override) return override;
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
|
||||||
|
if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'));
|
||||||
|
const updated: Bearer = {
|
||||||
|
...staged,
|
||||||
|
pendingMint: { ...staged.pendingMint, retireAfter },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
state.bearers[index] = updated;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
|
||||||
|
const source = state.bearers.find((bearer) => bearer.id === sourceId);
|
||||||
|
if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'));
|
||||||
|
source.spent = true;
|
||||||
|
state.bearers[index] = {
|
||||||
|
...state.bearers[index]!,
|
||||||
|
pendingMint: {
|
||||||
|
...state.bearers[index]!.pendingMint,
|
||||||
|
sourceBearerId: sourceId,
|
||||||
|
sourceRecoverySecret: recoverySecret,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
finalizeMintOutput: async (staged, note) => {
|
||||||
|
const changeset: NwcChangeset = { add: [], markSpent: [] };
|
||||||
|
await options.commitChangeset?.(changeset);
|
||||||
|
state.changesets.push(changeset);
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
|
||||||
|
if (index < 0) throw new Error('The staged bearer is missing.');
|
||||||
|
state.bearers[index] = { ...staged, ...note, pendingMint: undefined, updatedAt: Date.now() };
|
||||||
|
},
|
||||||
|
finalizePaymentReturn: async (staged, note) => {
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
|
||||||
|
if (index < 0) throw new Error('The staged bearer is missing.');
|
||||||
|
const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId;
|
||||||
|
if (sourceId) {
|
||||||
|
const source = state.bearers.find((bearer) => bearer.id === sourceId);
|
||||||
|
if (source) source.spent = true;
|
||||||
|
}
|
||||||
|
state.bearers[index] = {
|
||||||
|
...state.bearers[index]!,
|
||||||
|
...note,
|
||||||
|
pendingMint: undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
finalizeSpentMintOutput: (staged) => {
|
||||||
|
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
commitCarve: async (carve) => {
|
||||||
|
// a checkpoint is identified by its output secret: the landed phase's
|
||||||
|
// URL carries a signature the staged phase's did not have yet
|
||||||
|
const sameOutput = (url: string, noteUrl: string): boolean =>
|
||||||
|
serverOf(url) === serverOf(noteUrl) &&
|
||||||
|
noteK1(url) !== null &&
|
||||||
|
noteK1(url) === noteK1(noteUrl);
|
||||||
|
const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
|
||||||
|
(note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
|
||||||
|
);
|
||||||
|
const changeset: NwcChangeset = {
|
||||||
|
add: additions,
|
||||||
|
markSpent: carve.consumed.map((bearer) => bearer.id),
|
||||||
|
};
|
||||||
|
await options.commitChangeset?.(changeset);
|
||||||
|
state.changesets.push(changeset);
|
||||||
|
for (const bearer of state.bearers) {
|
||||||
|
if (changeset.markSpent.includes(bearer.id)) bearer.spent = true;
|
||||||
|
}
|
||||||
|
for (const note of additions) {
|
||||||
|
bearerCounter += 1;
|
||||||
|
state.bearers.unshift({
|
||||||
|
...note,
|
||||||
|
id: `carve-${bearerCounter}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url));
|
||||||
|
if (!committed) throw new Error('The carved note was not committed.');
|
||||||
|
return committed;
|
||||||
|
},
|
||||||
|
applyChangeset: async (changeset: NwcChangeset) => {
|
||||||
|
await options.commitChangeset?.(changeset);
|
||||||
|
state.changesets.push(changeset);
|
||||||
|
for (const id of changeset.markSpent) {
|
||||||
|
const bearer = state.bearers.find((b) => b.id === id);
|
||||||
|
if (bearer) bearer.spent = true;
|
||||||
|
}
|
||||||
|
for (const note of changeset.add) {
|
||||||
|
bearerCounter += 1;
|
||||||
|
state.bearers.push({
|
||||||
|
...note,
|
||||||
|
id: `added-${bearerCounter}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
state.errors.push(err);
|
||||||
|
},
|
||||||
|
transport: relay.transport,
|
||||||
|
kit: options.kit,
|
||||||
|
poll: options.poll ?? FAST_POLL,
|
||||||
|
claimPoll: options.claimPoll ?? FAST_POLL,
|
||||||
|
nowSeconds,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
relay,
|
||||||
|
walletServicePubkey: connection.walletServicePubkey,
|
||||||
|
state,
|
||||||
|
stop: service.stop,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubLocalStorage();
|
||||||
|
await saveLinkingKey(LINKING_KEY);
|
||||||
|
resetNow();
|
||||||
|
});
|
||||||
+24
-23
@@ -37,17 +37,18 @@
|
|||||||
// per-connection request queues
|
// per-connection request queues
|
||||||
// Budget/connection persistence lives in storage/nwcConnections.ts.
|
// Budget/connection persistence lives in storage/nwcConnections.ts.
|
||||||
|
|
||||||
export type {
|
export type { NwcBudget, NwcBudgetSpend, NwcConnectionRecord } from './storage/nwcConnections';
|
||||||
NwcBudget,
|
|
||||||
NwcBudgetSpend,
|
|
||||||
NwcConnectionRecord
|
|
||||||
} from './storage/nwcConnections'
|
|
||||||
export {
|
export {
|
||||||
|
clearNwcStorageForOwner,
|
||||||
|
clearUnownedNwcStorage,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
persistNwcConnection,
|
persistNwcConnection,
|
||||||
readNwcConnections,
|
readNwcConnections,
|
||||||
removeNwcConnection,
|
removeNwcConnection,
|
||||||
writeNwcConnections
|
writeNwcConnections,
|
||||||
} from './storage/nwcConnections'
|
} from './storage/nwcConnections';
|
||||||
|
export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
|
||||||
|
export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildConnectionString,
|
buildConnectionString,
|
||||||
@@ -55,14 +56,14 @@ export {
|
|||||||
createConnection,
|
createConnection,
|
||||||
deriveNwcWalletKey,
|
deriveNwcWalletKey,
|
||||||
nwcWalletPubkey,
|
nwcWalletPubkey,
|
||||||
parseConnectionString
|
parseConnectionString,
|
||||||
} from './nwc/connection'
|
} from './nwc/connection';
|
||||||
export type {
|
export type {
|
||||||
CreateConnectionOptions,
|
CreateConnectionOptions,
|
||||||
CreatedConnection,
|
CreatedConnection,
|
||||||
NwcConnectionInfo,
|
NwcConnectionInfo,
|
||||||
ParsedConnectionString
|
ParsedConnectionString,
|
||||||
} from './nwc/connection'
|
} from './nwc/connection';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
NWC_INFO_KIND,
|
NWC_INFO_KIND,
|
||||||
@@ -73,8 +74,8 @@ export {
|
|||||||
buildResponseEvent,
|
buildResponseEvent,
|
||||||
decryptRequest,
|
decryptRequest,
|
||||||
errResult,
|
errResult,
|
||||||
okResult
|
okResult,
|
||||||
} from './nwc/protocol'
|
} from './nwc/protocol';
|
||||||
export type {
|
export type {
|
||||||
DecryptedNwcRequest,
|
DecryptedNwcRequest,
|
||||||
NostrEvent,
|
NostrEvent,
|
||||||
@@ -82,16 +83,16 @@ export type {
|
|||||||
NwcErrorCode,
|
NwcErrorCode,
|
||||||
NwcMethod,
|
NwcMethod,
|
||||||
NwcRequest,
|
NwcRequest,
|
||||||
NwcResponse
|
NwcResponse,
|
||||||
} from './nwc/protocol'
|
} from './nwc/protocol';
|
||||||
|
|
||||||
export {defaultNwcTransport} from './nwc/transport'
|
export { defaultNwcTransport } from './nwc/transport';
|
||||||
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport'
|
export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
|
||||||
|
|
||||||
export {budgetRemainingMsat, recordSpend} from './nwc/budget'
|
export { budgetRemainingMsat, recordSpend } from './nwc/budget';
|
||||||
export type {PendingInvoice, RequestContext} from './nwc/context'
|
export type { PendingInvoice, RequestContext } from './nwc/context';
|
||||||
export {invoiceResult, resolvePaymentHash} from './nwc/invoices'
|
export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
|
||||||
export {payChangeset} from './nwc/pay'
|
export { payChangeset } from './nwc/pay';
|
||||||
|
|
||||||
export {startService} from './nwc/service'
|
export { startService } from './nwc/service';
|
||||||
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service'
|
export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||||
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
|
||||||
|
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc';
|
||||||
|
import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
|
||||||
|
import type { NostrFilter } from './nwc/transport';
|
||||||
|
import type { NwcChangeset } from './nwc';
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
|
||||||
|
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol';
|
||||||
|
import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
|
||||||
|
describe('service: request validation', () => {
|
||||||
|
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const response = await call(relay, walletServicePubkey, 'get_payments', {});
|
||||||
|
expect(response.result_type).toBe('get_payments');
|
||||||
|
expect(response.error?.code).toBe('NOT_IMPLEMENTED');
|
||||||
|
expect(response.result).toBeNull();
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const request = clientRequest(walletServicePubkey, 'this is not json');
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
|
||||||
|
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
|
||||||
|
expect(response.error?.code).toBe('OTHER');
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a JSON request without a method with an error', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
|
||||||
|
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
|
||||||
|
'OTHER',
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a request signed by a stranger key - silently', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const content = nip44v2.encrypt(
|
||||||
|
JSON.stringify({ method: 'get_balance', params: {} }),
|
||||||
|
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
|
||||||
|
);
|
||||||
|
const forged = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: NWC_REQUEST_KIND,
|
||||||
|
created_at: nowSeconds(),
|
||||||
|
tags: [
|
||||||
|
['p', walletServicePubkey],
|
||||||
|
['encryption', 'nip44_v2'],
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
STRANGER_SECRET,
|
||||||
|
);
|
||||||
|
relay.emit(forged);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const content = nip04Encrypt(
|
||||||
|
CLIENT_SECRET,
|
||||||
|
walletServicePubkey,
|
||||||
|
JSON.stringify({ method: 'get_balance', params: {} }),
|
||||||
|
);
|
||||||
|
const request = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: NWC_REQUEST_KIND,
|
||||||
|
created_at: nowSeconds(),
|
||||||
|
tags: [
|
||||||
|
['p', walletServicePubkey],
|
||||||
|
['encryption', 'nip17'],
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
relay.emit(request);
|
||||||
|
// the error answer goes out in the legacy scheme every client reads
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
|
||||||
|
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
|
||||||
|
'UNSUPPORTED_ENCRYPTION',
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
for (const scheme of ['none', 'nip04'] as const) {
|
||||||
|
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
|
||||||
|
relay.emit(request);
|
||||||
|
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
|
||||||
|
const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
|
||||||
|
expect(response.error).toBeNull();
|
||||||
|
expect(response.result).toEqual({ balance: 0 });
|
||||||
|
// the response mirrors the request's scheme
|
||||||
|
const event = requiredValue(
|
||||||
|
relay.published.find(
|
||||||
|
(e) =>
|
||||||
|
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(event.tags).toContainEqual(['encryption', 'nip04']);
|
||||||
|
}
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops requests older than the replay window unanswered', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
const stale = methodRequest(
|
||||||
|
walletServicePubkey,
|
||||||
|
'get_balance',
|
||||||
|
{},
|
||||||
|
'nip44_v2',
|
||||||
|
nowSeconds() - 1200,
|
||||||
|
);
|
||||||
|
relay.emit(stale);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up no new requests after stop', async () => {
|
||||||
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
||||||
|
await stop();
|
||||||
|
const request = methodRequest(walletServicePubkey, 'get_balance', {});
|
||||||
|
relay.emitAfterClose(request);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-18
@@ -5,33 +5,31 @@
|
|||||||
// check + record below atomic enough: no two pays of one connection ever
|
// check + record below atomic enough: no two pays of one connection ever
|
||||||
// run this concurrently.
|
// run this concurrently.
|
||||||
|
|
||||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
import type { NwcConnectionRecord } from '../storage/nwcConnections';
|
||||||
import {persistNwcConnection} from '../storage/nwcConnections'
|
import { persistNwcConnection } from '../storage/nwcConnections';
|
||||||
|
|
||||||
export const budgetRemainingMsat = (
|
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
|
||||||
record: NwcConnectionRecord,
|
const { maxMsat, periodMs } = record.budget;
|
||||||
nowMs: number
|
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
|
||||||
): number => {
|
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
|
// rolls the period when it expired, then adds the spend; persists (the
|
||||||
// caller's queue serialized this read-modify-write)
|
// caller's queue serialized this read-modify-write)
|
||||||
export const recordSpend = (
|
export const recordSpend = (
|
||||||
|
ownerId: string,
|
||||||
record: NwcConnectionRecord,
|
record: NwcConnectionRecord,
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
nowMs: number
|
nowMs: number,
|
||||||
): NwcConnectionRecord => {
|
): NwcConnectionRecord => {
|
||||||
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
|
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
|
||||||
return persistNwcConnection({
|
return persistNwcConnection(ownerId, {
|
||||||
...record,
|
...record,
|
||||||
spent: expired
|
spent: expired
|
||||||
? {periodStart: nowMs, msat: amountMsat}
|
? { periodStart: nowMs, msat: amountMsat }
|
||||||
: {
|
: {
|
||||||
periodStart: record.spent.periodStart,
|
periodStart: record.spent.periodStart,
|
||||||
msat: record.spent.msat + amountMsat
|
msat: record.spent.msat + amountMsat,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -20,138 +20,130 @@
|
|||||||
// The connection string follows NIP-47 exactly:
|
// The connection string follows NIP-47 exactly:
|
||||||
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
|
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
|
||||||
|
|
||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
import {getPublicKey} from 'nostr-tools/pure'
|
import { getPublicKey } from 'nostr-tools/pure';
|
||||||
|
|
||||||
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
|
import { linkingPubKeyHex } from '../keys';
|
||||||
import {persistNwcConnection} from '../storage/nwcConnections'
|
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
|
// Deterministic: sha256(linking key || context || client pubkey). The
|
||||||
// result is a secp256k1 secret key used ONLY as this connection's
|
// result is a secp256k1 secret key used ONLY as this connection's
|
||||||
// wallet-service identity - it signs and decrypts NIP-47 events for this
|
// wallet-service identity - it signs and decrypts NIP-47 events for this
|
||||||
// one client, nothing else.
|
// one client, nothing else.
|
||||||
export const deriveNwcWalletKey = (
|
export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array =>
|
||||||
linkingPrivKey: Uint8Array,
|
|
||||||
clientPubkey: string
|
|
||||||
): Uint8Array =>
|
|
||||||
sha256(
|
sha256(
|
||||||
new Uint8Array([
|
new Uint8Array([
|
||||||
...linkingPrivKey,
|
...linkingPrivKey,
|
||||||
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
|
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
|
||||||
...hexToBytes(clientPubkey)
|
...hexToBytes(clientPubkey),
|
||||||
])
|
]),
|
||||||
)
|
);
|
||||||
|
|
||||||
// the x-only nostr pubkey the client addresses its requests to
|
// the x-only nostr pubkey the client addresses its requests to
|
||||||
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
|
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
|
||||||
getPublicKey(walletSecretKey)
|
getPublicKey(walletSecretKey);
|
||||||
|
|
||||||
export type NwcConnectionInfo = {
|
export type NwcConnectionInfo = {
|
||||||
record: NwcConnectionRecord
|
record: NwcConnectionRecord;
|
||||||
walletServicePubkey: string
|
walletServicePubkey: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
// the runtime view of a persisted record: the record plus its re-derived
|
// the runtime view of a persisted record: the record plus its re-derived
|
||||||
// wallet-service identity
|
// wallet-service identity
|
||||||
export const connectionInfoOf = (
|
export const connectionInfoOf = (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
record: NwcConnectionRecord
|
record: NwcConnectionRecord,
|
||||||
): NwcConnectionInfo => ({
|
): NwcConnectionInfo => ({
|
||||||
record,
|
record,
|
||||||
walletServicePubkey: nwcWalletPubkey(
|
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
|
||||||
deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
|
});
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type CreatedConnection = NwcConnectionInfo & {
|
export type CreatedConnection = NwcConnectionInfo & {
|
||||||
// shown to the holder exactly once - it carries the client secret,
|
// shown to the holder exactly once - it carries the client secret,
|
||||||
// which the wallet deliberately does NOT store
|
// which the wallet deliberately does NOT store
|
||||||
connectionString: string
|
connectionString: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type CreateConnectionOptions = {
|
export type CreateConnectionOptions = {
|
||||||
relays: string[]
|
relays: string[];
|
||||||
budget: NwcBudget
|
budget: NwcBudget;
|
||||||
// test hook: a fixed client secret (32 bytes) instead of a random one
|
// test hook: a fixed client secret (32 bytes) instead of a random one
|
||||||
clientSecret?: Uint8Array
|
clientSecret?: Uint8Array;
|
||||||
now?: number
|
now?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Creates and persists a connection. The client secret is random by
|
// Creates and persists a connection. The client secret is random by
|
||||||
// default; the wallet-service key falls out of the derivation above.
|
// default; the wallet-service key falls out of the derivation above.
|
||||||
export const createConnection = (
|
export const createConnection = (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
options: CreateConnectionOptions
|
options: CreateConnectionOptions,
|
||||||
): CreatedConnection => {
|
): CreatedConnection => {
|
||||||
if (options.relays.length === 0) {
|
if (options.relays.length === 0) {
|
||||||
throw new Error('A connection needs at least one relay.')
|
throw new Error('A connection needs at least one relay.');
|
||||||
}
|
}
|
||||||
const clientSecret =
|
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
|
||||||
options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
|
const clientPubkey = getPublicKey(clientSecret);
|
||||||
const clientPubkey = getPublicKey(clientSecret)
|
const ownerId = linkingPubKeyHex(linkingPrivKey);
|
||||||
const record = persistNwcConnection({
|
const record = persistNwcConnection(ownerId, {
|
||||||
|
version: 1,
|
||||||
|
ownerId,
|
||||||
clientPubkey,
|
clientPubkey,
|
||||||
relays: options.relays,
|
relays: options.relays,
|
||||||
budget: options.budget,
|
budget: options.budget,
|
||||||
spent: {periodStart: options.now ?? Date.now(), msat: 0},
|
spent: { periodStart: options.now ?? Date.now(), msat: 0 },
|
||||||
createdAt: options.now ?? Date.now()
|
createdAt: options.now ?? Date.now(),
|
||||||
})
|
});
|
||||||
const info = connectionInfoOf(linkingPrivKey, record)
|
const info = connectionInfoOf(linkingPrivKey, record);
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
connectionString: buildConnectionString(
|
connectionString: buildConnectionString(
|
||||||
info.walletServicePubkey,
|
info.walletServicePubkey,
|
||||||
bytesToHex(clientSecret),
|
bytesToHex(clientSecret),
|
||||||
record.relays
|
record.relays,
|
||||||
)
|
),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const buildConnectionString = (
|
export const buildConnectionString = (
|
||||||
walletServicePubkey: string,
|
walletServicePubkey: string,
|
||||||
clientSecretHex: string,
|
clientSecretHex: string,
|
||||||
relays: string[]
|
relays: string[],
|
||||||
): string => {
|
): string => {
|
||||||
const query = relays
|
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
|
||||||
.map(relay => `relay=${encodeURIComponent(relay)}`)
|
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
|
||||||
.join('&')
|
};
|
||||||
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ParsedConnectionString = {
|
export type ParsedConnectionString = {
|
||||||
walletServicePubkey: string
|
walletServicePubkey: string;
|
||||||
clientSecret: string
|
clientSecret: string;
|
||||||
relays: string[]
|
relays: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
// parses a NIP-47 connection string; returns null for anything that isn't
|
// parses a NIP-47 connection string; returns null for anything that isn't
|
||||||
// exactly one (a client-side counterpart of buildConnectionString, here so
|
// exactly one (a client-side counterpart of buildConnectionString, here so
|
||||||
// the format has a tested inverse)
|
// the format has a tested inverse)
|
||||||
export const parseConnectionString = (
|
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
|
||||||
uri: string
|
let url: URL;
|
||||||
): ParsedConnectionString | null => {
|
|
||||||
let url: URL
|
|
||||||
try {
|
try {
|
||||||
url = new URL(uri.trim())
|
url = new URL(uri.trim());
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
if (url.protocol !== 'nostr+walletconnect:') return null
|
if (url.protocol !== 'nostr+walletconnect:') return null;
|
||||||
// the host is the wallet-service pubkey; normalize case so a
|
// the host is the wallet-service pubkey; normalize case so a
|
||||||
// hand-typed uppercase string still parses (nostr pubkeys are
|
// hand-typed uppercase string still parses (nostr pubkeys are
|
||||||
// conventionally lowercase hex)
|
// conventionally lowercase hex)
|
||||||
const walletServicePubkey = url.host.toLowerCase()
|
const walletServicePubkey = url.host.toLowerCase();
|
||||||
if (!HEX_64.test(walletServicePubkey)) return null
|
if (!HEX_64.test(walletServicePubkey)) return null;
|
||||||
const secret = url.searchParams.get('secret')
|
const secret = url.searchParams.get('secret');
|
||||||
if (!secret || !HEX_64.test(secret)) return null
|
if (!secret || !HEX_64.test(secret)) return null;
|
||||||
const relays = url.searchParams
|
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
|
||||||
.getAll('relay')
|
if (relays.length === 0) return null;
|
||||||
.filter(relay => /^wss?:\/\//.test(relay))
|
return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
|
||||||
if (relays.length === 0) return null
|
};
|
||||||
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,72 +5,119 @@
|
|||||||
// no logic, so every other nwc/ module can import from here without
|
// no logic, so every other nwc/ module can import from here without
|
||||||
// cycles.
|
// cycles.
|
||||||
|
|
||||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
import type { LnurlcashOptions } from 'lnurlcash-kit';
|
||||||
|
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type { Bearer, NewBearer } from '../types';
|
||||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
import type { NwcConnectionRecord } from '../storage/nwcConnections';
|
||||||
import type {PreparedMint} from '../ops'
|
import type { PreparedMint } from '../ops';
|
||||||
import type {PollOptions} from '../ops/shared'
|
import type { CarveResult } from '../ops';
|
||||||
|
import type { OutputSecretAllocator } from '../ops/allocation';
|
||||||
|
import type { MintSignatureKeys } from '../ops/shared';
|
||||||
|
import type { PollOptions } from '../ops/shared';
|
||||||
|
|
||||||
import type {NwcConnectionInfo} from './connection'
|
import type { NwcConnectionInfo } from './connection';
|
||||||
import type {NwcMethod} from './protocol'
|
import type { NwcMethod } from './protocol';
|
||||||
import type {NwcTransport} from './transport'
|
import type { NwcTransport } from './transport';
|
||||||
|
|
||||||
// the state delta the caller's store applies after an op ran: new notes
|
// the state delta the caller's store applies after an op ran: new notes
|
||||||
// to track, existing bearer ids to lock spent. Born-spent notes (a
|
// to track, existing bearer ids to lock spent. Born-spent notes (a
|
||||||
// freshly carved note that was melted away) are deliberately NOT part of
|
// freshly carved note that was melted away) are deliberately NOT part of
|
||||||
// it - they were never the wallet's money in a trackable state
|
// it - they were never the wallet's money in a trackable state
|
||||||
export type NwcChangeset = {
|
export type NwcChangeset = {
|
||||||
add: NewBearer[]
|
add: NewBearer[];
|
||||||
markSpent: string[]
|
markSpent: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type NwcServiceDeps = {
|
export type NwcServiceDeps = {
|
||||||
getBearers: () => Bearer[]
|
getBearers: () => Bearer[];
|
||||||
// the mint make_invoice issues invoices against (the wallet's default
|
// the mint make_invoice issues invoices against (the wallet's default
|
||||||
// mint - NIP-47's make_invoice carries no mint choice)
|
// mint - NIP-47's make_invoice carries no mint choice)
|
||||||
getDefaultMint: () => string | null
|
getDefaultMint: () => string | null;
|
||||||
|
assertCurrentOwner: () => void;
|
||||||
|
// the wallet's durable allocation path (BIP-32 reservation) for every
|
||||||
|
// note output secret a request stages
|
||||||
|
allocateOutputSecrets: (
|
||||||
|
server: string,
|
||||||
|
count: number,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<readonly string[]>;
|
||||||
|
// trusted current+previous signing keys a landed carve output verifies
|
||||||
|
// against - the trusted-mint registry, owner-scoped
|
||||||
|
mintSignatureKeys: MintSignatureKeys;
|
||||||
|
recoverPendingMints: (assertOwner: () => void) => Promise<void>;
|
||||||
|
persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer>;
|
||||||
|
discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
|
||||||
|
setMintOutputRetirement: (
|
||||||
|
staged: Bearer,
|
||||||
|
retireAfter: number,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
reserveMintOutputSource: (
|
||||||
|
staged: Bearer,
|
||||||
|
sourceId: string,
|
||||||
|
recoverySecret: string,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void>;
|
||||||
|
finalizePaymentReturn: (
|
||||||
|
staged: Bearer,
|
||||||
|
note: NewBearer,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
|
||||||
|
commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer>;
|
||||||
applyChangeset: (
|
applyChangeset: (
|
||||||
changeset: NwcChangeset,
|
changeset: NwcChangeset,
|
||||||
connection: NwcConnectionInfo,
|
connection: NwcConnectionInfo,
|
||||||
method: NwcMethod
|
method: NwcMethod,
|
||||||
) => void
|
assertOwner: () => void,
|
||||||
transport?: NwcTransport
|
) => Promise<void>;
|
||||||
|
transport?: NwcTransport;
|
||||||
// kit transport overrides (fetch injection, timeouts)
|
// kit transport overrides (fetch injection, timeouts)
|
||||||
kit?: LnurlcashOptions
|
kit?: LnurlcashOptions;
|
||||||
// verify-poll budget for pay_invoice outcome classification
|
// verify-poll budget for pay_invoice outcome classification
|
||||||
poll?: PollOptions
|
poll?: PollOptions;
|
||||||
// verify-poll budget for the background claim after make_invoice -
|
// verify-poll budget for the background claim after make_invoice -
|
||||||
// generous by default: the client pays the invoice whenever it pays it
|
// generous by default: the client pays the invoice whenever it pays it
|
||||||
claimPoll?: PollOptions
|
claimPoll?: PollOptions;
|
||||||
// background failures (a lost claim, a rejected publish) have no caller
|
// background failures (a lost claim, a rejected publish) have no caller
|
||||||
// to throw to - they surface here
|
// to throw to - they surface here
|
||||||
onError?: (error: unknown, connection: NwcConnectionInfo) => void
|
onError?: (error: unknown, connection: NwcConnectionInfo) => void;
|
||||||
// test hook: pinned clock for event timestamps and expirations
|
// test hook: pinned clock for event timestamps and expirations
|
||||||
nowSeconds?: () => number
|
nowSeconds?: () => number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type PendingInvoice = {
|
export type PendingInvoice = {
|
||||||
invoice: string
|
invoice: string;
|
||||||
paymentHash: string
|
paymentHash: string;
|
||||||
// the gross invoiced amount (net + mint fee) - what the payer pays
|
// the gross invoiced amount (net + mint fee) - what the payer pays
|
||||||
amountMsat: number
|
amountMsat: number;
|
||||||
description?: string
|
description?: string;
|
||||||
createdAt: number // unix seconds
|
createdAt: number; // unix seconds
|
||||||
expiresAt?: number
|
expiresAt?: number;
|
||||||
prepared: PreparedMint
|
prepared: PreparedMint;
|
||||||
state: 'pending' | 'settled' | 'failed'
|
stagedOutput: Bearer;
|
||||||
preimage?: string
|
state: 'pending' | 'settled' | 'failed';
|
||||||
settledAt?: number
|
preimage?: string;
|
||||||
}
|
settledAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
// what a method handler sees of its connection's runtime: the deps, the
|
// what a method handler sees of its connection's runtime: the deps, the
|
||||||
// (fresh) connection info - budget updates replace the record, so it's a
|
// (fresh) connection info - budget updates replace the record, so it's a
|
||||||
// getter, not a snapshot - the invoice registry, and the clock
|
// getter, not a snapshot - the invoice registry, and the clock
|
||||||
export type RequestContext = {
|
export type RequestContext = {
|
||||||
deps: NwcServiceDeps
|
deps: NwcServiceDeps;
|
||||||
connection: () => NwcConnectionInfo
|
connection: () => NwcConnectionInfo;
|
||||||
updateRecord: (record: NwcConnectionRecord) => void
|
updateRecord: (record: NwcConnectionRecord) => void;
|
||||||
invoices: Map<string, PendingInvoice>
|
invoices: Map<string, PendingInvoice>;
|
||||||
nowSeconds: () => number
|
nowSeconds: () => number;
|
||||||
}
|
assertOwner: () => void;
|
||||||
|
// Starts service-owned work only while the service accepts new work.
|
||||||
|
// Accepted tasks become part of stop's drain before key cleanup.
|
||||||
|
startBackground: (work: () => Promise<void>) => boolean;
|
||||||
|
// fires when the service stops: long OBSERVATION waits (the invoice
|
||||||
|
// claim poll) must interrupt themselves on it. Work that has already
|
||||||
|
// reached a fund-critical commit must NOT consult it - stop awaits
|
||||||
|
// those tasks through its drain
|
||||||
|
stopSignal: AbortSignal;
|
||||||
|
};
|
||||||
|
|||||||
+132
-84
@@ -4,25 +4,25 @@
|
|||||||
// results go OUT through deps.applyChangeset - this engine never touches
|
// results go OUT through deps.applyChangeset - this engine never touches
|
||||||
// wallet state itself.
|
// wallet state itself.
|
||||||
|
|
||||||
import {noteK1, sameInvoice} from 'lnurlcash-kit'
|
import { noteK1, sameInvoice } from 'lnurlcash-kit';
|
||||||
|
|
||||||
import type {Bearer} from '../types'
|
import type { Bearer } from '../types';
|
||||||
import type {PreparedMint} from '../ops'
|
import type { PreparedMint } from '../ops';
|
||||||
import {prepareMint} from '../ops'
|
import { prepareMint } from '../ops';
|
||||||
|
import { PollAbortedError } from '../ops/shared';
|
||||||
|
|
||||||
import type {PendingInvoice, RequestContext} from './context'
|
import type { PendingInvoice, RequestContext } from './context';
|
||||||
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
|
import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
|
||||||
import {handlePayInvoice} from './pay'
|
import { handlePayInvoice } from './pay';
|
||||||
import type {NwcRequest, NwcResponse} from './protocol'
|
import type { NwcRequest, NwcResponse } from './protocol';
|
||||||
import {NWC_METHODS, errResult, okResult} from './protocol'
|
import { NWC_METHODS, errResult, okResult } from './protocol';
|
||||||
|
|
||||||
|
const MAX_PENDING_MINT_OUTPUTS = 20;
|
||||||
|
|
||||||
// the same eligibility carve applies - the balance answers "what could
|
// the same eligibility carve applies - the balance answers "what could
|
||||||
// this wallet actually pay with right now"
|
// this wallet actually pay with right now"
|
||||||
const spendable = (bearer: Bearer): boolean =>
|
const spendable = (bearer: Bearer): boolean =>
|
||||||
!bearer.spent &&
|
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url);
|
||||||
bearer.callback !== '' &&
|
|
||||||
!bearer.deviceId &&
|
|
||||||
!!noteK1(bearer.url)
|
|
||||||
|
|
||||||
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
|
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
|
||||||
okResult('get_info', {
|
okResult('get_info', {
|
||||||
@@ -34,42 +34,97 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
|
|||||||
// block height/hash exists, so those fields are simply absent.
|
// block height/hash exists, so those fields are simply absent.
|
||||||
network: 'mainnet',
|
network: 'mainnet',
|
||||||
methods: [...NWC_METHODS],
|
methods: [...NWC_METHODS],
|
||||||
notifications: []
|
notifications: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
|
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
|
||||||
okResult('get_balance', {
|
okResult('get_balance', {
|
||||||
balance: ctx.deps
|
balance: ctx.deps
|
||||||
.getBearers()
|
.getBearers()
|
||||||
.filter(spendable)
|
.filter(spendable)
|
||||||
.reduce((sum, b) => sum + b.amount, 0)
|
.reduce((sum, b) => sum + b.amount, 0),
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleMakeInvoice = async (
|
const handleMakeInvoice = async (
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
const amountMsat = Number(params.amount)
|
const amountMsat = Number(params.amount);
|
||||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||||
return errResult(
|
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.');
|
||||||
'make_invoice',
|
|
||||||
'OTHER',
|
|
||||||
'Amount must be a positive whole number of msat.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const mint = ctx.deps.getDefaultMint()
|
const mint = ctx.deps.getDefaultMint();
|
||||||
if (!mint) {
|
if (!mint) {
|
||||||
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
|
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.');
|
||||||
}
|
}
|
||||||
let prepared: PreparedMint
|
const now = ctx.nowSeconds();
|
||||||
|
if (
|
||||||
|
ctx.deps
|
||||||
|
.getBearers()
|
||||||
|
.some(
|
||||||
|
(bearer) =>
|
||||||
|
!bearer.spent &&
|
||||||
|
bearer.pendingMint?.retireAfter !== undefined &&
|
||||||
|
bearer.pendingMint.retireAfter <= now,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await ctx.deps.recoverPendingMints(ctx.assertOwner);
|
||||||
|
}
|
||||||
|
const pendingOutputs = ctx.deps
|
||||||
|
.getBearers()
|
||||||
|
.filter((bearer) => bearer.pendingMint && !bearer.spent).length;
|
||||||
|
if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
|
||||||
|
return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.');
|
||||||
|
}
|
||||||
|
let prepared: PreparedMint;
|
||||||
|
let stagedOutput: Bearer | undefined;
|
||||||
try {
|
try {
|
||||||
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
|
prepared = await prepareMint(mint, amountMsat, {
|
||||||
|
...(ctx.deps.kit ?? {}),
|
||||||
|
assertOwner: ctx.assertOwner,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner);
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errResult(
|
if (stagedOutput) {
|
||||||
'make_invoice',
|
try {
|
||||||
'INTERNAL',
|
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
|
||||||
err instanceof Error ? err.message : String(err)
|
} catch (cleanupError) {
|
||||||
)
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
if (!stagedOutput) {
|
||||||
|
return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.');
|
||||||
|
}
|
||||||
|
const retireAfter = invoiceRetireAfter(prepared.invoice);
|
||||||
|
if (retireAfter !== null) {
|
||||||
|
try {
|
||||||
|
await ctx.deps.setMintOutputRetirement(stagedOutput, retireAfter, ctx.assertOwner);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const entry: PendingInvoice = {
|
const entry: PendingInvoice = {
|
||||||
invoice: prepared.invoice,
|
invoice: prepared.invoice,
|
||||||
@@ -77,77 +132,70 @@ const handleMakeInvoice = async (
|
|||||||
amountMsat: prepared.grossMsat,
|
amountMsat: prepared.grossMsat,
|
||||||
createdAt: ctx.nowSeconds(),
|
createdAt: ctx.nowSeconds(),
|
||||||
prepared,
|
prepared,
|
||||||
state: 'pending'
|
stagedOutput,
|
||||||
}
|
state: 'pending',
|
||||||
|
};
|
||||||
if (typeof params.description === 'string' && params.description) {
|
if (typeof params.description === 'string' && params.description) {
|
||||||
entry.description = params.description
|
entry.description = params.description;
|
||||||
}
|
}
|
||||||
const expiry = Number(params.expiry)
|
const expiry = Number(params.expiry);
|
||||||
if (Number.isInteger(expiry) && expiry > 0) {
|
if (Number.isInteger(expiry) && expiry > 0) {
|
||||||
entry.expiresAt = entry.createdAt + expiry
|
entry.expiresAt = entry.createdAt + expiry;
|
||||||
}
|
}
|
||||||
ctx.invoices.set(entry.paymentHash, entry)
|
ctx.invoices.set(entry.paymentHash, entry);
|
||||||
// phase two runs in the background; the invoice goes out now and
|
// phase two runs in the background; the invoice goes out now and
|
||||||
// lookup_invoice reports the settlement the claim observes
|
// lookup_invoice reports the settlement the service-owned claim observes
|
||||||
void settleAndClaim(ctx, entry).catch(err => {
|
const started = ctx.startBackground(() =>
|
||||||
entry.state = 'failed'
|
settleAndClaim(ctx, entry).catch((err) => {
|
||||||
ctx.deps.onError?.(err, ctx.connection())
|
entry.state = 'failed';
|
||||||
})
|
// an interrupted claim poll is normal service teardown (stop
|
||||||
return okResult('make_invoice', invoiceResult(entry))
|
// aborted it), not a background failure worth surfacing
|
||||||
}
|
if (err instanceof PollAbortedError) return;
|
||||||
|
ctx.deps.onError?.(err, ctx.connection());
|
||||||
const handleLookupInvoice = (
|
}),
|
||||||
ctx: RequestContext,
|
);
|
||||||
params: Record<string, unknown>
|
if (!started) {
|
||||||
): NwcResponse => {
|
entry.state = 'failed';
|
||||||
const invoiceParam =
|
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
|
||||||
typeof params.invoice === 'string' ? params.invoice : undefined
|
|
||||||
const hashParam =
|
|
||||||
typeof params.payment_hash === 'string'
|
|
||||||
? params.payment_hash.toLowerCase()
|
|
||||||
: undefined
|
|
||||||
if (!invoiceParam && !hashParam) {
|
|
||||||
return errResult(
|
|
||||||
'lookup_invoice',
|
|
||||||
'OTHER',
|
|
||||||
'Provide an invoice or a payment hash.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
|
return okResult('make_invoice', invoiceResult(entry));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLookupInvoice = (ctx: RequestContext, params: Record<string, unknown>): NwcResponse => {
|
||||||
|
const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
|
||||||
|
const hashParam =
|
||||||
|
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined;
|
||||||
|
if (!invoiceParam && !hashParam) {
|
||||||
|
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
|
||||||
|
}
|
||||||
|
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined;
|
||||||
if (!entry && invoiceParam) {
|
if (!entry && invoiceParam) {
|
||||||
for (const candidate of ctx.invoices.values()) {
|
for (const candidate of ctx.invoices.values()) {
|
||||||
if (sameInvoice(candidate.invoice, invoiceParam)) {
|
if (sameInvoice(candidate.invoice, invoiceParam)) {
|
||||||
entry = candidate
|
entry = candidate;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.')
|
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
|
||||||
}
|
}
|
||||||
return okResult('lookup_invoice', invoiceResult(entry))
|
return okResult('lookup_invoice', invoiceResult(entry));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const dispatch = async (
|
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
|
||||||
ctx: RequestContext,
|
|
||||||
request: NwcRequest
|
|
||||||
): Promise<NwcResponse> => {
|
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
case 'get_info':
|
case 'get_info':
|
||||||
return handleGetInfo(ctx)
|
return handleGetInfo(ctx);
|
||||||
case 'get_balance':
|
case 'get_balance':
|
||||||
return handleGetBalance(ctx)
|
return handleGetBalance(ctx);
|
||||||
case 'make_invoice':
|
case 'make_invoice':
|
||||||
return await handleMakeInvoice(ctx, request.params)
|
return await handleMakeInvoice(ctx, request.params);
|
||||||
case 'pay_invoice':
|
case 'pay_invoice':
|
||||||
return await handlePayInvoice(ctx, request.params)
|
return await handlePayInvoice(ctx, request.params);
|
||||||
case 'lookup_invoice':
|
case 'lookup_invoice':
|
||||||
return handleLookupInvoice(ctx, request.params)
|
return handleLookupInvoice(ctx, request.params);
|
||||||
default:
|
default:
|
||||||
return errResult(
|
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
|
||||||
request.method,
|
|
||||||
'NOT_IMPLEMENTED',
|
|
||||||
`Unknown method: ${request.method}.`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
+108
-96
@@ -1,49 +1,78 @@
|
|||||||
// The invoice registry the two-phase make_invoice / lookup_invoice pair
|
// The invoice registry the two-phase make_invoice / lookup_invoice pair
|
||||||
// shares: make_invoice answers with the invoice immediately while this
|
// shares. A minted note always lives at the wallet-chosen secret that was
|
||||||
// module's settleAndClaim watches settlement in the background, claims
|
// durably staged before quote creation; the payment preimage is only a
|
||||||
// the minted note (rotating it immediately), and only THEN records the
|
// receipt. Settlement becomes visible only after the staged bearer has
|
||||||
// settlement the lookup reports.
|
// been replaced with the confirmed note details.
|
||||||
//
|
|
||||||
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted
|
|
||||||
// note's initial secret. NIP-47 clients expect it as the settlement
|
|
||||||
// receipt, so it is handed out - but only after claimFromPreimage has
|
|
||||||
// rotated the fresh note, when that secret is burned and worthless. If
|
|
||||||
// the rotation failed (claimed.rotated === false) the preimage is
|
|
||||||
// withheld: fund safety over spec comfort.
|
|
||||||
|
|
||||||
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
|
import { isPreimage, sameInvoice } from 'lnurlcash-kit';
|
||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import type {NewBearer} from '../types'
|
import type { PreparedMint } from '../ops';
|
||||||
import type {PreparedMint} from '../ops'
|
import { MintedNoteSpentError } from '../ops';
|
||||||
import {claimFromPreimage} from '../ops/mint'
|
import { claimFromSecret } from '../ops/mint';
|
||||||
import {pollVerifyUntilSettled} from '../ops/shared'
|
import { pollVerifyUntilSettled } from '../ops/shared';
|
||||||
import type {PollOptions} from '../ops/shared'
|
import type { PollOptions } from '../ops/shared';
|
||||||
|
|
||||||
import type {PendingInvoice, RequestContext} from './context'
|
import type { PendingInvoice, RequestContext } from './context';
|
||||||
|
|
||||||
export const DEFAULT_CLAIM_POLL: Required<PollOptions> = {
|
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
|
||||||
intervalMs: 2000,
|
intervalMs: 2000,
|
||||||
intervalCapMs: 10_000,
|
intervalCapMs: 10_000,
|
||||||
maxWaitMs: 15 * 60_000
|
maxWaitMs: 15 * 60_000,
|
||||||
}
|
};
|
||||||
|
|
||||||
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
|
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||||
// convention) - that suffix is the invoice's real payment hash. A service
|
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
|
||||||
// that shapes its verify URL differently gets a wallet-local correlation
|
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
|
||||||
// id instead (sha256 of the invoice): still unique and stable for
|
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
|
||||||
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
|
|
||||||
|
// The kit has already validated the invoice before this parser runs. Read
|
||||||
|
// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
|
||||||
|
// durable forever rather than risking retirement while it could still settle.
|
||||||
|
export const invoiceRetireAfter = (invoice: string): number | null => {
|
||||||
|
const normalized = invoice.toLowerCase();
|
||||||
|
const separator = normalized.lastIndexOf('1');
|
||||||
|
if (separator < 1 || normalized.length - separator <= 6) return null;
|
||||||
|
const words = [...normalized.slice(separator + 1, -6)].map((character) =>
|
||||||
|
BECH32_CHARSET.indexOf(character),
|
||||||
|
);
|
||||||
|
if (words.length < 7 || words.some((word) => word < 0)) return null;
|
||||||
|
const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0);
|
||||||
|
if (!Number.isSafeInteger(timestamp)) return null;
|
||||||
|
let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS;
|
||||||
|
let offset = 7;
|
||||||
|
// Conformance invoices omit the 65-byte signature; real BOLT11 invoices
|
||||||
|
// include its fixed 104 words after the tagged fields.
|
||||||
|
const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length;
|
||||||
|
while (offset + 3 <= taggedEnd) {
|
||||||
|
const tag = words[offset];
|
||||||
|
const firstLength = words[offset + 1];
|
||||||
|
const secondLength = words[offset + 2];
|
||||||
|
if (tag === undefined || firstLength === undefined || secondLength === undefined) return null;
|
||||||
|
const length = firstLength * 32 + secondLength;
|
||||||
|
const valueStart = offset + 3;
|
||||||
|
const valueEnd = valueStart + length;
|
||||||
|
if (valueEnd > taggedEnd) return null;
|
||||||
|
if (tag === BOLT11_EXPIRY_TAG) {
|
||||||
|
expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0);
|
||||||
|
if (!Number.isSafeInteger(expiry)) return null;
|
||||||
|
}
|
||||||
|
offset = valueEnd;
|
||||||
|
}
|
||||||
|
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS;
|
||||||
|
return Number.isSafeInteger(retireAfter) ? retireAfter : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// LUD-21 verify URLs conventionally end in the payment hash. A service
|
||||||
|
// that uses another shape gets a stable wallet-local correlation id.
|
||||||
export const resolvePaymentHash = (prepared: PreparedMint): string => {
|
export const resolvePaymentHash = (prepared: PreparedMint): string => {
|
||||||
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
|
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
|
||||||
if (fromVerify) return fromVerify.toLowerCase()
|
if (fromVerify) return fromVerify.toLowerCase();
|
||||||
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
|
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
|
||||||
}
|
};
|
||||||
|
|
||||||
// the NIP-47 transaction object make_invoice and lookup_invoice share
|
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
|
||||||
export const invoiceResult = (
|
|
||||||
entry: PendingInvoice
|
|
||||||
): Record<string, unknown> => {
|
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
@@ -51,66 +80,49 @@ export const invoiceResult = (
|
|||||||
payment_hash: entry.paymentHash,
|
payment_hash: entry.paymentHash,
|
||||||
amount: entry.amountMsat,
|
amount: entry.amountMsat,
|
||||||
created_at: entry.createdAt,
|
created_at: entry.createdAt,
|
||||||
metadata: {}
|
metadata: {},
|
||||||
}
|
};
|
||||||
if (entry.description) result.description = entry.description
|
if (entry.description) result.description = entry.description;
|
||||||
if (entry.expiresAt) result.expires_at = entry.expiresAt
|
if (entry.expiresAt) result.expires_at = entry.expiresAt;
|
||||||
if (entry.state === 'settled' && entry.preimage) {
|
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
|
||||||
result.preimage = entry.preimage
|
if (entry.settledAt) result.settled_at = entry.settledAt;
|
||||||
}
|
return result;
|
||||||
if (entry.settledAt) result.settled_at = entry.settledAt
|
};
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// the background half of make_invoice: watch the invoice, and once it
|
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
|
||||||
// settles claim the note (rotating it immediately) and hand the fresh
|
const prepared = entry.prepared;
|
||||||
// bearer to the caller. Settlement is recorded LAST - after the rotate -
|
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
|
||||||
// so a preimage lookup can only ever reveal an already-burned secret.
|
let preimage: string | null = null;
|
||||||
// Throws on any failure; the caller marks the entry failed and reports
|
if (prepared.verifyUrl) {
|
||||||
// through deps.onError.
|
// Observation remains interruptible. Once settlement is observed, the
|
||||||
export const settleAndClaim = async (
|
// claim and durable update ignore shutdown while stop drains the task.
|
||||||
ctx: RequestContext,
|
const result = await pollVerifyUntilSettled(
|
||||||
entry: PendingInvoice
|
prepared.verifyUrl,
|
||||||
): Promise<void> => {
|
{ ...claimPoll, signal: ctx.stopSignal },
|
||||||
if (!entry.prepared.verifyUrl) {
|
ctx.deps.kit ?? {},
|
||||||
throw new Error(
|
);
|
||||||
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
|
if (!sameInvoice(result.pr, prepared.invoice)) {
|
||||||
)
|
throw new Error("The service's verify response is for a different invoice than requested.");
|
||||||
|
}
|
||||||
|
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage;
|
||||||
}
|
}
|
||||||
const result = await pollVerifyUntilSettled(
|
let claimed;
|
||||||
entry.prepared.verifyUrl,
|
try {
|
||||||
ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL,
|
claimed = await claimFromSecret(
|
||||||
ctx.deps.kit ?? {}
|
prepared,
|
||||||
)
|
prepared.verifyUrl ? claimPoll : { ...claimPoll, signal: ctx.stopSignal },
|
||||||
// a settled report only means this wallet's invoice was paid if it's
|
{
|
||||||
// for the invoice this wallet actually requested
|
...(ctx.deps.kit ?? {}),
|
||||||
if (!sameInvoice(result.pr, entry.prepared.invoice)) {
|
assertOwner: ctx.assertOwner,
|
||||||
throw new Error(
|
},
|
||||||
"The service's verify response is for a different invoice than requested."
|
);
|
||||||
)
|
} catch (error) {
|
||||||
|
if (!(error instanceof MintedNoteSpentError)) throw error;
|
||||||
|
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
const preimage = result.preimage
|
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
|
||||||
if (!preimage || !isPreimage(preimage)) {
|
entry.settledAt = ctx.nowSeconds();
|
||||||
throw new Error(
|
entry.state = 'settled';
|
||||||
'The payment settled but the service did not reveal the preimage.'
|
if (preimage) entry.preimage = preimage;
|
||||||
)
|
};
|
||||||
}
|
|
||||||
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
|
|
||||||
// other half) - invoked in two steps here because NWC needs the
|
|
||||||
// preimage, which claimMintedNote deliberately discards
|
|
||||||
const claimed = await claimFromPreimage(
|
|
||||||
entry.prepared,
|
|
||||||
preimage,
|
|
||||||
ctx.deps.kit ?? {}
|
|
||||||
)
|
|
||||||
const add: NewBearer[] = [claimed.note]
|
|
||||||
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
|
|
||||||
entry.settledAt = ctx.nowSeconds()
|
|
||||||
entry.state = 'settled'
|
|
||||||
if (claimed.rotated) {
|
|
||||||
// see the header: only a rotated note makes the preimage a worthless
|
|
||||||
// secret, safe to hand out as the settlement receipt
|
|
||||||
entry.preimage = preimage
|
|
||||||
}
|
|
||||||
ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice')
|
|
||||||
}
|
|
||||||
|
|||||||
+143
-83
@@ -9,161 +9,221 @@ import {
|
|||||||
decodeBolt11AmountMsat,
|
decodeBolt11AmountMsat,
|
||||||
fetchInvoiceVerification,
|
fetchInvoiceVerification,
|
||||||
isBolt11Invoice,
|
isBolt11Invoice,
|
||||||
noteK1
|
noteK1,
|
||||||
} from 'lnurlcash-kit'
|
withNewK1,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type { Bearer, NewBearer } from '../types';
|
||||||
import type {PayResult} from '../ops'
|
import type { PayResult } from '../ops';
|
||||||
import {UncertainOutcomeError, payWithBearers} from '../ops'
|
import { UncertainOutcomeError, payWithBearers } from '../ops';
|
||||||
|
|
||||||
import {budgetRemainingMsat, recordSpend} from './budget'
|
import { budgetRemainingMsat, recordSpend } from './budget';
|
||||||
import type {NwcChangeset, RequestContext} from './context'
|
import type { NwcChangeset, RequestContext } from './context';
|
||||||
import type {NwcResponse} from './protocol'
|
import type { NwcResponse } from './protocol';
|
||||||
import {errResult, okResult} from './protocol'
|
import { errResult, okResult } from './protocol';
|
||||||
|
|
||||||
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
|
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
|
||||||
// semantics: carve inputs lock spent, change is tracked, returned funds
|
// semantics: carve inputs lock spent, change is tracked, returned funds
|
||||||
// are tracked UNMARKED, rescued secrets are tracked unverified. One
|
// are tracked UNMARKED, rescued secrets are tracked unverified. The
|
||||||
|
// returned funds live in `rotatedNote` when the classification rotate
|
||||||
|
// re-secured them (burning the carved note in the process); one
|
||||||
// deliberate divergence: in the exact-match + funds-returned case the
|
// deliberate divergence: in the exact-match + funds-returned case the
|
||||||
// original bearer (old k1, burned server-side by the classification
|
// original bearer (old k1, burned server-side by that rotate) is left for
|
||||||
// rotate) is left for the next refresh to reconcile, exactly as the UI
|
// the next refresh to reconcile, exactly as the UI leaves it - the money
|
||||||
// leaves it - the money itself sits in the re-secured note, which IS
|
// itself sits in the re-secured note, which IS tracked.
|
||||||
// tracked.
|
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
|
||||||
export const payChangeset = (
|
const add: NewBearer[] = [];
|
||||||
bearers: Bearer[],
|
const markSpent: string[] = result.carve.consumed.map((b) => b.id);
|
||||||
result: PayResult
|
// the staged change's URL may lack the signature the landed result's URL
|
||||||
): NwcChangeset => {
|
// carries - identify outputs by their secret, not the whole URL
|
||||||
const add: NewBearer[] = []
|
const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null;
|
||||||
const markSpent: string[] = result.carve.consumed.map(b => b.id)
|
if (
|
||||||
if (result.carve.change) add.push(result.carve.change)
|
result.carve.change &&
|
||||||
|
!bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
|
||||||
|
) {
|
||||||
|
add.push(result.carve.change);
|
||||||
|
}
|
||||||
|
const carvedK1 = noteK1(result.carve.note.url);
|
||||||
|
const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined;
|
||||||
if (result.outcome === 'failed-funds-returned') {
|
if (result.outcome === 'failed-funds-returned') {
|
||||||
add.push(result.carve.note)
|
if (existing) {
|
||||||
|
const index = markSpent.indexOf(existing.id);
|
||||||
|
if (index >= 0) markSpent.splice(index, 1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// settled / still-pending / already-spent: the carved note is gone or
|
// settled / still-pending / already-spent: the carved note is gone or
|
||||||
// locked. When it was one of the wallet's own bearers (an exact-match
|
// locked. When it was one of the wallet's own bearers (an exact-match
|
||||||
// carve), lock that bearer; a freshly carved note is never added -
|
// carve), lock that bearer; a freshly carved note is never added -
|
||||||
// it was born spent
|
// it was born spent
|
||||||
const carvedK1 = noteK1(result.carve.note.url)
|
if (existing) markSpent.push(existing.id);
|
||||||
const existing = carvedK1
|
|
||||||
? bearers.find(b => noteK1(b.url) === carvedK1)
|
|
||||||
: undefined
|
|
||||||
if (existing) markSpent.push(existing.id)
|
|
||||||
}
|
}
|
||||||
if (result.rescuedNote) add.push(result.rescuedNote)
|
return { add, markSpent };
|
||||||
return {add, markSpent}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export const handlePayInvoice = async (
|
export const handlePayInvoice = async (
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
const invoice =
|
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
|
||||||
typeof params.invoice === 'string' ? params.invoice.trim() : ''
|
|
||||||
if (!isBolt11Invoice(invoice)) {
|
if (!isBolt11Invoice(invoice)) {
|
||||||
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
|
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.');
|
||||||
}
|
}
|
||||||
// the melt must match the invoice's amount exactly, so the amount is
|
// the melt must match the invoice's amount exactly, so the amount is
|
||||||
// read from the invoice itself - never trusted from the request
|
// read from the invoice itself - never trusted from the request
|
||||||
const amountMsat = decodeBolt11AmountMsat(invoice)
|
const amountMsat = decodeBolt11AmountMsat(invoice);
|
||||||
if (amountMsat === null || amountMsat <= 0) {
|
if (amountMsat === null || amountMsat <= 0) {
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
|
"Could not read this invoice's amount - amount-less invoices are not supported.",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (params.amount !== undefined && params.amount !== amountMsat) {
|
if (params.amount !== undefined && params.amount !== amountMsat) {
|
||||||
return errResult(
|
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
|
||||||
'pay_invoice',
|
|
||||||
'OTHER',
|
|
||||||
'The request\'s amount does not match the invoice.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (
|
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
|
||||||
amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())
|
|
||||||
) {
|
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'QUOTA_EXCEEDED',
|
'QUOTA_EXCEEDED',
|
||||||
'This payment exceeds the connection\'s budget.'
|
"This payment exceeds the connection's budget.",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const bearers = ctx.deps.getBearers()
|
const bearers = ctx.deps.getBearers();
|
||||||
let result: PayResult
|
let result: PayResult;
|
||||||
|
let committedCarve: Bearer | undefined;
|
||||||
|
let returnStage: Bearer | undefined;
|
||||||
try {
|
try {
|
||||||
result = await payWithBearers(bearers, invoice, {
|
result = await payWithBearers(bearers, invoice, {
|
||||||
poll: ctx.deps.poll ?? {},
|
poll: ctx.deps.poll ?? {},
|
||||||
kit: ctx.deps.kit ?? {}
|
kit: ctx.deps.kit ?? {},
|
||||||
})
|
assertOwner: ctx.assertOwner,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
|
||||||
|
mintSignatureKeys: ctx.deps.mintSignatureKeys,
|
||||||
|
onCarve: async (carve) => {
|
||||||
|
committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner);
|
||||||
|
},
|
||||||
|
onReturnReady: async (carve, recoverySecret) => {
|
||||||
|
const source =
|
||||||
|
committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!source) throw new Error('The carved note was not tracked.');
|
||||||
|
returnStage = await ctx.deps.persistMintOutput(
|
||||||
|
{
|
||||||
|
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 },
|
||||||
|
},
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
|
await ctx.deps.reserveMintOutputSource(
|
||||||
|
returnStage,
|
||||||
|
source.id,
|
||||||
|
recoverySecret,
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UncertainOutcomeError) {
|
if (err instanceof UncertainOutcomeError) {
|
||||||
// the carve's answer was lost and the probe couldn't tell: the
|
// the carve's answer was lost and the probe couldn't tell: the
|
||||||
// possible outputs carry fresh secrets that may be the only money
|
// possible outputs carry fresh secrets that may be the only money
|
||||||
// left - tracked unverified, never dropped
|
// left - tracked unverified, never dropped
|
||||||
ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
{add: err.possibleOutputs, markSpent: []},
|
{ add: err.possibleOutputs, markSpent: [] },
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice'
|
'pay_invoice',
|
||||||
)
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'INTERNAL',
|
'INTERNAL',
|
||||||
'The payment preparation could not be confirmed; possible new notes were stored unverified.'
|
'The payment preparation could not be confirmed; possible new notes were stored unverified.',
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
|
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
|
||||||
message
|
message,
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
if (returnStage) {
|
||||||
|
const returned = result.rotatedNote ?? result.rescuedNote;
|
||||||
|
if (returned) {
|
||||||
|
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
|
||||||
|
} else if (result.outcome !== 'unknown-still-pending') {
|
||||||
|
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const spendRecorded = (): void => {
|
const spendRecorded = (): void => {
|
||||||
ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now()))
|
// This conservative budget debit is persisted separately from bearer
|
||||||
}
|
// storage. A later bearer commit failure does not roll it back.
|
||||||
|
const connection = ctx.connection().record;
|
||||||
|
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
|
||||||
|
};
|
||||||
switch (result.outcome) {
|
switch (result.outcome) {
|
||||||
case 'settled': {
|
case 'settled': {
|
||||||
spendRecorded()
|
spendRecorded();
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
// the receipt NIP-47 clients expect: the melt's own payment
|
// the receipt NIP-47 clients expect: the melt's own payment
|
||||||
// preimage, re-read from the settle proof. A mint that reveals
|
// preimage, re-read from the settle proof. A mint that reveals
|
||||||
// none yields an empty preimage rather than a fabricated one.
|
// none yields an empty preimage rather than a fabricated one.
|
||||||
let preimage = ''
|
let preimage = '';
|
||||||
if (result.verifyUrl) {
|
if (result.verifyUrl) {
|
||||||
try {
|
try {
|
||||||
const proof = await fetchInvoiceVerification(
|
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
|
||||||
result.verifyUrl,
|
preimage = proof.preimage ?? '';
|
||||||
ctx.deps.kit ?? {}
|
} catch (error) {
|
||||||
)
|
|
||||||
preimage = proof.preimage ?? ''
|
|
||||||
} catch {
|
|
||||||
// the settle proof was already polled inside payWithBearers;
|
// the settle proof was already polled inside payWithBearers;
|
||||||
// a failed re-read must not flip the outcome
|
// a failed re-read must not flip the outcome
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return okResult('pay_invoice', {preimage})
|
return okResult('pay_invoice', { preimage });
|
||||||
}
|
}
|
||||||
case 'failed-funds-returned':
|
case 'failed-funds-returned':
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'PAYMENT_FAILED',
|
'PAYMENT_FAILED',
|
||||||
'The payment failed; the funds are back in the wallet.'
|
'The payment failed; the funds are back in the wallet.',
|
||||||
)
|
);
|
||||||
case 'note-already-spent':
|
case 'note-already-spent':
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'PAYMENT_FAILED',
|
'PAYMENT_FAILED',
|
||||||
'The note backing this payment was already spent; nothing was paid.'
|
'The note backing this payment was already spent; nothing was paid.',
|
||||||
)
|
);
|
||||||
case 'unknown-still-pending':
|
case 'unknown-still-pending':
|
||||||
spendRecorded()
|
spendRecorded();
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
);
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
'The payment is still in flight; the note stays locked until it reconciles.'
|
'The payment is still in flight; the note stays locked until it reconciles.',
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -13,26 +13,26 @@
|
|||||||
// - a kind 13194 replaceable info event advertises the supported methods
|
// - a kind 13194 replaceable info event advertises the supported methods
|
||||||
// and encryption schemes
|
// and encryption schemes
|
||||||
|
|
||||||
import type {Event as NostrEvent} from 'nostr-tools/core'
|
import type { Event as NostrEvent } from 'nostr-tools/core';
|
||||||
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
|
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
|
||||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
|
||||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
import { v2 as nip44v2 } from 'nostr-tools/nip44';
|
||||||
|
|
||||||
export type {NostrEvent}
|
export type { NostrEvent };
|
||||||
|
|
||||||
export const NWC_REQUEST_KIND = 23194
|
export const NWC_REQUEST_KIND = 23194;
|
||||||
export const NWC_RESPONSE_KIND = 23195
|
export const NWC_RESPONSE_KIND = 23195;
|
||||||
export const NWC_INFO_KIND = 13194
|
export const NWC_INFO_KIND = 13194;
|
||||||
|
|
||||||
export const NWC_METHODS = [
|
export const NWC_METHODS = [
|
||||||
'get_info',
|
'get_info',
|
||||||
'get_balance',
|
'get_balance',
|
||||||
'make_invoice',
|
'make_invoice',
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'lookup_invoice'
|
'lookup_invoice',
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
export type NwcMethod = (typeof NWC_METHODS)[number]
|
export type NwcMethod = (typeof NWC_METHODS)[number];
|
||||||
|
|
||||||
export type NwcErrorCode =
|
export type NwcErrorCode =
|
||||||
| 'RATE_LIMITED'
|
| 'RATE_LIMITED'
|
||||||
@@ -45,67 +45,61 @@ export type NwcErrorCode =
|
|||||||
| 'UNSUPPORTED_ENCRYPTION'
|
| 'UNSUPPORTED_ENCRYPTION'
|
||||||
| 'PAYMENT_FAILED'
|
| 'PAYMENT_FAILED'
|
||||||
| 'NOT_FOUND'
|
| 'NOT_FOUND'
|
||||||
| 'OTHER'
|
| 'OTHER';
|
||||||
|
|
||||||
export type NwcRequest = {
|
export type NwcRequest = {
|
||||||
method: string
|
method: string;
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type NwcResponse = {
|
export type NwcResponse = {
|
||||||
result_type: string
|
result_type: string;
|
||||||
error: {code: NwcErrorCode; message: string} | null
|
error: { code: NwcErrorCode; message: string } | null;
|
||||||
result: unknown
|
result: unknown;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const okResult = (method: string, result: unknown): NwcResponse => ({
|
export const okResult = (method: string, result: unknown): NwcResponse => ({
|
||||||
result_type: method,
|
result_type: method,
|
||||||
error: null,
|
error: null,
|
||||||
result
|
result,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const errResult = (
|
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
|
||||||
method: string,
|
|
||||||
code: NwcErrorCode,
|
|
||||||
message: string
|
|
||||||
): NwcResponse => ({
|
|
||||||
result_type: method,
|
result_type: method,
|
||||||
error: {code, message},
|
error: { code, message },
|
||||||
result: null
|
result: null,
|
||||||
})
|
});
|
||||||
|
|
||||||
// the two encryption schemes this service speaks; the scheme of a request
|
// the two encryption schemes this service speaks; the scheme of a request
|
||||||
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
|
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
|
||||||
// requested by the client")
|
// requested by the client")
|
||||||
export type NwcEncryption = 'nip44_v2' | 'nip04'
|
export type NwcEncryption = 'nip44_v2' | 'nip04';
|
||||||
|
|
||||||
const conversationKey = (
|
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
|
||||||
walletSecretKey: Uint8Array,
|
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
|
||||||
clientPubkey: string
|
|
||||||
): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
|
|
||||||
|
|
||||||
export const encryptFor = (
|
export const encryptFor = (
|
||||||
scheme: NwcEncryption,
|
scheme: NwcEncryption,
|
||||||
walletSecretKey: Uint8Array,
|
walletSecretKey: Uint8Array,
|
||||||
clientPubkey: string,
|
clientPubkey: string,
|
||||||
plaintext: string
|
plaintext: string,
|
||||||
): string =>
|
): string =>
|
||||||
scheme === 'nip44_v2'
|
scheme === 'nip44_v2'
|
||||||
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
|
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
|
||||||
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
|
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
|
||||||
|
|
||||||
const decryptFrom = (
|
const decryptFrom = (
|
||||||
scheme: NwcEncryption,
|
scheme: NwcEncryption,
|
||||||
walletSecretKey: Uint8Array,
|
walletSecretKey: Uint8Array,
|
||||||
clientPubkey: string,
|
clientPubkey: string,
|
||||||
content: string
|
content: string,
|
||||||
): string =>
|
): string =>
|
||||||
scheme === 'nip44_v2'
|
scheme === 'nip44_v2'
|
||||||
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
|
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
|
||||||
: nip04Decrypt(walletSecretKey, clientPubkey, content)
|
: nip04Decrypt(walletSecretKey, clientPubkey, content);
|
||||||
|
|
||||||
const tagValue = (event: NostrEvent, name: string): string | undefined =>
|
const tagValue = (event: NostrEvent, name: string): string | undefined =>
|
||||||
event.tags.find(t => t[0] === name)?.[1]
|
event.tags.find((t) => t[0] === name)?.[1];
|
||||||
|
|
||||||
// The outcome of validating + decrypting a candidate request event:
|
// The outcome of validating + decrypting a candidate request event:
|
||||||
// - a request to dispatch (encryption scheme carried so the response can
|
// - a request to dispatch (encryption scheme carried so the response can
|
||||||
@@ -118,34 +112,34 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
|
|||||||
// dropped silently, exactly what a relay full of strangers' traffic
|
// dropped silently, exactly what a relay full of strangers' traffic
|
||||||
// demands
|
// demands
|
||||||
export type DecryptedNwcRequest =
|
export type DecryptedNwcRequest =
|
||||||
| {respond: false; request: NwcRequest; encryption: NwcEncryption}
|
| { respond: false; request: NwcRequest; encryption: NwcEncryption }
|
||||||
| {respond: true; response: NwcResponse; encryption: NwcEncryption}
|
| { respond: true; response: NwcResponse; encryption: NwcEncryption };
|
||||||
|
|
||||||
export const decryptRequest = (
|
export const decryptRequest = (
|
||||||
walletSecretKey: Uint8Array,
|
walletSecretKey: Uint8Array,
|
||||||
walletServicePubkey: string,
|
walletServicePubkey: string,
|
||||||
clientPubkey: string,
|
clientPubkey: string,
|
||||||
event: NostrEvent,
|
event: NostrEvent,
|
||||||
nowSeconds: number = Math.floor(Date.now() / 1000)
|
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||||
): DecryptedNwcRequest | null => {
|
): DecryptedNwcRequest | null => {
|
||||||
if (event.kind !== NWC_REQUEST_KIND) return null
|
if (event.kind !== NWC_REQUEST_KIND) return null;
|
||||||
// only the authorized client may talk to this connection, and the
|
// only the authorized client may talk to this connection, and the
|
||||||
// request must actually be addressed to it
|
// request must actually be addressed to it
|
||||||
if (event.pubkey !== clientPubkey) return null
|
if (event.pubkey !== clientPubkey) return null;
|
||||||
if (tagValue(event, 'p') !== walletServicePubkey) return null
|
if (tagValue(event, 'p') !== walletServicePubkey) return null;
|
||||||
if (!verifyEvent(event)) return null
|
if (!verifyEvent(event)) return null;
|
||||||
// an expired request is ignored, never answered (NIP-47)
|
// an expired request is ignored, never answered (NIP-47)
|
||||||
const expiration = tagValue(event, 'expiration')
|
const expiration = tagValue(event, 'expiration');
|
||||||
if (expiration !== undefined && Number(expiration) < nowSeconds) {
|
if (expiration !== undefined && Number(expiration) < nowSeconds) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
|
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
|
||||||
const advertised = tagValue(event, 'encryption')
|
const advertised = tagValue(event, 'encryption');
|
||||||
let encryption: NwcEncryption
|
let encryption: NwcEncryption;
|
||||||
if (advertised === undefined || advertised === 'nip04') {
|
if (advertised === undefined || advertised === 'nip04') {
|
||||||
encryption = 'nip04'
|
encryption = 'nip04';
|
||||||
} else if (advertised === 'nip44_v2') {
|
} else if (advertised === 'nip44_v2') {
|
||||||
encryption = 'nip44_v2'
|
encryption = 'nip44_v2';
|
||||||
} else {
|
} else {
|
||||||
// the client asked for a scheme we don't speak - answer in the
|
// the client asked for a scheme we don't speak - answer in the
|
||||||
// legacy default, the one scheme every NIP-47 client must read
|
// legacy default, the one scheme every NIP-47 client must read
|
||||||
@@ -155,26 +149,26 @@ export const decryptRequest = (
|
|||||||
response: errResult(
|
response: errResult(
|
||||||
'',
|
'',
|
||||||
'UNSUPPORTED_ENCRYPTION',
|
'UNSUPPORTED_ENCRYPTION',
|
||||||
`Unsupported encryption scheme: ${advertised}.`
|
`Unsupported encryption scheme: ${advertised}.`,
|
||||||
)
|
),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
let plaintext: string
|
let plaintext: string;
|
||||||
try {
|
try {
|
||||||
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
|
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
|
||||||
} catch {
|
} catch {
|
||||||
// undecryptable - indistinguishable from relay noise; stay silent
|
// undecryptable - indistinguishable from relay noise; stay silent
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
let data: unknown
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(plaintext)
|
data = JSON.parse(plaintext);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
respond: true,
|
respond: true,
|
||||||
encryption,
|
encryption,
|
||||||
response: errResult('', 'OTHER', 'The request is not valid JSON.')
|
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
typeof data !== 'object' ||
|
typeof data !== 'object' ||
|
||||||
@@ -185,16 +179,14 @@ export const decryptRequest = (
|
|||||||
return {
|
return {
|
||||||
respond: true,
|
respond: true,
|
||||||
encryption,
|
encryption,
|
||||||
response: errResult('', 'OTHER', 'The request has no method.')
|
response: errResult('', 'OTHER', 'The request has no method.'),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
const request = data as NwcRequest
|
const request = data as NwcRequest;
|
||||||
const params =
|
const params =
|
||||||
typeof request.params === 'object' && request.params !== null
|
typeof request.params === 'object' && request.params !== null ? request.params : {};
|
||||||
? request.params
|
return { respond: false, request: { method: request.method, params }, encryption };
|
||||||
: {}
|
};
|
||||||
return {respond: false, request: {method: request.method, params}, encryption}
|
|
||||||
}
|
|
||||||
|
|
||||||
// signs the kind-23195 answer to a request, mirroring its encryption
|
// signs the kind-23195 answer to a request, mirroring its encryption
|
||||||
// scheme and referencing it via the 'e' tag
|
// scheme and referencing it via the 'e' tag
|
||||||
@@ -204,7 +196,7 @@ export const buildResponseEvent = (
|
|||||||
encryption: NwcEncryption,
|
encryption: NwcEncryption,
|
||||||
requestEventId: string,
|
requestEventId: string,
|
||||||
response: NwcResponse,
|
response: NwcResponse,
|
||||||
createdAt: number = Math.floor(Date.now() / 1000)
|
createdAt: number = Math.floor(Date.now() / 1000),
|
||||||
): NostrEvent =>
|
): NostrEvent =>
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -213,29 +205,24 @@ export const buildResponseEvent = (
|
|||||||
tags: [
|
tags: [
|
||||||
['p', clientPubkey],
|
['p', clientPubkey],
|
||||||
['e', requestEventId],
|
['e', requestEventId],
|
||||||
['encryption', encryption]
|
['encryption', encryption],
|
||||||
],
|
],
|
||||||
content: encryptFor(
|
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
|
||||||
encryption,
|
|
||||||
walletSecretKey,
|
|
||||||
clientPubkey,
|
|
||||||
JSON.stringify(response)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
walletSecretKey
|
walletSecretKey,
|
||||||
)
|
);
|
||||||
|
|
||||||
// the replaceable info event advertising this service's capabilities
|
// the replaceable info event advertising this service's capabilities
|
||||||
export const buildInfoEvent = (
|
export const buildInfoEvent = (
|
||||||
walletSecretKey: Uint8Array,
|
walletSecretKey: Uint8Array,
|
||||||
createdAt: number = Math.floor(Date.now() / 1000)
|
createdAt: number = Math.floor(Date.now() / 1000),
|
||||||
): NostrEvent =>
|
): NostrEvent =>
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
kind: NWC_INFO_KIND,
|
kind: NWC_INFO_KIND,
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
tags: [['encryption', 'nip44_v2 nip04']],
|
tags: [['encryption', 'nip44_v2 nip04']],
|
||||||
content: NWC_METHODS.join(' ')
|
content: NWC_METHODS.join(' '),
|
||||||
},
|
},
|
||||||
walletSecretKey
|
walletSecretKey,
|
||||||
)
|
);
|
||||||
|
|||||||
+161
-91
@@ -14,177 +14,247 @@
|
|||||||
// foreground-only design itself is documented in the nwc.ts façade
|
// foreground-only design itself is documented in the nwc.ts façade
|
||||||
// header.
|
// header.
|
||||||
|
|
||||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
import { linkingPubKeyHex } from '../keys';
|
||||||
import {readNwcConnections} from '../storage/nwcConnections'
|
import type { NwcConnectionRecord } from '../storage/nwcConnections';
|
||||||
|
import { readNwcConnections } from '../storage/nwcConnections';
|
||||||
|
import { assertSavedKeyOwner } from '../storage/currentOwner';
|
||||||
|
|
||||||
import type {NwcConnectionInfo} from './connection'
|
import type { NwcConnectionInfo } from './connection';
|
||||||
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
|
import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
|
||||||
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
|
import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
|
||||||
import {dispatch} from './dispatch'
|
import { dispatch } from './dispatch';
|
||||||
import type {
|
import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
|
||||||
NostrEvent,
|
import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
|
||||||
NwcEncryption,
|
import type { NwcSubscription } from './transport';
|
||||||
NwcRequest,
|
import { defaultNwcTransport } from './transport';
|
||||||
NwcResponse
|
|
||||||
} from './protocol'
|
|
||||||
import {
|
|
||||||
NWC_REQUEST_KIND,
|
|
||||||
buildInfoEvent,
|
|
||||||
buildResponseEvent,
|
|
||||||
decryptRequest
|
|
||||||
} from './protocol'
|
|
||||||
import type {NwcSubscription, NwcTransport} from './transport'
|
|
||||||
import {defaultNwcTransport} from './transport'
|
|
||||||
|
|
||||||
export type {NwcConnectionInfo}
|
export type { NwcConnectionInfo };
|
||||||
export type {NwcChangeset, NwcServiceDeps} from './context'
|
export type { NwcChangeset, NwcServiceDeps } from './context';
|
||||||
|
|
||||||
// requests older than this are dropped unanswered (see the header)
|
// requests older than this are dropped unanswered (see the header)
|
||||||
const MAX_REQUEST_AGE_SECONDS = 600
|
const MAX_REQUEST_AGE_SECONDS = 600;
|
||||||
|
|
||||||
type ConnectionRuntime = {
|
type ConnectionRuntime = {
|
||||||
info: NwcConnectionInfo
|
info: NwcConnectionInfo;
|
||||||
walletSecret: Uint8Array
|
// nulled and zeroed only after every tracked handler drains
|
||||||
|
walletSecret: Uint8Array | null;
|
||||||
// invoices this connection issued, by payment hash - in-memory only:
|
// invoices this connection issued, by payment hash - in-memory only:
|
||||||
// pending invoices don't survive a restart (lookup then answers
|
// pending invoices don't survive a restart (lookup then answers
|
||||||
// NOT_FOUND), same as any foreground-only wallet
|
// NOT_FOUND), same as any foreground-only wallet
|
||||||
invoices: Map<string, PendingInvoice>
|
invoices: Map<string, PendingInvoice>;
|
||||||
// serializes pay_invoice handling per connection - the budget check and
|
// serializes pay_invoice handling per connection - the budget check and
|
||||||
// the spend record can't interleave
|
// the spend record can't interleave
|
||||||
queue: Promise<unknown>
|
queue: Promise<unknown>;
|
||||||
sub: NwcSubscription
|
sub: NwcSubscription;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type NwcService = {
|
export type NwcService = {
|
||||||
// a snapshot of the served connections at startup; the persisted
|
// a snapshot of the served connections at startup; the persisted
|
||||||
// records (storage/nwcConnections) carry the live budget state
|
// records (storage/nwcConnections) carry the live budget state
|
||||||
connections: NwcConnectionInfo[]
|
connections: NwcConnectionInfo[];
|
||||||
// closes every relay subscription. In-flight handlers still finish -
|
// closes every relay subscription. In-flight handlers still finish -
|
||||||
// their changesets hold money - but no new requests are picked up
|
// their changesets hold money - but no new requests are picked up
|
||||||
stop: () => void
|
stop: () => Promise<void>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const startService = async (
|
export const startService = async (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
deps: NwcServiceDeps,
|
deps: NwcServiceDeps,
|
||||||
records: NwcConnectionRecord[] = readNwcConnections()
|
records?: NwcConnectionRecord[],
|
||||||
): Promise<NwcService> => {
|
): Promise<NwcService> => {
|
||||||
const transport = deps.transport ?? (await defaultNwcTransport())
|
const transport = deps.transport ?? (await defaultNwcTransport());
|
||||||
const nowSeconds = (): number =>
|
const ownerId = linkingPubKeyHex(linkingPrivKey);
|
||||||
deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
|
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
|
||||||
|
(record) => record.ownerId === ownerId,
|
||||||
|
);
|
||||||
|
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const publishResponse = async (
|
const publishResponse = async (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
|
walletSecret: Uint8Array,
|
||||||
requestEventId: string,
|
requestEventId: string,
|
||||||
encryption: NwcEncryption,
|
encryption: NwcEncryption,
|
||||||
response: NwcResponse
|
response: NwcResponse,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await transport.publish(
|
await transport.publish(
|
||||||
runtime.info.record.relays,
|
runtime.info.record.relays,
|
||||||
buildResponseEvent(
|
buildResponseEvent(
|
||||||
runtime.walletSecret,
|
walletSecret,
|
||||||
runtime.info.record.clientPubkey,
|
runtime.info.record.clientPubkey,
|
||||||
encryption,
|
encryption,
|
||||||
requestEventId,
|
requestEventId,
|
||||||
response,
|
response,
|
||||||
nowSeconds()
|
nowSeconds(),
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// pay_invoice goes through the connection's queue (budget atomicity);
|
let invoiceQueue: Promise<unknown> = Promise.resolve();
|
||||||
// everything else dispatches directly
|
|
||||||
|
// Payments serialize per connection for budget atomicity. Invoice
|
||||||
|
// creation serializes across the service so different clients cannot
|
||||||
|
// race the wallet-wide durable pending-output cap.
|
||||||
const dispatchSerialized = (
|
const dispatchSerialized = (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
request: NwcRequest
|
request: NwcRequest,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
|
if (request.method === 'make_invoice') {
|
||||||
|
const run = invoiceQueue.then(
|
||||||
|
() => dispatch(ctx, request),
|
||||||
|
() => dispatch(ctx, request),
|
||||||
|
);
|
||||||
|
invoiceQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
if (request.method !== 'pay_invoice') return dispatch(ctx, request);
|
||||||
const run = runtime.queue.then(
|
const run = runtime.queue.then(
|
||||||
() => dispatch(ctx, request),
|
() => dispatch(ctx, request),
|
||||||
() => dispatch(ctx, request)
|
() => dispatch(ctx, request),
|
||||||
)
|
);
|
||||||
runtime.queue = run.catch(() => undefined)
|
runtime.queue = run.catch(() => undefined);
|
||||||
return run
|
return run;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEvent = async (
|
const handleEvent = async (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
event: NostrEvent
|
event: NostrEvent,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const at = nowSeconds()
|
const walletSecret = runtime.walletSecret;
|
||||||
|
if (walletSecret === null) return;
|
||||||
|
const at = nowSeconds();
|
||||||
// replay safety (see the header): too-old requests are dropped
|
// replay safety (see the header): too-old requests are dropped
|
||||||
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
|
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return;
|
||||||
const decrypted = decryptRequest(
|
const decrypted = decryptRequest(
|
||||||
runtime.walletSecret,
|
walletSecret,
|
||||||
runtime.info.walletServicePubkey,
|
runtime.info.walletServicePubkey,
|
||||||
runtime.info.record.clientPubkey,
|
runtime.info.record.clientPubkey,
|
||||||
event,
|
event,
|
||||||
at
|
at,
|
||||||
)
|
);
|
||||||
if (decrypted === null) return
|
if (decrypted === null) return;
|
||||||
if (decrypted.respond) {
|
if (decrypted.respond) {
|
||||||
await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response)
|
await publishResponse(
|
||||||
return
|
runtime,
|
||||||
|
walletSecret,
|
||||||
|
event.id,
|
||||||
|
decrypted.encryption,
|
||||||
|
decrypted.response,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
|
const response = await dispatchSerialized(runtime, ctx, decrypted.request);
|
||||||
await publishResponse(runtime, event.id, decrypted.encryption, response)
|
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
|
||||||
}
|
};
|
||||||
|
|
||||||
const runtimes = records.map(record => {
|
let accepting = true;
|
||||||
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
|
// interrupts long observation waits (the invoice claim poll) at stop;
|
||||||
|
// the drain below still awaits tasks that reached a fund-critical commit
|
||||||
|
const stopController = new AbortController();
|
||||||
|
const inFlight = new Set<Promise<void>>();
|
||||||
|
const track = (task: Promise<void>): void => {
|
||||||
|
inFlight.add(task);
|
||||||
|
void task.then(
|
||||||
|
() => inFlight.delete(task),
|
||||||
|
() => inFlight.delete(task),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const startBackground = (work: () => Promise<void>): boolean => {
|
||||||
|
if (!accepting) return false;
|
||||||
|
track(work());
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let runtimes = ownedRecords.map((record) => {
|
||||||
|
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
|
||||||
const runtime: ConnectionRuntime = {
|
const runtime: ConnectionRuntime = {
|
||||||
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
|
info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
|
||||||
walletSecret,
|
walletSecret,
|
||||||
invoices: new Map(),
|
invoices: new Map(),
|
||||||
queue: Promise.resolve(),
|
queue: Promise.resolve(),
|
||||||
// replaced below, immediately - the field exists because the
|
// replaced below, immediately - the field exists because the
|
||||||
// subscription callback closes over the runtime
|
// subscription callback closes over the runtime
|
||||||
sub: {close: () => undefined}
|
sub: { close: () => undefined },
|
||||||
}
|
};
|
||||||
const ctx: RequestContext = {
|
const ctx: RequestContext = {
|
||||||
deps,
|
deps,
|
||||||
connection: () => runtime.info,
|
connection: () => runtime.info,
|
||||||
updateRecord: updated => {
|
updateRecord: (updated) => {
|
||||||
runtime.info = {...runtime.info, record: updated}
|
runtime.info = { ...runtime.info, record: updated };
|
||||||
},
|
},
|
||||||
invoices: runtime.invoices,
|
invoices: runtime.invoices,
|
||||||
nowSeconds
|
nowSeconds,
|
||||||
}
|
assertOwner: () => {
|
||||||
|
deps.assertCurrentOwner();
|
||||||
|
assertSavedKeyOwner(ownerId);
|
||||||
|
},
|
||||||
|
startBackground,
|
||||||
|
stopSignal: stopController.signal,
|
||||||
|
};
|
||||||
runtime.sub = transport.subscribe(
|
runtime.sub = transport.subscribe(
|
||||||
record.relays,
|
record.relays,
|
||||||
{
|
{
|
||||||
kinds: [NWC_REQUEST_KIND],
|
kinds: [NWC_REQUEST_KIND],
|
||||||
'#p': [runtime.info.walletServicePubkey],
|
'#p': [runtime.info.walletServicePubkey],
|
||||||
since: nowSeconds()
|
since: nowSeconds(),
|
||||||
},
|
},
|
||||||
event => {
|
(event) => {
|
||||||
void handleEvent(runtime, ctx, event).catch(err =>
|
if (!accepting) return;
|
||||||
deps.onError?.(err, runtime.info)
|
const handler = handleEvent(runtime, ctx, event).catch((err) => {
|
||||||
)
|
deps.onError?.(err, runtime.info);
|
||||||
}
|
});
|
||||||
)
|
track(handler);
|
||||||
return runtime
|
},
|
||||||
})
|
);
|
||||||
|
return runtime;
|
||||||
|
});
|
||||||
|
|
||||||
// info events: best-effort - a rejected publish must not sink startup;
|
// info events: best-effort - a rejected publish must not sink startup;
|
||||||
// the client learns capabilities from its first error-free exchange too
|
// the client learns capabilities from its first error-free exchange too
|
||||||
for (const runtime of runtimes) {
|
for (const runtime of runtimes) {
|
||||||
|
const walletSecret = runtime.walletSecret;
|
||||||
|
if (walletSecret === null) continue;
|
||||||
try {
|
try {
|
||||||
await transport.publish(
|
await transport.publish(
|
||||||
runtime.info.record.relays,
|
runtime.info.record.relays,
|
||||||
buildInfoEvent(runtime.walletSecret, nowSeconds())
|
buildInfoEvent(walletSecret, nowSeconds()),
|
||||||
)
|
);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
deps.onError?.(err, runtime.info)
|
deps.onError?.(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('NWC info publication failed.', { cause: error }),
|
||||||
|
runtime.info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connections = runtimes.map((runtime) => runtime.info);
|
||||||
|
let stopPromise: Promise<void> | null = null;
|
||||||
|
const stop = (): Promise<void> => {
|
||||||
|
if (stopPromise !== null) return stopPromise;
|
||||||
|
accepting = false;
|
||||||
|
for (const runtime of runtimes) runtime.sub.close();
|
||||||
|
stopController.abort();
|
||||||
|
stopPromise = Promise.allSettled([...inFlight])
|
||||||
|
.then((results) => {
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') throw result.reason;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
for (const runtime of runtimes) {
|
||||||
|
runtime.walletSecret?.fill(0);
|
||||||
|
runtime.walletSecret = null;
|
||||||
|
runtime.invoices.clear();
|
||||||
|
}
|
||||||
|
runtimes = [];
|
||||||
|
});
|
||||||
|
return stopPromise;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connections: runtimes.map(r => r.info),
|
connections,
|
||||||
stop: () => {
|
stop,
|
||||||
for (const runtime of runtimes) runtime.sub.close()
|
};
|
||||||
}
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,37 +4,37 @@
|
|||||||
// importing the service module never opens (or even references) a
|
// importing the service module never opens (or even references) a
|
||||||
// WebSocket.
|
// WebSocket.
|
||||||
|
|
||||||
import type {Filter as NostrFilter} from 'nostr-tools/filter'
|
import type { Filter as NostrFilter } from 'nostr-tools/filter';
|
||||||
|
|
||||||
import type {NostrEvent} from './protocol'
|
import type { NostrEvent } from './protocol';
|
||||||
|
|
||||||
export type {NostrFilter}
|
export type { NostrFilter };
|
||||||
|
|
||||||
export type NwcSubscription = {
|
export type NwcSubscription = {
|
||||||
close: () => void
|
close: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type NwcTransport = {
|
export type NwcTransport = {
|
||||||
publish: (relays: string[], event: NostrEvent) => Promise<void>
|
publish: (relays: string[], event: NostrEvent) => Promise<void>;
|
||||||
subscribe: (
|
subscribe: (
|
||||||
relays: string[],
|
relays: string[],
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
onEvent: (event: NostrEvent) => void
|
onEvent: (event: NostrEvent) => void,
|
||||||
) => NwcSubscription
|
) => NwcSubscription;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const defaultNwcTransport = async (): Promise<NwcTransport> => {
|
export const defaultNwcTransport = async (): Promise<NwcTransport> => {
|
||||||
const {SimplePool} = await import('nostr-tools/pool')
|
const { SimplePool } = await import('nostr-tools/pool');
|
||||||
const pool = new SimplePool()
|
const pool = new SimplePool();
|
||||||
return {
|
return {
|
||||||
publish: async (relays, event) => {
|
publish: async (relays, event) => {
|
||||||
const results = await Promise.allSettled(pool.publish(relays, event))
|
const results = await Promise.allSettled(pool.publish(relays, event));
|
||||||
// one honest relay accepting is enough - same rule as the backup
|
// one honest relay accepting is enough - same rule as the backup
|
||||||
if (!results.some(r => r.status === 'fulfilled')) {
|
if (!results.some((r) => r.status === 'fulfilled')) {
|
||||||
throw new Error('No relay accepted the event.')
|
throw new Error('No relay accepted the event.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
subscribe: (relays, filter, onEvent) =>
|
subscribe: (relays, filter, onEvent) =>
|
||||||
pool.subscribeMany(relays, filter, {onevent: onEvent})
|
pool.subscribeMany(relays, filter, { onevent: onEvent }),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import type { Bearer } from './types';
|
||||||
|
import type { CarveResult } from './ops';
|
||||||
|
import {
|
||||||
|
CarveCheckpointRequiredError,
|
||||||
|
OutputSecretAllocationRequiredError,
|
||||||
|
UncertainOutcomeError,
|
||||||
|
ensureExactAmount,
|
||||||
|
} from './ops';
|
||||||
|
import { requiredValue } from './test-utils';
|
||||||
|
import {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
makeBearer,
|
||||||
|
mint,
|
||||||
|
noteUrl,
|
||||||
|
secret,
|
||||||
|
signatureKeysFor,
|
||||||
|
} from './ops.testHarness';
|
||||||
|
|
||||||
|
describe('ensureExactAmount', () => {
|
||||||
|
it('returns an already-exact note untouched, burning nothing', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('01');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
const result = await ensureExactAmount([bearer], 21_000);
|
||||||
|
expect(noteK1(result.note.url)).toBe(k1);
|
||||||
|
expect(result.consumed).toEqual([]);
|
||||||
|
expect(result.change).toBeUndefined();
|
||||||
|
expect(instance.state.noteState(k1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('split path: carves an exact note off a larger one, with change', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('02');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
mintSignatureKeys: signatureKeysFor(instance),
|
||||||
|
});
|
||||||
|
expect(result.note.amount).toBe(5_000);
|
||||||
|
expect(result.note.verified).toBe(true);
|
||||||
|
expect(result.change?.amount).toBe(16_000);
|
||||||
|
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
|
||||||
|
expect(instance.state.noteState(k1)).toBe('burned');
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge path: combines notes summing exactly to the target', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const first = await makeBearer(instance, secret('03'), 3_000);
|
||||||
|
const second = await makeBearer(instance, secret('04'), 4_000);
|
||||||
|
const result = await ensureExactAmount([first, second], 7_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(result.note.amount).toBe(7_000);
|
||||||
|
expect(result.change).toBeUndefined();
|
||||||
|
expect(result.consumed).toHaveLength(2);
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned');
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned');
|
||||||
|
const mergedK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge+split path: splits the target off several notes in one request', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const first = await makeBearer(instance, secret('05'), 3_000);
|
||||||
|
const second = await makeBearer(instance, secret('06'), 4_000);
|
||||||
|
const result = await ensureExactAmount([first, second], 5_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(result.note.amount).toBe(5_000);
|
||||||
|
expect(result.change?.amount).toBe(2_000);
|
||||||
|
expect(result.consumed).toHaveLength(2);
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes spent and unverified notes from selection', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const spentBearer = await makeBearer(instance, secret('07'), 50_000);
|
||||||
|
const unverified: Bearer = {
|
||||||
|
...(await makeBearer(instance, secret('08'), 50_000)),
|
||||||
|
verified: false,
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
|
||||||
|
).rejects.toThrow(/enough/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an amount no mint can cover', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('09'), 5_000);
|
||||||
|
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
|
||||||
|
const instance = await mint({ dropAfterMutation: true });
|
||||||
|
const k1 = secret('10');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
|
||||||
|
expect(partK1).not.toBe(k1);
|
||||||
|
expect(instance.state.noteState(k1)).toBe('burned');
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('11');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
// every mutation answer is lost - the first attempt's AND the forced
|
||||||
|
// byte-identical replay's (the mint executed both; the replay is the
|
||||||
|
// recorded success served again, then dropped by the wrapper) - and
|
||||||
|
// every informational probe is unreachable, so neither the mutation
|
||||||
|
// nor the probe can be confirmed
|
||||||
|
const everythingDyingFetch: typeof fetch = async (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.includes('/w/cb')) {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
await response.arrayBuffer();
|
||||||
|
throw new Error('mutation response lost');
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('probe unreachable'));
|
||||||
|
};
|
||||||
|
const checkpoints: CarveResult[] = [];
|
||||||
|
const failure = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
fetch: everythingDyingFetch,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: (carve) => {
|
||||||
|
checkpoints.push(carve);
|
||||||
|
},
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
expect(failure).toBeInstanceOf(UncertainOutcomeError);
|
||||||
|
if (!(failure instanceof UncertainOutcomeError)) throw failure;
|
||||||
|
expect(failure.possibleOutputs).toEqual([]);
|
||||||
|
expect(checkpoints).toHaveLength(1);
|
||||||
|
expect(checkpoints[0]?.consumed).toEqual([]);
|
||||||
|
expect(checkpoints[0]?.note.amount).toBe(5_000);
|
||||||
|
expect(checkpoints[0]?.change?.amount).toBe(16_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('12'), 21_000);
|
||||||
|
const seen: CarveResult[] = [];
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: (carve) => {
|
||||||
|
seen.push(carve);
|
||||||
|
},
|
||||||
|
mintSignatureKeys: signatureKeysFor(instance),
|
||||||
|
});
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
expect(seen[0]?.note.verified).toBe(false);
|
||||||
|
expect(seen[0]?.consumed).toEqual([]);
|
||||||
|
expect(seen[0]?.change).toBeDefined();
|
||||||
|
expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url));
|
||||||
|
expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
|
||||||
|
noteK1(requiredValue(result.change).url),
|
||||||
|
);
|
||||||
|
// the landed checkpoint re-reports the SAME staged output (same secret)
|
||||||
|
// - its URL has since gained the mint's signature
|
||||||
|
expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url));
|
||||||
|
expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull();
|
||||||
|
expect(seen[1]?.note.verified).toBe(true);
|
||||||
|
expect(seen[1]?.change).toBeUndefined();
|
||||||
|
expect(seen[1]?.consumed).toEqual(result.consumed);
|
||||||
|
expect(result.consumed).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stage the split change again during the landed checkpoint', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('17'), 21_000);
|
||||||
|
const trackedSecrets = new Set<string>();
|
||||||
|
const retiredIds = new Set<string>();
|
||||||
|
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: (carve) => {
|
||||||
|
// checkpoints identify an output by its secret: the landed phase's
|
||||||
|
// URL has gained a signature the staged phase could not have had
|
||||||
|
const k1 = noteK1(carve.note.url);
|
||||||
|
if (k1) trackedSecrets.add(k1);
|
||||||
|
const changeK1 = carve.change ? noteK1(carve.change.url) : null;
|
||||||
|
if (changeK1) trackedSecrets.add(changeK1);
|
||||||
|
for (const consumed of carve.consumed) retiredIds.add(consumed.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trackedSecrets).toEqual(
|
||||||
|
new Set([
|
||||||
|
requiredValue(noteK1(result.note.url)),
|
||||||
|
requiredValue(noteK1(requiredValue(result.change).url)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(retiredIds).toEqual(new Set([bearer.id]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const sourceSecret = secret('15');
|
||||||
|
const bearer = await makeBearer(instance, sourceSecret, 21_000);
|
||||||
|
const seen: CarveResult[] = [];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([bearer], 5_000, {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: (carve) => {
|
||||||
|
seen.push(carve);
|
||||||
|
if (carve.consumed.length > 0) throw new Error('final commit failed');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/final commit failed/);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
expect(seen[0]?.consumed).toEqual([]);
|
||||||
|
expect(seen[0]?.change).toBeDefined();
|
||||||
|
expect(instance.state.noteState(sourceSecret)).toBe('burned');
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
|
||||||
|
'outstanding',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
instance.state.noteState(
|
||||||
|
requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
|
||||||
|
),
|
||||||
|
).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never fires onCarve for an already-exact note - nothing was burned', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('13'), 21_000);
|
||||||
|
let called = false;
|
||||||
|
const result = await ensureExactAmount([bearer], 21_000, {
|
||||||
|
onCarve: () => {
|
||||||
|
called = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(called).toBe(false);
|
||||||
|
expect(result.consumed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a typed durable checkpoint before a mutating carve', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('16'), 21_000);
|
||||||
|
|
||||||
|
await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
|
||||||
|
CarveCheckpointRequiredError,
|
||||||
|
);
|
||||||
|
expect(instance.state.noteState(secret('16'))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an onCarve failure before the carve reaches the mint', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('14'), 21_000);
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([bearer], 5_000, {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: () => {
|
||||||
|
throw new Error('commit failed');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/commit failed/);
|
||||||
|
expect(instance.state.noteState(secret('14'))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the split outputs from the caller allocation path, exactly two', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('80'), 21_000);
|
||||||
|
const allocations: Array<{ server: string; count: number }> = [];
|
||||||
|
const allocated = [secret('81'), secret('82')];
|
||||||
|
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets: (server, count) => {
|
||||||
|
allocations.push({ server, count });
|
||||||
|
return Promise.resolve(allocated.slice(0, count));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 2 }]);
|
||||||
|
expect(noteK1(result.note.url)).toBe(allocated[0]);
|
||||||
|
expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the merge output from the caller allocation path, exactly one', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const first = await makeBearer(instance, secret('83'), 3_000);
|
||||||
|
const second = await makeBearer(instance, secret('84'), 4_000);
|
||||||
|
const allocations: Array<{ server: string; count: number }> = [];
|
||||||
|
const allocated = [secret('85')];
|
||||||
|
|
||||||
|
const result = await ensureExactAmount([first, second], 7_000, {
|
||||||
|
onCarve: () => undefined,
|
||||||
|
allocateOutputSecrets: (server, count) => {
|
||||||
|
allocations.push({ server, count });
|
||||||
|
return Promise.resolve(allocated.slice(0, count));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
|
||||||
|
expect(noteK1(result.note.url)).toBe(allocated[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allocates nothing when one note already holds the exact amount', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('86'), 21_000);
|
||||||
|
let allocations = 0;
|
||||||
|
|
||||||
|
await ensureExactAmount([bearer], 21_000, {
|
||||||
|
allocateOutputSecrets: () => {
|
||||||
|
allocations += 1;
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allocations).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a mutating carve without an allocation path, before any staging', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const split = await makeBearer(instance, secret('87'), 21_000);
|
||||||
|
const mergeA = await makeBearer(instance, secret('88'), 3_000);
|
||||||
|
const mergeB = await makeBearer(instance, secret('89'), 4_000);
|
||||||
|
let mutations = 0;
|
||||||
|
const countingFetch: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.includes('/w/cb')) mutations += 1;
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }),
|
||||||
|
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([mergeA, mergeB], 7_000, {
|
||||||
|
fetch: countingFetch,
|
||||||
|
onCarve: () => undefined,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
|
||||||
|
|
||||||
|
expect(mutations).toBe(0);
|
||||||
|
expect(instance.state.noteState(secret('87'))).toBe('outstanding');
|
||||||
|
expect(instance.state.noteState(secret('88'))).toBe('outstanding');
|
||||||
|
expect(instance.state.noteState(secret('89'))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { hashK1, noteK1 } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
|
||||||
|
import { requiredValue } from './test-utils';
|
||||||
|
import type { Mint } from './ops.testHarness';
|
||||||
|
import {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
mint,
|
||||||
|
persistOutput,
|
||||||
|
secret,
|
||||||
|
settleLastInvoice,
|
||||||
|
} from './ops.testHarness';
|
||||||
|
|
||||||
|
const HEX32 = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
const boundInvoice = (instance: Mint) =>
|
||||||
|
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo);
|
||||||
|
|
||||||
|
describe('mint -> claim, comment-bound output', () => {
|
||||||
|
it('names the quote with the hash of a wallet-chosen secret', async () => {
|
||||||
|
const instance = await mint({ testHooks: true });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(prepared.noteSecret).toMatch(HEX32);
|
||||||
|
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims the output at the wallet secret without rotating it', async () => {
|
||||||
|
const instance = await mint({ testHooks: true });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
const preimage = await settleLastInvoice(instance);
|
||||||
|
const claimed = await claimMintedNote(prepared, {
|
||||||
|
intervalMs: 10,
|
||||||
|
intervalCapMs: 50,
|
||||||
|
maxWaitMs: 5_000,
|
||||||
|
});
|
||||||
|
expect(claimed.note.verified).toBe(true);
|
||||||
|
expect(claimed.note.amount).toBe(21_000);
|
||||||
|
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
|
||||||
|
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
|
||||||
|
expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
|
||||||
|
expect(instance.state.noteState(preimage)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out cleanly when the invoice is never paid', async () => {
|
||||||
|
const instance = await mint({ testHooks: true });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
|
||||||
|
).rejects.toThrow(/not confirmed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the required comment even when the optional h extension is advertised', async () => {
|
||||||
|
const instance = await mint({ mintToHash: true, testHooks: true });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
|
||||||
|
await settleLastInvoice(instance);
|
||||||
|
const claimed = await claimMintedNote(prepared, {
|
||||||
|
intervalMs: 10,
|
||||||
|
intervalCapMs: 50,
|
||||||
|
maxWaitMs: 5_000,
|
||||||
|
});
|
||||||
|
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
|
||||||
|
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the staged note secret from the caller allocation path', async () => {
|
||||||
|
const instance = await mint({ testHooks: true });
|
||||||
|
const allocations: Array<{ server: string; count: number }> = [];
|
||||||
|
const allocated = [secret('90')];
|
||||||
|
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets: (server, count) => {
|
||||||
|
allocations.push({ server, count });
|
||||||
|
return Promise.resolve(allocated.slice(0, count));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
|
||||||
|
expect(prepared.noteSecret).toBe(allocated[0]);
|
||||||
|
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => {
|
||||||
|
const instance = await mint({ testHooks: true });
|
||||||
|
let persisted = 0;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
|
||||||
|
persistOutput: () => {
|
||||||
|
persisted += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
|
||||||
|
|
||||||
|
expect(persisted).toBe(0);
|
||||||
|
expect(instance.state.invoices.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
NoteSpentError,
|
||||||
|
PendingNoteError,
|
||||||
|
fetchNoteInfo,
|
||||||
|
meltNote,
|
||||||
|
noteK1,
|
||||||
|
rotateNote,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
claimMintedNote,
|
||||||
|
OutputSecretAllocationRequiredError,
|
||||||
|
prepareMint,
|
||||||
|
receiveBearer,
|
||||||
|
ReceiveRotationStagingRequiredError,
|
||||||
|
} from './ops';
|
||||||
|
import { requiredValue } from './test-utils';
|
||||||
|
import type { NewBearer } from './types';
|
||||||
|
import {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
makeBearer,
|
||||||
|
mint,
|
||||||
|
noteUrl,
|
||||||
|
persistOutput,
|
||||||
|
secret,
|
||||||
|
settleLastInvoice,
|
||||||
|
stageRotation,
|
||||||
|
} from './ops.testHarness';
|
||||||
|
|
||||||
|
describe('mint fee', () => {
|
||||||
|
it('grosses the invoice up for an advertised mint fee', async () => {
|
||||||
|
const instance = await mint({ testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000 });
|
||||||
|
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
|
||||||
|
persistOutput,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
});
|
||||||
|
expect(prepared.grossMsat).toBeGreaterThan(100_000);
|
||||||
|
await settleLastInvoice(instance);
|
||||||
|
const claimed = await claimMintedNote(prepared, {
|
||||||
|
intervalMs: 10,
|
||||||
|
intervalCapMs: 20,
|
||||||
|
maxWaitMs: 5_000,
|
||||||
|
});
|
||||||
|
// the service's maxWithdrawable is authoritative: the fee is withheld
|
||||||
|
// from the gross, so the note nets between the asked value and the gross
|
||||||
|
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000);
|
||||||
|
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('receiveBearer', () => {
|
||||||
|
it('stages the rotation output before the rotate can reach the mint', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('94');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
const rotationSecret = secret('95');
|
||||||
|
let staged: NewBearer | undefined;
|
||||||
|
const observingFetch: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.includes('/w/cb')) expect(staged).toBeDefined();
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
|
||||||
|
fetch: observingFetch,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
|
||||||
|
stageRotation: (note) => {
|
||||||
|
staged = note;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(received.stage).toBe('finalize');
|
||||||
|
expect(received.rotated).toBe(true);
|
||||||
|
expect(noteK1(received.note.url)).toBe(rotationSecret);
|
||||||
|
expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret);
|
||||||
|
expect(requiredValue(staged).verified).toBe(false);
|
||||||
|
expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1);
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('burned');
|
||||||
|
expect(instance.state.noteState(rotationSecret)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports discard when the rotate provably never landed', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('96');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
const rotationSecret = secret('97');
|
||||||
|
let staged: NewBearer | undefined;
|
||||||
|
const failingFetch: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'));
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
|
||||||
|
fetch: failingFetch,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
|
||||||
|
stageRotation: (note) => {
|
||||||
|
staged = note;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(received.stage).toBe('discard');
|
||||||
|
expect(received.rotated).toBe(false);
|
||||||
|
expect(received.note.verified).toBe(true);
|
||||||
|
expect(received.rotationError).toBeDefined();
|
||||||
|
// nothing landed: both the sender's copy and the would-be output live on
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('outstanding');
|
||||||
|
expect(instance.state.noteState(rotationSecret)).toBeNull();
|
||||||
|
expect(staged).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('20');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
stageRotation,
|
||||||
|
});
|
||||||
|
expect(received.rotated).toBe(true);
|
||||||
|
expect(received.stage).toBe('finalize');
|
||||||
|
expect(received.note.amount).toBe(21_000);
|
||||||
|
expect(received.note.verified).toBe(true);
|
||||||
|
const newK1 = requiredValue(noteK1(received.note.url));
|
||||||
|
expect(newK1).not.toBe(senderK1);
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('burned');
|
||||||
|
expect(instance.state.noteState(newK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a note the wallet already holds', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('21');
|
||||||
|
const existing = await makeBearer(instance, senderK1, 21_000);
|
||||||
|
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
|
||||||
|
/already/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a spent note as definitively spent', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('22');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
const info = await fetchNoteInfo(bearer.url);
|
||||||
|
await rotateNote(info.callback, k1);
|
||||||
|
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
|
||||||
|
NoteSpentError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
|
||||||
|
const instance = await mint({ meltNeverSettles: true });
|
||||||
|
const k1 = secret('23');
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000);
|
||||||
|
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
|
||||||
|
await expect(
|
||||||
|
receiveBearer(noteUrl(instance, k1, 21_000), [], {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
stageRotation,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(PendingNoteError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to rotate without a durable staging checkpoint', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('9a');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }),
|
||||||
|
).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError);
|
||||||
|
// the sender's copy is untouched - no rotation was attempted
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to rotate without an allocation path', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('9b');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }),
|
||||||
|
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { noteK1 } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OutputSecretAllocationRequiredError,
|
||||||
|
PayReturnCheckpointRequiredError,
|
||||||
|
payWithBearers,
|
||||||
|
} from './ops';
|
||||||
|
import { requiredValue } from './test-utils';
|
||||||
|
import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
|
||||||
|
|
||||||
|
describe('payWithBearers', () => {
|
||||||
|
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('30'), 21_000);
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('settled');
|
||||||
|
expect(instance.state.noteState(secret('30'))).toBe('burned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays a Lightning Address by requesting an invoice first', async () => {
|
||||||
|
const payer = await mint();
|
||||||
|
const bearer = await makeBearer(payer, secret('31'), 21_000);
|
||||||
|
const payeeFetch: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
tag: 'payRequest',
|
||||||
|
callback: 'https://payee.example/invoice',
|
||||||
|
minSendable: 1_000,
|
||||||
|
maxSendable: 100_000_000,
|
||||||
|
metadata: '[]',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.startsWith('https://payee.example/invoice')) {
|
||||||
|
return Promise.resolve(Response.json({ pr: 'lnbc210n1pjqrstuvwxyz' }));
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
const result = await payWithBearers([bearer], 'alice@payee.example', {
|
||||||
|
amountMsat: 21_000,
|
||||||
|
kit: { fetch: payeeFetch },
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('settled');
|
||||||
|
expect(result.invoice).toMatch(/^lnbc/);
|
||||||
|
expect(payer.state.noteState(secret('31'))).toBe('burned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carves the exact amount out of a larger note before melting', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('32'), 50_000);
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: () => undefined,
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('settled');
|
||||||
|
expect(instance.state.noteState(secret('32'))).toBe('burned');
|
||||||
|
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
|
||||||
|
expect(result.carve.change?.amount).toBe(29_000);
|
||||||
|
const change = requiredValue(result.carve.change);
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
|
||||||
|
const instance = await mint({ meltAlwaysFails: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('33'), 21_000);
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onReturnReady: () => undefined,
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('failed-funds-returned');
|
||||||
|
expect(instance.state.noteState(secret('33'))).toBe('burned');
|
||||||
|
const rotatedNote = requiredValue(result.rotatedNote);
|
||||||
|
expect(rotatedNote.verified).toBe(true);
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for durable recovery-secret staging before rotating returned funds', async () => {
|
||||||
|
const instance = await mint({ meltAlwaysFails: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('39'), 21_000);
|
||||||
|
let releaseStage: (() => void) | undefined;
|
||||||
|
const stageGate = new Promise<void>((resolve) => {
|
||||||
|
releaseStage = resolve;
|
||||||
|
});
|
||||||
|
let stageStarted: (() => void) | undefined;
|
||||||
|
const stageEntered = new Promise<void>((resolve) => {
|
||||||
|
stageStarted = resolve;
|
||||||
|
});
|
||||||
|
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onReturnReady: async () => {
|
||||||
|
stageStarted?.();
|
||||||
|
await stageGate;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await stageEntered;
|
||||||
|
expect(instance.state.noteState(secret('39'))).toBe('outstanding');
|
||||||
|
releaseStage?.();
|
||||||
|
expect((await payment).outcome).toBe('failed-funds-returned');
|
||||||
|
expect(instance.state.noteState(secret('39'))).toBe('burned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a never-settling melt as unknown-still-pending', async () => {
|
||||||
|
const instance = await mint({ meltNeverSettles: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('34'), 21_000);
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onReturnReady: () => undefined,
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('unknown-still-pending');
|
||||||
|
expect(instance.state.noteState(secret('34'))).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('35'), 21_000);
|
||||||
|
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/);
|
||||||
|
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts before the melt when the carve commit hook fails', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('36'), 21_000);
|
||||||
|
// paying 10_500 off a 21_000 note forces a split, which must commit
|
||||||
|
// through the hook before the melt may start
|
||||||
|
await expect(
|
||||||
|
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: () => {
|
||||||
|
throw new Error('commit failed');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/commit failed/);
|
||||||
|
// The checkpoint failed before either split or melt reached the mint.
|
||||||
|
expect(instance.state.noteState(secret('36'))).toBe('outstanding');
|
||||||
|
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the return recovery secret from the caller allocation path', async () => {
|
||||||
|
const instance = await mint({ meltAlwaysFails: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('8a'), 21_000);
|
||||||
|
const allocations: Array<{ server: string; count: number }> = [];
|
||||||
|
const allocated = [secret('8b')];
|
||||||
|
const stagedSecrets: string[] = [];
|
||||||
|
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
allocateOutputSecrets: (server, count) => {
|
||||||
|
allocations.push({ server, count });
|
||||||
|
return Promise.resolve(allocated.slice(0, count));
|
||||||
|
},
|
||||||
|
onReturnReady: (_carve, recoverySecret) => {
|
||||||
|
stagedSecrets.push(recoverySecret);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('failed-funds-returned');
|
||||||
|
expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
|
||||||
|
expect(stagedSecrets).toEqual(allocated);
|
||||||
|
expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allocates nothing while the melt settles cleanly', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const bearer = await makeBearer(instance, secret('8c'), 21_000);
|
||||||
|
let allocations = 0;
|
||||||
|
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
|
||||||
|
allocateOutputSecrets: () => {
|
||||||
|
allocations += 1;
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('settled');
|
||||||
|
expect(allocations).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the return classification without its durable checkpoint', async () => {
|
||||||
|
const instance = await mint({ meltAlwaysFails: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('8d'), 21_000);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
allocateOutputSecrets,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError);
|
||||||
|
// the payment failed and the mint restored the note; the refusal came
|
||||||
|
// before the classification rotate, so nothing else moved
|
||||||
|
expect(instance.state.noteState(secret('8d'))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the return classification without an allocation path', async () => {
|
||||||
|
const instance = await mint({ meltAlwaysFails: true });
|
||||||
|
const bearer = await makeBearer(instance, secret('8e'), 21_000);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
|
||||||
|
onReturnReady: () => undefined,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
|
||||||
|
expect(instance.state.noteState(secret('8e'))).toBe('outstanding');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { noteK1 } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import type { CarveResult } from './ops';
|
||||||
|
import { ensureExactAmount, receiveBearer } from './ops';
|
||||||
|
import { requiredValue } from './test-utils';
|
||||||
|
import {
|
||||||
|
allocateOutputSecrets,
|
||||||
|
makeBearer,
|
||||||
|
mint,
|
||||||
|
noteUrl,
|
||||||
|
retryingCbFetch,
|
||||||
|
secret,
|
||||||
|
stageRotation,
|
||||||
|
} from './ops.testHarness';
|
||||||
|
|
||||||
|
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
|
||||||
|
// can execute the first attempt of a mutation and refuse the byte-identical
|
||||||
|
// repeat as an already-spent input, which is the only answer the wallet
|
||||||
|
// ever sees. Every mutation that carries fresh secrets must probe the
|
||||||
|
// would-be outputs before believing such a refusal: landed means the
|
||||||
|
// carried secrets are the only money left.
|
||||||
|
describe('mutation retry refusals', () => {
|
||||||
|
it('rescues the outputs when the split answer is a retry refusal', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('60');
|
||||||
|
const bearer = await makeBearer(instance, k1, 50_000);
|
||||||
|
const result = await ensureExactAmount([bearer], 25_000, {
|
||||||
|
fetch: retryingCbFetch(),
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: () => undefined,
|
||||||
|
});
|
||||||
|
// the split landed on the first (unseen) attempt: the input is burned
|
||||||
|
// and both outputs are live under the secrets the wallet carried
|
||||||
|
expect(instance.state.noteState(k1)).toBe('burned');
|
||||||
|
expect(result.consumed).toHaveLength(1);
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
expect(partK1).not.toBe(k1);
|
||||||
|
expect(result.note.amount).toBe(25_000);
|
||||||
|
expect(instance.state.noteState(partK1)).toBe('outstanding');
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
|
||||||
|
expect(instance.state.noteState(changeK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rescues the combined note when the merge answer is a retry refusal', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const a = await makeBearer(instance, secret('61'), 20_000);
|
||||||
|
const b = await makeBearer(instance, secret('62'), 5_000);
|
||||||
|
const result = await ensureExactAmount([a, b], 25_000, {
|
||||||
|
fetch: retryingCbFetch(),
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: () => undefined,
|
||||||
|
});
|
||||||
|
expect(result.consumed).toHaveLength(2);
|
||||||
|
expect(instance.state.noteState(secret('61'))).toBe('burned');
|
||||||
|
expect(instance.state.noteState(secret('62'))).toBe('burned');
|
||||||
|
const mergedK1 = requiredValue(noteK1(result.note.url));
|
||||||
|
expect(result.note.amount).toBe(25_000);
|
||||||
|
expect(instance.state.noteState(mergedK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const senderK1 = secret('63');
|
||||||
|
instance.state.creditNote(senderK1, 21_000);
|
||||||
|
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
|
||||||
|
fetch: retryingCbFetch(),
|
||||||
|
allocateOutputSecrets,
|
||||||
|
stageRotation,
|
||||||
|
});
|
||||||
|
expect(received.rotated).toBe(true);
|
||||||
|
expect(received.note.amount).toBe(21_000);
|
||||||
|
expect(instance.state.noteState(senderK1)).toBe('burned');
|
||||||
|
const k1 = requiredValue(noteK1(received.note.url));
|
||||||
|
expect(k1).not.toBe(senderK1);
|
||||||
|
expect(instance.state.noteState(k1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still fails a genuine refusal - a note spent before the split', async () => {
|
||||||
|
const instance = await mint();
|
||||||
|
const k1 = secret('64');
|
||||||
|
const bearer = await makeBearer(instance, k1, 50_000);
|
||||||
|
instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
|
||||||
|
const checkpoints: CarveResult[] = [];
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([bearer], 25_000, {
|
||||||
|
fetch: retryingCbFetch(),
|
||||||
|
allocateOutputSecrets,
|
||||||
|
onCarve: (carve) => {
|
||||||
|
checkpoints.push(carve);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/spent/i);
|
||||||
|
expect(checkpoints).toHaveLength(1);
|
||||||
|
expect(checkpoints[0]?.consumed).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user