62 Commits
Author SHA1 Message Date
protom bfa0572090 Merge pull request #4 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.3
2026-08-24 19:07:58 +02:00
github-actions[bot] 6892a3cc07 chore(main): release sattle 0.0.3 2026-08-24 14:30:42 +00:00
protom 338b2812e3 Merge pull request #3 from tompro/refactor/wallet-integrity
fix(wallet): enforce owner-bound integrity and durable commits
2026-08-24 16:30:06 +02:00
protom e892a330fc fix: consume owner monitor transition failures 2026-08-24 16:26:29 +02:00
protom 72f387f6e5 docs: document wallet integrity boundaries 2026-08-22 16:56:50 +02:00
protom 76d59d8d08 test: harden NWC browser lifecycle coverage 2026-08-22 16:56:49 +02:00
protom a40079200d test: cover wallet lifecycle through the browser 2026-08-22 16:56:49 +02:00
protom 578613c869 refactor: split wallet onboarding panels 2026-08-22 16:56:49 +02:00
protom 3c2a2cc4a5 feat: await trusted mint management actions 2026-08-22 16:56:49 +02:00
protom 401a26dc6b fix: fence send and pay flows 2026-08-22 16:56:38 +02:00
protom f087ebe2f2 fix: surface receive post-commit trust failures 2026-08-22 16:56:38 +02:00
protom c59521a421 fix: commit carved wallet funds atomically 2026-08-22 16:56:38 +02:00
protom 3f6010b312 feat: restore Nostr backups through wallet lifecycle 2026-08-22 16:56:37 +02:00
protom 99e6ae1b12 fix: await wallet commits in the NWC store 2026-08-22 16:56:37 +02:00
protom 81c28ec16d feat: scope trusted mint state in Pinia 2026-08-22 16:56:25 +02:00
protom e98373491f fix: drain accepted NWC work before wallet lock 2026-08-22 16:56:25 +02:00
protom 2e0f0038f7 feat: invalidate stale wallet tabs 2026-08-22 16:56:25 +02:00
protom 3acc63e968 fix: clear runtime state after lifecycle failures 2026-08-22 16:56:25 +02:00
protom c319e11d3e feat: activate only proven wallet owners 2026-08-22 16:56:24 +02:00
protom 62b93baa05 feat: serialize wallet lifecycle transitions 2026-08-22 16:56:11 +02:00
protom 5b3a69e910 feat: apply wallet fund changes atomically 2026-08-22 16:56:11 +02:00
protom 9fa4abdaf3 feat: fence wallet mutations by lifecycle owner 2026-08-22 16:56:11 +02:00
protom 54031dc8de fix: publish activity only after persistence 2026-08-22 16:56:10 +02:00
protom e9bbb358aa fix: mirror trusted mint commits across tabs 2026-08-22 16:56:10 +02:00
protom 83c9e5b95f feat: converge trusted mints on storage events 2026-08-22 16:55:47 +02:00
protom d735aca885 feat: serialize trusted mint transactions 2026-08-22 16:55:47 +02:00
protom 82f008b33f feat: validate trusted mint registry envelopes 2026-08-22 16:55:47 +02:00
protom d890a69c40 feat: preserve trusted mint rekey transitions 2026-08-22 16:55:47 +02:00
protom 7890a492f3 refactor: export owner-aware NWC APIs 2026-08-22 16:55:47 +02:00
protom 3ed214c157 feat: drain tracked NWC handlers on stop 2026-08-22 16:55:36 +02:00
protom 513c67fc14 fix: hide invoice settlement until commit 2026-08-22 16:55:35 +02:00
protom 09b7b63c9d fix: await NWC payment bearer commits 2026-08-22 16:55:35 +02:00
protom fff24a75b2 fix: preserve conservative NWC budget debits 2026-08-22 16:55:35 +02:00
protom b52172c0cb fix: validate NWC requests before dispatch 2026-08-22 16:55:35 +02:00
protom 228a92b0f0 feat: create owner-bound NWC connections 2026-08-22 16:55:24 +02:00
protom 9f6bcad706 feat: scope NWC storage to wallet owners 2026-08-22 16:55:23 +02:00
protom 3054834cdb test: split NWC protocol harness 2026-08-22 16:55:23 +02:00
protom 845ec410e5 feat: expose fund ownership assertions 2026-08-22 16:55:23 +02:00
protom 02f901c87c fix: fence inter-mint transfer operations 2026-08-22 16:55:23 +02:00
protom c4c9d8a94a fix: fence invoice payment operations 2026-08-22 16:55:12 +02:00
protom 7f69f781f5 fix: fence mint and receive operations 2026-08-22 16:55:12 +02:00
protom 6fa4283ed6 fix: fence carve operations before mutation 2026-08-22 16:55:12 +02:00
protom 22420b9112 test: split wallet operation harness 2026-08-22 16:55:12 +02:00
protom f55f208836 feat: await owner-aware Nostr restores 2026-08-22 16:55:12 +02:00
protom 7607e47358 feat: apply owner-aware wallet backups 2026-08-22 16:55:00 +02:00
protom 0862a10b01 fix: validate auxiliary wallet storage 2026-08-22 16:55:00 +02:00
protom 586d12b666 feat: commit bearer changesets in one write 2026-08-22 16:55:00 +02:00
protom 69fbdb68ee feat: add persisted owner guards and events 2026-08-22 16:55:00 +02:00
protom 1dbf5788f9 fix: normalize storage lock failures 2026-08-22 16:55:00 +02:00
protom 12e4ec9199 feat: scope passkey slots to proven owners 2026-08-22 16:54:49 +02:00
protom d9be457221 feat: harden passkey ceremonies 2026-08-22 16:54:49 +02:00
protom 058b10ddf1 refactor: isolate passkey wrapping crypto 2026-08-22 16:54:48 +02:00
protom acfab83438 feat: bind saved keys to versioned wallet owners 2026-08-22 16:54:48 +02:00
protom 0e0133f550 test: split Nostr backup integration cases 2026-08-22 16:54:48 +02:00
protom b258d86af7 refactor: isolate Nostr backup publishing transport 2026-08-22 16:54:29 +02:00
protom 2a90343e7e refactor: validate Nostr backup event payloads 2026-08-22 16:54:29 +02:00
protom b6fff605a1 refactor: tighten protocol amount helpers 2026-08-22 16:54:29 +02:00
protom 8548afcd21 refactor: add strict JSON parsing helpers 2026-08-22 16:54:29 +02:00
protom 30d1d0aeae build: pin LNURLcash protocol packages to npm 0.1.1 2026-08-22 16:54:28 +02:00
protom 84441f53e3 Merge pull request #2 from tompro/chore/mit-license
docs: add MIT license
2026-08-20 13:43:37 +02:00
protom cb5e0e3586 docs: add MIT license 2026-08-20 13:40:59 +02:00
protom 22e987bbb3 docs: marketing readme with brand assets and app screenshots 2026-08-20 12:30:54 +02:00
172 changed files with 15656 additions and 5935 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.0.2"
".": "0.0.3"
}
+20 -6
View File
@@ -26,7 +26,7 @@ e2e/ # Playwright (own AGENTS.md)
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| ------------------- | ------------------------------------------------------ | ---------------------------------------------- |
| 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 |
| Native feature | `src/capabilities/` | never import plugins elsewhere |
@@ -36,14 +36,14 @@ e2e/ # Playwright (own AGENTS.md)
## CODE MAP
| Module | Role |
|--------|------|
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `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/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key |
| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup |
| `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
@@ -79,8 +79,22 @@ npm run cap:sync # build + cap sync android
- NixOS: use the flake dev shell (nodejs_22 + chromium for e2e).
`sass-embedded` is aliased to pure-JS `sass` via npm overrides.
- lnurlcash-kit comes from `github:TheCryptoDonkey/lnurlcash-kit` pinned to
a commit — the `prepare` script (our merged PR#1) builds dist on install.
- `lnurlcash-kit@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm
registry packages. Keep their manifest pins, registry tarball URLs, and lock
integrity values intact.
- Credential and authorization records are bound to the canonical owner ID
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
keep the protocol core untouched; `src/lnurlcash` has an eslint override.
- Gitea remote dropped; origin = GitHub. Gitea mirror = pull-mirror on the
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [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)
+21
View File
@@ -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.
+55 -26
View File
@@ -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
pnpm install
# or: yarn/npm/bun install
```
<p align="center">
<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>
<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
quasar dev
```
## What is sattle?
### 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
pnpm run lint
# or: yarn/npm/bun run lint
```
It's an installable web app (PWA) that works on any modern phone or desktop, with
an Android app wrapping the same experience. Built for people who just want to
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
pnpm run lint:check
# or: yarn/npm/bun run lint:check
```
## Highlights
### 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
quasar build
```
## How it works
### 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

+27 -12
View File
@@ -40,8 +40,10 @@ interface MockNoteInfoOptions {
}
interface MockTargetMintOptions {
// the mint's signing pubkey (66 hex chars), advertised in the payRequest
// and note responses
// the mint's signing pubkey (66 hex chars), advertised on the mint-address
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
// LUD-25 announces it on the withdraw side only, and a too-generous mock
// once masked a real bug
mintPubkey: string;
// the invoice this mint hands out (and reports back as settled) - keep it
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
@@ -97,24 +99,38 @@ export class MintMocker {
);
}
// A mint that never answers its mint-address discovery endpoint with
// anything usable - prepareMint treats that as "no mint-address support"
// and falls back to the plain LNURL-pay guess.
private async mockNoMintAddress(origin: string): Promise<void> {
// 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(options: MockTargetMintOptions, origin: string): Promise<void> {
await this.page.route(
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
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: options.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)
// needs: the payRequest at the standard mint@ address, the invoice
// callback, an immediately-settled verify endpoint revealing the
// preimage, and the note info + rotate the claim then performs.
// needs: the mint-address discovery endpoint carrying the mint metadata,
// the payRequest at the standard mint@ address, the invoice callback, an
// immediately-settled verify endpoint revealing the preimage, and the
// note info + rotate the claim then performs.
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
await this.mockNoMintAddress(origin);
await this.mockMintAddress(options, origin);
await this.mockNoteInfo(
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
origin,
@@ -129,7 +145,6 @@ export class MintMocker {
minSendable: 1000,
maxSendable: 100_000_000_000,
withdrawLink: `${origin}${NOTE_PATH}`,
mintPubkey: options.mintPubkey,
metadata: options.mintFeeMetadata ?? '[]',
});
},
+91
View File
@@ -79,6 +79,97 @@ test.describe('Security page', () => {
// auto-lock: display-only for now
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_linking_key') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('ownerId' in saved) ||
typeof saved.ownerId !== 'string'
) {
throw new Error('expected saved wallet owner');
}
const wrap = {
hkdfSalt: '11'.repeat(16),
iv: '22'.repeat(12),
wrappedKey: '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: 1,
},
{
...wrap,
credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 1,
},
]),
);
});
// 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 for a markerless saved wallet', async ({ page }) => {
// Given an encrypted saved key and passkey slot without owner markers
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
wrappedKey: '77'.repeat(48),
createdAt: 1,
},
]),
);
});
// When the locked wallet renders
await page.goto('/');
// Then passkey-first unlock is unavailable until another owner proof
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
});
});
test.describe('Welcome: restore from nostr', () => {
+2 -1
View File
@@ -63,9 +63,10 @@ test.describe('NWC settings page', () => {
await expect(page.getByText('shown only once', { exact: false })).toBeVisible();
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.');
// 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.host).toMatch(/^[0-9a-f]{64}$/);
expect(url.searchParams.get('secret')).toMatch(/^[0-9a-f]{64}$/);
+51
View File
@@ -5,6 +5,7 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
// a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note
@@ -58,4 +59,54 @@ test.describe('Receive bearer note', () => {
await page.keyboard.press('Escape');
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, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
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, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
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');
});
});
+208
View File
@@ -0,0 +1,208 @@
import type { Page, TestInfo } from '@playwright/test';
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
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_linking_key');
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();
}
});
});
+242
View File
@@ -0,0 +1,242 @@
import { bytesToHex } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { test, expect } from '../fixtures';
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet
// with ownerless passkey/NWC/trust residue must migrate to the proven owner
// during password unlock - before the NWC service may start - and forgetting
// the wallet must drain/stop that service and remove every wallet-owned key
// before a successor wallet can be created without any of the old residue.
//
// The legacy encrypted record is produced here with the exact same KDF/wrap
// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the
// owner marker a current build would stamp.
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const PASSWORD = 'correct horse battery staple';
const LINKING_KEY_HEX = '07'.repeat(32);
const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true));
const MINT_PUBKEY = '02' + 'aa'.repeat(32);
function hexToBytesLocal(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
const PBKDF2_ITERATIONS = 210_000;
const legacyEncryptedRecord = async (
valueHex: string,
password: string,
): Promise<{ salt: string; iv: string; ciphertext: string }> => {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(valueHex),
),
);
return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
const WALLET_KEYS = [
'sattle_linking_key',
'sattle_bearers',
'sattle_activity',
'sattle_settings',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
] as const;
test.describe('wallet lifecycle', () => {
test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({
page,
}) => {
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD);
await page.addInitScript(
({ storedRecord, mintPubkey }) => {
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord }));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]),
);
},
{ storedRecord: record, mintPubkey: MINT_PUBKEY },
);
await page.goto('/#/');
// the unlock screen proves the app boot finished and the dev hooks exist
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect
.poll(async () => page.evaluate(() => typeof window.__sattleNwcTest))
.toBe('object');
// no real relay traffic once the migrated enabled state starts the service
await page.evaluate(() => {
window.__nwcSubs = [];
window.__sattleNwcTest.setTransport({
publish: () => Promise.resolve(),
subscribe: () => {
const sub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
// When the holder proves the wallet by password
await page.locator('.unlock-card input').fill(PASSWORD);
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
// Then the wallet unlocks...
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// ...and every legacy namespace was migrated to the proven owner BEFORE
// the NWC service could start (the subscriptions below only exist because
// the migrated owner-scoped enabled record read true)
const migrated = await page.evaluate(() => {
const field = (storageKey: string, name: string): unknown => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
return typeof value === 'object' && value !== null
? Object.entries(value).find(([key]) => key === name)?.[1]
: undefined;
};
const fields = (storageKey: string, name: string): unknown[] => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
if (!Array.isArray(value)) return [];
return value.map((entry: unknown) =>
typeof entry === 'object' && entry !== null
? Object.entries(entry).find(([key]) => key === name)?.[1]
: undefined,
);
};
const mints: unknown = field('sattle_trusted_mints', 'mints');
return {
savedKeyOwner: field('sattle_linking_key', 'ownerId'),
passkeyOwners: fields('sattle_passkey_slots', 'ownerId'),
nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'),
nwcEnabled: field('sattle_nwc_enabled', 'enabled'),
nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'),
trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'),
trustedMintServers: Array.isArray(mints)
? mints.map((mint: unknown) =>
typeof mint === 'object' && mint !== null && 'server' in mint
? mint.server
: undefined,
)
: [],
};
});
expect(migrated.savedKeyOwner).toBe(OWNER_ID);
expect(migrated.passkeyOwners).toEqual([OWNER_ID]);
expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({
ownerId: OWNER_ID,
enabled: true,
});
expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]);
expect(migrated.trustedMintsOwner).toBe(OWNER_ID);
expect(migrated.trustedMintServers).toEqual(['legacy.example']);
await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0);
// When the wallet is forgotten
await page.evaluate(() => window.__sattleWalletTest.forget());
// Then the app lands on the no-wallet screen, the service is drained
// (every subscription closed), and no wallet-owned key remains
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
const remaining = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[...WALLET_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();
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// Then it is a different owner with zero adopted residue
const successor = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null');
return {
ownerId:
typeof saved === 'object' && saved !== null && 'ownerId' in saved
? saved.ownerId
: undefined,
residueKeys: [
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
].filter((key) => localStorage.getItem(key) !== null),
};
});
expect(successor.ownerId).not.toBe(OWNER_ID);
expect(successor.residueKeys).toEqual([]);
});
});
+10 -9
View File
@@ -1,12 +1,12 @@
{
"name": "sattle",
"version": "0.0.2",
"version": "0.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sattle",
"version": "0.0.2",
"version": "0.0.3",
"hasInstallScript": true,
"dependencies": {
"@agicash/qr-scanner": "^0.1.2",
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"lnurlcash-kit": "0.1.1",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
"lnurlcash-conformance": "0.1.1",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
@@ -5961,8 +5961,9 @@
}
},
"node_modules/lnurlcash-conformance": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-conformance.git#3d63f8ac3f3b8b87f5b490ff4a55c310afe4e5e2",
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz",
"integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5986,9 +5987,9 @@
}
},
"node_modules/lnurlcash-kit": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-kit.git#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"integrity": "sha512-xgO7ykIBD5SdGV7M6xyhDJcfgbkvlHxmBmXu2gBs/Cew/RAfH7GRZSCdeVexo6vRGDXhX6IjqqqzfFXrYqkM9A==",
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz",
"integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.3.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "sattle",
"version": "0.0.2",
"version": "0.0.3",
"description": "A Quasar Project",
"productName": "sattle",
"author": "protom <office@protom.eu>",
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76",
"lnurlcash-kit": "0.1.1",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance",
"lnurlcash-conformance": "0.1.1",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
+2 -1
View File
@@ -21,7 +21,8 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:9333',
trace: 'on-first-retry',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
launchOptions: {
executablePath: process.env.CHROMIUM_PATH || findSystemChromium(),
+18
View File
@@ -3,6 +3,15 @@ import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup';
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 store at app start - a plaintext-stored key unlocks straight away,
// a password-encrypted one lands on 'locked' for the unlock screen, and no
@@ -17,3 +26,12 @@ export default defineBoot(async () => {
// client requests; on lock it stops and drops the key-material closure
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(),
};
}
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys';
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots';
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
const pluginMocks = vi.hoisted(() => ({
authenticate: vi.fn<() => Promise<void>>(),
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
}));
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
AndroidBiometryStrength: { weak: 'weak' },
BiometricAuth: { authenticate: pluginMocks.authenticate },
BiometryError: class BiometryError extends Error {},
BiometryErrorType: { userCancel: 'userCancel' },
}));
vi.mock('@aparajita/capacitor-secure-storage', () => ({
SecureStorage: { get: pluginMocks.secureGet },
}));
vi.mock('./platform', () => ({ isNative: () => true }));
import { unlockWithBiometrics } from './biometricUnlock';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const WRAP_SECRET = new Uint8Array(32).fill(3);
beforeEach(() => {
vi.clearAllMocks();
stubLocalStorage();
pluginMocks.authenticate.mockResolvedValue();
pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET));
});
describe('biometric unlock owner proof', () => {
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => {
// Given an ownerless legacy wallet and credential residue plus a biometric wrap
// whose claimed pubkey does not match the key it unwraps
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) };
const legacySlot = {
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
};
const legacyNwc = [
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
];
const legacyTrust = [
{
server: 'legacy.example',
mintPubkey: '02' + 'aa'.repeat(32),
addedAt: 1,
locked: false,
},
];
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey));
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot]));
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc));
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust));
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY);
localStorage.setItem(
'sattle_biometric_wrap',
JSON.stringify({
...wrap,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY),
createdAt: 1,
}),
);
const before = new Map([
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')],
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')],
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')],
]);
// When biometric unwrap reaches the pubkey proof check
const attempt = unlockWithBiometrics();
// Then no key crosses the capability boundary and no legacy namespace is adopted
await expect(attempt).rejects.toThrow('different wallet');
expect(savedKeyOwnerId()).toBeNull();
expect(readPasskeySlots()).toEqual([]);
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value);
});
});
+31 -238
View File
@@ -202,19 +202,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import QrCode from '../QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
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';
import { useReceiveLightningDialog } from '@/composables/useReceiveLightningDialog';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
@@ -222,232 +211,36 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const CUSTOM_MINT = '__custom__';
// whole-sat display for received amounts (msat remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
type Step = 'form' | 'invoice' | 'success';
const step = ref<Step>('form');
// ---- form ----
const amountSats = ref<number | null>(null);
const mintChoice = ref('');
const customMint = ref('');
const preparing = ref(false);
const formError = ref('');
type MintOption = { label: string; value: string };
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);
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 = '';
},
);
const {
CUSTOM_MINT,
amountSats,
claimError,
copyInvoice,
createInvoice,
customMint,
feeSats,
formError,
formValid,
grossSats,
mintChoice,
mintOptions,
netSats,
prepared,
preparing,
receivedSats,
receivedServer,
resumeWaiting,
retryClaim,
rotationWarning,
showTrust,
skipTrust,
step,
stopWaiting,
trustMint,
trustNodeAlias,
trustServer,
waiting,
} = useReceiveLightningDialog(props, emit);
</script>
<style lang="scss" scoped>
+29 -214
View File
@@ -14,12 +14,7 @@
<!-- input -->
<q-card-section v-if="step === 'input'" class="q-pt-sm">
<qr-scanner
v-if="scanning"
class="q-mb-md"
@decode="onScan"
@error="onScanError"
/>
<qr-scanner v-if="scanning" class="q-mb-md" @decode="onScan" @error="onScanError" />
<q-input
v-model="input"
@@ -82,16 +77,16 @@
<template #avatar>
<q-icon name="info" color="info" />
</template>
The mint could not be reached, so the note is stored unconfirmed at the
sender's declared amount. Refresh it later to confirm.
The mint could not be reached, so the note is stored unconfirmed at the sender's declared
amount. Refresh it later to confirm.
</q-banner>
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
The note is in your wallet, but it could not be fully secured yet — the
sender may still hold a copy. You can secure it later.
The note is in your wallet, but it could not be fully secured yet — the sender may still
hold a copy. You can secure it later.
</q-banner>
<q-btn
@@ -114,8 +109,8 @@
<strong>{{ trustServer }}</strong>
</div>
<div class="text-caption text-grey-5 q-mb-md">
Trusting saves the mint so it is offered next time. You can manage trusted
mints in Settings.
Trusting saves the mint so it is offered next time. You can manage trusted mints in
Settings.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
@@ -134,22 +129,8 @@
</template>
<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 { receiveBearer } from '@/lnurlcash/ops';
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';
import { useReceiveTokenDialog } from '@/composables/useReceiveTokenDialog';
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
const emit = defineEmits<{
@@ -157,193 +138,27 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
// whole-sat display for received amounts (msat remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errMsg = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
// distinct, jargon-free error states for the definitive service answers
type ReceiveErrorKind =
| 'spent'
| 'unknown'
| 'pending'
| 'duplicate'
| 'invalid'
| 'generic'
| '';
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 = '';
},
);
const {
busy,
errorIcon,
errorKind,
errorText,
input,
inputValid,
onScan,
onScanError,
receive,
receivedSats,
receivedServer,
rotationWarning,
scanning,
showTrust,
skipTrust,
step,
trustMint,
trustServer,
unverifiedNote,
} = useReceiveTokenDialog(props, emit);
</script>
<style lang="scss" scoped>
+23 -236
View File
@@ -7,18 +7,8 @@
//
// Fund-safety order when applying the carve: fresh notes are added to the
// 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 { readClipboard } from '@/capabilities/clipboard';
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';
import { usePayInvoiceDialog } from '@/composables/usePayInvoiceDialog';
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
const emit = defineEmits<{
@@ -26,231 +16,28 @@ const emit = defineEmits<{
sent: [];
}>();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
// guarded: the Notify plugin registration lives in quasar.config, outside
// this component's control - a missing registration must not break a flow
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
type Step = 'input' | 'confirm' | 'working' | 'result';
type TargetKind = 'invoice' | 'address';
type PendingPayment = {
kind: TargetKind;
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;
};
const {
addressAmountSats,
closeResult,
formatSats,
inlineError,
input,
msatToSats,
onScan,
onScanError,
paste,
pay,
pendingPayment,
proceed,
result,
resultAmountSats,
show,
showScanner,
stage,
step,
targetKind,
truncatedInput,
} = usePayInvoiceDialog(props, emit);
</script>
<template>
+23 -176
View File
@@ -7,19 +7,8 @@
// 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
// 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 { writeClipboard } from '@/capabilities/clipboard';
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';
import { useSendTokenDialog } from '@/composables/useSendTokenDialog';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
@@ -27,170 +16,28 @@ const emit = defineEmits<{
sent: [];
}>();
const $q = useQuasar();
const wallet = useWalletStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
// guarded: the Notify plugin registration lives in quasar.config, outside
// this component's control - a missing registration must not break a flow
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
type Step = 'amount' | 'ready';
const step = ref<Step>('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 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;
};
const {
amountError,
amountSats,
canPrepare,
canShare,
copyNote,
errorMessage,
finishKeep,
finishRemove,
formatSats,
msatToSats,
noteDisplayValue,
prepare,
prepared,
preparing,
removing,
revealed,
shareNote,
show,
step,
wallet,
} = useSendTokenDialog(props, emit);
</script>
<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?.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>
+152
View File
@@ -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,
};
};
+46
View File
@@ -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,
};
};
+235
View File
@@ -0,0 +1,235 @@
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 type { NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { 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…';
try {
const ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
const transfer = await transferBetweenMints(
wallet.bearers,
satsToMsat(sats),
targetInput.value,
{ assertOwner: ownerFence },
);
stage.value = 'Confirming the result…';
const carved = await commitCarve(wallet, transfer.carve, commitContext);
if (transfer.rescuedNote) {
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
}
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') {
await wallet.markSpent(carved.id, ownerFence);
const claimed = transfer.mintedAtTarget;
if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await addCommittedBearers(wallet, notes, commitContext);
}
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') {
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.markSpent(carved.id, ownerFence);
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
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 ?? null;
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';
}
};
return {
CUSTOM_TARGET,
amountSats,
customTarget,
formFilled,
inlineError,
move,
proceed,
result,
router,
setMax,
sourceOptions,
sourceServer,
stage,
step,
targetChoice,
targetFeeText,
targetInput,
targetOptions,
};
};
+238
View File
@@ -0,0 +1,238 @@
import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops';
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;
try {
ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
const paid = await payWithBearers(
wallet.bearers,
payment.input,
payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence }
: { assertOwner: ownerFence },
);
stage.value = 'Confirming the result…';
const committed = await commitCarve(wallet, paid.carve, commitContext);
if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
}
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';
}
};
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,245 @@
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
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 { 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 waiting = ref(false);
const claimError = ref('');
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,
ownerFence: WalletOwnerFence,
): Promise<void> => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
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 || claimRun) return;
waiting.value = true;
claimError.value = '';
const current = prepared.value;
claimRun = (async () => {
try {
const ownerFence = wallet.captureOwnerFence();
await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current,
ownerFence,
);
} catch (error) {
if (!(error instanceof Error)) throw error;
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) });
} finally {
waiting.value = false;
}
})();
};
const createInvoice = async (): Promise<void> => {
const sats = amountSats.value;
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
prepared.value = next;
claimRun = null;
claimError.value = '';
step.value = 'invoice';
beginClaim();
} catch (error) {
if (!(error instanceof Error)) throw error;
formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value });
} finally {
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;
waiting.value = false;
rotationWarning.value = '';
},
);
return {
CUSTOM_MINT,
amountSats,
claimError,
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,
};
};
+183
View File
@@ -0,0 +1,183 @@
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 { NewBearer } 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();
try {
const ownerFence = wallet.captureOwnerFence();
const claimed = await receiveBearer(value, wallet.bearers, {
assertOwner: ownerFence,
});
const note = claimed.note;
const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
} 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) {
classifyError(error instanceof Error ? error : new Error(String(error)));
} finally {
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,
};
};
+194
View File
@@ -0,0 +1,194 @@
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 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;
try {
ownerFence = wallet.captureOwnerFence();
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: ownerFence,
});
const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted });
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 {
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;
try {
await wallet.markSpent(note.id, wallet.captureOwnerFence());
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 {
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,
};
};
+130
View File
@@ -0,0 +1,130 @@
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 { 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 === 'sattle_bearers') {
writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable');
}
setItem(key, value);
});
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {});
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 === 'sattle_bearers')).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
// sattle_bearers 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();
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { CarveResult } from '@/lnurlcash/ops';
import type { BearerChangeset } from '@/lnurlcash/storage';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = {
readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly applyChangeset: (
changeset: BearerChangeset,
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. Hence a
// single changeset through the wallet's one-write boundary, never an
// add-then-markSpent sequence of separate writes.
export const commitCarve = async (
wallet: CarveWallet,
carve: CarveResult,
context: CarveCommitContext,
): Promise<Bearer> => {
const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
const additions: NewBearer[] = [];
if (!existing) additions.push(carve.note);
if (carve.change) additions.push(carve.change);
let added: Bearer[];
try {
added = await wallet.applyChangeset(
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
},
context.ownerFence,
);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message);
added = error.committedBearers;
}
const committed = existing ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
return committed;
};
+4
View File
@@ -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);
+18 -4
View File
@@ -22,8 +22,9 @@ lnurlcash-kit directly.
```
ops.ts / ops/ # flows: carve (exact-amount), mint, pay, receiveBearer,
# transfer (inter-mint); ops.ts is the façade
storage/ # encrypted bearers + activity log, settings, backup,
# nwcConnections, passkeySlots; storage.ts is the façade
storage/ # encrypted bearers + activity log, owner-bound NWC,
# passkey and trusted-mint records, settings, backup;
# storage.ts is the façade
keys.ts # BIP39, LUD-05 linking key derivation, password wrap
passkeys.ts + passkeyWrap.ts # WebAuthn PRF wrap (same linking key)
nostrBackup.ts + nostr/ # kind-30078 backup, NIP-44 self-encryption
@@ -38,8 +39,21 @@ test-utils.ts # mock mint harness used by *.test.ts
- Style: NO semicolons, 2-space indent, single quotes, `{braced}` imports
without inner spaces — deliberately different from the rest of the app
(eslint override); keep the tested core diffable against its lineage.
- Storage: localStorage keys `sattle_*`; strict shape validation on read,
malformed entries dropped; read-modify-write under `withStorageLock`.
- Storage: localStorage keys `sattle_*`; strict shape validation on read.
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
touch the network. No WebSocket at import time (lazy `import()`).
- Every module header comment explains the WHY, including failure models.
+4 -3
View File
@@ -22,7 +22,7 @@ import {
grossUpForMintFee,
mintAddressUrl,
resolveMintInput,
serverOf
serverOf,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
import {ceilMsatToSat, floorMsatToSat} from './units'
@@ -55,7 +55,7 @@ export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
// reached right now.
export const quoteMintFee = async (
mintInput: string,
options: LnurlcashOptions = {}
options: LnurlcashOptions = {},
): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput)
if (!url) return null
@@ -69,8 +69,9 @@ export const quoteMintFee = async (
if (addressUrl) {
try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink
} catch {
} catch (error) {
// no mint-address support - the plain payRequest guess still works
if (!(error instanceof Error)) throw error
}
}
let fee: MintFee | null
+20
View File
@@ -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
})
+316
View File
@@ -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)
})
})
+103 -100
View File
@@ -1,8 +1,4 @@
import {
mnemonicToSeedSync,
generateMnemonic,
validateMnemonic
} from '@scure/bip39'
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'
@@ -10,6 +6,20 @@ import {sha256} from '@noble/hashes/sha2.js'
import {secp256k1} from '@noble/curves/secp256k1.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {
parseStoredSecret,
stampStoredSecretOwner,
storedSecretClaimedOwnerId,
storedSecretOwnerId,
stripStoredSecretOwner,
STORED_SECRET_VERSION,
type StoredSecret,
} from './storage/storedSecret'
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
export {isValidStoredSecret} from './storage/storedSecret'
export type {StoredSecret} from './storage/storedSecret'
// The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where
@@ -30,16 +40,12 @@ const readUint32BE = (bytes: Uint8Array, offset: number): number =>
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
// a seed restored there or here produces the same identity for a given domain
export const deriveLud05LinkingKey = (
seedPhrase: string,
domain: string
): Uint8Array => {
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
const master = HDKey.fromMasterSeed(seed)
const hashingKeyNode = master.derive("m/138'/0")
if (!hashingKeyNode.privateKey)
throw new Error('Could not derive hashing key')
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key')
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
// path suffix longs are raw BIP32 child indices: whether each level ends up
@@ -55,12 +61,9 @@ export const deriveLud05LinkingKey = (
// the HMAC half of the derivation, split out so the LUD-05 test vector
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
// directly - see keys.test.ts
export const lud05PathSuffix = (
hashingKey: Uint8Array,
domain: string
): number[] => {
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
return [0, 4, 8, 12].map(i => readUint32BE(material, i))
return [0, 4, 8, 12].map((i) => readUint32BE(material, i))
}
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
@@ -75,56 +78,21 @@ export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
// 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 raw = localStorage.getItem(storageKey)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
return isValidStoredSecret(parsed) ? parsed : null
return parseStoredSecret(parsed)?.secret ?? null
} catch {
return null
}
}
const deriveAesKeyFromPassword = (
password: string,
salt: Uint8Array
): Promise<CryptoKey> =>
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
crypto.subtle
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
.then(baseKey =>
.then((baseKey) =>
crypto.subtle.deriveKey(
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
@@ -132,13 +100,13 @@ const deriveAesKeyFromPassword = (
name: 'PBKDF2',
salt: new Uint8Array(salt),
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256'
hash: 'SHA-256',
},
baseKey,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt']
)
['encrypt', 'decrypt'],
),
)
export type EncryptedSecretParts = {
@@ -149,29 +117,25 @@ export type EncryptedSecretParts = {
export const encryptSecretParts = async (
value: string,
password: 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)
)
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)),
)
return {
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext)
ciphertext: bytesToHex(ciphertext),
}
}
// rejects (WebCrypto's own auth-tag check) if the password is wrong
export const decryptSecretParts = async (
parts: EncryptedSecretParts,
password: string
password: string,
): Promise<string> => {
const salt = hexToBytes(parts.salt)
const iv = hexToBytes(parts.iv)
@@ -179,7 +143,7 @@ export const decryptSecretParts = async (
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
aesKey,
hexToBytes(parts.ciphertext)
hexToBytes(parts.ciphertext),
)
return new TextDecoder().decode(plaintext)
}
@@ -188,17 +152,64 @@ export const decryptSecretParts = async (
// 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'
//
// The record also carries an ownerId marker: the lowercase compressed
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
// other wallet-owned record (passkeys, NWC, trusted mints) to this exact
// identity. Failure modes of the marker API:
// - new writes always carry the marker derived from the key being saved;
// - a record restored from a backup/relay is installed OWNERLESS - its
// file-carried marker is an unproven claim and is stripped on restore;
// - an ownerless legacy record stays usable but cannot establish ownership;
// malformed or unsupported owner-bearing metadata rejects the whole record;
// - ensureSavedKeyOwner stamps the marker after the caller proved the key
// (successful password/plaintext unlock or matching biometric unwrap),
// preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record.
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyExists = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyIsEncrypted = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY)
// 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 => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null || stored.enc === true) return null
@@ -207,30 +218,33 @@ export const getPlainLinkingKey = (): Uint8Array | null => {
export const saveLinkingKey = async (
linkingPrivKey: Uint8Array,
password?: string
password?: string,
): Promise<void> => {
const hex = bytesToHex(linkingPrivKey)
const ownerId = linkingPubKeyHex(linkingPrivKey)
if (!password) {
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex})
JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}),
)
return
}
const parts = await encryptSecretParts(hex, password)
localStorage.setItem(
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 => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored))
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)))
}
export const decryptSavedLinkingKey = async (
password: string
): Promise<Uint8Array> => {
export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
return hexToBytes(await decryptSecretParts(stored, password))
@@ -246,43 +260,32 @@ export const clearSavedLinkingKey = (): void => {
// a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
export const deriveBearerAesKey = (
linkingPrivKey: Uint8Array
): Promise<CryptoKey> => {
const material = sha256(
new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])
)
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, [
'encrypt',
'decrypt'
])
export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]))
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt'])
}
export type EncryptedRecordParts = {iv: string; ciphertext: string}
export const encryptRecord = async (
aesKey: CryptoKey,
value: object
value: object,
): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
aesKey,
utf8ToBytes(JSON.stringify(value))
)
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))),
)
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
}
export const decryptRecord = async <T>(
export const decryptRecord = async (
aesKey: CryptoKey,
parts: EncryptedRecordParts
): Promise<T> => {
parts: EncryptedRecordParts,
): Promise<unknown> => {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: hexToBytes(parts.iv)},
aesKey,
hexToBytes(parts.ciphertext)
hexToBytes(parts.ciphertext),
)
return JSON.parse(new TextDecoder().decode(plaintext)) as T
return JSON.parse(new TextDecoder().decode(plaintext))
}
+102
View File
@@ -0,0 +1,102 @@
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'
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)
})
})
+165
View File
@@ -0,0 +1,165 @@
// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the
// typed field read undefined forever), and a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an
// HTTPS default anywhere in the receive path.
import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops'
import {mintAddressCacheInfo} from './trustedMints'
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)}`
// 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
}
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)
// 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 () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
// published conformance 0.1.1 emits lnurlw:// by default - NOT https
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// 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.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(m.state.noteState(preimage)).toBe('burned')
})
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',
metadata: '[]',
},
],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
])
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch})
expect(prepared.withdrawLink).toBe('https://mint.example/note')
// 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),
)
})
})
+15 -29
View File
@@ -43,7 +43,7 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'
export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes',
mints: 'mints',
settings: 'settings'
settings: 'settings',
}
// the decrypted payload of each part, without its envelope
@@ -62,8 +62,7 @@ export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]))
// the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string =>
getPublicKey(secretKey)
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey)
// NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else
@@ -76,13 +75,9 @@ const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
// per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
export const dTagOf = (event: NostrEvent): string =>
event.tags.find(t => t[0] === 'd')?.[1] ?? ''
export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? ''
const envelopeFor = (
part: BackupPart,
payload: BackupPartPayload[BackupPart]
): string => {
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) {
case 'notes':
return JSON.stringify({version: 1, bearers: payload})
@@ -98,26 +93,23 @@ export const buildBackupEvent = <P extends BackupPart>(
secretKey: Uint8Array,
part: P,
payload: BackupPartPayload[P],
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: createdAt,
tags: [['d', BACKUP_D_TAGS[part]]],
content: nip44v2.encrypt(
envelopeFor(part, payload),
selfConversationKey(secretKey)
)
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey
secretKey,
)
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
secretKey: Uint8Array,
parts: Partial<BackupPartPayload>,
createdAt?: number
createdAt?: number,
): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000)
const events: NostrEvent[] = []
@@ -138,10 +130,7 @@ export type ParsedBackupEvent =
// (record counts, field lengths, pubkey patterns) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file
// backups.
const parsePayload = (
dTag: BackupPart,
data: unknown
): ParsedBackupEvent | null => {
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null
const envelope = data as Record<string, unknown>
if (envelope.version !== 1) return null
@@ -151,10 +140,10 @@ const parsePayload = (
const bearers = envelope.bearers as unknown[]
if (
!bearers.every(
r =>
(r) =>
typeof (r as EncryptedBearerRecord)?.id === 'string' &&
typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string'
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
)
) {
return null
@@ -166,9 +155,9 @@ const parsePayload = (
const mints = envelope.trustedMints as unknown[]
if (
!mints.every(
m =>
(m) =>
typeof (m as TrustedMint)?.server === 'string' &&
typeof (m as TrustedMint)?.mintPubkey === 'string'
typeof (m as TrustedMint)?.mintPubkey === 'string',
)
) {
return null
@@ -180,10 +169,7 @@ const parsePayload = (
return null
}
const settings = envelope.settings as Record<string, unknown>
if (
settings.defaultMint !== undefined &&
typeof settings.defaultMint !== 'string'
) {
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null
}
return {part: 'settings', settings: settings as WalletSettings}
@@ -197,7 +183,7 @@ const parsePayload = (
// skip nulls - one junk event must never sink a restore.
export const parseBackupEvent = (
secretKey: Uint8Array,
event: NostrEvent
event: NostrEvent,
): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null
if (event.pubkey !== getPublicKey(secretKey)) return null
+6 -6
View File
@@ -22,9 +22,7 @@ export type BackupPublisherOptions = {
onError?: (error: unknown) => void
}
export const createBackupPublisher = (
options: BackupPublisherOptions
): BackupPublisher => {
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null
let pending: Partial<BackupPartPayload> | null = null
let running: Promise<void> | null = null
@@ -45,7 +43,9 @@ export const createBackupPublisher = (
try {
await options.publish(snapshot)
} catch (error) {
options.onError?.(error)
options.onError?.(
error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}),
)
}
}
}
@@ -61,7 +61,7 @@ export const createBackupPublisher = (
}
return {
schedule: parts => {
schedule: (parts) => {
pending = parts
clearTimer()
timer = setTimeout(() => void fire(), options.delayMs)
@@ -70,6 +70,6 @@ export const createBackupPublisher = (
cancel: () => {
clearTimer()
pending = null
}
},
}
}
+17 -20
View File
@@ -10,12 +10,9 @@
import type {RestoreResult} from '../storage/backup'
import {applyBackup} from '../storage/backup'
import {linkingPubKeyHex} from '../keys'
import type {
BackupPart,
BackupPartPayload,
NostrEvent
} from './events'
import type {BackupPart, BackupPartPayload, NostrEvent} from './events'
import {
BACKUP_EVENT_KIND,
BACKUP_PARTS,
@@ -23,7 +20,7 @@ import {
buildBackupEvent,
deriveBackupKey,
dTagOf,
parseBackupEvent
parseBackupEvent,
} from './events'
import type {BackupTransport} from './transport'
import {defaultTransport} from './transport'
@@ -42,20 +39,17 @@ export const publishBackup = async (
secretKey: Uint8Array,
parts: Partial<BackupPartPayload>,
relays: string[],
options: PublishBackupOptions = {}
options: PublishBackupOptions = {},
): Promise<PublishBackupResult> => {
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: []}
const transport = options.transport ?? (await defaultTransport())
const published: BackupPart[] = []
for (const part of present) {
const payload = parts[part]
if (payload === undefined) continue
await transport.publish(
relays,
buildBackupEvent(secretKey, part, payload, at)
)
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at))
published.push(part)
}
return {published}
@@ -75,12 +69,12 @@ export type FetchBackupOptions = {
export const fetchBackup = async (
pubkey: string,
relays: string[],
options: FetchBackupOptions
options: FetchBackupOptions,
): Promise<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport())
const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND],
authors: [pubkey]
authors: [pubkey],
})
const byTag = new Map<string, NostrEvent[]>()
for (const event of events) {
@@ -130,23 +124,26 @@ export type NostrRestoreResult = RestoreResult & {
export const restoreFromNostr = async (
linkingPrivKey: Uint8Array,
relays: string[],
options: {transport?: BackupTransport} = {}
options: {transport?: BackupTransport} = {},
): Promise<NostrRestoreResult> => {
const secretKey = deriveBackupKey(linkingPrivKey)
const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey,
transport: options.transport
transport: options.transport,
})
const result = applyBackup({
const result = await applyBackup(
{
type: 'sattle-backup',
version: 1,
createdAt: Date.now(),
bearers: parts.notes ?? [],
trustedMints: parts.mints,
settings: parts.settings
})
settings: parts.settings,
},
linkingPubKeyHex(linkingPrivKey),
)
return {
...result,
found: BACKUP_PARTS.filter(part => parts[part] !== undefined)
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
}
}
+2 -2
View File
@@ -23,10 +23,10 @@ export const defaultTransport = async (): Promise<BackupTransport> => {
const results = await Promise.allSettled(pool.publish(relays, event))
// one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway
if (!results.some(r => r.status === 'fulfilled')) {
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.')
}
},
fetch: (relays, filter) => pool.querySync(relays, filter)
fetch: (relays, filter) => pool.querySync(relays, filter),
}
}
+191
View File
@@ -0,0 +1,191 @@
// 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 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, requiredValue(events[0]))).toEqual({
part: 'notes',
bearers: records,
})
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints',
trustedMints: mints,
})
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings',
settings,
})
})
it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', records)
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
expect(event.content).not.toContain('ciphertext')
})
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()
})
})
+112
View File
@@ -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,211 @@
// 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'}}])
})
})
+223
View File
@@ -0,0 +1,223 @@
// 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('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 saveLinkingKey(LINKING_KEY)
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID})
persistSettings({defaultMint: 'mint.example'})
await publishBackup(
secretKey,
{
notes: readEncryptedBearers(),
mints: readTrustedMints(OWNER_ID),
settings: loadSettings(),
},
RELAYS,
{transport},
)
// device B: the same seed on empty storage
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
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: 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()
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()
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: readEncryptedBearers(), 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)
})
})
+5 -530
View File
@@ -1,530 +1,5 @@
// 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} 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'}}])
})
})
import './nostrBackup.keys.cases'
import './nostrBackup.codec.cases'
import './nostrBackup.transport.cases'
import './nostrBackup.restore.cases'
import './nostrBackup.publisher.cases'
@@ -0,0 +1,160 @@
// 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: [{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,
})
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'})
})
})
+3 -8
View File
@@ -24,14 +24,9 @@ export {
backupPubkey,
buildBackupEvent,
buildBackupEvents,
parseBackupEvent
} from './nostr/events'
export type {
NostrEvent,
BackupPart,
BackupPartPayload,
ParsedBackupEvent
parseBackupEvent,
} from './nostr/events'
export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events'
export type {BackupTransport, NostrFilter} from './nostr/transport'
@@ -40,7 +35,7 @@ export type {
PublishBackupOptions,
PublishBackupResult,
FetchBackupOptions,
NostrRestoreResult
NostrRestoreResult,
} from './nostr/sync'
export {createBackupPublisher} from './nostr/publisher'
+144
View File
@@ -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',
)
})
})
+66
View File
@@ -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()
})
})
+206
View File
@@ -0,0 +1,206 @@
// 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: 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: 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}$/)
// 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(nowSeconds())
const preimage = requiredString(settled.result?.preimage)
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
// the minted note was claimed AND rotated before settlement was
// recorded: the preimage the client just learned is a burned secret,
// and the wallet's fresh note is the only live one
expect(m.state.noteState(preimage)).toBe('burned')
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
expect(noteK1(minted.url)).not.toBe(preimage)
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding')
await stop()
})
it('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)
})
})
+241
View File
@@ -0,0 +1,241 @@
// 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: 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('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()
})
})
+159
View File
@@ -0,0 +1,159 @@
// 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,
getBearers: () => [],
getDefaultMint: () => null,
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,
getBearers: () => [],
getDefaultMint: () => null,
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()
})
})
+235
View File
@@ -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()
})
})
+158
View File
@@ -0,0 +1,158 @@
// 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},
})
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 = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(returned.spent).toBeUndefined()
expect(returned.amount).toBe(21_000)
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
await stop()
})
})
+173
View File
@@ -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
View File
@@ -1,816 +1,9 @@
// 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,
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()
})
})
import './nwc.connection.cases'
import './nwc.storage.cases'
import './nwc.ownership.cases'
import './nwc.validation.cases'
import './nwc.pay-a.cases'
import './nwc.pay-b.cases'
import './nwc.fencing.cases'
import './nwc.invoice-a.cases'
import './nwc.invoice-b.cases'
+188
View File
@@ -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
}
+207
View File
@@ -0,0 +1,207 @@
// 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 {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
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>
}): 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,
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
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()
})
+11 -10
View File
@@ -37,17 +37,18 @@
// per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts.
export type {
NwcBudget,
NwcBudgetSpend,
NwcConnectionRecord
} from './storage/nwcConnections'
export type {NwcBudget, NwcBudgetSpend, NwcConnectionRecord} from './storage/nwcConnections'
export {
clearNwcStorageForOwner,
clearUnownedNwcStorage,
migrateLegacyNwcStorage,
persistNwcConnection,
readNwcConnections,
removeNwcConnection,
writeNwcConnections
writeNwcConnections,
} from './storage/nwcConnections'
export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled'
export type {NwcLegacyMigrationResult} from './storage/nwcConnections'
export {
buildConnectionString,
@@ -55,13 +56,13 @@ export {
createConnection,
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString
parseConnectionString,
} from './nwc/connection'
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString
ParsedConnectionString,
} from './nwc/connection'
export {
@@ -73,7 +74,7 @@ export {
buildResponseEvent,
decryptRequest,
errResult,
okResult
okResult,
} from './nwc/protocol'
export type {
DecryptedNwcRequest,
@@ -82,7 +83,7 @@ export type {
NwcErrorCode,
NwcMethod,
NwcRequest,
NwcResponse
NwcResponse,
} from './nwc/protocol'
export {defaultNwcTransport} from './nwc/transport'
+187
View File
@@ -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)
})
})
+6 -8
View File
@@ -8,10 +8,7 @@
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
export const budgetRemainingMsat = (
record: NwcConnectionRecord,
nowMs: number
): number => {
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
const {maxMsat, periodMs} = record.budget
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
return Math.max(0, maxMsat - record.spent.msat)
@@ -20,18 +17,19 @@ export const budgetRemainingMsat = (
// rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write)
export const recordSpend = (
ownerId: string,
record: NwcConnectionRecord,
amountMsat: number,
nowMs: number
nowMs: number,
): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
return persistNwcConnection({
return persistNwcConnection(ownerId, {
...record,
spent: expired
? {periodStart: nowMs, msat: amountMsat}
: {
periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat
}
msat: record.spent.msat + amountMsat,
},
})
}
+19 -27
View File
@@ -24,6 +24,7 @@ import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPublicKey} from 'nostr-tools/pure'
import {linkingPubKeyHex} from '../keys'
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
@@ -35,16 +36,13 @@ const HEX_64 = /^[0-9a-f]{64}$/i
// result is a secp256k1 secret key used ONLY as this connection's
// wallet-service identity - it signs and decrypts NIP-47 events for this
// one client, nothing else.
export const deriveNwcWalletKey = (
linkingPrivKey: Uint8Array,
clientPubkey: string
): Uint8Array =>
export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array =>
sha256(
new Uint8Array([
...linkingPrivKey,
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey)
])
...hexToBytes(clientPubkey),
]),
)
// the x-only nostr pubkey the client addresses its requests to
@@ -60,12 +58,10 @@ export type NwcConnectionInfo = {
// wallet-service identity
export const connectionInfoOf = (
linkingPrivKey: Uint8Array,
record: NwcConnectionRecord
record: NwcConnectionRecord,
): NwcConnectionInfo => ({
record,
walletServicePubkey: nwcWalletPubkey(
deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
)
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
})
export type CreatedConnection = NwcConnectionInfo & {
@@ -86,20 +82,22 @@ export type CreateConnectionOptions = {
// default; the wallet-service key falls out of the derivation above.
export const createConnection = (
linkingPrivKey: Uint8Array,
options: CreateConnectionOptions
options: CreateConnectionOptions,
): CreatedConnection => {
if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.')
}
const clientSecret =
options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientPubkey = getPublicKey(clientSecret)
const record = persistNwcConnection({
const ownerId = linkingPubKeyHex(linkingPrivKey)
const record = persistNwcConnection(ownerId, {
version: 1,
ownerId,
clientPubkey,
relays: options.relays,
budget: options.budget,
spent: {periodStart: options.now ?? Date.now(), msat: 0},
createdAt: options.now ?? Date.now()
createdAt: options.now ?? Date.now(),
})
const info = connectionInfoOf(linkingPrivKey, record)
return {
@@ -107,19 +105,17 @@ export const createConnection = (
connectionString: buildConnectionString(
info.walletServicePubkey,
bytesToHex(clientSecret),
record.relays
)
record.relays,
),
}
}
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: string,
relays: string[]
relays: string[],
): string => {
const query = relays
.map(relay => `relay=${encodeURIComponent(relay)}`)
.join('&')
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&')
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
}
@@ -132,9 +128,7 @@ export type ParsedConnectionString = {
// parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse)
export const parseConnectionString = (
uri: string
): ParsedConnectionString | null => {
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
let url: URL
try {
url = new URL(uri.trim())
@@ -149,9 +143,7 @@ export const parseConnectionString = (
if (!HEX_64.test(walletServicePubkey)) return null
const secret = url.searchParams.get('secret')
if (!secret || !HEX_64.test(secret)) return null
const relays = url.searchParams
.getAll('relay')
.filter(relay => /^wss?:\/\//.test(relay))
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay))
if (relays.length === 0) return null
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
}
+13 -2
View File
@@ -30,11 +30,13 @@ export type NwcServiceDeps = {
// the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null
assertCurrentOwner: () => void
applyChangeset: (
changeset: NwcChangeset,
connection: NwcConnectionInfo,
method: NwcMethod
) => void
method: NwcMethod,
assertOwner: () => void,
) => Promise<void>
transport?: NwcTransport
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
@@ -73,4 +75,13 @@ export type RequestContext = {
updateRecord: (record: NwcConnectionRecord) => void
invoices: Map<string, PendingInvoice>
nowSeconds: () => number
assertOwner: () => void
// Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup.
startBackground: (work: () => Promise<void>) => boolean
// 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
}
+26 -44
View File
@@ -9,6 +9,7 @@ import {noteK1, sameInvoice} from 'lnurlcash-kit'
import type {Bearer} from '../types'
import type {PreparedMint} from '../ops'
import {prepareMint} from '../ops'
import {PollAbortedError} from '../ops/shared'
import type {PendingInvoice, RequestContext} from './context'
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
@@ -19,10 +20,7 @@ import {NWC_METHODS, errResult, okResult} from './protocol'
// the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean =>
!bearer.spent &&
bearer.callback !== '' &&
!bearer.deviceId &&
!!noteK1(bearer.url)
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url)
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', {
@@ -34,7 +32,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
// block height/hash exists, so those fields are simply absent.
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: []
notifications: [],
})
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
@@ -42,20 +40,16 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
balance: ctx.deps
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0)
.reduce((sum, b) => sum + b.amount, 0),
})
const handleMakeInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const amountMsat = Number(params.amount)
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
return errResult(
'make_invoice',
'OTHER',
'Amount must be a positive whole number of msat.'
)
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.')
}
const mint = ctx.deps.getDefaultMint()
if (!mint) {
@@ -65,11 +59,7 @@ const handleMakeInvoice = async (
try {
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
} catch (err) {
return errResult(
'make_invoice',
'INTERNAL',
err instanceof Error ? err.message : String(err)
)
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err))
}
const entry: PendingInvoice = {
invoice: prepared.invoice,
@@ -77,7 +67,7 @@ const handleMakeInvoice = async (
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
state: 'pending'
state: 'pending',
}
if (typeof params.description === 'string' && params.description) {
entry.description = params.description
@@ -88,30 +78,29 @@ const handleMakeInvoice = async (
}
ctx.invoices.set(entry.paymentHash, entry)
// phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the claim observes
void settleAndClaim(ctx, entry).catch(err => {
// lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed'
// an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing
if (err instanceof PollAbortedError) return
ctx.deps.onError?.(err, ctx.connection())
})
}),
)
if (!started) {
entry.state = 'failed'
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.')
}
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 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
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined
if (!invoiceParam && !hashParam) {
return errResult(
'lookup_invoice',
'OTHER',
'Provide an invoice or a payment hash.'
)
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.')
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
if (!entry && invoiceParam) {
@@ -128,10 +117,7 @@ const handleLookupInvoice = (
return okResult('lookup_invoice', invoiceResult(entry))
}
export const dispatch = async (
ctx: RequestContext,
request: NwcRequest
): Promise<NwcResponse> => {
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
switch (request.method) {
case 'get_info':
return handleGetInfo(ctx)
@@ -144,10 +130,6 @@ export const dispatch = async (
case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params)
default:
return errResult(
request.method,
'NOT_IMPLEMENTED',
`Unknown method: ${request.method}.`
)
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`)
}
}
+27 -27
View File
@@ -23,10 +23,10 @@ import type {PollOptions} from '../ops/shared'
import type {PendingInvoice, RequestContext} from './context'
export const DEFAULT_CLAIM_POLL: Required<PollOptions> = {
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000
maxWaitMs: 15 * 60_000,
}
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
@@ -41,9 +41,7 @@ export const resolvePaymentHash = (prepared: PreparedMint): string => {
}
// 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> = {
type: 'incoming',
state: entry.state,
@@ -51,7 +49,7 @@ export const invoiceResult = (
payment_hash: entry.paymentHash,
amount: entry.amountMsat,
created_at: entry.createdAt,
metadata: {}
metadata: {},
}
if (entry.description) result.description = entry.description
if (entry.expiresAt) result.expires_at = entry.expiresAt
@@ -64,47 +62,50 @@ export const invoiceResult = (
// the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh
// bearer to the caller. Settlement is recorded LAST - after the rotate -
// so a preimage lookup can only ever reveal an already-burned secret.
// bearer to the caller. Settlement is recorded LAST - after the rotate and
// bearer commit - so lookup can only reveal durably tracked funds and an
// already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports
// through deps.onError.
export const settleAndClaim = async (
ctx: RequestContext,
entry: PendingInvoice
): Promise<void> => {
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
if (!entry.prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
// the observation half is interruptible (the client may never pay, so
// the poll can legally outlive the service); once settlement is seen,
// everything below - claim, rotate, bearer commit - is fund-critical and
// deliberately ignores the stop signal: stop's drain awaits it
const result = await pollVerifyUntilSettled(
entry.prepared.verifyUrl,
ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL,
ctx.deps.kit ?? {}
{...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal},
ctx.deps.kit ?? {},
)
// a settled report only means this wallet's invoice was paid if it's
// for the invoice this wallet actually requested
if (!sameInvoice(result.pr, entry.prepared.invoice)) {
throw new Error(
"The service's verify response is for a different invoice than requested."
)
throw new Error("The service's verify response is for a different invoice than requested.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error(
'The payment settled but the service did not reveal the preimage.'
)
throw new Error('The payment settled but the service did not reveal the preimage.')
}
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
// other half) - invoked in two steps here because NWC needs the
// preimage, which claimMintedNote deliberately discards
const claimed = await claimFromPreimage(
entry.prepared,
preimage,
ctx.deps.kit ?? {}
)
const claimed = await claimFromPreimage(entry.prepared, preimage, {
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
})
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
await ctx.deps.applyChangeset(
{add, markSpent: []},
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
if (claimed.rotated) {
@@ -112,5 +113,4 @@ export const settleAndClaim = async (
// secret, safe to hand out as the settlement receipt
entry.preimage = preimage
}
ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice')
}
+51 -40
View File
@@ -9,7 +9,7 @@ import {
decodeBolt11AmountMsat,
fetchInvoiceVerification,
isBolt11Invoice,
noteK1
noteK1,
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
@@ -29,12 +29,9 @@ import {errResult, okResult} from './protocol'
// rotate) is left for the next refresh to reconcile, exactly as the UI
// leaves it - the money itself sits in the re-secured note, which IS
// tracked.
export const payChangeset = (
bearers: Bearer[],
result: PayResult
): NwcChangeset => {
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map(b => b.id)
const markSpent: string[] = result.carve.consumed.map((b) => b.id)
if (result.carve.change) add.push(result.carve.change)
if (result.outcome === 'failed-funds-returned') {
add.push(result.carve.note)
@@ -44,9 +41,7 @@ export const payChangeset = (
// carve), lock that bearer; a freshly carved note is never added -
// it was born spent
const carvedK1 = noteK1(result.carve.note.url)
const existing = carvedK1
? bearers.find(b => noteK1(b.url) === carvedK1)
: undefined
const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
if (existing) markSpent.push(existing.id)
}
if (result.rescuedNote) add.push(result.rescuedNote)
@@ -55,10 +50,9 @@ export const payChangeset = (
export const handlePayInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
params: Record<string, unknown>,
): Promise<NwcResponse> => {
const invoice =
typeof params.invoice === 'string' ? params.invoice.trim() : ''
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : ''
if (!isBolt11Invoice(invoice)) {
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
}
@@ -69,23 +63,17 @@ export const handlePayInvoice = async (
return errResult(
'pay_invoice',
'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) {
return errResult(
'pay_invoice',
'OTHER',
'The request\'s amount does not match the invoice.'
)
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.")
}
if (
amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())
) {
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
'This payment exceeds the connection\'s budget.'
"This payment exceeds the connection's budget.",
)
}
const bearers = ctx.deps.getBearers()
@@ -93,77 +81,100 @@ export const handlePayInvoice = async (
try {
result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {}
kit: ctx.deps.kit ?? {},
assertOwner: ctx.assertOwner,
})
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the
// possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped
ctx.deps.applyChangeset(
await ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []},
ctx.connection(),
'pay_invoice'
'pay_invoice',
ctx.assertOwner,
)
return errResult(
'pay_invoice',
'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)
return errResult(
'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message
message,
)
}
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) {
case 'settled': {
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
// the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one.
let preimage = ''
if (result.verifyUrl) {
try {
const proof = await fetchInvoiceVerification(
result.verifyUrl,
ctx.deps.kit ?? {}
)
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {})
preimage = proof.preimage ?? ''
} catch {
} catch (error) {
// the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome
if (!(error instanceof Error)) throw error
}
}
return okResult('pay_invoice', {preimage})
}
case 'failed-funds-returned':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
return errResult(
'pay_invoice',
'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':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
return errResult(
'pay_invoice',
'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':
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
await ctx.deps.applyChangeset(
payChangeset(bearers, result),
ctx.connection(),
'pay_invoice',
ctx.assertOwner,
)
return errResult(
'pay_invoice',
'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.'
'The payment is still in flight; the note stays locked until it reconciles.',
)
}
}
+22 -36
View File
@@ -29,7 +29,7 @@ export const NWC_METHODS = [
'get_balance',
'make_invoice',
'pay_invoice',
'lookup_invoice'
'lookup_invoice',
] as const
export type NwcMethod = (typeof NWC_METHODS)[number]
@@ -61,17 +61,13 @@ export type NwcResponse = {
export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method,
error: null,
result
result,
})
export const errResult = (
method: string,
code: NwcErrorCode,
message: string
): NwcResponse => ({
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method,
error: {code, message},
result: null
result: null,
})
// the two encryption schemes this service speaks; the scheme of a request
@@ -79,16 +75,14 @@ export const errResult = (
// requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04'
const conversationKey = (
walletSecretKey: Uint8Array,
clientPubkey: string
): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
export const encryptFor = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
plaintext: string
plaintext: string,
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
@@ -98,14 +92,14 @@ const decryptFrom = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
content: string
content: string,
): string =>
scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content)
const tagValue = (event: NostrEvent, name: string): string | undefined =>
event.tags.find(t => t[0] === name)?.[1]
event.tags.find((t) => t[0] === name)?.[1]
// The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can
@@ -126,7 +120,7 @@ export const decryptRequest = (
walletServicePubkey: string,
clientPubkey: string,
event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000)
nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => {
if (event.kind !== NWC_REQUEST_KIND) return null
// only the authorized client may talk to this connection, and the
@@ -155,8 +149,8 @@ export const decryptRequest = (
response: errResult(
'',
'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`
)
`Unsupported encryption scheme: ${advertised}.`,
),
}
}
let plaintext: string
@@ -173,7 +167,7 @@ export const decryptRequest = (
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.')
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
}
}
if (
@@ -185,14 +179,11 @@ export const decryptRequest = (
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.')
response: errResult('', 'OTHER', 'The request has no method.'),
}
}
const request = data as NwcRequest
const params =
typeof request.params === 'object' && request.params !== null
? request.params
: {}
const params = typeof request.params === 'object' && request.params !== null ? request.params : {}
return {respond: false, request: {method: request.method, params}, encryption}
}
@@ -204,7 +195,7 @@ export const buildResponseEvent = (
encryption: NwcEncryption,
requestEventId: string,
response: NwcResponse,
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
@@ -213,29 +204,24 @@ export const buildResponseEvent = (
tags: [
['p', clientPubkey],
['e', requestEventId],
['encryption', encryption]
['encryption', encryption],
],
content: encryptFor(
encryption,
walletSecretKey,
clientPubkey,
JSON.stringify(response)
)
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
},
walletSecretKey
walletSecretKey,
)
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
walletSecretKey: Uint8Array,
createdAt: number = Math.floor(Date.now() / 1000)
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
kind: NWC_INFO_KIND,
created_at: createdAt,
tags: [['encryption', 'nip44_v2 nip04']],
content: NWC_METHODS.join(' ')
content: NWC_METHODS.join(' '),
},
walletSecretKey
walletSecretKey,
)
+97 -44
View File
@@ -14,26 +14,18 @@
// foreground-only design itself is documented in the nwc.ts façade
// header.
import {linkingPubKeyHex} from '../keys'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {readNwcConnections} from '../storage/nwcConnections'
import {assertSavedKeyOwner} from '../storage/currentOwner'
import type {NwcConnectionInfo} from './connection'
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
import {dispatch} from './dispatch'
import type {
NostrEvent,
NwcEncryption,
NwcRequest,
NwcResponse
} from './protocol'
import {
NWC_REQUEST_KIND,
buildInfoEvent,
buildResponseEvent,
decryptRequest
} from './protocol'
import type {NwcSubscription, NwcTransport} from './transport'
import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol'
import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol'
import type {NwcSubscription} from './transport'
import {defaultNwcTransport} from './transport'
export type {NwcConnectionInfo}
@@ -44,7 +36,8 @@ const MAX_REQUEST_AGE_SECONDS = 600
type ConnectionRuntime = {
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:
// pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet
@@ -61,34 +54,38 @@ export type NwcService = {
connections: NwcConnectionInfo[]
// closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up
stop: () => void
stop: () => Promise<void>
}
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records: NwcConnectionRecord[] = readNwcConnections()
records?: NwcConnectionRecord[],
): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport())
const nowSeconds = (): number =>
deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
const ownerId = linkingPubKeyHex(linkingPrivKey)
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId,
)
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
const publishResponse = async (
runtime: ConnectionRuntime,
walletSecret: Uint8Array,
requestEventId: string,
encryption: NwcEncryption,
response: NwcResponse
response: NwcResponse,
): Promise<void> => {
await transport.publish(
runtime.info.record.relays,
buildResponseEvent(
runtime.walletSecret,
walletSecret,
runtime.info.record.clientPubkey,
encryption,
requestEventId,
response,
nowSeconds()
)
nowSeconds(),
),
)
}
@@ -97,12 +94,12 @@ export const startService = async (
const dispatchSerialized = (
runtime: ConnectionRuntime,
ctx: RequestContext,
request: NwcRequest
request: NwcRequest,
): Promise<NwcResponse> => {
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
const run = runtime.queue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request)
() => dispatch(ctx, request),
)
runtime.queue = run.catch(() => undefined)
return run
@@ -111,28 +108,53 @@ export const startService = async (
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent
event: NostrEvent,
): Promise<void> => {
const walletSecret = runtime.walletSecret
if (walletSecret === null) return
const at = nowSeconds()
// replay safety (see the header): too-old requests are dropped
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
const decrypted = decryptRequest(
runtime.walletSecret,
walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at
at,
)
if (decrypted === null) return
if (decrypted.respond) {
await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response)
await publishResponse(
runtime,
walletSecret,
event.id,
decrypted.encryption,
decrypted.response,
)
return
}
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
// 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 = {
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
@@ -141,29 +163,37 @@ export const startService = async (
queue: Promise.resolve(),
// replaced below, immediately - the field exists because the
// subscription callback closes over the runtime
sub: {close: () => undefined}
sub: {close: () => undefined},
}
const ctx: RequestContext = {
deps,
connection: () => runtime.info,
updateRecord: updated => {
updateRecord: (updated) => {
runtime.info = {...runtime.info, record: updated}
},
invoices: runtime.invoices,
nowSeconds
nowSeconds,
assertOwner: () => {
deps.assertCurrentOwner()
assertSavedKeyOwner(ownerId)
},
startBackground,
stopSignal: stopController.signal,
}
runtime.sub = transport.subscribe(
record.relays,
{
kinds: [NWC_REQUEST_KIND],
'#p': [runtime.info.walletServicePubkey],
since: nowSeconds()
since: nowSeconds(),
},
event => {
void handleEvent(runtime, ctx, event).catch(err =>
(event) => {
if (!accepting) return
const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info)
)
}
})
track(handler)
},
)
return runtime
})
@@ -171,20 +201,43 @@ export const startService = async (
// info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) {
const walletSecret = runtime.walletSecret
if (walletSecret === null) continue
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(runtime.walletSecret, nowSeconds())
buildInfoEvent(walletSecret, nowSeconds()),
)
} catch (error) {
deps.onError?.(
error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}),
runtime.info,
)
} catch (err) {
deps.onError?.(err, runtime.info)
}
}
return {
connections: runtimes.map(r => r.info),
stop: () => {
const connections = runtimes.map((runtime) => runtime.info)
let stopPromise: Promise<void> | null = null
const stop = (): Promise<void> => {
if (stopPromise !== null) return stopPromise
accepting = false
for (const runtime of runtimes) runtime.sub.close()
stopController.abort()
stopPromise = Promise.all([...inFlight])
.then(() => undefined)
.finally(() => {
for (const runtime of runtimes) {
runtime.walletSecret?.fill(0)
runtime.walletSecret = null
runtime.invoices.clear()
}
runtimes = []
})
return stopPromise
}
return {
connections,
stop,
}
}
+3 -4
View File
@@ -19,7 +19,7 @@ export type NwcTransport = {
subscribe: (
relays: string[],
filter: NostrFilter,
onEvent: (event: NostrEvent) => void
onEvent: (event: NostrEvent) => void,
) => NwcSubscription
}
@@ -30,11 +30,10 @@ export const defaultNwcTransport = async (): Promise<NwcTransport> => {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
// one honest relay accepting is enough - same rule as the backup
if (!results.some(r => r.status === 'fulfilled')) {
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.')
}
},
subscribe: (relays, filter, onEvent) =>
pool.subscribeMany(relays, filter, {onevent: onEvent})
subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}),
}
}
+122
View File
@@ -0,0 +1,122 @@
import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import type {Bearer} from './types'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
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)
expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(instance.state.noteState(k1)).toBe('burned')
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('03'), 3_000)
const second = await makeBearer(instance, secret('04'), 4_000)
const result = await ensureExactAmount([first, second], 7_000)
expect(result.note.amount).toBe(7_000)
expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned')
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000)
})
it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('05'), 3_000)
const second = await makeBearer(instance, secret('06'), 4_000)
const result = await ensureExactAmount([first, second], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000)
})
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)),
callback: '',
}
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)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(partK1).not.toBe(k1)
expect(instance.state.noteState(k1)).toBe('burned')
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(instance, k1, 21_000)
const probeKillingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return fetch(input, init)
return Promise.reject(new Error('probe unreachable'))
}
const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch,
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2)
const first = requiredValue(failure.possibleOutputs[0])
const second = requiredValue(failure.possibleOutputs[1])
expect(first.amount).toBe(5_000)
expect(second.amount).toBe(16_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
).toBe(5_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000)
})
})
+106
View File
@@ -0,0 +1,106 @@
import {describe, expect, it} from 'vitest'
import {
NoteSpentError,
PendingNoteError,
buildNoteUrl,
fetchNoteInfo,
meltNote,
noteK1,
rotateNote,
} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness'
describe('mint -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('grosses the invoice up for an advertised mint fee', async () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(instance)
const info = await fetchNoteInfo(
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat),
)
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000)
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}),
).rejects.toThrow(/different invoice/)
})
it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
})
describe('receiveBearer', () => {
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
const instance = await mint()
const senderK1 = secret('20')
instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [])
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(received.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(received.note.url))
expect(newK1).not.toBe(senderK1)
expect(instance.state.noteState(senderK1)).toBe('burned')
expect(instance.state.noteState(newK1)).toBe('outstanding')
})
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), [])).rejects.toBeInstanceOf(
PendingNoteError,
)
})
})
+75
View File
@@ -0,0 +1,75 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import {payWithBearers} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness'
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 payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, {
amountMsat: 21_000,
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
})
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(payer.state.noteState(secret('31'))).toBe('burned')
})
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},
})
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},
})
expect(result.outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('33'))).toBe('burned')
const returnedK1 = requiredValue(noteK1(result.carve.note.url))
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
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},
})
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)
})
})
+4 -606
View File
@@ -1,606 +1,4 @@
// The operations engine against the conformance mock mint - a real HTTP
// server that can be told to misbehave. The happy paths matter, but the
// adversarial modes (dropped mutations, failed melts) are what prove the
// fund-safety invariants: fresh secrets are never lost, and melt outcomes
// are classified by proof, not by hope.
import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import {
NoteSpentError,
PendingNoteError,
buildNoteUrl,
fetchNoteInfo,
meltNote,
noteK1,
rotateNote
} from 'lnurlcash-kit'
import type {Bearer} from './types'
import {
UncertainOutcomeError,
claimMintedNote,
ensureExactAmount,
payWithBearers,
prepareMint,
receiveBearer,
transferBetweenMints
} from './ops'
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()))
})
const secret = (seed: string) =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
const noteUrl = (m: Mint, k1: string, amountMsat?: number) =>
buildNoteUrl(`${m.url}/w`, k1, amountMsat)
// a verified, ready-to-spend bearer fixture: funded on the mock mint and
// read back through the informational GET, exactly as a real receive would
// learn its callback and authoritative amount
let fixtureCounter = 0
const makeBearer = async (
m: Mint,
k1: string,
amountMsat: number
): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat)
const url = noteUrl(m, k1, amountMsat)
const info = await fetchNoteInfo(url)
fixtureCounter += 1
return {
id: `fixture-${fixtureCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now()
}
}
// paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step).
// Returns the paid invoice's preimage, which IS the fresh note's secret.
const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1)!
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`)
return m.state.invoices.get(paymentHash)!.preimage
}
// waits for a mint to have an invoice at all, then settles it - for flows
// that request the invoice deep inside a single awaited call (transfer),
// where the test has to play the arriving payment mid-flight
const settleWhenRequested = async (m: Mint): Promise<string> => {
for (let i = 0; i < 200 && m.state.invoices.size === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
return settleLastInvoice(m)
}
// the mock burns a melted note 20ms after the melt - a transfer can
// resolve off the TARGET's settlement faster than that, so source-burn
// assertions wait for the mock's own timer instead of racing it
const expectBurned = async (m: Mint, k1: string): Promise<void> => {
for (let i = 0; i < 200 && m.state.noteState(k1) !== 'burned'; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(m.state.noteState(k1)).toBe('burned')
}
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const m = await mint()
const k1 = secret('01')
const bearer = await makeBearer(m, 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(m.state.noteState(k1)).toBe('outstanding')
})
it('split path: carves an exact note off a larger one, with change', async () => {
const m = await mint()
const k1 = secret('02')
const bearer = await makeBearer(m, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
expect(result.consumed.map(b => b.id)).toEqual([bearer.id])
// the input is burned; both outputs are live and worth what the result claims
expect(m.state.noteState(k1)).toBe('burned')
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000)
})
it('merge path: combines notes summing exactly to the target', async () => {
const m = await mint()
const a = await makeBearer(m, secret('03'), 3_000)
const b = await makeBearer(m, secret('04'), 4_000)
const result = await ensureExactAmount([a, b], 7_000)
expect(result.note.amount).toBe(7_000)
expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
expect(m.state.noteState(noteK1(a.url)!)).toBe('burned')
expect(m.state.noteState(noteK1(b.url)!)).toBe('burned')
const mergedK1 = noteK1(result.note.url)!
expect((await fetchNoteInfo(noteUrl(m, mergedK1))).maxWithdrawable).toBe(7_000)
})
it('merge+split path: splits the target off several notes in one request', async () => {
const m = await mint()
const a = await makeBearer(m, secret('05'), 3_000)
const b = await makeBearer(m, secret('06'), 4_000)
const result = await ensureExactAmount([a, b], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(2_000)
})
it('excludes spent and unverified notes from selection', async () => {
const m = await mint()
const spentBearer = await makeBearer(m, secret('07'), 50_000)
const unverified: Bearer = {
...(await makeBearer(m, secret('08'), 50_000)),
callback: ''
}
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000)
).rejects.toThrow(/enough/)
})
it('refuses an amount no mint can cover', async () => {
const m = await mint()
const bearer = await makeBearer(m, 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 m = await mint({dropAfterMutation: true})
const k1 = secret('10')
const bearer = await makeBearer(m, k1, 21_000)
// the split's response never arrives - but the mutation landed, so the
// probe resolves the ambiguity and the carried secrets are adopted
const result = await ensureExactAmount([bearer], 5_000)
const partK1 = noteK1(result.note.url)!
const changeK1 = noteK1(result.change!.url)!
expect(partK1).not.toBe(k1)
expect(m.state.noteState(k1)).toBe('burned')
expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000)
})
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const m = await mint({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(m, k1, 21_000)
// mutations go to the mint (and land, dropped); every informational GET
// fails, so the probe cannot resolve the ambiguity either
const probeKillingFetch: typeof fetch = (input, init) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
if (url.includes('/w/cb')) return fetch(input, init)
return Promise.reject(new Error('probe unreachable'))
}
const err = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch
}).catch((e: unknown) => e)
expect(err).toBeInstanceOf(UncertainOutcomeError)
const outputs = (err as UncertainOutcomeError).possibleOutputs
expect(outputs).toHaveLength(2)
// both possible outputs carry their fresh secrets, at the expected
// amounts - if the split landed, these are the only money left
expect(outputs[0]!.amount).toBe(5_000)
expect(outputs[1]!.amount).toBe(16_000)
expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[0]!.url)!))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[1]!.url)!))).maxWithdrawable).toBe(16_000)
})
})
describe('mint -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(m)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
// the preimage IS the initial note secret - after the rotate, that
// secret (which the mint necessarily saw) is worthless, and the
// wallet's fresh secret is the only live note
expect(m.state.noteState(preimage)).toBe('burned')
const k1 = noteK1(claimed.note.url)!
expect(k1).not.toBe(preimage)
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('grosses the invoice up for an advertised mint fee', async () => {
const m = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(m)
// the service's fee math is authoritative - the credited note nets
// roughly what was asked for (within fee-rounding slack), never more
// than the gross
const info = await fetchNoteInfo(
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat)
)
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000)
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat)
// this mock regenerates the proof's pr from the NET amount rather than
// echoing the stored invoice, so the strict same-invoice guard in
// claimMintedNote correctly refuses to bind it - the guard working as
// designed against a mismatched proof
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500})
).rejects.toThrow(/different invoice/)
})
it('times out cleanly when the invoice is never paid', async () => {
const m = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100})
).rejects.toThrow(/not confirmed/i)
})
})
describe('receiveBearer', () => {
it('verifies an incoming note and rotates it, burning the sender\'s copy', async () => {
const m = await mint()
// the "sender" hands over this URL - they know its secret
const senderK1 = secret('20')
m.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(m, senderK1, 21_000), [])
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(received.note.verified).toBe(true)
const newK1 = noteK1(received.note.url)!
expect(newK1).not.toBe(senderK1)
expect(m.state.noteState(senderK1)).toBe('burned')
expect(m.state.noteState(newK1)).toBe('outstanding')
})
it('refuses a note the wallet already holds', async () => {
const m = await mint()
const senderK1 = secret('21')
const existing = await makeBearer(m, senderK1, 21_000)
await expect(
receiveBearer(noteUrl(m, senderK1, 21_000), [existing])
).rejects.toThrow(/already/)
})
it('surfaces a spent note as definitively spent', async () => {
const m = await mint()
const k1 = secret('22')
const bearer = await makeBearer(m, k1, 21_000)
// burn it server-side (a rotate by the "other" copy of the wallet)
const info = await fetchNoteInfo(bearer.url)
await rotateNote(info.callback, k1)
await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError
)
})
it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const m = await mint({meltNeverSettles: true})
const k1 = secret('23')
const bearer = await makeBearer(m, k1, 21_000)
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz')
await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf(
PendingNoteError
)
})
})
describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('30'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
})
expect(result.outcome).toBe('settled')
expect(m.state.noteState(secret('30'))).toBe('burned')
})
it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint()
const payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers(
[bearer],
`mint@127.0.0.1:${payee.port}`,
{amountMsat: 21_000, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}}
)
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(payer.state.noteState(secret('31'))).toBe('burned')
})
it('carves the exact amount out of a larger note before melting', async () => {
const m = await mint()
const bearer = await makeBearer(m, secret('32'), 50_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
})
expect(result.outcome).toBe('settled')
// the split happened: input burned, the 21000 sat note melted, and the
// change note is tracked for the wallet to keep
expect(m.state.noteState(secret('32'))).toBe('burned')
expect(result.carve.consumed.map(b => b.id)).toEqual([bearer.id])
expect(result.carve.change?.amount).toBe(29_000)
expect(m.state.noteState(noteK1(result.carve.change!.url)!)).toBe('outstanding')
})
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const m = await mint({meltAlwaysFails: true})
const bearer = await makeBearer(m, secret('33'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
expect(result.outcome).toBe('failed-funds-returned')
// the mint restored the note, and the classification rotate re-secured
// it (the melt had put its k1 on the wire): the old secret is burned,
// the fresh one in the result is outstanding at the full amount
expect(m.state.noteState(secret('33'))).toBe('burned')
const returnedK1 = noteK1(result.carve.note.url)!
expect(m.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
it('classifies a never-settling melt as unknown-still-pending', async () => {
const m = await mint({meltNeverSettles: true})
const bearer = await makeBearer(m, secret('34'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
expect(result.outcome).toBe('unknown-still-pending')
expect(m.state.noteState(secret('34'))).toBe('pending')
})
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const m = await mint()
const bearer = await makeBearer(m, 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)
})
})
describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
it('moves value to another mint: melt at source, claim + rotate at target', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('40')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
// the transfer is now waiting on the target invoice settling - the
// mock mints can't actually pay each other, so the settle hook plays
// the melt's payment arriving
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0
})
expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1)
const claimed = result.mintedAtTarget!
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
// the preimage is the secret the target mint necessarily saw - after
// the rotate it is worthless there, and the wallet's fresh secret is
// the only live note
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = noteK1(claimed.note.url)!
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
it('refuses an amount no source mint can cover', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000)
await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`)
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('rejects a transfer onto the mint the notes are already on', async () => {
const m = await mint()
const k1 = secret('42')
const bearer = await makeBearer(m, k1, 21_000)
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${m.port}`)
).rejects.toThrow(/different target/)
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint()
// not via the mint() helper - a dead server stays out of afterEach
const dead = await createMockMint()
const deadAddress = `mint@127.0.0.1:${dead.port}`
await dead.close()
const k1 = secret('43')
// a note LARGER than the transfer amount, so a premature carve would
// show up here as a burn
const bearer = await makeBearer(source, k1, 50_000)
await expect(
transferBetweenMints([bearer], 21_000, deadAddress)
).rejects.toThrow()
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
// unconfirmedMutation: the melt's response confirms nothing, so the
// melt's outcome is uncertain - the target invoice settling is the
// transfer's ground truth
const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true})
const k1 = secret('44')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
// the melt had landed despite its lost answer - the source note is
// gone, and the target note came out the other end
await expectBurned(source, k1)
expect(result.mintedAtTarget?.note.amount).toBe(21_000)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
// echoWrongK1: the target settles the invoice and reveals the
// preimage, but its informational GET then breaks the claim
const source = await mint()
const target = await mint({testHooks: true, echoWrongK1: true})
const k1 = secret('45')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: fastPoll}
)
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled-claim-failed')
await expectBurned(source, k1)
// the preimage IS the note secret - surfaced unverified, not lost
const note = result.claimMaterial?.note
expect(note).toBeDefined()
expect(noteK1(note!.url)).toBe(preimage)
expect(note!.verified).toBe(false)
expect(note!.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
})
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint()
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
const k1 = secret('46')
// covers the requested net exactly - but not the grossed-up invoice
const bearer = await makeBearer(source, k1, 100_000)
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`)
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true})
const target = await mint({testHooks: true})
const k1 = secret('47')
const bearer = await makeBearer(source, k1, 21_000)
const result = await transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
{poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}}
)
expect(result.outcome).toBe('failed-funds-returned')
expect(result.mintedAtTarget).toBeUndefined()
// the classification rotate re-secured the note (the melt had put its
// k1 on the wire): the old secret is burned, the fresh one in the
// result is outstanding at the full amount
expect(source.state.noteState(k1)).toBe('burned')
const returnedK1 = noteK1(result.carve.note.url)!
expect(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
})
import './ops.carve.cases'
import './ops.mint-receive.cases'
import './ops.pay.cases'
import './ops.transfer.cases'
+71
View File
@@ -0,0 +1,71 @@
import {afterEach, expect} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit'
import type {Bearer} from './types'
import {requiredValue} from './test-utils'
export type Mint = Awaited<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options)
mints.push(instance)
return instance
}
afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close()))
})
export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
buildNoteUrl(`${instance.url}/w`, k1, amountMsat)
let fixtureCounter = 0
export const makeBearer = async (
instance: Mint,
k1: string,
amountMsat: number,
): Promise<Bearer> => {
instance.state.creditNote(k1, amountMsat)
const url = noteUrl(instance, k1, amountMsat)
const info = await fetchNoteInfo(url)
fixtureCounter += 1
return {
id: `fixture-${fixtureCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: instance.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
export const settleLastInvoice = async (instance: Mint): Promise<string> => {
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1))
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`)
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`)
return requiredValue(instance.state.invoices.get(paymentHash)).preimage
}
export const settleWhenRequested = async (instance: Mint): Promise<string> => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
return settleLastInvoice(instance)
}
export const expectBurned = async (instance: Mint, k1: string): Promise<void> => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
expect(instance.state.noteState(k1)).toBe('burned')
}
+136
View File
@@ -0,0 +1,136 @@
import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {noteK1} from 'lnurlcash-kit'
import {transferBetweenMints} from './ops'
import {requiredValue} from './test-utils'
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
it('moves value to another mint: melt at source, claim + rotate at target', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('40')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(result.invoice).toMatch(/^lnbc/)
expect(result.quote).toEqual({
requestedMsat: 21_000,
grossMsat: 21_000,
targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0,
})
expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
it('refuses an amount no source mint can cover', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000)
await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('rejects a transfer onto the mint the notes are already on', async () => {
const instance = await mint()
const k1 = secret('42')
const bearer = await makeBearer(instance, k1, 21_000)
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
).rejects.toThrow(/different target/)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint()
const dead = await createMockMint()
const deadAddress = `mint@127.0.0.1:${dead.port}`
await dead.close()
const k1 = secret('43')
const bearer = await makeBearer(source, k1, 50_000)
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow()
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true})
const k1 = secret('44')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
expect(result.mintedAtTarget?.note.amount).toBe(21_000)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
const source = await mint()
const target = await mint({testHooks: true, echoWrongK1: true})
const k1 = secret('45')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled-claim-failed')
await expectBurned(source, k1)
const note = requiredValue(result.claimMaterial?.note)
expect(noteK1(note.url)).toBe(preimage)
expect(note.verified).toBe(false)
expect(note.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
})
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint()
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
const k1 = secret('46')
const bearer = await makeBearer(source, k1, 100_000)
await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true})
const target = await mint({testHooks: true})
const k1 = secret('47')
const bearer = await makeBearer(source, k1, 21_000)
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
expect(result.outcome).toBe('failed-funds-returned')
expect(result.mintedAtTarget).toBeUndefined()
expect(source.state.noteState(k1)).toBe('burned')
const returnedK1 = requiredValue(noteK1(result.carve.note.url))
expect(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000)
})
})
+1 -1
View File
@@ -41,5 +41,5 @@ export type {
TransferOptions,
TransferOutcome,
TransferQuote,
TransferResult
TransferResult,
} from './ops/transfer'
+32 -47
View File
@@ -11,11 +11,11 @@ import {
serverOf,
settleNote,
splitNote,
withNewK1
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import {UncertainOutcomeError} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input
@@ -49,13 +49,13 @@ export type CarveResult = {
export const ensureExactAmount = async (
bearers: Bearer[],
amountMsat: number,
options: LnurlcashOptions = {}
options: FundOperationOptions = {},
): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
}
const eligible = bearers.filter(
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url)
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
// per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at
@@ -68,19 +68,17 @@ export const ensureExactAmount = async (
let pick: Bearer[] | null = null
for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount)
const exact = sorted.find(b => b.amount === amountMsat)
const exact = sorted.find((b) => b.amount === amountMsat)
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
if (!candidate) continue
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
}
if (!pick) {
throw new Error(
'No mint holds enough verified, unspent balance to cover that amount.'
)
throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
}
const base = pick[0]
const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map(b => requireNoteK1(b.url))
const k1s = pick.map((b) => requireNoteK1(b.url))
if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched
@@ -90,9 +88,9 @@ export const ensureExactAmount = async (
callback: base.callback,
amount: base.amount,
verified: base.verified,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
},
consumed: []
consumed: [],
}
}
@@ -100,13 +98,14 @@ export const ensureExactAmount = async (
// merge path: many notes, exact sum - merge into one, then settle it
// (true value + fresh secret; a failed settle leaves an unverified
// note a refresh can repair, not a lost secret)
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
const unverified: NewBearer = {
url: withNewK1(base.url, merged.k1, total, merged.signature),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
}
// a merge whose answer was lost leaves the service in an unknown
// state from here - settling fires another mutation (the rotate
@@ -115,27 +114,16 @@ export const ensureExactAmount = async (
// refresh repair.
if (merged.rescued) return {note: unverified, consumed: pick}
try {
const settled = await settleNote(
base.url,
merged.k1,
total,
merged.signature,
options
)
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return {
note: {
url: withNewK1(
base.url,
settled.k1,
settled.amountMsat,
settled.signature
),
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
},
consumed: pick
consumed: pick,
}
} catch {
return {note: unverified, consumed: pick}
@@ -154,6 +142,7 @@ export const ensureExactAmount = async (
// state, so the change is NOT settled (that would fire another mutation
// at it, whose own ambiguous failure would strand the rescued secret)
let rescued = false
assertFundOwner(options)
try {
const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1
@@ -178,16 +167,16 @@ export const ensureExactAmount = async (
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey
}
]
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secrets are the only money
@@ -200,7 +189,7 @@ export const ensureExactAmount = async (
callback: base.callback,
amount: amountMsat,
verified: partVerified,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
}
// settleNote: the change may be worth less than total - amount if this
// mint charges split fees (LUD-25 deducts them from change, never the
@@ -211,7 +200,7 @@ export const ensureExactAmount = async (
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
}
if (!rescued) {
try {
@@ -220,22 +209,18 @@ export const ensureExactAmount = async (
changeK1,
total - amountMsat,
changeSignature,
options
options,
)
change = {
url: withNewK1(
base.url,
settled.k1,
settled.amountMsat,
settled.signature
),
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey
mintPubkey: base.mintPubkey,
}
} catch {
} catch (error) {
// settle is best-effort - the unverified change above is still tracked
if (!(error instanceof Error)) throw error
}
}
return {note, change, consumed: pick}
@@ -270,7 +255,7 @@ const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
total: number,
options: LnurlcashOptions
options: FundOperationOptions,
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
try {
const merged = await mergeNotes(base.callback, k1s, options)
@@ -288,9 +273,9 @@ const mergeAmbiguitySafe = async (
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey
}
]
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secret is the only money left
+23 -43
View File
@@ -19,13 +19,14 @@ import {
rotateNote,
sameInvoice,
serverOf,
withNewK1
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintAddressInfo} from 'lnurlcash-kit'
import type {MintAddressInfo} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared'
import {pollVerifyUntilSettled} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
export type PreparedMint = {
invoice: string
@@ -52,7 +53,7 @@ export type PreparedMint = {
export const prepareMint = async (
mintInput: string,
amountMsat: number,
options: LnurlcashOptions = {}
options: FundOperationOptions = {},
): Promise<PreparedMint> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
@@ -69,21 +70,20 @@ export const prepareMint = async (
try {
nodeInfo = await fetchMintAddress(addressUrl, options)
payUrl = nodeInfo.payLink
} catch {
} catch (error) {
// no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error
}
}
const info = await fetchPayRequest(payUrl, options)
if (!info.withdrawLink) {
throw new Error(
'This payRequest does not advertise lnurlcash minting (no withdrawLink).'
)
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).')
}
const grossMsat = ceilMsatToSat(
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat,
)
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error('Amount is outside this mint\'s sendable range.')
throw new Error("Amount is outside this mint's sendable range.")
}
const invoice = await requestInvoice(info.callback, grossMsat, options)
const prepared: PreparedMint = {
@@ -95,7 +95,7 @@ export const prepareMint = async (
withdrawLink: info.withdrawLink,
server: serverOf(payUrl),
username: lightningAddressUsername(payUrl),
nodeInfo
nodeInfo,
}
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
return prepared
@@ -123,11 +123,11 @@ export type ClaimedNote = {
export const claimMintedNote = async (
prepared: PreparedMint,
poll: PollOptions = {},
options: LnurlcashOptions = {}
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
if (!prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
const verifyUrl = prepared.verifyUrl
@@ -135,15 +135,11 @@ export const claimMintedNote = async (
// a settled report only means this wallet's invoice was paid if it's for
// the invoice this wallet actually requested
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error(
"The service's verify response is for a different invoice than requested."
)
throw new Error("The service's verify response is for a different invoice than requested.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error(
'The payment settled but the service did not reveal the preimage.'
)
throw new Error('The payment settled but the service did not reveal the preimage.')
}
return claimFromPreimage(prepared, preimage, options)
}
@@ -168,15 +164,12 @@ export type ClaimTarget = {
export const claimFromPreimage = async (
claim: ClaimTarget,
preimage: string,
options: LnurlcashOptions = {}
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
// declare the invoiced amount (a claim - not yet confirmed) so the note
// is self-describing even before the verifying GET below
const declaredUrl = buildNoteUrl(
claim.withdrawLink,
preimage,
claim.expectedNoteValueMsat
)
const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat)
assertFundOwner(options)
// the service's maxWithdrawable is authoritative - SERVICE's own fee
// math might not match this wallet's estimate, and the note is worth
// exactly maxWithdrawable regardless
@@ -186,7 +179,7 @@ export const claimFromPreimage = async (
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: true
verified: true,
}
if (mintPubkey) base.mintPubkey = mintPubkey
@@ -196,12 +189,7 @@ export const claimFromPreimage = async (
let rotationError: string | undefined
try {
const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options)
url = withNewK1(
declaredUrl,
rotatedNote.k1,
noteInfo.maxWithdrawable,
rotatedNote.signature
)
url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature)
} catch (err) {
rotated = false
if (err instanceof AmbiguousMutationError) {
@@ -210,24 +198,16 @@ export const claimFromPreimage = async (
const outcome = await probeBurnedNote(declaredUrl, options)
if (outcome === 'gone') {
// the burn landed - adopt the fresh secret as the note
url = withNewK1(
declaredUrl,
err.newSecrets[0],
noteInfo.maxWithdrawable
)
url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
// can't tell: the preimage note is returned either way - the
// possible rotated copy goes alongside it, both refreshable
possibleCopy = {
url: withNewK1(
declaredUrl,
err.newSecrets[0],
noteInfo.maxWithdrawable
),
url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
+15 -16
View File
@@ -16,14 +16,15 @@ import {
resolveLnurlInput,
rotateNote,
sameInvoice,
withNewK1
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve'
import type {PollOptions} from './shared'
import {pollVerifyUntilSettled} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
export type PayOutcome =
| 'settled'
@@ -54,6 +55,7 @@ export type PayOptions = {
poll?: PollOptions
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
}
// A melt's resolved promise only means the payment is in flight; the
@@ -69,9 +71,9 @@ export type PayOptions = {
export const payWithBearers = async (
bearers: Bearer[],
input: string,
{amountMsat, poll = {}, kit = {}}: PayOptions = {}
{amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {},
): Promise<PayResult> => {
const options = kit
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
let invoice: string
let amount: number
const trimmed = input.trim()
@@ -79,7 +81,7 @@ export const payWithBearers = async (
const decoded = decodeBolt11AmountMsat(trimmed)
if (decoded === null || decoded <= 0) {
throw new Error(
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
"Could not read this invoice's amount - amount-less invoices are not supported.",
)
}
invoice = trimmed
@@ -97,7 +99,7 @@ export const payWithBearers = async (
}
const info = await fetchPayRequest(url, options)
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
throw new Error('Amount is outside the payee\'s sendable range.')
throw new Error("Amount is outside the payee's sendable range.")
}
const result = await requestInvoice(info.callback, amountMsat, options)
invoice = result.pr
@@ -106,6 +108,7 @@ export const payWithBearers = async (
const carve = await ensureExactAmount(bearers, amount, options)
const k1 = requireNoteK1(carve.note.url)
if (carve.consumed.length === 0) assertFundOwner(options)
let melt: MeltResult
try {
melt = await meltNote(carve.note.callback, k1, invoice, options)
@@ -136,11 +139,7 @@ export const payWithBearers = async (
// either way (a service regenerating synthetic prs in proofs) and is
// tolerated.
const proofAmount = decodeBolt11AmountMsat(proof.pr)
if (
!sameInvoice(proof.pr, invoice) &&
proofAmount !== null &&
proofAmount !== amount
) {
if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) {
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
}
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
@@ -158,12 +157,12 @@ export const payWithBearers = async (
...carve,
note: {
...carve.note,
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature)
}
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
},
},
invoice,
amountMsat: amount,
verifyUrl
verifyUrl,
}
} catch (err) {
if (err instanceof PendingNoteError) {
@@ -183,7 +182,7 @@ export const payWithBearers = async (
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
callback: carve.note.callback,
amount,
verified: false
verified: false,
}
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
return {
@@ -192,7 +191,7 @@ export const payWithBearers = async (
invoice,
amountMsat: amount,
verifyUrl,
rescuedNote
rescuedNote,
}
}
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
+10 -8
View File
@@ -10,12 +10,13 @@ import {
NoteUnknownError,
PendingNoteError,
probeBurnedNote,
withNewK1
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note,
@@ -23,13 +24,14 @@ import type {ClaimedNote} from './mint'
export const receiveBearer = async (
input: string,
existing: Bearer[],
options: LnurlcashOptions = {}
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
const note = await receiveNote(input, existing)
if (!note.verified || !note.callback) {
return {note, rotated: false}
}
try {
assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note)
return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) {
@@ -49,9 +51,9 @@ export const receiveBearer = async (
return {
note: {
...note,
url: withNewK1(note.url, err.newSecrets[0], note.amount)
url: withNewK1(note.url, err.newSecrets[0], note.amount),
},
rotated: true
rotated: true,
}
}
if (outcome === 'unknown') {
@@ -59,14 +61,14 @@ export const receiveBearer = async (
url: withNewK1(note.url, err.newSecrets[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return {
note,
rotated: false,
possibleCopy,
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`,
}
}
}
@@ -75,7 +77,7 @@ export const receiveBearer = async (
return {
note,
rotated: false,
rotationError: err instanceof Error ? err.message : String(err)
rotationError: err instanceof Error ? err.message : String(err),
}
}
}
+66 -9
View File
@@ -19,45 +19,102 @@ export class UncertainOutcomeError extends Error {
}
}
// the wait was interrupted from outside (service shutdown) - distinct
// from budget exhaustion so the caller can treat it as normal teardown
export class PollAbortedError extends Error {
constructor() {
super('The wait was interrupted by shutdown.')
this.name = 'PollAbortedError'
}
}
export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void
}
export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.()
}
export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number
intervalCapMs?: number
// total budget before giving up
maxWaitMs?: number
// aborts the wait promptly (shutdown). Only the WAIT is interruptible:
// callers pass this for work whose observation phase may outlive the
// caller - once pollVerifyUntilSettled has returned, the signal no
// longer reaches anything
signal?: AbortSignal
}
const DEFAULT_POLL: Required<PollOptions> = {
const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 1000,
intervalCapMs: 5000,
maxWaitMs: 120_000
maxWaitMs: 120_000,
}
const sleep = (ms: number): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms))
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
// a sleep that ends immediately on abort instead of riding out its timer
const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new PollAbortedError())
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
if (signal.aborted) {
clearTimeout(timer)
reject(new PollAbortedError())
return
}
signal.addEventListener('abort', onAbort, {once: true})
})
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion.
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = async (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions
options: LnurlcashOptions,
): Promise<VerifyResult> => {
const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll}
const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL,
...poll,
}
const fetchOptions: LnurlcashOptions = signal
? {
...options,
fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch
return base(input, {...init, signal})
},
}
: options
const deadline = Date.now() + maxWaitMs
let delay = intervalMs
let lastError: unknown = null
while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError()
try {
const result = await fetchInvoiceVerification(verifyUrl, options)
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
if (result.settled) return result
lastError = null
} catch (err) {
// the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError()
lastError = err
}
await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal)
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
delay = Math.min(delay * 2, intervalCapMs)
}
if (lastError instanceof Error) {
+20 -28
View File
@@ -24,7 +24,7 @@ import {
rotateNote,
sameInvoice,
serverOf,
withNewK1
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
@@ -33,7 +33,8 @@ import {ensureExactAmount} from './carve'
import type {ClaimedNote} from './mint'
import {claimFromPreimage, prepareMint} from './mint'
import type {PollOptions} from './shared'
import {pollVerifyUntilSettled} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated)
@@ -102,15 +103,16 @@ export type TransferOptions = {
poll?: PollOptions
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
}
export const transferBetweenMints = async (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
{poll = {}, kit = {}}: TransferOptions = {}
{poll = {}, kit = {}, assertOwner}: TransferOptions = {},
): Promise<TransferResult> => {
const options = kit
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
}
@@ -120,7 +122,7 @@ export const transferBetweenMints = async (
const prepared = await prepareMint(targetMint, amountMsat, options)
if (!prepared.verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.'
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const verifyUrl = prepared.verifyUrl
@@ -129,19 +131,17 @@ export const transferBetweenMints = async (
// goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing)
const eligible = bearers.filter(
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url)
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
const offTarget = eligible.filter(b => serverOf(b.url) !== targetServer)
const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer)
if (eligible.length > 0 && offTarget.length === 0) {
throw new Error(
'That\'s the mint these notes are already on - pick a different target.'
)
throw new Error("That's the mint these notes are already on - pick a different target.")
}
const quote: TransferQuote = {
requestedMsat: amountMsat,
grossMsat: prepared.grossMsat,
targetMintFeeMsat: prepared.grossMsat - amountMsat,
sourceMeltFeeReserveMsat: 0
sourceMeltFeeReserveMsat: 0,
}
// carving burns its inputs server-side, so it happens only once the
// target is known good and the invoice exists
@@ -151,12 +151,13 @@ export const transferBetweenMints = async (
const claimMaterial: TransferClaimMaterial = {
invoice,
withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat
expectedNoteValueMsat: prepared.expectedNoteValueMsat,
}
// from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
const k1 = requireNoteK1(carve.note.url)
if (carve.consumed.length === 0) assertFundOwner(options)
try {
await meltNote(carve.note.callback, k1, invoice, options)
} catch (err) {
@@ -197,20 +198,16 @@ export const transferBetweenMints = async (
// the melt settled - the money is now the preimage note at the
// target and nowhere else; surface it rather than lose it
const note: NewBearer = {
url: buildNoteUrl(
prepared.withdrawLink,
proof.preimage,
prepared.expectedNoteValueMsat
),
url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note}
claimMaterial: {...claimMaterial, note},
}
}
} catch {
@@ -229,14 +226,9 @@ export const transferBetweenMints = async (
...carve,
note: {
...carve.note,
url: withNewK1(
carve.note.url,
rotated.k1,
carve.note.amount,
rotated.signature
)
}
}
url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature),
},
},
}
} catch (err) {
if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
@@ -251,7 +243,7 @@ export const transferBetweenMints = async (
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
callback: carve.note.callback,
amount: carve.note.amount,
verified: false
verified: false,
}
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
return {...base, outcome: 'failed-funds-returned', rescuedNote}
+15
View File
@@ -0,0 +1,15 @@
// Legacy passkey slots may be adopted only after another unlock path has
// proven and stamped the saved wallet owner. The linking key is checked
// against that marker before markerless slots are changed under the lock.
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
import {withStorageLock} from './storageLock'
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
const ownerId = savedKeyOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.')
}
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId))
}
+12 -23
View File
@@ -16,15 +16,11 @@ const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1'
export const derivePasskeyWrapKey = async (
prfOutput: Uint8Array,
hkdfSalt: Uint8Array
hkdfSalt: Uint8Array,
): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey(
'raw',
new Uint8Array(prfOutput),
'HKDF',
false,
['deriveKey']
)
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(prfOutput), 'HKDF', false, [
'deriveKey',
])
return crypto.subtle.deriveKey(
// the copies pin the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
@@ -32,33 +28,29 @@ export const derivePasskeyWrapKey = async (
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(hkdfSalt),
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO))
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)),
},
baseKey,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt']
['encrypt', 'decrypt'],
)
}
export const wrapLinkingKeyWithPrf = async (
prfOutput: Uint8Array,
linkingKey: Uint8Array
linkingKey: Uint8Array,
): Promise<PasskeyWrap> => {
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
wrapKey,
new Uint8Array(linkingKey)
)
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, new Uint8Array(linkingKey)),
)
return {
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext)
wrappedKey: bytesToHex(ciphertext),
}
}
@@ -66,16 +58,13 @@ export const wrapLinkingKeyWithPrf = async (
// i.e. a different passkey than the one that created the slot
export const unwrapLinkingKeyWithPrf = async (
prfOutput: Uint8Array,
wrap: PasskeyWrap
wrap: PasskeyWrap,
): Promise<Uint8Array> => {
const wrapKey = await derivePasskeyWrapKey(
prfOutput,
hexToBytes(wrap.hkdfSalt)
)
const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt))
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))},
wrapKey,
new Uint8Array(hexToBytes(wrap.wrappedKey))
new Uint8Array(hexToBytes(wrap.wrappedKey)),
)
return new Uint8Array(plaintext)
}
+173
View File
@@ -0,0 +1,173 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('pure wrap crypto', () => {
it('round-trips a linking key through a PRF-derived wrap', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap)
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
})
it('rejects unwrap with a different PRF output', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow()
})
it('rejects unwrap with a tampered HKDF salt', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}),
).rejects.toThrow()
})
it('rejects unwrap with a tampered ciphertext', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
const flipped = `${wrap.wrappedKey.slice(0, -2)}${wrap.wrappedKey.endsWith('00') ? '01' : '00'}`
await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}),
).rejects.toThrow()
})
it('derives wrap keys deterministically from the same PRF output and salt', async () => {
const salt = new Uint8Array(16).fill(1)
const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt)
const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt)
const record = await encryptRecord(a, {v: 1})
await expect(decryptRecord(b, record)).resolves.toEqual({v: 1})
})
})
+178
View File
@@ -0,0 +1,178 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('multiple passkeys', () => {
it('keeps slots independent: each passkey unlocks the same key', async () => {
const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, {
credentials: laptop,
name: 'laptop',
})
const phoneSlot = await registerPasskey(LINKING_KEY, {
credentials: phone,
name: 'phone',
})
expect(readPasskeySlots()).toHaveLength(2)
// independent wrap keys: same plaintext, different salts and ciphertexts
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
})
it('removePasskey drops exactly one slot and leaves the rest working', async () => {
const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, {
credentials: laptop,
})
await registerPasskey(LINKING_KEY, {credentials: phone})
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
expect(readPasskeySlots()).toHaveLength(1)
// the removed passkey no longer matches any offered credential
await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow('cancelled')
// the survivor is unaffected
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
// removing again is a no-op
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
})
})
+243
View File
@@ -0,0 +1,243 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('slot ownership', () => {
it('binds a new slot to the proven saved wallet owner', async () => {
// Given the saved wallet has a canonical owner marker
const auth = new FakeAuthenticator()
// When its linking key registers a passkey
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
// Then the slot carries that same canonical owner
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
expect(readPasskeySlots()).toEqual([slot])
})
it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
// Given one valid current-owner slot plus copies with untrusted owners
const auth = new FakeAuthenticator()
const current = await registerPasskey(LINKING_KEY, {credentials: auth})
const foreign = {
...current,
credentialId: '11'.repeat(16),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
}
const malformed = {
...current,
credentialId: '22'.repeat(16),
ownerId: 'not-an-owner',
}
const unowned = {
credentialId: '33'.repeat(16),
hkdfSalt: current.hkdfSalt,
iv: current.iv,
wrappedKey: current.wrappedKey,
createdAt: current.createdAt,
}
writeRawSlots([foreign, malformed, unowned])
// When the current wallet asks for its slots
const slots = readPasskeySlots()
// Then no foreign or unproven slot is exposed
expect(slots).toEqual([])
expect(hasPasskeySlots()).toBe(false)
})
it('does not offer markerless slots for passkey-first unlock', async () => {
// Given a legacy slot and a saved key with no proven owner marker
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
writeRawSlots(legacy)
removeSavedOwnerMarker()
// When passkey unlock is attempted before another proof path
const attempt = unlockWithPasskey({credentials: auth})
// Then it fails before asking the authenticator
await expect(attempt).rejects.toThrow('No passkeys')
expect(auth.getCalls).toBe(0)
expect(hasPasskeySlots()).toBe(false)
})
it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
// Given markerless residue from the old wallet
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
writeRawSlots(legacy)
// When a different wallet is installed with its canonical owner
await saveLinkingKey(OTHER_LINKING_KEY)
// Then the residue stays unowned and unavailable to the new wallet
expect(readPasskeySlots()).toEqual([])
expect(hasPasskeySlots()).toBe(false)
expect(readRawSlots()).toEqual(legacy)
})
it('adopts legacy slots only after the saved wallet owner is proven', async () => {
// Given a legacy encrypted wallet and its markerless passkey slot
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
writeRawSlots(legacy)
await saveLinkingKey(LINKING_KEY, 'correct horse')
removeSavedOwnerMarker()
// When migration is attempted before and then after password proof
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
const provenKey = await decryptSavedLinkingKey('correct horse')
ensureSavedKeyOwner(provenKey)
await migrateLegacyPasskeySlots(provenKey)
// Then the same slot is stamped once for that proven owner and unlocks
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
expect(readPasskeySlots()).toHaveLength(1)
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
await expect(unlockWithPasskey({credentials: auth})).resolves.toEqual(LINKING_KEY)
})
})
+228
View File
@@ -0,0 +1,228 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('slot ownership (continued)', () => {
it('rejects an unwrapped key that does not match the saved proven owner', async () => {
// Given a current-owner slot whose authenticated wrap was replaced with
// a valid wrap of another wallet key
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth,
})
const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY)
writeRawSlots([{...slot, ...foreignWrap}])
// When the authenticator successfully unwraps that foreign key
const attempt = unlockWithPasskey({credentials: auth})
// Then owner validation rejects it before activation can receive it
await expect(attempt).rejects.toThrow('different wallet')
})
it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
// Given an authenticator that replaces the saved wallet before returning
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const stale: PasskeyCredentials = {
create: auth.create,
get: async (options) => {
const credential = await auth.get(options)
await saveLinkingKey(OTHER_LINKING_KEY)
return credential
},
}
// When the old wallet's ceremony completes after replacement
const attempt = unlockWithPasskey({credentials: stale})
// Then the old linking key is never returned for activation
await expect(attempt).rejects.toThrow('different wallet')
})
it('does not adopt a slot carrying a malformed owner marker', async () => {
// Given an otherwise valid slot whose owner claim is malformed
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const malformed = {...slot, ownerId: 'not-an-owner'}
writeRawSlots([malformed])
// When the current owner performs the legacy migration
await migrateLegacyPasskeySlots(LINKING_KEY)
// Then only truly markerless legacy slots are eligible
expect(readPasskeySlots()).toEqual([])
expect(readRawSlots()).toEqual([malformed])
})
it('cannot remove a foreign-owner slot', async () => {
// Given a slot owned by another wallet remains in shared storage
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
writeRawSlots([foreign])
// When the current owner asks to remove that credential id
const removed = await removePasskey(slot.credentialId)
// Then the foreign record is untouched
expect(removed).toBe(false)
expect(readRawSlots()).toEqual([foreign])
})
it('rewraps only current-owner slots and preserves foreign slots', async () => {
// Given current and foreign slots share storage
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const foreign = {
...slot,
credentialId: '44'.repeat(16),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
}
writeRawSlots([slot, foreign])
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth,
})
// When the current wallet rewraps its slots
await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]]))
// Then the foreign slot did not require output and remains byte-identical
expect(readRawSlots()).toContainEqual(foreign)
})
})
@@ -0,0 +1,245 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('registration and unlock', () => {
it('registers a passkey and unlocks the same linking key', async () => {
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {
credentials: auth,
name: 'laptop',
})
expect(slot.name).toBe('laptop')
expect(readPasskeySlots()).toEqual([slot])
expect(hasPasskeySlots()).toBe(true)
const unwrapped = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
})
it('never stores the linking key in the clear', async () => {
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const raw = localStorage.getItem('sattle_passkey_slots')
expect(raw).toBeTruthy()
expect(raw).not.toContain(bytesToHex(LINKING_KEY))
})
it('yields the same key material unlock(password) yields', async () => {
const linkingKey = crypto.getRandomValues(new Uint8Array(32))
await saveLinkingKey(linkingKey, 'correct horse')
const auth = new FakeAuthenticator()
await registerPasskey(linkingKey, {credentials: auth})
const viaPassword = await decryptSavedLinkingKey('correct horse')
const viaPasskey = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword))
// and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock
const passwordAes = await deriveBearerAesKey(viaPassword)
const record = await encryptRecord(passwordAes, {note: 'still readable'})
const passkeyAes = await deriveBearerAesKey(viaPasskey)
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
note: 'still readable',
})
})
it('falls back to a get ceremony when create only reports prf.enabled', async () => {
const auth = new FakeAuthenticator()
auth.prfResultsOnCreate = false
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
expect(auth.getCalls).toBe(1)
const unwrapped = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
})
it('refuses registration when the authenticator has no PRF support', async () => {
const auth = new FakeAuthenticator()
auth.supportsPrf = false
await expect(registerPasskey(LINKING_KEY, {credentials: auth})).rejects.toThrow('PRF')
expect(hasPasskeySlots()).toBe(false)
})
it('throws on a cancelled registration ceremony', async () => {
const cancelled: PasskeyCredentials = {
create: async () => null,
get: async () => null,
}
await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow(
'cancelled',
)
expect(hasPasskeySlots()).toBe(false)
})
it('throws before any ceremony when no passkeys are registered', async () => {
const auth = new FakeAuthenticator()
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('No passkeys')
expect(auth.getCalls).toBe(0)
})
it('rejects unlock when the passkey returns no PRF secret', async () => {
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
auth.prfResultsOnGet = false
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('PRF secret')
})
it('rejects unlock when the ceremony yields an unregistered credential', async () => {
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
const rogue: PasskeyCredentials = {
create: async () => null,
get: async () => ({
type: 'public-key',
rawId: crypto.getRandomValues(new Uint8Array(16)),
getClientExtensionResults: () => ({
prf: {enabled: true, results: {first: new Uint8Array(32)}},
}),
}),
}
await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow('not registered')
})
it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
auth.rotateSecret(slot.credentialId)
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow()
})
})
+188
View File
@@ -0,0 +1,188 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('rewrap for the current owner', () => {
it('refreshes every current-owner wrap all-or-nothing', async () => {
const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, {
credentials: laptop,
})
const phoneSlot = await registerPasskey(LINKING_KEY, {
credentials: phone,
})
// partial coverage aborts before writing
const partial = new Map([
[
laptopSlot.credentialId,
await getPasskeyPrfOutput(laptopSlot.credentialId, {
credentials: laptop,
}),
],
])
await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap')
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
// full coverage refreshes both wraps around the same proven owner key
const fresh = new Map([
[
laptopSlot.credentialId,
await getPasskeyPrfOutput(laptopSlot.credentialId, {
credentials: laptop,
}),
],
[
phoneSlot.credentialId,
await getPasskeyPrfOutput(phoneSlot.credentialId, {
credentials: phone,
}),
],
])
await rewrapAllSlots(LINKING_KEY, fresh)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
expect(readPasskeySlots()[0]?.wrappedKey).not.toBe(laptopSlot.wrappedKey)
// credential ids and labels survive the re-wrap
expect(
readPasskeySlots()
.map((s) => s.credentialId)
.sort(),
).toEqual([laptopSlot.credentialId, phoneSlot.credentialId].sort())
})
})
+156
View File
@@ -0,0 +1,156 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('slot storage hygiene', () => {
it('drops malformed entries instead of throwing', async () => {
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([...stored, {credentialId: 'zz', hkdfSalt: 1}, 'garbage', null]),
)
expect(readPasskeySlots()).toEqual([slot])
})
it('treats unparseable storage as empty', () => {
localStorage.setItem('sattle_passkey_slots', '{not json')
expect(readPasskeySlots()).toEqual([])
expect(hasPasskeySlots()).toBe(false)
})
})
+180
View File
@@ -0,0 +1,180 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>()
supportsPrf = true
prfResultsOnCreate = true
prfResultsOnGet = true
createCalls = 0
getCalls = 0
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1
const salt = options?.publicKey?.extensions?.prf?.eval?.first
const id = crypto.getRandomValues(new Uint8Array(16))
const secret = crypto.getRandomValues(new Uint8Array(32))
this.held.set(bytesToHex(id), {id, secret})
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}),
}
: {},
}),
}
}
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1
const pk = options?.publicKey
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)))
const match = allowed.find((hex) => this.held.has(hex))
const held = match ? this.held.get(match) : undefined
if (!held) return null
const salt = pk?.extensions?.prf?.eval?.first
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
: {},
}),
}
}
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId)
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
}
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32)
out.set(hmac(sha256, secret, toBytes(salt)))
return out
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
describe('passkeySupported', () => {
it('is false without a PublicKeyCredential probe', async () => {
// node test env has no PublicKeyCredential global: the default lookup
// finds nothing
await expect(passkeySupported()).resolves.toBe(false)
})
it('is false without a user-verifying platform authenticator', async () => {
await expect(
passkeySupported({
isUserVerifyingPlatformAuthenticatorAvailable: async () => false,
getClientCapabilities: async () => ({'extension:prf': true}),
}),
).resolves.toBe(false)
})
it('checks extension:prf when client capabilities are available', async () => {
const platform = {
isUserVerifyingPlatformAuthenticatorAvailable: async () => true,
}
await expect(
passkeySupported({
...platform,
getClientCapabilities: async () => ({'extension:prf': true}),
}),
).resolves.toBe(true)
await expect(
passkeySupported({
...platform,
getClientCapabilities: async () => ({'extension:prf': false}),
}),
).resolves.toBe(false)
})
it('is optimistic when capabilities cannot be pre-detected', async () => {
await expect(
passkeySupported({
isUserVerifyingPlatformAuthenticatorAvailable: async () => true,
}),
).resolves.toBe(true)
})
})

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