mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
Compare commits
59
Commits
84441f53e3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa0572090 | ||
|
|
6892a3cc07 | ||
|
|
338b2812e3 | ||
|
|
e892a330fc | ||
|
|
72f387f6e5 | ||
|
|
76d59d8d08 | ||
|
|
a40079200d | ||
|
|
578613c869 | ||
|
|
3c2a2cc4a5 | ||
|
|
401a26dc6b | ||
|
|
f087ebe2f2 | ||
|
|
c59521a421 | ||
|
|
3f6010b312 | ||
|
|
99e6ae1b12 | ||
|
|
81c28ec16d | ||
|
|
e98373491f | ||
|
|
2e0f0038f7 | ||
|
|
3acc63e968 | ||
|
|
c319e11d3e | ||
|
|
62b93baa05 | ||
|
|
5b3a69e910 | ||
|
|
9fa4abdaf3 | ||
|
|
54031dc8de | ||
|
|
e9bbb358aa | ||
|
|
83c9e5b95f | ||
|
|
d735aca885 | ||
|
|
82f008b33f | ||
|
|
d890a69c40 | ||
|
|
7890a492f3 | ||
|
|
3ed214c157 | ||
|
|
513c67fc14 | ||
|
|
09b7b63c9d | ||
|
|
fff24a75b2 | ||
|
|
b52172c0cb | ||
|
|
228a92b0f0 | ||
|
|
9f6bcad706 | ||
|
|
3054834cdb | ||
|
|
845ec410e5 | ||
|
|
02f901c87c | ||
|
|
c4c9d8a94a | ||
|
|
7f69f781f5 | ||
|
|
6fa4283ed6 | ||
|
|
22420b9112 | ||
|
|
f55f208836 | ||
|
|
7607e47358 | ||
|
|
0862a10b01 | ||
|
|
586d12b666 | ||
|
|
69fbdb68ee | ||
|
|
1dbf5788f9 | ||
|
|
12e4ec9199 | ||
|
|
d9be457221 | ||
|
|
058b10ddf1 | ||
|
|
acfab83438 | ||
|
|
0e0133f550 | ||
|
|
b258d86af7 | ||
|
|
2a90343e7e | ||
|
|
b6fff605a1 | ||
|
|
8548afcd21 | ||
|
|
30d1d0aeae |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.0.2"
|
||||
".": "0.0.3"
|
||||
}
|
||||
|
||||
@@ -25,25 +25,25 @@ 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 |
|
||||
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
|
||||
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
|
||||
| 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 |
|
||||
| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent |
|
||||
| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters |
|
||||
|
||||
## 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/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 |
|
||||
| Module | Role |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer |
|
||||
| `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` | 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+27
-12
@@ -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 ?? '[]',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}$/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Generated
+10
-9
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,152 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import {
|
||||
fetchMintAddress,
|
||||
fetchPayRequest,
|
||||
lightningAddressUsername,
|
||||
mintAddressUrl,
|
||||
resolveMintInput,
|
||||
serverOf,
|
||||
} from 'lnurlcash-kit';
|
||||
|
||||
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
||||
import { useMintsStore } from '@/stores/mints';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
|
||||
export const useManageMintsPage = () => {
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const mints = useMintsStore();
|
||||
const wallet = useWalletStore();
|
||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||
if (typeof $q.notify === 'function') {
|
||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||
}
|
||||
};
|
||||
const fingerprint = (pubkey: string): string =>
|
||||
pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey;
|
||||
const balanceAt = (server: string): string =>
|
||||
(wallet.balanceByMintSats.get(server) ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
const suggestions = computed(() =>
|
||||
mints.PUBLIC_MINTS.filter(
|
||||
(address) => !mints.mints.some((mint) => mint.server === address.replace(/^@/, '')),
|
||||
),
|
||||
);
|
||||
const banner = ref('');
|
||||
const addServer = ref('');
|
||||
const addPubkey = ref('');
|
||||
const discovering = ref('');
|
||||
const confirmingRemove = ref(false);
|
||||
const removeTarget = ref('');
|
||||
const trustResult = (result: string, server: string): void => {
|
||||
if (result === 'rekey-pending') {
|
||||
toast('warning', `${server} advertised a different signing key - review it above.`);
|
||||
} else if (result === 'unchanged') {
|
||||
toast('info', `${server} is already trusted.`);
|
||||
} else {
|
||||
toast('positive', `${server} is now trusted.`);
|
||||
}
|
||||
};
|
||||
const trustManual = async (): Promise<void> => {
|
||||
banner.value = '';
|
||||
try {
|
||||
const result = await mints.trust(addServer.value, addPubkey.value);
|
||||
trustResult(result, addServer.value.trim());
|
||||
addServer.value = '';
|
||||
addPubkey.value = '';
|
||||
} catch (error) {
|
||||
banner.value = error instanceof Error ? error.message : 'Could not trust that mint.';
|
||||
}
|
||||
};
|
||||
const trustSuggestion = async (address: string): Promise<void> => {
|
||||
if (discovering.value) return;
|
||||
banner.value = '';
|
||||
discovering.value = address;
|
||||
try {
|
||||
const url = resolveMintInput(address);
|
||||
if (!url) throw new Error('That mint address cannot be resolved.');
|
||||
let nodeInfo = null;
|
||||
let payUrl = url;
|
||||
const addressUrl = mintAddressUrl(url);
|
||||
if (addressUrl) {
|
||||
try {
|
||||
nodeInfo = await fetchMintAddress(addressUrl);
|
||||
payUrl = nodeInfo.payLink;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error;
|
||||
}
|
||||
}
|
||||
const info = await fetchPayRequest(payUrl);
|
||||
const announcedKey = nodeInfo?.nodePubkey ?? info.mintPubkey;
|
||||
if (!announcedKey) {
|
||||
throw new Error("This mint didn't announce its signing key - add it manually instead.");
|
||||
}
|
||||
const server = serverOf(payUrl);
|
||||
const result = await mints.trust(
|
||||
server,
|
||||
announcedKey,
|
||||
mintAddressCacheInfo(nodeInfo, lightningAddressUsername(payUrl)),
|
||||
);
|
||||
trustResult(result, server);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not reach that mint.';
|
||||
banner.value = `Could not add ${address}: ${message}`;
|
||||
} finally {
|
||||
discovering.value = '';
|
||||
}
|
||||
};
|
||||
const askRemove = (server: string): void => {
|
||||
banner.value = '';
|
||||
removeTarget.value = server;
|
||||
confirmingRemove.value = true;
|
||||
};
|
||||
const doRemove = async (): Promise<void> => {
|
||||
confirmingRemove.value = false;
|
||||
try {
|
||||
await mints.remove(removeTarget.value);
|
||||
if (mints.defaultMint === removeTarget.value) mints.setDefaultMint(null);
|
||||
toast('positive', `${removeTarget.value} removed.`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error;
|
||||
banner.value = `${removeTarget.value} can't be removed while you hold notes from it - move or spend them first.`;
|
||||
}
|
||||
};
|
||||
const confirmRekey = async (server: string): Promise<void> => {
|
||||
banner.value = '';
|
||||
try {
|
||||
await mints.confirmRekey(server);
|
||||
} catch (error) {
|
||||
banner.value = error instanceof Error ? error.message : 'Could not confirm that signing key.';
|
||||
}
|
||||
};
|
||||
const dismissRekey = async (server: string): Promise<void> => {
|
||||
banner.value = '';
|
||||
try {
|
||||
await mints.dismissRekey(server);
|
||||
} catch (error) {
|
||||
banner.value = error instanceof Error ? error.message : 'Could not dismiss that signing key.';
|
||||
}
|
||||
};
|
||||
return {
|
||||
addPubkey,
|
||||
addServer,
|
||||
askRemove,
|
||||
balanceAt,
|
||||
banner,
|
||||
confirmingRemove,
|
||||
confirmRekey,
|
||||
discovering,
|
||||
dismissRekey,
|
||||
doRemove,
|
||||
fingerprint,
|
||||
mints,
|
||||
removeTarget,
|
||||
router,
|
||||
suggestions,
|
||||
trustManual,
|
||||
trustSuggestion,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref } from 'vue';
|
||||
import { Notify } from 'quasar';
|
||||
import { useMintsStore } from '@/stores/mints';
|
||||
|
||||
export const useMintTrustPrompt = () => {
|
||||
const mints = useMintsStore();
|
||||
const showTrust = ref(false);
|
||||
const trustServer = ref('');
|
||||
const trustPubkey = ref('');
|
||||
const trustNodeAlias = ref('');
|
||||
const openTrust = (server: string, pubkey: string, nodeAlias = ''): void => {
|
||||
trustServer.value = server;
|
||||
trustPubkey.value = pubkey;
|
||||
trustNodeAlias.value = nodeAlias;
|
||||
showTrust.value = true;
|
||||
};
|
||||
const trustMint = async (): Promise<void> => {
|
||||
try {
|
||||
await mints.trust(trustServer.value, trustPubkey.value, {
|
||||
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
|
||||
});
|
||||
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
||||
} catch (error) {
|
||||
const caught = error instanceof Error ? error : new Error(String(error));
|
||||
Notify.create({ type: 'negative', message: caught.message });
|
||||
} finally {
|
||||
showTrust.value = false;
|
||||
}
|
||||
};
|
||||
const skipTrust = (): void => {
|
||||
showTrust.value = false;
|
||||
Notify.create({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
||||
});
|
||||
};
|
||||
return {
|
||||
openTrust,
|
||||
showTrust,
|
||||
skipTrust,
|
||||
trustMint,
|
||||
trustNodeAlias,
|
||||
trustServer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
export const parseJsonObject = (source: string): Record<string, unknown> => {
|
||||
const parsed: unknown = JSON.parse(source)
|
||||
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.')
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const parseJsonArray = (source: string): unknown[] => {
|
||||
const parsed: unknown = JSON.parse(source)
|
||||
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.')
|
||||
return Array.from(parsed, (value: unknown) => value)
|
||||
}
|
||||
|
||||
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
|
||||
parseJsonArray(source).map((value) => {
|
||||
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.')
|
||||
return value
|
||||
})
|
||||
@@ -0,0 +1,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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+22
-25
@@ -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
|
||||
})
|
||||
const result = applyBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
bearers: parts.notes ?? [],
|
||||
trustedMints: parts.mints,
|
||||
settings: parts.settings
|
||||
transport: options.transport,
|
||||
})
|
||||
const result = await applyBackup(
|
||||
{
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
bearers: parts.notes ?? [],
|
||||
trustedMints: parts.mints,
|
||||
settings: parts.settings,
|
||||
},
|
||||
linkingPubKeyHex(linkingPrivKey),
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
found: BACKUP_PARTS.filter(part => parts[part] !== undefined)
|
||||
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'}}])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// derivation, the request/response cycle over an in-memory relay (the
|
||||
// transport is injected - no network), every method against the
|
||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||
// requests never execute, and a settled preimage only ever reveals an
|
||||
// already-rotated (burned) note secret.
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
|
||||
import {
|
||||
NWC_INFO_KIND,
|
||||
NWC_REQUEST_KIND,
|
||||
NWC_RESPONSE_KIND,
|
||||
buildConnectionString,
|
||||
connectionInfoOf,
|
||||
createConnection,
|
||||
deriveNwcWalletKey,
|
||||
migrateLegacyNwcStorage,
|
||||
parseConnectionString,
|
||||
readNwcEnabled,
|
||||
readNwcConnections,
|
||||
startService,
|
||||
writeNwcEnabled,
|
||||
writeNwcConnections,
|
||||
} from './nwc'
|
||||
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||
import type {NostrFilter} from './nwc/transport'
|
||||
import type {NwcChangeset} from './nwc'
|
||||
import type {Bearer} from './types'
|
||||
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
import {
|
||||
CLIENT_PUBKEY,
|
||||
CLIENT_SECRET,
|
||||
FAST_POLL,
|
||||
LINKING_KEY,
|
||||
OTHER_LINKING_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
RELAYS,
|
||||
STRANGER_SECRET,
|
||||
clientRequest,
|
||||
createFakeRelay,
|
||||
deferred,
|
||||
foreignConnectionFixture,
|
||||
methodRequest,
|
||||
nowSeconds,
|
||||
storeForeignConnection,
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||
describe('connection strings', () => {
|
||||
it('round-trips build -> parse, including several relays', () => {
|
||||
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
|
||||
'wss://relay-a.example',
|
||||
'wss://relay-b.example/path?q=1',
|
||||
])
|
||||
expect(uri).toBe(
|
||||
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
|
||||
)
|
||||
expect(parseConnectionString(uri)).toEqual({
|
||||
walletServicePubkey: 'ab'.repeat(32),
|
||||
clientSecret: 'cd'.repeat(32),
|
||||
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects strings that are not connection strings', () => {
|
||||
expect(parseConnectionString('not a uri')).toBeNull()
|
||||
expect(parseConnectionString('https://example.com')).toBeNull()
|
||||
// missing secret
|
||||
expect(
|
||||
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
|
||||
).toBeNull()
|
||||
// missing relay
|
||||
expect(
|
||||
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
|
||||
).toBeNull()
|
||||
// a non-hex pubkey
|
||||
expect(
|
||||
parseConnectionString(
|
||||
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('createConnection returns a string that parses back to the same connection', () => {
|
||||
const connection = createConnection(LINKING_KEY, {
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 100_000, periodMs: 86_400_000},
|
||||
clientSecret: CLIENT_SECRET,
|
||||
})
|
||||
const parsed = parseConnectionString(connection.connectionString)
|
||||
expect(parsed).toEqual({
|
||||
walletServicePubkey: connection.walletServicePubkey,
|
||||
clientSecret: '11'.repeat(32),
|
||||
relays: RELAYS,
|
||||
})
|
||||
// the record persisted WITHOUT the client secret - it is handed out
|
||||
// once, in the connection string, and never stored
|
||||
const records = readNwcConnections(OWNER_ID)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY)
|
||||
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveNwcWalletKey', () => {
|
||||
it('is pinned: derivation changes would silently orphan every connection', () => {
|
||||
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
|
||||
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
|
||||
)
|
||||
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
|
||||
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
|
||||
)
|
||||
})
|
||||
|
||||
it('derives a distinct key per client and per linking key', () => {
|
||||
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
|
||||
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
|
||||
base,
|
||||
)
|
||||
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base)
|
||||
})
|
||||
|
||||
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
|
||||
const first = createConnection(LINKING_KEY, {
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 100_000, periodMs: 86_400_000},
|
||||
clientSecret: CLIENT_SECRET,
|
||||
})
|
||||
expect(first.walletServicePubkey).toBe(
|
||||
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
// Stale-owner fencing end to end: a service accepted work while its owner
|
||||
// was still installed, and a second tab replaced the saved key AFTER the
|
||||
// fence's last safe point - mid-melt, past the boundary where aborting is
|
||||
// no longer possible. What must not happen is any stale-owner WRITE:
|
||||
// no bearer changeset, no budget debit, no success response.
|
||||
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||
|
||||
import {readNwcConnections} from './nwc'
|
||||
import {
|
||||
methodRequest,
|
||||
OTHER_LINKING_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
import {makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||
|
||||
describe('service: post-boundary stale-owner fencing', () => {
|
||||
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
|
||||
// Given a running service whose next mint call coincides with a second
|
||||
// tab installing its own wallet (the first fetch is the melt itself:
|
||||
// the exact-match carve performs no mint call of its own)
|
||||
const m = await mint()
|
||||
let swapped = false
|
||||
const swappingFetch: typeof fetch = (input, init) => {
|
||||
if (!swapped) {
|
||||
swapped = true
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: false,
|
||||
value: bytesToHex(OTHER_LINKING_KEY),
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
version: 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fetch(input, init)
|
||||
}
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
kit: {fetch: swappingFetch},
|
||||
})
|
||||
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)]
|
||||
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
// When the pay flows past the irreversible boundary and reaches the
|
||||
// first owner-bound persistence (the conservative budget debit)
|
||||
relay.emit(request)
|
||||
await waitFor(() => state.errors.length > 0)
|
||||
|
||||
// Then the melt genuinely happened (we are past the boundary) ...
|
||||
expect(swapped).toBe(true)
|
||||
expect(m.state.noteState('d7'.repeat(32))).toBe('burned')
|
||||
// ... but nothing under the stale owner moved: no budget debit, no
|
||||
// bearer changeset, no success response (the failure surfaced through
|
||||
// onError instead)
|
||||
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0)
|
||||
expect(state.changesets).toEqual([])
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
|
||||
await stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// derivation, the request/response cycle over an in-memory relay (the
|
||||
// transport is injected - no network), every method against the
|
||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||
// requests never execute, and a settled preimage only ever reveals an
|
||||
// already-rotated (burned) note secret.
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
|
||||
import {
|
||||
NWC_INFO_KIND,
|
||||
NWC_REQUEST_KIND,
|
||||
NWC_RESPONSE_KIND,
|
||||
buildConnectionString,
|
||||
connectionInfoOf,
|
||||
createConnection,
|
||||
deriveNwcWalletKey,
|
||||
migrateLegacyNwcStorage,
|
||||
parseConnectionString,
|
||||
readNwcEnabled,
|
||||
readNwcConnections,
|
||||
startService,
|
||||
writeNwcEnabled,
|
||||
writeNwcConnections,
|
||||
} from './nwc'
|
||||
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||
import type {NostrFilter} from './nwc/transport'
|
||||
import type {NwcChangeset} from './nwc'
|
||||
import type {Bearer} from './types'
|
||||
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {requiredString, requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
import {
|
||||
CLIENT_PUBKEY,
|
||||
CLIENT_SECRET,
|
||||
FAST_POLL,
|
||||
LINKING_KEY,
|
||||
OTHER_LINKING_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
RELAYS,
|
||||
STRANGER_SECRET,
|
||||
clientRequest,
|
||||
createFakeRelay,
|
||||
deferred,
|
||||
foreignConnectionFixture,
|
||||
methodRequest,
|
||||
nowSeconds,
|
||||
storeForeignConnection,
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||
describe('service: pay_invoice', () => {
|
||||
it('applies the settled changeset before publishing success', async () => {
|
||||
const m = await mint()
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
||||
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)]
|
||||
|
||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
expect(response.error).toBeNull()
|
||||
expect(state.changesets).toHaveLength(1)
|
||||
expect(state.bearers[0]?.spent).toBe(true)
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
|
||||
const m = await mint()
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
budgetMsat: 50_000,
|
||||
})
|
||||
const foreign = foreignConnectionFixture(
|
||||
{maxMsat: 99_000, periodMs: 86_400_000},
|
||||
nowSeconds() * 1000,
|
||||
)
|
||||
storeForeignConnection(foreign.record)
|
||||
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)]
|
||||
|
||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
expect(response.error).toBeNull()
|
||||
expect(requiredString(response.result?.preimage)).toHaveLength(64)
|
||||
|
||||
// the note is gone (melted) and locked spent via the changeset
|
||||
expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
|
||||
expect(requiredValue(state.bearers[0]).spent).toBe(true)
|
||||
|
||||
// the spend was recorded against the budget, persisted
|
||||
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000)
|
||||
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('waits for a deferred bearer commit before publishing payment success', async () => {
|
||||
const m = await mint()
|
||||
const commit = deferred()
|
||||
let commitStarted = false
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
commitChangeset: () => {
|
||||
commitStarted = true
|
||||
return commit.promise
|
||||
},
|
||||
})
|
||||
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)]
|
||||
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
relay.emit(request)
|
||||
await waitFor(() => commitStarted)
|
||||
|
||||
// a non-awaiting engine publishes within milliseconds of the commit
|
||||
// call (one local verify re-read + encrypt); this window is far wider
|
||||
// than that, so an early response here can only mean a missing barrier
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
|
||||
commit.resolve()
|
||||
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null)
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('reports a rejected bearer commit without publishing payment success', async () => {
|
||||
const m = await mint()
|
||||
const commit = deferred()
|
||||
const commitError = new Error('bearer commit failed')
|
||||
let commitStarted = false
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
commitChangeset: () => {
|
||||
commitStarted = true
|
||||
return commit.promise
|
||||
},
|
||||
})
|
||||
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)]
|
||||
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
relay.emit(request)
|
||||
await waitFor(() => commitStarted)
|
||||
commit.reject(commitError)
|
||||
await waitFor(
|
||||
() =>
|
||||
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
|
||||
)
|
||||
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
|
||||
expect(state.errors).toEqual([commitError])
|
||||
// the conservative budget debit is persisted separately from bearer
|
||||
// storage and deliberately NOT rolled back: the budget was debited,
|
||||
// the bearer was never locked spent
|
||||
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
|
||||
expect(state.bearers[0]?.spent).toBeUndefined()
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
|
||||
const m = await mint()
|
||||
const commit = deferred()
|
||||
let commitStarted = false
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
commitChangeset: () => {
|
||||
commitStarted = true
|
||||
return commit.promise
|
||||
},
|
||||
})
|
||||
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)]
|
||||
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
relay.emit(request)
|
||||
await waitFor(() => commitStarted)
|
||||
// stop closes subscriptions; in-flight handlers still finish (their
|
||||
// changesets hold money) - a repeated stop interrupts nothing twice
|
||||
let stopped = false
|
||||
const firstStop = stop().then(() => {
|
||||
stopped = true
|
||||
})
|
||||
const repeatedStop = stop()
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
// subscriptions close IMMEDIATELY, before the drain completes
|
||||
expect(relay.subscriptionCount()).toBe(0)
|
||||
|
||||
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {})
|
||||
relay.emitAfterClose(afterStop)
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull()
|
||||
|
||||
commit.resolve()
|
||||
await Promise.all([firstStop, repeatedStop])
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull()
|
||||
expect(state.bearers[0]?.spent).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a stale saved owner before touching the mint', async () => {
|
||||
const m = await mint()
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
||||
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)]
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: false,
|
||||
value: bytesToHex(OTHER_LINKING_KEY),
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
version: 1,
|
||||
}),
|
||||
)
|
||||
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
|
||||
relay.emit(request)
|
||||
await waitFor(
|
||||
() =>
|
||||
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
|
||||
)
|
||||
|
||||
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding')
|
||||
expect(state.changesets).toEqual([])
|
||||
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL')
|
||||
await stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// derivation, the request/response cycle over an in-memory relay (the
|
||||
// transport is injected - no network), every method against the
|
||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||
// requests never execute, and a settled preimage only ever reveals an
|
||||
// already-rotated (burned) note secret.
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
|
||||
import {
|
||||
NWC_INFO_KIND,
|
||||
NWC_REQUEST_KIND,
|
||||
NWC_RESPONSE_KIND,
|
||||
buildConnectionString,
|
||||
connectionInfoOf,
|
||||
createConnection,
|
||||
deriveNwcWalletKey,
|
||||
migrateLegacyNwcStorage,
|
||||
parseConnectionString,
|
||||
readNwcEnabled,
|
||||
readNwcConnections,
|
||||
startService,
|
||||
writeNwcEnabled,
|
||||
writeNwcConnections,
|
||||
} from './nwc'
|
||||
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||
import type {NostrFilter} from './nwc/transport'
|
||||
import type {NwcChangeset} from './nwc'
|
||||
import type {Bearer} from './types'
|
||||
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
import {
|
||||
CLIENT_PUBKEY,
|
||||
CLIENT_SECRET,
|
||||
FAST_POLL,
|
||||
LINKING_KEY,
|
||||
OTHER_LINKING_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
RELAYS,
|
||||
STRANGER_SECRET,
|
||||
clientRequest,
|
||||
createFakeRelay,
|
||||
deferred,
|
||||
foreignConnectionFixture,
|
||||
methodRequest,
|
||||
nowSeconds,
|
||||
storeForeignConnection,
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||
describe('storage validation', () => {
|
||||
it('drops malformed records instead of throwing', () => {
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
{clientPubkey: 'nope'},
|
||||
{
|
||||
version: 1,
|
||||
ownerId: OWNER_ID,
|
||||
clientPubkey: CLIENT_PUBKEY,
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 1000, periodMs: 1000},
|
||||
spent: {periodStart: 0, msat: 0},
|
||||
createdAt: 0,
|
||||
},
|
||||
]),
|
||||
)
|
||||
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
|
||||
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY)
|
||||
})
|
||||
|
||||
it('returns nothing for garbage json', () => {
|
||||
localStorage.setItem('sattle_nwc_connections', '{{{')
|
||||
expect(readNwcConnections(OWNER_ID)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only strictly parsed records belonging to the requested owner', () => {
|
||||
const current = createConnection(LINKING_KEY, {
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 1000, periodMs: 1000},
|
||||
clientSecret: CLIENT_SECRET,
|
||||
now: 10,
|
||||
}).record
|
||||
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record
|
||||
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
|
||||
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
...raw,
|
||||
foreign,
|
||||
{...current, ownerId: 'malformed'},
|
||||
{
|
||||
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 3000, periodMs: 3000},
|
||||
spent: {periodStart: 0, msat: 0},
|
||||
createdAt: 30,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
expect(readNwcConnections(OWNER_ID)).toEqual([current])
|
||||
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign])
|
||||
})
|
||||
|
||||
it('adopts ownerless connections and enabled state only after owner proof', async () => {
|
||||
await saveLinkingKey(LINKING_KEY)
|
||||
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}')
|
||||
if (typeof saved !== 'object' || saved === null) {
|
||||
throw new TypeError('Expected a saved linking-key record')
|
||||
}
|
||||
Reflect.deleteProperty(saved, 'ownerId')
|
||||
Reflect.deleteProperty(saved, 'version')
|
||||
localStorage.setItem('sattle_linking_key', JSON.stringify(saved))
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
{
|
||||
clientPubkey: CLIENT_PUBKEY,
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 1000, periodMs: 1000},
|
||||
spent: {periodStart: 0, msat: 0},
|
||||
createdAt: 0,
|
||||
},
|
||||
]),
|
||||
)
|
||||
localStorage.setItem('sattle_nwc_enabled', 'true')
|
||||
|
||||
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow()
|
||||
expect(readNwcConnections(OWNER_ID)).toEqual([])
|
||||
expect(readNwcEnabled(OWNER_ID)).toBe(false)
|
||||
|
||||
ensureSavedKeyOwner(LINKING_KEY)
|
||||
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||
connections: 1,
|
||||
enabled: true,
|
||||
})
|
||||
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
|
||||
expect(readNwcEnabled(OWNER_ID)).toBe(true)
|
||||
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||
connections: 0,
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose one wallet enabled state to another owner', () => {
|
||||
writeNwcEnabled(OWNER_ID, true)
|
||||
|
||||
expect(readNwcEnabled(OWNER_ID)).toBe(true)
|
||||
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats malformed owner-bearing enabled records as disabled', () => {
|
||||
for (const value of [
|
||||
{version: 1, ownerId: 'malformed', enabled: true},
|
||||
{version: 2, ownerId: OWNER_ID, enabled: true},
|
||||
{version: 1, ownerId: OWNER_ID, enabled: 'true'},
|
||||
]) {
|
||||
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value))
|
||||
expect(readNwcEnabled(OWNER_ID)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
+9
-816
@@ -1,816 +1,9 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// 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'
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// derivation, the request/response cycle over an in-memory relay (the
|
||||
// transport is injected - no network), every method against the
|
||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||
// requests never execute, and a settled preimage only ever reveals an
|
||||
// already-rotated (burned) note secret.
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
|
||||
import {
|
||||
NWC_INFO_KIND,
|
||||
NWC_REQUEST_KIND,
|
||||
NWC_RESPONSE_KIND,
|
||||
buildConnectionString,
|
||||
connectionInfoOf,
|
||||
createConnection,
|
||||
deriveNwcWalletKey,
|
||||
migrateLegacyNwcStorage,
|
||||
parseConnectionString,
|
||||
readNwcEnabled,
|
||||
readNwcConnections,
|
||||
startService,
|
||||
writeNwcEnabled,
|
||||
writeNwcConnections,
|
||||
} from './nwc'
|
||||
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||
import type {NostrFilter} from './nwc/transport'
|
||||
import type {NwcChangeset} from './nwc'
|
||||
import type {Bearer} from './types'
|
||||
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
export const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
||||
export const CLIENT_SECRET = hexToBytes('11'.repeat(32))
|
||||
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
|
||||
export const STRANGER_SECRET = hexToBytes('22'.repeat(32))
|
||||
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
|
||||
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY)
|
||||
|
||||
// never connected - the in-memory relay below stands in
|
||||
export const RELAYS = ['wss://relay-a.example']
|
||||
|
||||
let NOW = 1_800_000_000
|
||||
export const nowSeconds = (): number => NOW
|
||||
|
||||
export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
|
||||
|
||||
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
|
||||
connectionInfoOf(OTHER_LINKING_KEY, {
|
||||
version: 1,
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
clientPubkey: getPublicKey(STRANGER_SECRET),
|
||||
relays: RELAYS,
|
||||
budget,
|
||||
spent: {periodStart: now, msat: 0},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
export const storeForeignConnection = (record: NwcConnectionRecord): void => {
|
||||
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
|
||||
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
|
||||
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]))
|
||||
}
|
||||
|
||||
// an in-memory relay set: subscriptions register, emit delivers to every
|
||||
// matching one, publish records
|
||||
export const createFakeRelay = (): {
|
||||
transport: NwcTransport
|
||||
published: NostrEvent[]
|
||||
emit: (event: NostrEvent) => void
|
||||
emitAfterClose: (event: NostrEvent) => void
|
||||
subscriptionCount: () => number
|
||||
} => {
|
||||
const published: NostrEvent[] = []
|
||||
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
|
||||
const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
|
||||
const transport: NwcTransport = {
|
||||
publish: (_relays, event) => {
|
||||
published.push(event)
|
||||
return Promise.resolve()
|
||||
},
|
||||
subscribe: (_relays, filter, onEvent) => {
|
||||
const sub = {filter, onEvent}
|
||||
subs.push(sub)
|
||||
allSubs.push(sub)
|
||||
return {
|
||||
close: () => {
|
||||
const index = subs.indexOf(sub)
|
||||
if (index >= 0) subs.splice(index, 1)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const deliver = (
|
||||
targets: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[],
|
||||
event: NostrEvent,
|
||||
): void => {
|
||||
for (const sub of [...targets]) {
|
||||
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind)
|
||||
const wanted = sub.filter['#p']
|
||||
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''))
|
||||
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since
|
||||
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
|
||||
}
|
||||
}
|
||||
const emit = (event: NostrEvent): void => deliver(subs, event)
|
||||
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event)
|
||||
return {
|
||||
transport,
|
||||
published,
|
||||
emit,
|
||||
emitAfterClose,
|
||||
subscriptionCount: () => subs.length,
|
||||
}
|
||||
}
|
||||
|
||||
export type Encryption = 'nip44_v2' | 'nip04' | 'none'
|
||||
|
||||
// a NIP-47 request exactly as a real client would build it, signed by the
|
||||
// connection's client secret
|
||||
export const clientRequest = (
|
||||
walletServicePubkey: string,
|
||||
content: string,
|
||||
scheme: Encryption = 'nip44_v2',
|
||||
createdAt: number = NOW,
|
||||
): NostrEvent => {
|
||||
const tags: string[][] = [['p', walletServicePubkey]]
|
||||
if (scheme !== 'none') tags.push(['encryption', scheme])
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: NWC_REQUEST_KIND,
|
||||
created_at: createdAt,
|
||||
tags,
|
||||
content:
|
||||
scheme === 'nip44_v2'
|
||||
? nip44v2.encrypt(
|
||||
content,
|
||||
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey),
|
||||
)
|
||||
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
|
||||
},
|
||||
CLIENT_SECRET,
|
||||
)
|
||||
}
|
||||
|
||||
export const methodRequest = (
|
||||
walletServicePubkey: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
scheme: Encryption = 'nip44_v2',
|
||||
createdAt?: number,
|
||||
): NostrEvent =>
|
||||
clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt)
|
||||
|
||||
// generous: a failed/never-settling melt is only classified after the
|
||||
// verify-poll budget (seconds) runs out
|
||||
export const waitFor = async (cond: () => boolean): Promise<void> => {
|
||||
for (let i = 0; i < 3000 && !cond(); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
expect(cond()).toBe(true)
|
||||
}
|
||||
|
||||
export const deferred = (): {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (reason: Error) => void
|
||||
} => {
|
||||
let resolve = (): void => undefined
|
||||
let reject = (_reason: Error): void => undefined
|
||||
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return {promise, resolve, reject}
|
||||
}
|
||||
|
||||
export const resetNow = (): void => {
|
||||
NOW = 1_800_000_000
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
ctx.deps.onError?.(err, ctx.connection())
|
||||
})
|
||||
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}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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: runtimes.map(r => r.info),
|
||||
stop: () => {
|
||||
for (const runtime of runtimes) runtime.sub.close()
|
||||
}
|
||||
connections,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -41,5 +41,5 @@ export type {
|
||||
TransferOptions,
|
||||
TransferOutcome,
|
||||
TransferQuote,
|
||||
TransferResult
|
||||
TransferResult,
|
||||
} from './ops/transfer'
|
||||
|
||||
+32
-47
@@ -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
@@ -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
@@ -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,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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,458 +1,9 @@
|
||||
// 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,
|
||||
passkeySupported,
|
||||
readPasskeySlots,
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapLinkingKeyWithPrf,
|
||||
wrapLinkingKeyWithPrf
|
||||
} from './passkeys'
|
||||
import {
|
||||
decryptRecord,
|
||||
decryptSavedLinkingKey,
|
||||
deriveBearerAesKey,
|
||||
encryptRecord,
|
||||
saveLinkingKey
|
||||
} from './keys'
|
||||
import {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
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubLocalStorage()
|
||||
})
|
||||
|
||||
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})
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewrap on linking-key rotation', () => {
|
||||
it('re-wraps every slot onto the new key, 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: both slots still unwrap the
|
||||
// OLD key afterwards
|
||||
const partial = new Map([
|
||||
[
|
||||
laptopSlot.credentialId,
|
||||
await getPasskeyPrfOutput(laptopSlot.credentialId, {
|
||||
credentials: laptop
|
||||
})
|
||||
]
|
||||
])
|
||||
await expect(rewrapAllSlots(OTHER_LINKING_KEY, partial)).rejects.toThrow(
|
||||
'partial re-wrap'
|
||||
)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(
|
||||
bytesToHex(LINKING_KEY)
|
||||
)
|
||||
|
||||
// full coverage: both slots now unwrap the NEW key
|
||||
const fresh = new Map([
|
||||
[
|
||||
laptopSlot.credentialId,
|
||||
await getPasskeyPrfOutput(laptopSlot.credentialId, {
|
||||
credentials: laptop
|
||||
})
|
||||
],
|
||||
[
|
||||
phoneSlot.credentialId,
|
||||
await getPasskeyPrfOutput(phoneSlot.credentialId, {
|
||||
credentials: phone
|
||||
})
|
||||
]
|
||||
])
|
||||
await rewrapAllSlots(OTHER_LINKING_KEY, fresh)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(
|
||||
bytesToHex(OTHER_LINKING_KEY)
|
||||
)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(
|
||||
bytesToHex(OTHER_LINKING_KEY)
|
||||
)
|
||||
// credential ids and labels survive the re-wrap
|
||||
expect(readPasskeySlots().map(s => s.credentialId).sort()).toEqual(
|
||||
[laptopSlot.credentialId, phoneSlot.credentialId].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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: unknown[] = JSON.parse(
|
||||
localStorage.getItem('sattle_passkey_slots') ?? '[]'
|
||||
) as unknown[]
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
import './passkeys.crypto.cases'
|
||||
import './passkeys.registration.cases'
|
||||
import './passkeys.ownership-a.cases'
|
||||
import './passkeys.ownership-b.cases'
|
||||
import './passkeys.multiple.cases'
|
||||
import './passkeys.rewrap.cases'
|
||||
import './passkeys.storage.cases'
|
||||
import './passkeys.support.cases'
|
||||
import './passkeys.version.cases'
|
||||
|
||||
+66
-76
@@ -30,22 +30,21 @@
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||
|
||||
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||
import {withStorageLock} from './storageLock'
|
||||
import type {PasskeySlot} from './storage/passkeySlots'
|
||||
import {
|
||||
PASSKEY_SLOTS_STORAGE_KEY,
|
||||
PASSKEY_SLOT_VERSION,
|
||||
readPasskeySlots,
|
||||
writePasskeySlots
|
||||
writePasskeySlots,
|
||||
} from './storage/passkeySlots'
|
||||
import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap'
|
||||
|
||||
export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots'
|
||||
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots'
|
||||
export {
|
||||
derivePasskeyWrapKey,
|
||||
wrapLinkingKeyWithPrf,
|
||||
unwrapLinkingKeyWithPrf
|
||||
} from './passkeyWrap'
|
||||
export {migrateLegacyPasskeySlots} from './passkeyOwnership'
|
||||
export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap'
|
||||
|
||||
// 32 bytes, fixed - the authenticator requires exactly 32
|
||||
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1'))
|
||||
@@ -62,9 +61,7 @@ export type CeremonyCredential = {
|
||||
|
||||
// the slice of navigator.credentials the ceremonies need
|
||||
export type PasskeyCredentials = {
|
||||
create(
|
||||
options?: CredentialCreationOptions
|
||||
): Promise<CeremonyCredential | null>
|
||||
create(options?: CredentialCreationOptions): Promise<CeremonyCredential | null>
|
||||
get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null>
|
||||
}
|
||||
|
||||
@@ -76,13 +73,11 @@ export type PasskeySupportProbe = {
|
||||
// the one runtime narrow at the browser boundary: navigator.credentials
|
||||
// resolves to the Credential supertype, but a publicKey ceremony always
|
||||
// produces a PublicKeyCredential
|
||||
const asCeremonyCredential = (
|
||||
credential: Credential | null
|
||||
): CeremonyCredential | null => {
|
||||
if (!credential || credential.type !== 'public-key') return null
|
||||
if (!('rawId' in credential)) return null
|
||||
if (!('getClientExtensionResults' in credential)) return null
|
||||
return credential as unknown as CeremonyCredential
|
||||
const asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => {
|
||||
if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) {
|
||||
return null
|
||||
}
|
||||
return credential
|
||||
}
|
||||
|
||||
const defaultCredentials = (): PasskeyCredentials => {
|
||||
@@ -91,8 +86,8 @@ const defaultCredentials = (): PasskeyCredentials => {
|
||||
}
|
||||
const container = navigator.credentials
|
||||
return {
|
||||
create: options => container.create(options).then(asCeremonyCredential),
|
||||
get: options => container.get(options).then(asCeremonyCredential)
|
||||
create: (options) => container.create(options).then(asCeremonyCredential),
|
||||
get: (options) => container.get(options).then(asCeremonyCredential),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +96,8 @@ const defaultCredentials = (): PasskeyCredentials => {
|
||||
// direct pre-flight check on older clients - where getClientCapabilities
|
||||
// exists we can ask for it, elsewhere this returns true optimistically and
|
||||
// registration itself fails with a clear error.
|
||||
export const passkeySupported = async (
|
||||
probe?: PasskeySupportProbe
|
||||
): Promise<boolean> => {
|
||||
const p =
|
||||
probe ??
|
||||
(typeof PublicKeyCredential !== 'undefined'
|
||||
? PublicKeyCredential
|
||||
: undefined)
|
||||
export const passkeySupported = async (probe?: PasskeySupportProbe): Promise<boolean> => {
|
||||
const p = probe ?? (typeof PublicKeyCredential !== 'undefined' ? PublicKeyCredential : undefined)
|
||||
if (!p) return false
|
||||
if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false
|
||||
if (p.getClientCapabilities) {
|
||||
@@ -135,25 +124,21 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => {
|
||||
// rotation
|
||||
export const getPasskeyPrfOutput = async (
|
||||
credentialId: string,
|
||||
options: {credentials?: PasskeyCredentials} = {}
|
||||
options: {credentials?: PasskeyCredentials} = {},
|
||||
): Promise<Uint8Array> => {
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const assertion = await credentials.get({
|
||||
publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: [
|
||||
{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}
|
||||
],
|
||||
allowCredentials: [{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}],
|
||||
userVerification: 'required',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
|
||||
},
|
||||
})
|
||||
if (!assertion) throw new Error('Passkey ceremony was cancelled.')
|
||||
const prfOutput = prfOutputOf(assertion)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
|
||||
)
|
||||
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
|
||||
}
|
||||
return prfOutput
|
||||
}
|
||||
@@ -172,8 +157,12 @@ export type RegisterPasskeyOptions = {
|
||||
// those get a follow-up get() against the fresh credential.
|
||||
export const registerPasskey = async (
|
||||
linkingKey: Uint8Array,
|
||||
options: RegisterPasskeyOptions = {}
|
||||
options: RegisterPasskeyOptions = {},
|
||||
): Promise<PasskeySlot> => {
|
||||
const ownerId = savedKeyOwnerId()
|
||||
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||
throw new Error('Passkey registration requires the proven saved wallet owner.')
|
||||
}
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const credential = await credentials.create({
|
||||
publicKey: {
|
||||
@@ -184,29 +173,27 @@ export const registerPasskey = async (
|
||||
// discoverable-credential login is used
|
||||
id: crypto.getRandomValues(new Uint8Array(16)),
|
||||
name: 'sattle wallet',
|
||||
displayName: 'sattle wallet'
|
||||
displayName: 'sattle wallet',
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{type: 'public-key', alg: -7}, // ES256
|
||||
{type: 'public-key', alg: -257} // RS256
|
||||
{type: 'public-key', alg: -257}, // RS256
|
||||
],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: options.authenticatorAttachment ?? 'platform',
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'required'
|
||||
userVerification: 'required',
|
||||
},
|
||||
attestation: 'none',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
|
||||
},
|
||||
})
|
||||
if (!credential) throw new Error('Passkey registration was cancelled.')
|
||||
const credentialId = bytesToHex(toBytes(credential.rawId))
|
||||
let prfOutput = prfOutputOf(credential)
|
||||
if (!prfOutput) {
|
||||
if (credential.getClientExtensionResults().prf?.enabled !== true) {
|
||||
throw new Error(
|
||||
'This authenticator does not support the WebAuthn PRF extension.'
|
||||
)
|
||||
throw new Error('This authenticator does not support the WebAuthn PRF extension.')
|
||||
}
|
||||
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
|
||||
}
|
||||
@@ -215,14 +202,14 @@ export const registerPasskey = async (
|
||||
credentialId,
|
||||
...wrap,
|
||||
createdAt: Date.now(),
|
||||
...(options.name !== undefined ? {name: options.name} : {})
|
||||
...(options.name !== undefined ? {name: options.name} : {}),
|
||||
ownerId,
|
||||
version: PASSKEY_SLOT_VERSION,
|
||||
}
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
const slots = readPasskeySlots().filter(
|
||||
s => s.credentialId !== credentialId
|
||||
)
|
||||
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
||||
slots.push(slot)
|
||||
writePasskeySlots(slots)
|
||||
writePasskeySlots(ownerId, slots)
|
||||
})
|
||||
return slot
|
||||
}
|
||||
@@ -231,85 +218,88 @@ export const registerPasskey = async (
|
||||
// slot's credential, then unwrap. Yields the exact same linking key
|
||||
// unlock(password) yields - the caller activates the wallet with it.
|
||||
export const unlockWithPasskey = async (
|
||||
options: {credentials?: PasskeyCredentials} = {}
|
||||
options: {credentials?: PasskeyCredentials} = {},
|
||||
): Promise<Uint8Array> => {
|
||||
const ownerId = savedKeyOwnerId()
|
||||
const slots = readPasskeySlots()
|
||||
if (slots.length === 0) {
|
||||
if (ownerId === null || slots.length === 0) {
|
||||
throw new Error('No passkeys registered on this device.')
|
||||
}
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const assertion = await credentials.get({
|
||||
publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: slots.map(slot => ({
|
||||
allowCredentials: slots.map((slot) => ({
|
||||
type: 'public-key',
|
||||
id: new Uint8Array(hexToBytes(slot.credentialId))
|
||||
id: new Uint8Array(hexToBytes(slot.credentialId)),
|
||||
})),
|
||||
userVerification: 'required',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
|
||||
},
|
||||
})
|
||||
if (!assertion) throw new Error('Passkey ceremony was cancelled.')
|
||||
const credentialId = bytesToHex(toBytes(assertion.rawId))
|
||||
const slot = slots.find(s => s.credentialId === credentialId)
|
||||
const slot = slots.find((s) => s.credentialId === credentialId)
|
||||
if (!slot) {
|
||||
throw new Error('The passkey used is not registered with this wallet.')
|
||||
}
|
||||
const prfOutput = prfOutputOf(assertion)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
|
||||
)
|
||||
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
|
||||
}
|
||||
return unwrapLinkingKeyWithPrf(prfOutput, slot)
|
||||
const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot)
|
||||
if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||
throw new Error('This passkey belongs to a different wallet.')
|
||||
}
|
||||
return linkingKey
|
||||
}
|
||||
|
||||
// Removes the slot only: WebAuthn has no API to delete the credential from
|
||||
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
|
||||
// Returns whether a slot was actually removed.
|
||||
export const removePasskey = async (
|
||||
credentialId: string
|
||||
): Promise<boolean> => {
|
||||
export const removePasskey = async (credentialId: string): Promise<boolean> => {
|
||||
const ownerId = savedKeyOwnerId()
|
||||
if (ownerId === null) return false
|
||||
let removed = false
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
const slots = readPasskeySlots()
|
||||
const kept = slots.filter(s => s.credentialId !== credentialId)
|
||||
const kept = slots.filter((s) => s.credentialId !== credentialId)
|
||||
removed = kept.length !== slots.length
|
||||
if (removed) writePasskeySlots(kept)
|
||||
if (removed) writePasskeySlots(ownerId, kept)
|
||||
})
|
||||
return removed
|
||||
}
|
||||
|
||||
// Re-wraps every slot around NEW key material - needed on linking-key
|
||||
// rotation (restoring a different seed while keeping the passkeys). Each
|
||||
// slot's wrap secret lives only inside its authenticator, so the caller
|
||||
// Refreshes every current-owner slot around the same proven key material.
|
||||
// Each slot's wrap secret lives only inside its authenticator, so the caller
|
||||
// must supply a fresh PRF output per credential (one getPasskeyPrfOutput
|
||||
// ceremony each). All-or-nothing: a slot without a PRF output aborts the
|
||||
// whole re-wrap before anything is written, since a half-rewrapped set
|
||||
// would keep unlocking the OLD key with the uncovered passkeys.
|
||||
// whole refresh before anything is written.
|
||||
//
|
||||
// A password change does NOT need this: the password wrap (keys.ts) and the
|
||||
// passkey slots wrap the same linking key independently, so re-encrypting
|
||||
// the stored key under a new password leaves every slot valid.
|
||||
export const rewrapAllSlots = async (
|
||||
linkingKey: Uint8Array,
|
||||
prfOutputs: ReadonlyMap<string, Uint8Array>
|
||||
prfOutputs: ReadonlyMap<string, Uint8Array>,
|
||||
): Promise<void> => {
|
||||
const ownerId = savedKeyOwnerId()
|
||||
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||
throw new Error('Passkey re-wrap requires the proven saved wallet owner.')
|
||||
}
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
|
||||
const slots = readPasskeySlots()
|
||||
const rewrapped: PasskeySlot[] = []
|
||||
for (const slot of slots) {
|
||||
const prfOutput = prfOutputs.get(slot.credentialId)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.'
|
||||
)
|
||||
throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.')
|
||||
}
|
||||
rewrapped.push({
|
||||
...slot,
|
||||
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey))
|
||||
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)),
|
||||
})
|
||||
}
|
||||
writePasskeySlots(rewrapped)
|
||||
writePasskeySlots(ownerId, rewrapped)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||
|
||||
import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
|
||||
import type {PasskeySlot} from './passkeys'
|
||||
import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
||||
const KEY_STORAGE = 'sattle_linking_key'
|
||||
const SLOT_STORAGE = 'sattle_passkey_slots'
|
||||
|
||||
const SLOT_BASE = {
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
} as const
|
||||
|
||||
const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
|
||||
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots))
|
||||
}
|
||||
|
||||
describe('passkey-slot schema version', () => {
|
||||
beforeEach(() => {
|
||||
stubLocalStorage()
|
||||
})
|
||||
it('reads a current version 1 slot for the exact saved owner', () => {
|
||||
// Given a current saved-key marker and passkey record
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 1}
|
||||
writeRawSlots([slot])
|
||||
|
||||
// When current-owner slots are read
|
||||
// Then the recognized version is exposed unchanged
|
||||
expect(readPasskeySlots()).toEqual([slot])
|
||||
})
|
||||
|
||||
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => {
|
||||
// Given records written before schema versioning
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
|
||||
)
|
||||
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}])
|
||||
|
||||
// When migration is attempted before and after the saved key proves ownership
|
||||
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||
const provenKey = getPlainLinkingKey()
|
||||
if (provenKey === null) throw new Error('expected the compatible plaintext key')
|
||||
ensureSavedKeyOwner(provenKey)
|
||||
await migrateLegacyPasskeySlots(provenKey)
|
||||
|
||||
// Then both compatible legacy forms become current records
|
||||
expect(savedKeyOwnerId()).toBe(ownerId)
|
||||
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
|
||||
{...SLOT_BASE, ownerId, version: 1},
|
||||
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1},
|
||||
])
|
||||
})
|
||||
|
||||
it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => {
|
||||
// Given an otherwise valid slot carrying unrecognized metadata
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
writeRawSlots([{...SLOT_BASE, ownerId, version}])
|
||||
|
||||
// When slots are parsed
|
||||
// Then future or malformed records are unavailable and not downgraded to legacy
|
||||
expect(readPasskeySlots()).toEqual([])
|
||||
})
|
||||
|
||||
it('hides a foreign version 1 slot from the current owner', () => {
|
||||
// Given the saved owner and slot owner differ
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}])
|
||||
|
||||
// When the current wallet reads passkeys
|
||||
// Then the foreign credential is unavailable
|
||||
expect(readPasskeySlots()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
withNewK1,
|
||||
NoteSpentError,
|
||||
NoteUnknownError,
|
||||
PendingNoteError
|
||||
PendingNoteError,
|
||||
} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from './types'
|
||||
|
||||
@@ -17,20 +17,13 @@ import type {Bearer, NewBearer} from './types'
|
||||
// always puts k1 on the wire, so receive.ts's caller should rotate right
|
||||
// after, see secureReceivedNote). Returns the note even when the info fetch
|
||||
// fails - a bearer is better stored unverified than dropped.
|
||||
export const receiveNote = async (
|
||||
input: string,
|
||||
existing: Bearer[]
|
||||
): Promise<NewBearer> => {
|
||||
export const receiveNote = async (input: string, existing: Bearer[]): Promise<NewBearer> => {
|
||||
const url = resolveNoteInput(input)
|
||||
if (!url) {
|
||||
throw new Error('Not an LNURLcash bearer note (needs a k1).')
|
||||
}
|
||||
const k1 = noteK1(url)
|
||||
if (
|
||||
existing.some(
|
||||
b => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url)
|
||||
)
|
||||
) {
|
||||
if (existing.some((b) => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url))) {
|
||||
throw new Error('This note is already in your wallet.')
|
||||
}
|
||||
try {
|
||||
@@ -40,7 +33,7 @@ export const receiveNote = async (
|
||||
callback: info.callback,
|
||||
amount: info.maxWithdrawable,
|
||||
verified: true,
|
||||
mintPubkey: info.mintPubkey
|
||||
mintPubkey: info.mintPubkey,
|
||||
}
|
||||
} catch (err) {
|
||||
// the service positively told us this k1 is dead, unknown, or locked
|
||||
@@ -62,7 +55,7 @@ export const receiveNote = async (
|
||||
url,
|
||||
callback: '',
|
||||
amount: noteDeclaredAmount(url) ?? 0,
|
||||
verified: false
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Backup restore waits for owner-bound trusted-mint convergence before it
|
||||
// reports success, while retaining the hostile-file merge policy.
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
|
||||
import {linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {applyBackup, buildBackup} from './storage'
|
||||
import {addTrustedMint, readTrustedMints} from './trustedMints'
|
||||
import {stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
|
||||
const KEY_A = '02' + 'aa'.repeat(32)
|
||||
const KEY_B = '03' + 'bb'.repeat(32)
|
||||
|
||||
type LockRequest = {
|
||||
readonly callback: () => unknown
|
||||
readonly resolve: (value: unknown) => void
|
||||
readonly reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
class DeferredLocks {
|
||||
readonly requests: LockRequest[] = []
|
||||
|
||||
readonly request = (_name: string, callback: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.requests.push({callback, resolve, reject})
|
||||
})
|
||||
|
||||
async releaseNext(): Promise<void> {
|
||||
const request = this.requests.shift()
|
||||
if (!request) throw new Error('Expected a queued lock request.')
|
||||
try {
|
||||
request.resolve(await request.callback())
|
||||
} catch (error) {
|
||||
request.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backup = (server: string, mintPubkey: string) => ({
|
||||
type: 'sattle-backup' as const,
|
||||
version: 1 as const,
|
||||
createdAt: 1,
|
||||
bearers: [],
|
||||
trustedMints: [
|
||||
{
|
||||
server,
|
||||
mintPubkey,
|
||||
addedAt: 1,
|
||||
locked: true,
|
||||
pendingMintPubkey: KEY_B,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const installProvenOwner = (): Promise<void> => saveLinkingKey(LINKING_KEY)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
stubLocalStorage()
|
||||
})
|
||||
|
||||
describe('owner-bound backup restore', () => {
|
||||
it('exports only the active owner trusted-mint registry', async () => {
|
||||
await installProvenOwner()
|
||||
await addTrustedMint('backup-mint.example', KEY_A, {ownerId: OWNER_ID})
|
||||
|
||||
expect(buildBackup(OWNER_ID).trustedMints).toEqual([
|
||||
expect.objectContaining({server: 'backup-mint.example', mintPubkey: KEY_A}),
|
||||
])
|
||||
expect(buildBackup(OWNER_ID).ownerId).toBe(OWNER_ID)
|
||||
expect(buildBackup().trustedMints).toEqual([])
|
||||
})
|
||||
|
||||
it('drops fresh-device mint trust instead of using a valid file owner marker', async () => {
|
||||
const result = await applyBackup({
|
||||
...backup('file-mint.example', KEY_A),
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
|
||||
expect(result.trustedMintsAdded).toBe(0)
|
||||
expect(readTrustedMints(OWNER_ID)).toEqual([])
|
||||
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not attach file mints to a malformed owner marker', async () => {
|
||||
const result = await applyBackup({
|
||||
...backup('file-mint.example', KEY_A),
|
||||
ownerId: 'malformed-owner',
|
||||
})
|
||||
|
||||
expect(result.trustedMintsAdded).toBe(0)
|
||||
expect(readTrustedMints(OWNER_ID)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not resolve before the trusted-mint merge commits', async () => {
|
||||
await installProvenOwner()
|
||||
const locks = new DeferredLocks()
|
||||
vi.stubGlobal('navigator', {locks})
|
||||
|
||||
let settled = false
|
||||
const restoring = applyBackup(backup('backup-mint.example', KEY_A), OWNER_ID).then((result) => {
|
||||
settled = true
|
||||
return result
|
||||
})
|
||||
await vi.waitFor(() => expect(locks.requests).toHaveLength(1))
|
||||
|
||||
expect(settled).toBe(false)
|
||||
await locks.releaseNext()
|
||||
|
||||
expect((await restoring).trustedMintsAdded).toBe(1)
|
||||
const restored = readTrustedMints(OWNER_ID)
|
||||
expect(restored).toEqual([
|
||||
expect.objectContaining({
|
||||
server: 'backup-mint.example',
|
||||
mintPubkey: KEY_A,
|
||||
locked: false,
|
||||
unconfirmed: true,
|
||||
}),
|
||||
])
|
||||
expect(restored[0]?.pendingMintPubkey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a local locked pin and pending rekey authoritative', async () => {
|
||||
await installProvenOwner()
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
ownerId: OWNER_ID,
|
||||
mints: [
|
||||
{
|
||||
server: 'local.example',
|
||||
mintPubkey: KEY_A,
|
||||
addedAt: 1,
|
||||
locked: true,
|
||||
pendingMintPubkey: KEY_B,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await applyBackup(backup('local.example', KEY_B), OWNER_ID)
|
||||
|
||||
expect(result.trustedMintsAdded).toBe(0)
|
||||
expect(readTrustedMints(OWNER_ID)).toEqual([
|
||||
expect.objectContaining({
|
||||
mintPubkey: KEY_A,
|
||||
locked: true,
|
||||
pendingMintPubkey: KEY_B,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user