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 type {NwcConnectionRecord} from '../storage/nwcConnections'
|
||||||
import {persistNwcConnection} from '../storage/nwcConnections'
|
import {persistNwcConnection} from '../storage/nwcConnections'
|
||||||
|
|
||||||
export const budgetRemainingMsat = (
|
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
|
||||||
record: NwcConnectionRecord,
|
|
||||||
nowMs: number
|
|
||||||
): number => {
|
|
||||||
const {maxMsat, periodMs} = record.budget
|
const {maxMsat, periodMs} = record.budget
|
||||||
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
|
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat
|
||||||
return Math.max(0, maxMsat - record.spent.msat)
|
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
|
// rolls the period when it expired, then adds the spend; persists (the
|
||||||
// caller's queue serialized this read-modify-write)
|
// caller's queue serialized this read-modify-write)
|
||||||
export const recordSpend = (
|
export const recordSpend = (
|
||||||
|
ownerId: string,
|
||||||
record: NwcConnectionRecord,
|
record: NwcConnectionRecord,
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
nowMs: number
|
nowMs: number,
|
||||||
): NwcConnectionRecord => {
|
): NwcConnectionRecord => {
|
||||||
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
|
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
|
||||||
return persistNwcConnection({
|
return persistNwcConnection(ownerId, {
|
||||||
...record,
|
...record,
|
||||||
spent: expired
|
spent: expired
|
||||||
? {periodStart: nowMs, msat: amountMsat}
|
? {periodStart: nowMs, msat: amountMsat}
|
||||||
: {
|
: {
|
||||||
periodStart: record.spent.periodStart,
|
periodStart: record.spent.periodStart,
|
||||||
msat: record.spent.msat + amountMsat
|
msat: record.spent.msat + amountMsat,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user