mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
// 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);
|
|
}
|
|
});
|
|
});
|