mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
182 lines
7.2 KiB
TypeScript
182 lines
7.2 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('service ownership', () => {
|
|
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
|
|
const current = createConnection(LINKING_KEY, {
|
|
relays: RELAYS,
|
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
|
clientSecret: CLIENT_SECRET,
|
|
now: 0,
|
|
});
|
|
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
|
|
storeForeignConnection(foreign.record);
|
|
const relay = createFakeRelay();
|
|
|
|
const service = await startService(LINKING_KEY, {
|
|
assertCurrentOwner: () => undefined,
|
|
allocateOutputSecrets: () => Promise.resolve([]),
|
|
mintSignatureKeys: () => [],
|
|
recoverPendingMints: () => Promise.resolve(),
|
|
reserveMintOutputSource: () => Promise.resolve(),
|
|
finalizePaymentReturn: () => Promise.resolve(),
|
|
getBearers: () => [],
|
|
getDefaultMint: () => null,
|
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
|
commitCarve: () => Promise.reject(new Error('unused')),
|
|
applyChangeset: () => Promise.resolve(),
|
|
transport: relay.transport,
|
|
nowSeconds,
|
|
});
|
|
|
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
|
|
expect(relay.subscriptionCount()).toBe(1);
|
|
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
|
|
expect(service.connections).not.toContainEqual(foreign);
|
|
await service.stop();
|
|
});
|
|
|
|
it('ignores foreign records handed in through the records snapshot', async () => {
|
|
// the injected-records path bypasses storage, so the service's own
|
|
// owner filter is the only boundary here (stale-ownership probe: a
|
|
// snapshot from a previous wallet must not be served)
|
|
const current = createConnection(LINKING_KEY, {
|
|
relays: RELAYS,
|
|
budget: { maxMsat: 1000, periodMs: 1000 },
|
|
clientSecret: CLIENT_SECRET,
|
|
now: 0,
|
|
});
|
|
const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
|
|
const relay = createFakeRelay();
|
|
|
|
const service = await startService(
|
|
LINKING_KEY,
|
|
{
|
|
assertCurrentOwner: () => undefined,
|
|
allocateOutputSecrets: () => Promise.resolve([]),
|
|
mintSignatureKeys: () => [],
|
|
recoverPendingMints: () => Promise.resolve(),
|
|
reserveMintOutputSource: () => Promise.resolve(),
|
|
finalizePaymentReturn: () => Promise.resolve(),
|
|
getBearers: () => [],
|
|
getDefaultMint: () => null,
|
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
|
commitCarve: () => Promise.reject(new Error('unused')),
|
|
applyChangeset: () => Promise.resolve(),
|
|
transport: relay.transport,
|
|
nowSeconds,
|
|
},
|
|
[current.record, foreign.record],
|
|
);
|
|
|
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
|
|
expect(relay.subscriptionCount()).toBe(1);
|
|
// the foreign snapshot record must not be persisted for the new owner
|
|
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
|
|
await service.stop();
|
|
});
|
|
});
|
|
|
|
describe('service: info and balance', () => {
|
|
it('publishes a kind-13194 info event on startup', async () => {
|
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
|
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
|
|
expect(info.pubkey).toBe(walletServicePubkey);
|
|
expect(info.content).toContain('pay_invoice');
|
|
expect(info.content).toContain('make_invoice');
|
|
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
|
|
await stop();
|
|
});
|
|
|
|
it('answers get_info with the connection identity and method list', async () => {
|
|
const { relay, walletServicePubkey, stop } = await startTestService({});
|
|
const response = await call(relay, walletServicePubkey, 'get_info', {});
|
|
expect(response.error).toBeNull();
|
|
expect(response.result_type).toBe('get_info');
|
|
expect(response.result).toMatchObject({
|
|
alias: 'sattle',
|
|
pubkey: walletServicePubkey,
|
|
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
|
|
});
|
|
await stop();
|
|
});
|
|
|
|
it('answers get_balance with the spendable total only', async () => {
|
|
const m = await mint();
|
|
const { relay, walletServicePubkey, state, stop } = await startTestService({});
|
|
state.bearers = [
|
|
await makeBearer(m, 'aa'.repeat(32), 21_000),
|
|
await makeBearer(m, 'bb'.repeat(32), 5_000),
|
|
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
|
|
];
|
|
const response = await call(relay, walletServicePubkey, 'get_balance', {});
|
|
expect(response.error).toBeNull();
|
|
expect(response.result).toEqual({ balance: 26_000 });
|
|
await stop();
|
|
});
|
|
});
|