Files
sattle/src/lnurlcash/nwc.pay-a.cases.ts
T

236 lines
8.5 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 {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()
})
})