mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
236 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|