mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
test(e2e): cover signed BIP-32 wallet flows
This commit is contained in:
+279
-29
@@ -1,13 +1,26 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
||||
|
||||
// The mock mint lives on a .test origin that never resolves - every request
|
||||
// to it is intercepted by Playwright and fulfilled in-process, so the specs
|
||||
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
|
||||
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
|
||||
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
|
||||
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
|
||||
// callback shape also answers a melt (meltNote sends k1 + pr to the same
|
||||
// callback and only requires status "OK").
|
||||
// mutating GET on the callback (rotate/split/merge/melt) expects
|
||||
// {status: "OK"} plus the LUD-25 signature(s) over every minted output -
|
||||
// the app forces the kit's requireSignatures policy, so an unsigned
|
||||
// mutation answer is not a success the wallet accepts quietly.
|
||||
//
|
||||
// Signing: each origin has a stable secp256k1 signing key derived from its
|
||||
// origin string, so a spec can pin the advertised mintPubkey via
|
||||
// signingPubkey(origin). `invalidSignatures = true` signs every output with
|
||||
// a foreign key instead - the notes are real mint-side, but the offline
|
||||
// proof verifies against no key the mint advertises, which is exactly the
|
||||
// "landed but unverifiable" case the wallet must refuse to mark spendable.
|
||||
//
|
||||
// Every method takes an optional origin, so a spec can stand up a SECOND
|
||||
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
|
||||
@@ -29,13 +42,28 @@ const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
// deterministic per-origin signing keys: the mock mints are stable across
|
||||
// tests, and the key derivation needs nothing but the origin string
|
||||
const signingKeyFor = (origin: string): Uint8Array =>
|
||||
sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`));
|
||||
|
||||
// the key every output is signed with in invalid-signature mode: a real
|
||||
// secp256k1 key, just never advertised by any mock mint
|
||||
const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key'));
|
||||
|
||||
// a melt's settle proof reveals a payment receipt - derived from the paid
|
||||
// invoice so it is stable per payment and never collides with a note secret
|
||||
const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`)));
|
||||
|
||||
interface MockNoteInfoOptions {
|
||||
// note value in msat, reported as maxWithdrawable
|
||||
amountMsat?: number;
|
||||
// when set, the mint answers the ERROR envelope with this reason instead
|
||||
// (a reason matching /spent/i surfaces the "already been spent" UI)
|
||||
spentReason?: string;
|
||||
// the mint's signing pubkey, advertised as mintPubkey in responses
|
||||
// the mint's signing pubkey, advertised as mintPubkey in responses -
|
||||
// defaults to the mock origin's real signing key (kit 0.8 refuses a
|
||||
// withdrawRequest that publishes none, so every mock mint has one)
|
||||
mintPubkey?: string;
|
||||
}
|
||||
|
||||
@@ -43,14 +71,14 @@ interface MockTargetMintOptions {
|
||||
// the mint's signing pubkey (66 hex chars), advertised on the mint-address
|
||||
// (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest:
|
||||
// LUD-25 announces it on the withdraw side only, and a too-generous mock
|
||||
// once masked a real bug
|
||||
mintPubkey: string;
|
||||
// once masked a real bug. Defaults to the origin's real signing key.
|
||||
mintPubkey?: string;
|
||||
// the invoice this mint hands out (and reports back as settled) - keep it
|
||||
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
|
||||
// amount cross-check
|
||||
invoice: string;
|
||||
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
|
||||
// claimed note's secret)
|
||||
// the payment preimage a settled verify reveals - a receipt distinct from
|
||||
// the wallet-chosen secret committed in the invoice comment
|
||||
preimage: string;
|
||||
// value of the note a claim mints, in msat
|
||||
noteAmountMsat: number;
|
||||
@@ -62,10 +90,69 @@ interface MockTargetMintOptions {
|
||||
export class MintMocker {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
// when true, every minted output is signed with a foreign key: the
|
||||
// mutation lands mint-side, but the signature verifies against no key
|
||||
// this mint advertises - the wallet must keep the outputs unverified
|
||||
invalidSignatures = false;
|
||||
|
||||
// every signature this mock issued, in order - lets a spec assert the
|
||||
// mint actually signed (the kit's requireSignatures policy makes an
|
||||
// unsigned success impossible to confuse with a signed one)
|
||||
readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = [];
|
||||
|
||||
// the mint's advertised signing pubkey for an origin (compressed, hex)
|
||||
signingPubkey(origin = MINT_ORIGIN): string {
|
||||
return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true));
|
||||
}
|
||||
|
||||
// note id (sha256 of the secret) -> outstanding value in msat, per origin.
|
||||
// A real mint keys its ledger by the hash; the mock does the same so a
|
||||
// mutation can sign the output with the amount the inputs actually held.
|
||||
private readonly outstanding = new Map<string, number>();
|
||||
private readonly burned = new Set<string>();
|
||||
// invoices this mint reports as settled on its verify endpoint, per origin
|
||||
private readonly settledInvoices = new Map<string, string[]>();
|
||||
|
||||
private noteKey(origin: string, noteId: string): string {
|
||||
return `${origin}|${noteId}`;
|
||||
}
|
||||
|
||||
private sign(origin: string, h: string, amountMsat: number): string {
|
||||
const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin);
|
||||
// noble's 'recovered' layout is [recovery, r, s]; the wire layout is
|
||||
// r || s || recovery (the kit accepts either, the trailing layout is
|
||||
// what lnurl-mint emits)
|
||||
const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, {
|
||||
format: 'recovered',
|
||||
prehash: false,
|
||||
});
|
||||
this.signaturesIssued.push({ origin, h, amountMsat });
|
||||
return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]]));
|
||||
}
|
||||
|
||||
// mint outputs at the given hashes, burning the inputs - the ledger
|
||||
// transition every successful mutation makes
|
||||
private remint(
|
||||
origin: string,
|
||||
inputIds: string[],
|
||||
outputs: [h: string, amountMsat: number][],
|
||||
): void {
|
||||
for (const id of inputIds) {
|
||||
this.outstanding.delete(this.noteKey(origin, id));
|
||||
this.burned.add(this.noteKey(origin, id));
|
||||
}
|
||||
for (const [h, amountMsat] of outputs) {
|
||||
this.outstanding.set(this.noteKey(origin, h), amountMsat);
|
||||
}
|
||||
}
|
||||
|
||||
// The note's informational GET: a spec-shaped withdrawRequest, or the
|
||||
// ERROR envelope for a note the mint considers spent. The kit validates
|
||||
// that the service echoes back the queried k1, so read it off the request.
|
||||
// ERROR envelope for a note the mint considers spent or never issued. The
|
||||
// kit validates that the service echoes back the queried k1, so read it
|
||||
// off the request. A first-seen k1 registers at the configured amount -
|
||||
// the mock's way of "issuing" the note a spec hands to the wallet.
|
||||
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
|
||||
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
@@ -74,27 +161,147 @@ export class MintMocker {
|
||||
return;
|
||||
}
|
||||
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
||||
if (!/^[0-9a-f]{64}$/i.test(k1)) {
|
||||
// a by-hash lookup (h=) or a malformed k1 - this mock answers
|
||||
// secret-keyed info only
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||
return;
|
||||
}
|
||||
const id = this.noteKey(origin, hashK1(k1));
|
||||
if (this.burned.has(id)) {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||
return;
|
||||
}
|
||||
if (!this.outstanding.has(id)) {
|
||||
this.outstanding.set(id, options.amountMsat ?? 0);
|
||||
}
|
||||
const amountMsat = this.outstanding.get(id) ?? 0;
|
||||
await fulfillJson(route, {
|
||||
tag: 'withdrawRequest',
|
||||
callback: `${origin}${CALLBACK_PATH}`,
|
||||
k1,
|
||||
minWithdrawable: options.amountMsat ?? 0,
|
||||
maxWithdrawable: options.amountMsat ?? 0,
|
||||
minWithdrawable: amountMsat,
|
||||
maxWithdrawable: amountMsat,
|
||||
defaultDescription: 'mock mint note',
|
||||
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
|
||||
mintPubkey,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
|
||||
// params) both expect a plain {status: "OK"} confirmation. No sig is
|
||||
// returned - a plain LUD-03-style service that does not sign notes.
|
||||
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
|
||||
// The settle proof a melt's verify URL serves: settled once the melt
|
||||
// landed, carrying the paid invoice and its preimage receipt.
|
||||
private async mockVerify(origin: string): Promise<void> {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
||||
async (route: Route) => {
|
||||
const pr = new URL(route.request().url()).searchParams.get('pr') ?? '';
|
||||
const settled = (this.settledInvoices.get(origin) ?? []).includes(pr);
|
||||
await fulfillJson(route, {
|
||||
settled,
|
||||
...(settled ? { preimage: preimageFor(pr) } : {}),
|
||||
pr,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The mutating callback GET, signed per LUD-25:
|
||||
// - melt (k1 + pr): burns the input, answers OK with a settle-proof URL
|
||||
// - rotate (k1 + h): burns the input, mints at h for its full value
|
||||
// - split (k1... + amount + h + h2): carves amount off the inputs' total,
|
||||
// minting h for it and h2 for the change
|
||||
// - merge (k1... + h): burns all inputs, mints h for their total
|
||||
// Every minted output's signature rides in the response (sig / sig2), and
|
||||
// a mutation naming an unknown or burned input is refused like a real
|
||||
// mint's. A byte-identical replay gets the same success - the mutations
|
||||
// are deterministic in their inputs, so the replayed answer re-derives
|
||||
// the same signatures.
|
||||
async mockMutationEndpoint(origin = MINT_ORIGIN): Promise<void> {
|
||||
await this.mockVerify(origin);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, { status: 'OK' });
|
||||
const url = new URL(route.request().url());
|
||||
const k1s = url.searchParams.getAll('k1');
|
||||
if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||
return;
|
||||
}
|
||||
const pr = url.searchParams.get('pr');
|
||||
const h = url.searchParams.get('h');
|
||||
const h2 = url.searchParams.get('h2');
|
||||
const amountRaw = url.searchParams.get('amount');
|
||||
const inputIds = k1s.map((k1) => hashK1(k1));
|
||||
const lookup = (id: string): number | 'unknown' | 'burned' => {
|
||||
const key = this.noteKey(origin, id);
|
||||
if (this.burned.has(key)) return 'burned';
|
||||
return this.outstanding.get(key) ?? 'unknown';
|
||||
};
|
||||
|
||||
// melt: the note burns once the payment settles; the mock answers
|
||||
// OK (payment in flight) and the verify endpoint reports it settled
|
||||
if (pr !== null) {
|
||||
for (const id of inputIds) {
|
||||
const state = lookup(id);
|
||||
if (state === 'burned') {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||
return;
|
||||
}
|
||||
if (state === 'unknown') {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.remint(origin, inputIds, []);
|
||||
const paid = this.settledInvoices.get(origin) ?? [];
|
||||
paid.push(pr);
|
||||
this.settledInvoices.set(origin, paid);
|
||||
await fulfillJson(route, {
|
||||
status: 'OK',
|
||||
verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// every other mutation mints outputs whose signatures the wallet
|
||||
// must be able to verify offline
|
||||
const total = inputIds.reduce((sum, id) => {
|
||||
const state = lookup(id);
|
||||
return typeof state === 'number' ? sum + state : sum;
|
||||
}, 0);
|
||||
for (const id of inputIds) {
|
||||
const state = lookup(id);
|
||||
if (state === 'burned') {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' });
|
||||
return;
|
||||
}
|
||||
if (state === 'unknown') {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (h !== null && h2 !== null && amountRaw !== null) {
|
||||
// split: amount carved off, the rest is change
|
||||
const amountMsat = Number(amountRaw);
|
||||
this.remint(origin, inputIds, [
|
||||
[h, amountMsat],
|
||||
[h2, total - amountMsat],
|
||||
]);
|
||||
await fulfillJson(route, {
|
||||
status: 'OK',
|
||||
sig: this.sign(origin, h, amountMsat),
|
||||
sig2: this.sign(origin, h2, total - amountMsat),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (h !== null) {
|
||||
// rotate (one input) or merge (several): full value at h
|
||||
this.remint(origin, inputIds, [[h, total]]);
|
||||
await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -105,7 +312,11 @@ export class MintMocker {
|
||||
// nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises.
|
||||
// The payLink points back at the lnurlp route below, as prepareMint treats
|
||||
// it as the authoritative place to read the payRequest from.
|
||||
private async mockMintAddress(options: MockTargetMintOptions, origin: string): Promise<void> {
|
||||
private async mockMintAddress(
|
||||
options: MockTargetMintOptions,
|
||||
origin: string,
|
||||
mintPubkey: string,
|
||||
): Promise<void> {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
|
||||
async (route: Route) => {
|
||||
@@ -114,7 +325,7 @@ export class MintMocker {
|
||||
callback: `${origin}${CALLBACK_PATH}`,
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 100_000_000_000,
|
||||
mintPubkey: options.mintPubkey,
|
||||
mintPubkey,
|
||||
payLink: `${origin}/.well-known/lnurlp/mint`,
|
||||
nodeCapacity: 500_000_000,
|
||||
nodeNumChannels: 4,
|
||||
@@ -127,15 +338,33 @@ export class MintMocker {
|
||||
// Everything the target side of a transfer (or a Lightning receive)
|
||||
// needs: the mint-address discovery endpoint carrying the mint metadata,
|
||||
// the payRequest at the standard mint@ address, the invoice callback, an
|
||||
// immediately-settled verify endpoint revealing the preimage, and the
|
||||
// note info + rotate the claim then performs.
|
||||
// immediately-settled verify endpoint revealing the payment receipt,
|
||||
// and note info at the wallet-chosen committed secret.
|
||||
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
|
||||
await this.mockMintAddress(options, origin);
|
||||
await this.mockNoteInfo(
|
||||
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
|
||||
origin,
|
||||
const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin);
|
||||
let stagedHash: string | null = null;
|
||||
let settled = false;
|
||||
await this.mockMintAddress(options, origin, mintPubkey);
|
||||
await this.mockMutationEndpoint(origin);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
||||
if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
tag: 'withdrawRequest',
|
||||
callback: `${origin}${CALLBACK_PATH}`,
|
||||
k1,
|
||||
minWithdrawable: options.noteAmountMsat,
|
||||
maxWithdrawable: options.noteAmountMsat,
|
||||
defaultDescription: 'mock minted note',
|
||||
mintPubkey,
|
||||
});
|
||||
},
|
||||
);
|
||||
await this.mockRotateOk(origin);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
|
||||
async (route: Route) => {
|
||||
@@ -144,6 +373,7 @@ export class MintMocker {
|
||||
callback: `${origin}/pay`,
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000_000,
|
||||
commentAllowed: 64,
|
||||
withdrawLink: `${origin}${NOTE_PATH}`,
|
||||
metadata: options.mintFeeMetadata ?? '[]',
|
||||
});
|
||||
@@ -152,18 +382,38 @@ export class MintMocker {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
|
||||
async (route: Route) => {
|
||||
const request = new URL(route.request().url());
|
||||
const comment = request.searchParams.get('comment');
|
||||
const commitment = request.searchParams.get('h');
|
||||
if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' });
|
||||
return;
|
||||
}
|
||||
stagedHash = comment.toLowerCase();
|
||||
await fulfillJson(route, {
|
||||
pr: options.invoice,
|
||||
verify: `${origin}/verify`,
|
||||
});
|
||||
},
|
||||
);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
const paidInvoice = new URL(route.request().url()).searchParams.get('pr');
|
||||
if (paidInvoice !== options.invoice) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await fulfillJson(route, { status: 'OK' });
|
||||
},
|
||||
);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, {
|
||||
settled: true,
|
||||
preimage: options.preimage,
|
||||
settled,
|
||||
...(settled ? { preimage: options.preimage } : {}),
|
||||
pr: options.invoice,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
||||
import { MINT_ORIGIN, NOTE_PATH } from './MintMocker';
|
||||
import type { MintMocker } from './MintMocker';
|
||||
|
||||
// Drives onboarding end to end on a fresh browser context: /#/welcome ->
|
||||
// "Create wallet" (empty password = stored unencrypted) -> confirm the
|
||||
@@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise<void> => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForURL(/#\/$/);
|
||||
};
|
||||
|
||||
// Give a fresh wallet one verified, spendable note at the mock mint: the
|
||||
// mock answers the note's informational GET and signs every mutation, and
|
||||
// the receive's first-contact trust prompt is confirmed, pinning the mint's
|
||||
// signing key so the wallet can verify what this mint mints.
|
||||
export const fundMockMintWallet = async (
|
||||
page: Page,
|
||||
mint: MintMocker,
|
||||
amountMsat: number,
|
||||
): Promise<void> => {
|
||||
await mint.mockNoteInfo({ amountMsat });
|
||||
await mint.mockMutationEndpoint();
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Receive' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
await dialog
|
||||
.locator('textarea')
|
||||
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat));
|
||||
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
||||
await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible();
|
||||
// the note advertises the mint's key, so the first-contact trust prompt
|
||||
// opens as its own dialog - trust it (that also pins the signing key)
|
||||
await page
|
||||
.locator('.q-dialog', { hasText: 'New mint' })
|
||||
.getByRole('button', { name: 'Trust this mint' })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||
|
||||
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
|
||||
// platform authenticator, so the security page is asserted in its honest
|
||||
@@ -12,6 +16,72 @@ const VALID_PHRASE =
|
||||
const readSettings = async (page: import('@playwright/test').Page) =>
|
||||
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
|
||||
|
||||
// A v2 encrypted wallet-material record built with the app's own wrap
|
||||
// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that
|
||||
// need a saved, locked wallet without driving the onboarding UI.
|
||||
const PASSWORD = 'correct horse battery staple';
|
||||
const PBKDF2_ITERATIONS = 210_000;
|
||||
|
||||
const encryptedWalletMaterialRecord = async (
|
||||
password: string,
|
||||
): Promise<{
|
||||
enc: true;
|
||||
salt: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
materialHash: string;
|
||||
ownerId: string;
|
||||
version: 2;
|
||||
}> => {
|
||||
const linkingKeyHex = '07'.repeat(32);
|
||||
const material = {
|
||||
version: 2,
|
||||
linkingKeyHex,
|
||||
cashRootHex: '08'.repeat(64),
|
||||
};
|
||||
const serialized = JSON.stringify(material);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
aesKey,
|
||||
new TextEncoder().encode(serialized),
|
||||
),
|
||||
);
|
||||
return {
|
||||
enc: true,
|
||||
salt: bytesToHex(salt),
|
||||
iv: bytesToHex(iv),
|
||||
ciphertext: bytesToHex(ciphertext),
|
||||
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
|
||||
ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)),
|
||||
version: 2,
|
||||
};
|
||||
};
|
||||
|
||||
const hexToBytesLocal = (hex: string): Uint8Array => {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
test.describe('Backup page', () => {
|
||||
test('renders all sections and exports a JSON backup file', async ({ page }) => {
|
||||
await createFreshWallet(page);
|
||||
@@ -93,19 +163,22 @@ test.describe('Security page', () => {
|
||||
});
|
||||
await createFreshWallet(page);
|
||||
await page.evaluate(() => {
|
||||
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
|
||||
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
|
||||
if (
|
||||
typeof saved !== 'object' ||
|
||||
saved === null ||
|
||||
!('ownerId' in saved) ||
|
||||
typeof saved.ownerId !== 'string'
|
||||
typeof saved.ownerId !== 'string' ||
|
||||
!('materialHash' in saved) ||
|
||||
typeof saved.materialHash !== 'string'
|
||||
) {
|
||||
throw new Error('expected saved wallet owner');
|
||||
}
|
||||
const wrap = {
|
||||
hkdfSalt: '11'.repeat(16),
|
||||
iv: '22'.repeat(12),
|
||||
wrappedKey: '33'.repeat(48),
|
||||
materialHash: saved.materialHash,
|
||||
wrappedMaterial: '33'.repeat(48),
|
||||
createdAt: 1,
|
||||
};
|
||||
localStorage.setItem(
|
||||
@@ -116,14 +189,14 @@ test.describe('Security page', () => {
|
||||
credentialId: '44'.repeat(16),
|
||||
name: 'Current wallet passkey',
|
||||
ownerId: saved.ownerId,
|
||||
version: 1,
|
||||
version: 2,
|
||||
},
|
||||
{
|
||||
...wrap,
|
||||
credentialId: '55'.repeat(16),
|
||||
name: 'Foreign wallet passkey',
|
||||
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
|
||||
version: 1,
|
||||
version: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -137,36 +210,39 @@ test.describe('Security page', () => {
|
||||
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => {
|
||||
// Given an encrypted saved key and passkey slot without owner markers
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: true,
|
||||
salt: '11'.repeat(16),
|
||||
iv: '22'.repeat(12),
|
||||
ciphertext: '33'.repeat(48),
|
||||
}),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([
|
||||
{
|
||||
credentialId: '44'.repeat(16),
|
||||
hkdfSalt: '55'.repeat(16),
|
||||
iv: '66'.repeat(12),
|
||||
wrappedKey: '77'.repeat(48),
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Given an encrypted v2 saved wallet whose only passkey slot belongs to
|
||||
// a DIFFERENT owner (a foreign wallet's residue on the same device)
|
||||
const record = await encryptedWalletMaterialRecord(PASSWORD);
|
||||
await page.addInitScript(
|
||||
({ storedRecord }) => {
|
||||
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([
|
||||
{
|
||||
credentialId: '44'.repeat(16),
|
||||
hkdfSalt: '55'.repeat(16),
|
||||
iv: '66'.repeat(12),
|
||||
materialHash: '77'.repeat(32),
|
||||
wrappedMaterial: '88'.repeat(48),
|
||||
createdAt: 1,
|
||||
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
|
||||
version: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
{ storedRecord: record },
|
||||
);
|
||||
|
||||
// When the locked wallet renders
|
||||
await page.goto('/');
|
||||
|
||||
// Then passkey-first unlock is unavailable until another owner proof
|
||||
// Then passkey-first unlock is unavailable - the one slot on this
|
||||
// device is not this wallet's
|
||||
await expect(page.getByText('Wallet locked')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise<void> => {
|
||||
callback: 'https://mint.600.wtf/pay',
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000_000,
|
||||
commentAllowed: 64,
|
||||
withdrawLink: 'https://mint.600.wtf/note',
|
||||
metadata: '[]',
|
||||
}),
|
||||
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
|
||||
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
|
||||
// hold a 21-sat note from the mock mint - holding funds locks the mint
|
||||
// against removal
|
||||
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await mint.mockNoteInfo({ amountMsat: 21_000 });
|
||||
await mint.mockMutationEndpoint();
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Receive' }).click();
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
||||
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
import { MINT2_ORIGIN } from '../helpers/MintMocker';
|
||||
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
|
||||
|
||||
const AMOUNT_MSAT = 50_000; // 50 sats
|
||||
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
|
||||
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
|
||||
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
|
||||
// 64 hex chars - a valid payment receipt, distinct from the claimed note secret
|
||||
const PREIMAGE = 'ef'.repeat(32);
|
||||
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
|
||||
// amount cross-check against the requested value
|
||||
@@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer';
|
||||
|
||||
// give the wallet a verified, spendable 50-sat note at the source mint
|
||||
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Receive' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
await dialog
|
||||
.locator('textarea')
|
||||
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
|
||||
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
||||
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
|
||||
// the note advertises a mint key, so the first-contact trust prompt opens
|
||||
// as its own dialog - trust it (that also locks the mint)
|
||||
await page
|
||||
.locator('.q-dialog', { hasText: 'New mint' })
|
||||
.getByRole('button', { name: 'Trust this mint' })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
|
||||
};
|
||||
|
||||
// pick an option from a Quasar select identified by its label
|
||||
@@ -78,7 +56,6 @@ test.describe('Move funds', () => {
|
||||
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
|
||||
await mint.mockTargetMint(
|
||||
{
|
||||
mintPubkey: TARGET_PUBKEY,
|
||||
invoice: INVOICE,
|
||||
preimage: PREIMAGE,
|
||||
noteAmountMsat: AMOUNT_MSAT,
|
||||
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
|
||||
|
||||
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
|
||||
await fundSourceMint(page, mint);
|
||||
// the target mint: hands out the invoice, reports it settled with the
|
||||
// preimage, and answers the claim's note info + rotation
|
||||
// the target mint commits the wallet-chosen secret, hands out the invoice,
|
||||
// and exposes that note only after the source-mint melt pays it
|
||||
await mint.mockTargetMint(
|
||||
{
|
||||
mintPubkey: TARGET_PUBKEY,
|
||||
invoice: INVOICE,
|
||||
preimage: PREIMAGE,
|
||||
noteAmountMsat: AMOUNT_MSAT,
|
||||
|
||||
+126
-1
@@ -1,17 +1,34 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
|
||||
import { hexToBytes } from '@noble/hashes/utils.js';
|
||||
import { finalizeEvent } from 'nostr-tools/pure';
|
||||
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04';
|
||||
|
||||
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
|
||||
// test that runs the service injects a fake transport through the dev-only
|
||||
// window hook (stores/nwc.ts) before enabling - every other test stays
|
||||
// UI-only with the service off.
|
||||
|
||||
// a Nostr event as the fake transport carries it - the plain JSON shape
|
||||
// NIP-47 requests and responses take on the wire
|
||||
type WireEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig: string;
|
||||
};
|
||||
|
||||
type FakeSub = { closed: boolean; close: () => void };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__sattleNwcTest: { setTransport: (transport: unknown) => void };
|
||||
__nwcSubs: FakeSub[];
|
||||
__nwcPublished: WireEvent[];
|
||||
__nwcOnEvent?: (event: WireEvent) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +163,112 @@ test.describe('NWC settings page', () => {
|
||||
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => {
|
||||
// a funded wallet: 50 sats at the mock mint, its signing key pinned
|
||||
await fundMockMintWallet(page, mint, 50_000);
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
|
||||
// create a connection and read its one-time URI: the wallet service
|
||||
// pubkey is the host, the client secret the `secret` param
|
||||
await openNwcPage(page);
|
||||
await page.getByRole('button', { name: 'Create connection' }).click();
|
||||
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
|
||||
expect(uri).toBeTruthy();
|
||||
if (!uri) throw new Error('Expected the one-time NWC connection string.');
|
||||
await page.getByRole('button', { name: "Done - I've saved it" }).click();
|
||||
const connectionUrl = new URL(uri);
|
||||
const walletServicePubkey = connectionUrl.host;
|
||||
const clientSecret = connectionUrl.searchParams.get('secret') ?? '';
|
||||
|
||||
// a fake transport that captures the request handler and every
|
||||
// published event, installed before the service starts
|
||||
await page.evaluate(() => {
|
||||
window.__nwcSubs = [];
|
||||
window.__nwcPublished = [];
|
||||
window.__sattleNwcTest.setTransport({
|
||||
publish: (_relays: string[], event: WireEvent) => {
|
||||
window.__nwcPublished.push(event);
|
||||
return Promise.resolve();
|
||||
},
|
||||
subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => {
|
||||
window.__nwcOnEvent = onEvent;
|
||||
const sub: FakeSub = {
|
||||
closed: false,
|
||||
close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
window.__nwcSubs.push(sub);
|
||||
return sub;
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click();
|
||||
await expect(page.getByText('Service running', { exact: false })).toBeVisible();
|
||||
await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function');
|
||||
|
||||
// a 21-sat invoice the mint will settle; the request is exactly what a
|
||||
// NIP-47 client sends: kind 23194, NIP-04 encrypted to the service,
|
||||
// schnorr-signed by the connection's client secret
|
||||
const invoice = 'lnbc210n1pjqrstuvwxyz';
|
||||
const content = await nip04Encrypt(
|
||||
clientSecret,
|
||||
walletServicePubkey,
|
||||
JSON.stringify({ method: 'pay_invoice', params: { invoice } }),
|
||||
);
|
||||
const request = finalizeEvent(
|
||||
{
|
||||
kind: 23194,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', walletServicePubkey],
|
||||
['encryption', 'nip04'],
|
||||
],
|
||||
content,
|
||||
},
|
||||
hexToBytes(clientSecret),
|
||||
);
|
||||
await page.evaluate((event) => window.__nwcOnEvent?.(event), request);
|
||||
|
||||
// the service pays and answers on the same scheme
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
(id) =>
|
||||
window.__nwcPublished.find(
|
||||
(event) =>
|
||||
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
|
||||
) ?? null,
|
||||
request.id,
|
||||
),
|
||||
)
|
||||
.not.toBeNull();
|
||||
const responseEvent = await page.evaluate(
|
||||
(id) =>
|
||||
window.__nwcPublished.find(
|
||||
(event) =>
|
||||
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
|
||||
) ?? null,
|
||||
request.id,
|
||||
);
|
||||
if (!responseEvent) throw new Error('the service published no response');
|
||||
const response: unknown = JSON.parse(
|
||||
await nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content),
|
||||
);
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
throw new Error('the service response is not an object');
|
||||
}
|
||||
const result = response as { result_type?: unknown; error?: unknown; result?: unknown };
|
||||
expect(result.result_type).toBe('pay_invoice');
|
||||
expect(result.error).toBeNull();
|
||||
// the receipt is the melt's payment preimage from the mint's own
|
||||
// settle proof - 64 hex chars, never a note secret
|
||||
const payload = result.result as { preimage?: unknown };
|
||||
expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// the wallet paid 21 of its 50 sats; the change stays in the wallet
|
||||
await page.goto('/#/');
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('29');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
|
||||
const AMOUNT_MSAT = 21_000; // 21 sats
|
||||
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
|
||||
|
||||
// a syntactically valid bearer note against the mock mint - the k1 is a
|
||||
// fresh random secret, so every test redeems a distinct note
|
||||
@@ -26,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise<void> => {
|
||||
test.describe('Receive bearer note', () => {
|
||||
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||
await mint.mockRotateOk();
|
||||
await mint.mockMutationEndpoint();
|
||||
await createFreshWallet(page);
|
||||
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
@@ -38,6 +37,18 @@ test.describe('Receive bearer note', () => {
|
||||
// were really consumed (the kit bug this guards against silently fell
|
||||
// back to the declared amount without any request)
|
||||
await expect(dialog.locator('.q-banner')).toHaveCount(0);
|
||||
// the mint signed the rotation's output (LUD-25) - the wallet's
|
||||
// requireSignatures policy accepted a signed answer, not a rescue
|
||||
expect(
|
||||
mint.signaturesIssued.some(
|
||||
(entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// the note advertised the mint's key, so the first-contact trust prompt
|
||||
// opened - dismiss it, then close the success screen
|
||||
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
|
||||
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
|
||||
});
|
||||
@@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => {
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||
await mint.mockMutationEndpoint();
|
||||
await createFreshWallet(page);
|
||||
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
await redeemNote(page, freshNoteUrl());
|
||||
@@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => {
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
|
||||
await mint.mockMutationEndpoint();
|
||||
await createFreshWallet(page);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
fromBech32Lnurl,
|
||||
noteDeclaredAmount,
|
||||
noteK1,
|
||||
noteSignature,
|
||||
verifyNoteSignature,
|
||||
} from 'lnurlcash-kit';
|
||||
import { MINT_ORIGIN } from '../helpers/MintMocker';
|
||||
import { fundMockMintWallet } from '../helpers/wallet';
|
||||
|
||||
const FUNDING_MSAT = 50_000; // 50 sats
|
||||
const SEND_MSAT = 21_000; // 21 sats
|
||||
|
||||
// give the wallet a verified, spendable 50-sat note at the mock mint, with
|
||||
// the mint's signing key pinned through the first-contact trust prompt
|
||||
const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
|
||||
await fundMockMintWallet(page, mint, FUNDING_MSAT);
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
};
|
||||
|
||||
// prepare a note hand-over and read the displayed note off the clipboard
|
||||
const prepareNote = async (page: Page, sats: string): Promise<string> => {
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
|
||||
await dialog.getByLabel('Amount (sats)').fill(sats);
|
||||
await dialog.getByRole('button', { name: 'Prepare note' }).click();
|
||||
await expect(dialog.getByText('Tap to reveal')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Copy' }).click();
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
};
|
||||
|
||||
test.describe('Send a note', () => {
|
||||
test('a carved note carries a signature that verifies against the mint', async ({
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await fundMockMint(page, mint);
|
||||
|
||||
// the mint signed the split's two outputs (the carved note and the
|
||||
// change), and the note the dialog hands over carries that signature
|
||||
// in its URL
|
||||
const beforeSend = mint.signaturesIssued.length;
|
||||
const bech32 = await prepareNote(page, '21');
|
||||
const issued = mint.signaturesIssued
|
||||
.slice(beforeSend)
|
||||
.map((entry) => entry.amountMsat)
|
||||
.sort((a, b) => a - b);
|
||||
expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]);
|
||||
const url = fromBech32Lnurl(bech32.trim());
|
||||
expect(url).not.toBeNull();
|
||||
if (!url) throw new Error('the dialog handed over an undecodable note');
|
||||
const k1 = noteK1(url);
|
||||
const amountMsat = noteDeclaredAmount(url);
|
||||
const signature = noteSignature(url);
|
||||
expect(k1).not.toBeNull();
|
||||
expect(amountMsat).toBe(SEND_MSAT);
|
||||
expect(signature).not.toBeNull();
|
||||
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
|
||||
// the offline proof verifies against the key the mint advertised and
|
||||
// the wallet pinned - this is what marked the carved note spendable
|
||||
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true);
|
||||
|
||||
// keep the note: nothing was handed over, the balance is untouched
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
|
||||
await dialog.getByRole('button', { name: 'Keep in wallet' }).click();
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
});
|
||||
|
||||
test('outputs the mint signs with a foreign key never become spendable', async ({
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await fundMockMint(page, mint);
|
||||
|
||||
// the mint's mutations land but are signed by a key it never advertised
|
||||
mint.invalidSignatures = true;
|
||||
const bech32 = await prepareNote(page, '21');
|
||||
|
||||
// the note exists (the split landed mint-side), but its offline proof
|
||||
// verifies against no key this mint publishes
|
||||
const url = fromBech32Lnurl(bech32.trim());
|
||||
if (!url) throw new Error('the dialog handed over an undecodable note');
|
||||
const k1 = noteK1(url);
|
||||
const amountMsat = noteDeclaredAmount(url);
|
||||
const signature = noteSignature(url);
|
||||
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
|
||||
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false);
|
||||
|
||||
// keep the note: the balance is not corrupted by the bad proof
|
||||
const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' });
|
||||
await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click();
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
|
||||
// the carve's outputs stayed unverified, so the wallet refuses to spend
|
||||
// from them - a second preparation fails instead of moving bad notes
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const retry = page.locator('.q-dialog', { hasText: 'Send a note' });
|
||||
await retry.getByLabel('Amount (sats)').fill('21');
|
||||
await retry.getByRole('button', { name: 'Prepare note' }).click();
|
||||
await expect(
|
||||
retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }),
|
||||
).toBeVisible();
|
||||
await expect(retry.getByText('Tap to reveal')).toHaveCount(0);
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise<
|
||||
|
||||
const ownerId = async (page: Page): Promise<string | null> =>
|
||||
page.evaluate(() => {
|
||||
const raw = localStorage.getItem('sattle_linking_key');
|
||||
const raw = localStorage.getItem('sattle_wallet_material_v2');
|
||||
if (raw === null) return null;
|
||||
const saved: unknown = JSON.parse(raw);
|
||||
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null;
|
||||
|
||||
@@ -1,215 +1,95 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
||||
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet
|
||||
// with ownerless passkey/NWC/trust residue must migrate to the proven owner
|
||||
// during password unlock - before the NWC service may start - and forgetting
|
||||
// the wallet must drain/stop that service and remove every wallet-owned key
|
||||
// before a successor wallet can be created without any of the old residue.
|
||||
//
|
||||
// The legacy encrypted record is produced here with the exact same KDF/wrap
|
||||
// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the
|
||||
// owner marker a current build would stamp.
|
||||
// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
|
||||
// install - the linking-key-only record, the pre-v2 bearer store and their
|
||||
// ownerless residue - is never migrated (the cash root is unrecoverable
|
||||
// from a linking key), so boot resets the device to the uninstalled state
|
||||
// before any route can expose it, and a successor wallet starts without
|
||||
// any of the old residue.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
|
||||
}
|
||||
}
|
||||
|
||||
const PASSWORD = 'correct horse battery staple';
|
||||
const LINKING_KEY_HEX = '07'.repeat(32);
|
||||
const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true));
|
||||
const MINT_PUBKEY = '02' + 'aa'.repeat(32);
|
||||
|
||||
function hexToBytesLocal(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const PBKDF2_ITERATIONS = 210_000;
|
||||
|
||||
const legacyEncryptedRecord = async (
|
||||
valueHex: string,
|
||||
password: string,
|
||||
): Promise<{ salt: string; iv: string; ciphertext: string }> => {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
aesKey,
|
||||
new TextEncoder().encode(valueHex),
|
||||
),
|
||||
);
|
||||
return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
|
||||
};
|
||||
|
||||
const WALLET_KEYS = [
|
||||
// every namespace the alpha wallet could have touched
|
||||
const LEGACY_KEYS = [
|
||||
'sattle_linking_key',
|
||||
'sattle_bearers',
|
||||
'sattle_activity',
|
||||
'sattle_settings',
|
||||
'sattle_wallet_material_v2',
|
||||
'sattle_passkey_slots',
|
||||
'sattle_nwc_connections',
|
||||
'sattle_nwc_enabled',
|
||||
'sattle_trusted_mints',
|
||||
'sattle_biometric_wrap',
|
||||
'sattle_activity',
|
||||
'sattle_settings',
|
||||
] as const;
|
||||
|
||||
test.describe('wallet lifecycle', () => {
|
||||
test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({
|
||||
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue
|
||||
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD);
|
||||
await page.addInitScript(
|
||||
({ storedRecord, mintPubkey }) => {
|
||||
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord }));
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([
|
||||
{
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
{
|
||||
clientPubkey: '55'.repeat(32),
|
||||
relays: ['wss://relay.example'],
|
||||
budget: { maxMsat: 1000, periodMs: 60_000 },
|
||||
spent: { periodStart: 0, msat: 0 },
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem('sattle_nwc_enabled', 'true');
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]),
|
||||
);
|
||||
},
|
||||
{ storedRecord: record, mintPubkey: MINT_PUBKEY },
|
||||
);
|
||||
// Given a legacy encrypted linking-key record plus ownerless residue
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: true,
|
||||
salt: '11'.repeat(16),
|
||||
iv: '22'.repeat(12),
|
||||
ciphertext: '33'.repeat(48),
|
||||
}),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_bearers',
|
||||
JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([
|
||||
{
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
{
|
||||
clientPubkey: '55'.repeat(32),
|
||||
relays: ['wss://relay.example'],
|
||||
budget: { maxMsat: 1000, periodMs: 60_000 },
|
||||
spent: { periodStart: 0, msat: 0 },
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem('sattle_nwc_enabled', 'true');
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify([
|
||||
{
|
||||
server: 'legacy.example',
|
||||
mintPubkey: `02${'aa'.repeat(32)}`,
|
||||
addedAt: 1,
|
||||
locked: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// When the app boots
|
||||
await page.goto('/#/');
|
||||
|
||||
// the unlock screen proves the app boot finished and the dev hooks exist
|
||||
await expect(page.getByText('Wallet locked')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => typeof window.__sattleNwcTest))
|
||||
.toBe('object');
|
||||
|
||||
// no real relay traffic once the migrated enabled state starts the service
|
||||
await page.evaluate(() => {
|
||||
window.__nwcSubs = [];
|
||||
window.__sattleNwcTest.setTransport({
|
||||
publish: () => Promise.resolve(),
|
||||
subscribe: () => {
|
||||
const sub = {
|
||||
closed: false,
|
||||
close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
window.__nwcSubs.push(sub);
|
||||
return sub;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// When the holder proves the wallet by password
|
||||
await page.locator('.unlock-card input').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
|
||||
|
||||
// Then the wallet unlocks...
|
||||
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
|
||||
|
||||
// ...and every legacy namespace was migrated to the proven owner BEFORE
|
||||
// the NWC service could start (the subscriptions below only exist because
|
||||
// the migrated owner-scoped enabled record read true)
|
||||
const migrated = await page.evaluate(() => {
|
||||
const field = (storageKey: string, name: string): unknown => {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
|
||||
return typeof value === 'object' && value !== null
|
||||
? Object.entries(value).find(([key]) => key === name)?.[1]
|
||||
: undefined;
|
||||
};
|
||||
const fields = (storageKey: string, name: string): unknown[] => {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry: unknown) =>
|
||||
typeof entry === 'object' && entry !== null
|
||||
? Object.entries(entry).find(([key]) => key === name)?.[1]
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
const mints: unknown = field('sattle_trusted_mints', 'mints');
|
||||
return {
|
||||
savedKeyOwner: field('sattle_linking_key', 'ownerId'),
|
||||
passkeyOwners: fields('sattle_passkey_slots', 'ownerId'),
|
||||
nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'),
|
||||
nwcEnabled: field('sattle_nwc_enabled', 'enabled'),
|
||||
nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'),
|
||||
trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'),
|
||||
trustedMintServers: Array.isArray(mints)
|
||||
? mints.map((mint: unknown) =>
|
||||
typeof mint === 'object' && mint !== null && 'server' in mint
|
||||
? mint.server
|
||||
: undefined,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
});
|
||||
expect(migrated.savedKeyOwner).toBe(OWNER_ID);
|
||||
expect(migrated.passkeyOwners).toEqual([OWNER_ID]);
|
||||
expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({
|
||||
ownerId: OWNER_ID,
|
||||
enabled: true,
|
||||
});
|
||||
expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]);
|
||||
expect(migrated.trustedMintsOwner).toBe(OWNER_ID);
|
||||
expect(migrated.trustedMintServers).toEqual(['legacy.example']);
|
||||
await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0);
|
||||
|
||||
// When the wallet is forgotten
|
||||
await page.evaluate(() => window.__sattleWalletTest.forget());
|
||||
|
||||
// Then the app lands on the no-wallet screen, the service is drained
|
||||
// (every subscription closed), and no wallet-owned key remains
|
||||
// Then the alpha reset landed on the uninstalled state - no unlock
|
||||
// surface for a wallet that can never be derived again
|
||||
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
|
||||
.toBe(true);
|
||||
await expect(page.getByText('Wallet locked')).toHaveCount(0);
|
||||
|
||||
// ...and every namespace the alpha wallet could have touched is gone
|
||||
const remaining = await page.evaluate(
|
||||
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
|
||||
[...WALLET_KEYS],
|
||||
[...LEGACY_KEYS],
|
||||
);
|
||||
expect(remaining).toEqual([]);
|
||||
|
||||
@@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => {
|
||||
await page.getByRole('button', { name: 'Create wallet' }).click();
|
||||
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
|
||||
|
||||
// Then it is a different owner with zero adopted residue
|
||||
const successor = await page.evaluate(() => {
|
||||
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null');
|
||||
return {
|
||||
ownerId:
|
||||
typeof saved === 'object' && saved !== null && 'ownerId' in saved
|
||||
? saved.ownerId
|
||||
: undefined,
|
||||
residueKeys: [
|
||||
'sattle_passkey_slots',
|
||||
'sattle_nwc_connections',
|
||||
'sattle_nwc_enabled',
|
||||
'sattle_trusted_mints',
|
||||
].filter((key) => localStorage.getItem(key) !== null),
|
||||
};
|
||||
});
|
||||
expect(successor.ownerId).not.toBe(OWNER_ID);
|
||||
expect(successor.residueKeys).toEqual([]);
|
||||
// Then it unlocks into a working wallet with zero adopted residue
|
||||
// (settings/activity are the live app's own namespaces - the residue
|
||||
// check covers only the stores a predecessor could have planted)
|
||||
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
|
||||
const residue = await page.evaluate(
|
||||
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
|
||||
[
|
||||
'sattle_linking_key',
|
||||
'sattle_bearers',
|
||||
'sattle_passkey_slots',
|
||||
'sattle_nwc_connections',
|
||||
'sattle_nwc_enabled',
|
||||
'sattle_trusted_mints',
|
||||
'sattle_biometric_wrap',
|
||||
],
|
||||
);
|
||||
expect(residue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user