mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: preserve conservative NWC budget debits
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -8,10 +8,7 @@
|
||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
||||
import {persistNwcConnection} from '../storage/nwcConnections'
|
||||
|
||||
export const budgetRemainingMsat = (
|
||||
record: NwcConnectionRecord,
|
||||
nowMs: number
|
||||
): number => {
|
||||
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
|
||||
const {maxMsat, periodMs} = record.budget
|
||||
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
|
||||
return Math.max(0, maxMsat - record.spent.msat)
|
||||
@@ -20,18 +17,19 @@ export const budgetRemainingMsat = (
|
||||
// rolls the period when it expired, then adds the spend; persists (the
|
||||
// caller's queue serialized this read-modify-write)
|
||||
export const recordSpend = (
|
||||
ownerId: string,
|
||||
record: NwcConnectionRecord,
|
||||
amountMsat: number,
|
||||
nowMs: number
|
||||
nowMs: number,
|
||||
): NwcConnectionRecord => {
|
||||
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
|
||||
return persistNwcConnection({
|
||||
return persistNwcConnection(ownerId, {
|
||||
...record,
|
||||
spent: expired
|
||||
? {periodStart: nowMs, msat: amountMsat}
|
||||
: {
|
||||
periodStart: record.spent.periodStart,
|
||||
msat: record.spent.msat + amountMsat
|
||||
}
|
||||
msat: record.spent.msat + amountMsat,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user