feat: nip-47 nwc wallet service engine with per-connection budgets

This commit is contained in:
2026-08-20 01:20:41 +02:00
parent 34c97708f8
commit 33318a5150
12 changed files with 2201 additions and 0 deletions
+816
View File
@@ -0,0 +1,816 @@
// 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,
createConnection,
deriveNwcWalletKey,
parseConnectionString,
readNwcConnections,
startService,
writeNwcConnections
} from './nwc'
import type {NostrEvent, NwcConnectionRecord, NwcTransport} from './nwc'
import type {NostrFilter} from './nwc/transport'
import type {NwcChangeset} from './nwc'
import type {Bearer} from './types'
import {stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const CLIENT_SECRET = hexToBytes('11'.repeat(32))
const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET)
const STRANGER_SECRET = hexToBytes('22'.repeat(32))
// never connected - the in-memory relay below stands in
const RELAYS = ['wss://relay-a.example']
let NOW = 1_800_000_000
const nowSeconds = (): number => NOW
const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
// an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records
const createFakeRelay = (): {
transport: NwcTransport
published: NostrEvent[]
emit: (event: NostrEvent) => void
} => {
const published: NostrEvent[] = []
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = []
const transport: NwcTransport = {
publish: (_relays, event) => {
published.push(event)
return Promise.resolve()
},
subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent}
subs.push(sub)
return {
close: () => {
const index = subs.indexOf(sub)
if (index >= 0) subs.splice(index, 1)
}
}
}
}
const emit = (event: NostrEvent): void => {
for (const sub of [...subs]) {
const kindsMatch =
!sub.filter.kinds || sub.filter.kinds.includes(event.kind)
const wanted = sub.filter['#p']
const pMatch =
!wanted ||
event.tags.some(t => t[0] === 'p' && wanted.includes(t[1] ?? ''))
const sinceMatch =
sub.filter.since === undefined || event.created_at >= sub.filter.since
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event)
}
}
return {transport, published, emit}
}
type Encryption = 'nip44_v2' | 'nip04' | 'none'
// a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret
const clientRequest = (
walletServicePubkey: string,
content: string,
scheme: Encryption = 'nip44_v2',
createdAt: number = NOW
): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]]
if (scheme !== 'none') tags.push(['encryption', scheme])
return finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: createdAt,
tags,
content:
scheme === 'nip44_v2'
? nip44v2.encrypt(
content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey)
)
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content)
},
CLIENT_SECRET
)
}
const methodRequest = (
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2',
createdAt?: number
): NostrEvent =>
clientRequest(
walletServicePubkey,
JSON.stringify({method, params}),
scheme,
createdAt
)
// generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out
const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) {
await new Promise(resolve => setTimeout(resolve, 5))
}
expect(cond()).toBe(true)
}
type NwcResponsePayload = {
result_type: string
error: {code: string; message: string} | null
result: Record<string, unknown> | null
}
const readResponse = (
published: NostrEvent[],
requestId: string,
scheme: Encryption
): NwcResponsePayload | null => {
const event = published.find(
e =>
e.kind === NWC_RESPONSE_KIND &&
e.tags.some(t => t[0] === 'e' && t[1] === requestId)
)
if (!event) return null
const plaintext =
scheme === 'nip44_v2'
? nip44v2.decrypt(
event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey)
)
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content)
return JSON.parse(plaintext) as NwcResponsePayload
}
// drives one full request/response round trip over the fake relay
const call = async (
relay: ReturnType<typeof createFakeRelay>,
walletServicePubkey: string,
method: string,
params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2'
): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme)
relay.emit(request)
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null)
return readResponse(relay.published, request.id, scheme)!
}
type Mint = Awaited<ReturnType<typeof createMockMint>>
const mints: Mint[] = []
const mint = async (
options: Parameters<typeof createMockMint>[0] = {}
): Promise<Mint> => {
const m = await createMockMint(options)
mints.push(m)
return m
}
afterEach(async () => {
await Promise.all(mints.splice(0).map(m => m.close()))
})
let bearerCounter = 0
const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat)
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
const info = await fetchNoteInfo(url)
bearerCounter += 1
return {
id: `bearer-${bearerCounter}`,
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: m.state.pubkey,
createdAt: Date.now(),
updatedAt: Date.now()
}
}
// the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret
const startTestService = async (options: {
budgetMsat?: number
periodMs?: number
defaultMint?: string | null
linkingKey?: Uint8Array
poll?: typeof FAST_POLL
}): Promise<{
relay: ReturnType<typeof createFakeRelay>
walletServicePubkey: string
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]}
stop: () => void
}> => {
const budgetMsat = options.budgetMsat ?? 1_000_000_000
const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000},
clientSecret: CLIENT_SECRET,
now: NOW * 1000
})
const relay = createFakeRelay()
const state = {
bearers: [] as Bearer[],
changesets: [] as NwcChangeset[],
errors: [] as unknown[]
}
const service = await startService(options.linkingKey ?? LINKING_KEY, {
getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null,
applyChangeset: (changeset: NwcChangeset) => {
state.changesets.push(changeset)
for (const id of changeset.markSpent) {
const bearer = state.bearers.find(b => b.id === id)
if (bearer) bearer.spent = true
}
for (const note of changeset.add) {
bearerCounter += 1
state.bearers.push({
...note,
id: `added-${bearerCounter}`,
createdAt: Date.now(),
updatedAt: Date.now()
})
}
},
onError: err => {
state.errors.push(err)
},
transport: relay.transport,
poll: options.poll ?? FAST_POLL,
claimPoll: FAST_POLL,
nowSeconds
})
return {
relay,
walletServicePubkey: connection.walletServicePubkey,
state,
stop: service.stop
}
}
beforeEach(() => {
stubLocalStorage()
NOW = 1_800_000_000
})
describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString(
'ab'.repeat(32),
'cd'.repeat(32),
['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
)
expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`
)
expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1']
})
})
it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull()
expect(parseConnectionString('https://example.com')).toBeNull()
// missing secret
expect(
parseConnectionString(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`
)
).toBeNull()
// missing relay
expect(
parseConnectionString(
`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`
)
).toBeNull()
// a non-hex pubkey
expect(
parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32)
)
).toBeNull()
})
it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
clientSecret: CLIENT_SECRET
})
const parsed = parseConnectionString(connection.connectionString)
expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32),
relays: RELAYS
})
// the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored
const records = readNwcConnections()
expect(records).toHaveLength(1)
expect(records[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32))
})
})
describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d'
)
expect(
getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
).toBe('bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf')
})
it('derives a distinct key per client and per linking key', () => {
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))
expect(
bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))
).not.toBe(base)
expect(
bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))
).not.toBe(base)
})
it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, {
relays: RELAYS,
budget: {maxMsat: 100_000, periodMs: 86_400_000},
clientSecret: CLIENT_SECRET
})
expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf'
)
})
})
describe('storage validation', () => {
it('drops malformed records instead of throwing', () => {
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{clientPubkey: 'nope'},
{
clientPubkey: CLIENT_PUBKEY,
relays: RELAYS,
budget: {maxMsat: 1000, periodMs: 1000},
spent: {periodStart: 0, msat: 0},
createdAt: 0
}
])
)
expect(readNwcConnections()).toHaveLength(1)
expect(readNwcConnections()[0]!.clientPubkey).toBe(CLIENT_PUBKEY)
})
it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{')
expect(readNwcConnections()).toEqual([])
})
})
describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const info = relay.published.find(e => e.kind === NWC_INFO_KIND)
expect(info).toBeDefined()
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'])
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'
]
})
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})
stop()
})
})
describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'get_payments', {})
expect(response.result_type).toBe('get_payments')
expect(response.error?.code).toBe('NOT_IMPLEMENTED')
expect(response.result).toBeNull()
stop()
})
it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, 'this is not json')
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
)
const response = readResponse(relay.published, request.id, 'nip44_v2')!
expect(response.error?.code).toBe('OTHER')
stop()
})
it('answers a JSON request without a method with an error', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}}))
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip44_v2') !== null
)
expect(readResponse(relay.published, request.id, 'nip44_v2')!.error?.code).toBe(
'OTHER'
)
stop()
})
it('ignores a request signed by a stranger key - silently', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const content = nip44v2.encrypt(
JSON.stringify({method: 'get_balance', params: {}}),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey)
)
const forged = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: NOW,
tags: [['p', walletServicePubkey], ['encryption', 'nip44_v2']],
content
},
STRANGER_SECRET
)
relay.emit(forged)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
stop()
})
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const content = nip04Encrypt(
CLIENT_SECRET,
walletServicePubkey,
JSON.stringify({method: 'get_balance', params: {}})
)
const request = finalizeEvent(
{
kind: NWC_REQUEST_KIND,
created_at: NOW,
tags: [['p', walletServicePubkey], ['encryption', 'nip17']],
content
},
CLIENT_SECRET
)
relay.emit(request)
// the error answer goes out in the legacy scheme every client reads
await waitFor(
() => readResponse(relay.published, request.id, 'nip04') !== null
)
expect(
readResponse(relay.published, request.id, 'nip04')!.error?.code
).toBe('UNSUPPORTED_ENCRYPTION')
stop()
})
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme)
relay.emit(request)
await waitFor(
() => readResponse(relay.published, request.id, 'nip04') !== null
)
const response = readResponse(relay.published, request.id, 'nip04')!
expect(response.error).toBeNull()
expect(response.result).toEqual({balance: 0})
// the response mirrors the request's scheme
const event = relay.published.find(
e =>
e.kind === NWC_RESPONSE_KIND &&
e.tags.some(t => t[0] === 'e' && t[1] === request.id)
)!
expect(event.tags).toContainEqual(['encryption', 'nip04'])
}
stop()
})
it('drops requests older than the replay window unanswered', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const stale = methodRequest(
walletServicePubkey,
'get_balance',
{},
'nip44_v2',
NOW - 1200
)
relay.emit(stale)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
stop()
})
it('picks up no new requests after stop', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
stop()
const request = methodRequest(walletServicePubkey, 'get_balance', {})
relay.emit(request)
await new Promise(resolve => setTimeout(resolve, 100))
expect(
relay.published.filter(e => e.kind === NWC_RESPONSE_KIND)
).toHaveLength(0)
})
})
describe('service: pay_invoice', () => {
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
})
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(typeof response.result?.preimage).toBe('string')
expect((response.result?.preimage as string).length).toBe(64)
// the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned')
expect(state.bearers[0]!.spent).toBe(true)
// the spend was recorded against the budget, persisted
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
stop()
})
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
budgetMsat: 20_000
})
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
// nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
expect(state.bearers[0]!.spent).toBeUndefined()
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
stop()
})
it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({
budgetMsat: 21_000,
periodMs: 60_000
})
// simulate a fully spent budget from a period that ended long ago
const record: NwcConnectionRecord = readNwcConnections()[0]!
writeNwcConnections([
{...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}}
])
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error).toBeNull()
expect(readNwcConnections()[0]!.spent.msat).toBe(21_000)
stop()
})
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint()
const {relay, walletServicePubkey, state, stop} = await startTestService({})
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
stop()
})
it('rejects a request amount that mismatches the invoice amount', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/match/i)
stop()
})
it('rejects an amount-less invoice instead of guessing', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('OTHER')
expect(response.error?.message).toMatch(/amount/i)
stop()
})
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({meltAlwaysFails: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
// a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}
})
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz'
})
expect(response.error?.code).toBe('PAYMENT_FAILED')
// the funds came back, re-secured: the old secret burned, a fresh one
// tracked unspent via the changeset - and no budget spend recorded
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
const returned = state.bearers.find(b => b.id.startsWith('added-'))
expect(returned).toBeDefined()
expect(returned!.spent).toBeUndefined()
expect(returned!.amount).toBe(21_000)
expect(m.state.noteState(noteK1(returned!.url)!)).toBe('outstanding')
expect(readNwcConnections()[0]!.spent.msat).toBe(0)
stop()
})
})
describe('service: make_invoice / lookup_invoice', () => {
it('issues an invoice, settles it in the background, and reports the preimage', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
description: 'nwc test',
expiry: 3600
})
expect(made.error).toBeNull()
expect(made.result).toMatchObject({
type: 'incoming',
state: 'pending',
amount: 21_000,
description: 'nwc test',
created_at: NOW,
expires_at: NOW + 3600
})
const invoice = made.result!.invoice as string
const paymentHash = made.result!.payment_hash as string
expect(invoice).toMatch(/^lnbc/)
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
// before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash
})
expect(pending.error).toBeNull()
expect(pending.result?.state).toBe('pending')
expect(pending.result?.preimage).toBeUndefined()
// the "payer" pays the invoice; the background claim settles and
// mints the note
const settleRes = await fetch(
`${m.url}/_test/settle?payment_hash=${paymentHash}`
)
expect(settleRes.ok).toBe(true)
await waitFor(() =>
state.changesets.some(c => c.add.length > 0)
)
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
expect(settled.result?.settled_at).toBe(NOW)
const preimage = settled.result?.preimage as string
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
// the minted note was claimed AND rotated before settlement was
// recorded: the preimage the client just learned is a burned secret,
// and the wallet's fresh note is the only live one
expect(m.state.noteState(preimage)).toBe('burned')
const minted = state.bearers.find(b => b.id.startsWith('added-'))!
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
expect(noteK1(minted.url)).not.toBe(preimage)
expect(m.state.noteState(noteK1(minted.url)!)).toBe('outstanding')
stop()
})
it('finds an invoice by its invoice string too', async () => {
const m = await mint({testHooks: true})
const {relay, walletServicePubkey, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000
})
const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: (made.result!.invoice as string).toUpperCase()
})
expect(found.error).toBeNull()
expect(found.result?.payment_hash).toBe(made.result!.payment_hash)
stop()
})
it('answers an unknown invoice with NOT_FOUND', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32)
})
expect(response.error?.code).toBe('NOT_FOUND')
stop()
})
it('answers make_invoice without a default mint with INTERNAL', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({
defaultMint: null
})
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000
})
expect(response.error?.code).toBe('INTERNAL')
stop()
})
it('answers a make_invoice with a bad amount with OTHER', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({})
const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5
})
expect(response.error?.code).toBe('OTHER')
stop()
})
})
+97
View File
@@ -0,0 +1,97 @@
// NWC (Nostr Wallet Connect, NIP-47): the wallet-SERVICE side. Clients
// (Alby-style apps) hold a connection string and talk to this wallet over
// public relays with end-to-end-encrypted request/response events; this
// engine validates, decrypts, dispatches onto the ops engine and answers.
//
// FOREGROUND-ONLY, by design: the service runs only while the app is open
// and the wallet unlocked (the wallet-service keys derive from the linking
// key, which only exists in memory then). There is no background runner
// and no push channel: requests sent while the app is closed wait on the
// relay, and any request older than ten minutes when it finally arrives
// is DROPPED UNANSWERED rather than executed late (a pay_invoice executed
// after the client gave up could double-pay). An always-on sattle would
// need a headless signer holding a derived key - deliberately out of
// scope for the PWA.
//
// Framework-free, and no WebSocket is touched at import time: the default
// transport (nostr-tools' SimplePool) is imported lazily on first use,
// and tests inject a fake transport instead. nostr-tools was chosen over
// @getalby/sdk on purpose: the wallet-service side needs exactly event
// signing, NIP-44/NIP-04 crypto and a subscription - all already pinned -
// and the sdk's own websocket management would fight this project's
// injectable-transport pattern.
//
// Split by concern; this façade re-exports everything:
// nwc/connection.ts - createConnection, the deterministic
// wallet-service key derivation, and the
// connection-string codec
// nwc/protocol.ts - the pure NIP-47 event codec (validate/decrypt,
// build/encrypt), no I/O
// nwc/transport.ts - the injectable relay transport
// nwc/context.ts - the shared deps/changeset/invoice-registry types
// nwc/budget.ts - the per-connection rolling budget
// nwc/invoices.ts - the two-phase invoice registry + background claim
// nwc/pay.ts - pay_invoice (budget-first melt)
// nwc/dispatch.ts - the remaining method handlers + the dispatch switch
// nwc/service.ts - startService/stopService: subscriptions and the
// per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts.
export type {
NwcBudget,
NwcBudgetSpend,
NwcConnectionRecord
} from './storage/nwcConnections'
export {
persistNwcConnection,
readNwcConnections,
removeNwcConnection,
writeNwcConnections
} from './storage/nwcConnections'
export {
buildConnectionString,
connectionInfoOf,
createConnection,
deriveNwcWalletKey,
nwcWalletPubkey,
parseConnectionString
} from './nwc/connection'
export type {
CreateConnectionOptions,
CreatedConnection,
NwcConnectionInfo,
ParsedConnectionString
} from './nwc/connection'
export {
NWC_INFO_KIND,
NWC_METHODS,
NWC_REQUEST_KIND,
NWC_RESPONSE_KIND,
buildInfoEvent,
buildResponseEvent,
decryptRequest,
errResult,
okResult
} from './nwc/protocol'
export type {
DecryptedNwcRequest,
NostrEvent,
NwcEncryption,
NwcErrorCode,
NwcMethod,
NwcRequest,
NwcResponse
} from './nwc/protocol'
export {defaultNwcTransport} from './nwc/transport'
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport'
export {budgetRemainingMsat, recordSpend} from './nwc/budget'
export type {PendingInvoice, RequestContext} from './nwc/context'
export {invoiceResult, resolvePaymentHash} from './nwc/invoices'
export {payChangeset} from './nwc/pay'
export {startService} from './nwc/service'
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service'
+37
View File
@@ -0,0 +1,37 @@
// The per-connection NWC budget: max msat per rolling period, persisted
// in storage/nwcConnections so a restart (or a browser crash mid-session)
// doesn't reset a client's allowance. The service serializes pay_invoice
// per connection through its request queue, which is what makes the
// check + record below atomic enough: no two pays of one connection ever
// run this concurrently.
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
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)
}
// rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write)
export const recordSpend = (
record: NwcConnectionRecord,
amountMsat: number,
nowMs: number
): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs
return persistNwcConnection({
...record,
spent: expired
? {periodStart: nowMs, msat: amountMsat}
: {
periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat
}
})
}
+157
View File
@@ -0,0 +1,157 @@
// NWC connections: the model half of the NIP-47 wallet service - creating
// connections, deriving their keys, and the connection-string codec. No
// relay I/O here; the runtime lives in service.ts.
//
// Key material, per connection:
// - the CLIENT keypair: a fresh random secret, handed to the client app
// exactly once inside the connection string. The wallet stores only its
// pubkey (NIP-47: the wallet service should not store the secret it
// generates for the client) - a leaked device backup then can't
// impersonate a client, and a lost connection string simply means
// creating a new connection.
// - the WALLET-SERVICE keypair: derived, not generated:
// sha256(linking key || context || client pubkey), the same construction
// as nostr/events.ts's deriveBackupKey under a different context string.
// Deterministic derivation is what lets a restored seed serve its old
// connections again without any extra backup: the persisted record
// (client pubkey, relays, budget) re-yields the identical wallet key, so
// the client keeps talking to the same wallet-service pubkey.
//
// The connection string follows NIP-47 exactly:
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPublicKey} from 'nostr-tools/pure'
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} from '../storage/nwcConnections'
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1'
const HEX_64 = /^[0-9a-f]{64}$/i
// Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's
// wallet-service identity - it signs and decrypts NIP-47 events for this
// one client, nothing else.
export const deriveNwcWalletKey = (
linkingPrivKey: Uint8Array,
clientPubkey: string
): Uint8Array =>
sha256(
new Uint8Array([
...linkingPrivKey,
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey)
])
)
// the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
getPublicKey(walletSecretKey)
export type NwcConnectionInfo = {
record: NwcConnectionRecord
walletServicePubkey: string
}
// the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity
export const connectionInfoOf = (
linkingPrivKey: Uint8Array,
record: NwcConnectionRecord
): NwcConnectionInfo => ({
record,
walletServicePubkey: nwcWalletPubkey(
deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
)
})
export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store
connectionString: string
}
export type CreateConnectionOptions = {
relays: string[]
budget: NwcBudget
// test hook: a fixed client secret (32 bytes) instead of a random one
clientSecret?: Uint8Array
now?: number
}
// Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above.
export const createConnection = (
linkingPrivKey: Uint8Array,
options: CreateConnectionOptions
): CreatedConnection => {
if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.')
}
const clientSecret =
options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientPubkey = getPublicKey(clientSecret)
const record = persistNwcConnection({
clientPubkey,
relays: options.relays,
budget: options.budget,
spent: {periodStart: options.now ?? Date.now(), msat: 0},
createdAt: options.now ?? Date.now()
})
const info = connectionInfoOf(linkingPrivKey, record)
return {
...info,
connectionString: buildConnectionString(
info.walletServicePubkey,
bytesToHex(clientSecret),
record.relays
)
}
}
export const buildConnectionString = (
walletServicePubkey: string,
clientSecretHex: string,
relays: string[]
): string => {
const query = relays
.map(relay => `relay=${encodeURIComponent(relay)}`)
.join('&')
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`
}
export type ParsedConnectionString = {
walletServicePubkey: string
clientSecret: string
relays: string[]
}
// parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse)
export const parseConnectionString = (
uri: string
): ParsedConnectionString | null => {
let url: URL
try {
url = new URL(uri.trim())
} catch {
return null
}
if (url.protocol !== 'nostr+walletconnect:') return null
// the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex)
const walletServicePubkey = url.host.toLowerCase()
if (!HEX_64.test(walletServicePubkey)) return null
const secret = url.searchParams.get('secret')
if (!secret || !HEX_64.test(secret)) return null
const relays = url.searchParams
.getAll('relay')
.filter(relay => /^wss?:\/\//.test(relay))
if (relays.length === 0) return null
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
}
+76
View File
@@ -0,0 +1,76 @@
// The shared types of the NWC service runtime: the deps the caller
// provides, the changeset the caller's store applies after an op ran, the
// pending-invoice registry entry, and the per-request context the method
// handlers (dispatch.ts, pay.ts, invoices.ts) operate on. Types only -
// no logic, so every other nwc/ module can import from here without
// cycles.
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import type {PreparedMint} from '../ops'
import type {PollOptions} from '../ops/shared'
import type {NwcConnectionInfo} from './connection'
import type {NwcMethod} from './protocol'
import type {NwcTransport} from './transport'
// the state delta the caller's store applies after an op ran: new notes
// to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state
export type NwcChangeset = {
add: NewBearer[]
markSpent: string[]
}
export type NwcServiceDeps = {
getBearers: () => Bearer[]
// the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null
applyChangeset: (
changeset: NwcChangeset,
connection: NwcConnectionInfo,
method: NwcMethod
) => void
transport?: NwcTransport
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
// verify-poll budget for pay_invoice outcome classification
poll?: PollOptions
// verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it
claimPoll?: PollOptions
// background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here
onError?: (error: unknown, connection: NwcConnectionInfo) => void
// test hook: pinned clock for event timestamps and expirations
nowSeconds?: () => number
}
export type PendingInvoice = {
invoice: string
paymentHash: string
// the gross invoiced amount (net + mint fee) - what the payer pays
amountMsat: number
description?: string
createdAt: number // unix seconds
expiresAt?: number
prepared: PreparedMint
state: 'pending' | 'settled' | 'failed'
preimage?: string
settledAt?: number
}
// what a method handler sees of its connection's runtime: the deps, the
// (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = {
deps: NwcServiceDeps
connection: () => NwcConnectionInfo
updateRecord: (record: NwcConnectionRecord) => void
invoices: Map<string, PendingInvoice>
nowSeconds: () => number
}
+153
View File
@@ -0,0 +1,153 @@
// Method dispatch: get_info / get_balance / make_invoice /
// lookup_invoice live here directly, pay_invoice in pay.ts (it carries
// the budget logic). Bearers always come IN through deps.getBearers and
// results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself.
import {noteK1, sameInvoice} from 'lnurlcash-kit'
import type {Bearer} from '../types'
import type {PreparedMint} from '../ops'
import {prepareMint} from '../ops'
import type {PendingInvoice, RequestContext} from './context'
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
import {handlePayInvoice} from './pay'
import type {NwcRequest, NwcResponse} from './protocol'
import {NWC_METHODS, errResult, okResult} from './protocol'
// the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean =>
!bearer.spent &&
bearer.callback !== '' &&
!bearer.deviceId &&
!!noteK1(bearer.url)
const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', {
alias: 'sattle',
color: '#55ffcc',
pubkey: ctx.connection().walletServicePubkey,
// a bearer-note wallet has no chain view of its own - the network is
// whatever the mints' invoices say, mainnet in practice. No honest
// block height/hash exists, so those fields are simply absent.
network: 'mainnet',
methods: [...NWC_METHODS],
notifications: []
})
const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', {
balance: ctx.deps
.getBearers()
.filter(spendable)
.reduce((sum, b) => sum + b.amount, 0)
})
const handleMakeInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
): Promise<NwcResponse> => {
const amountMsat = Number(params.amount)
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
return errResult(
'make_invoice',
'OTHER',
'Amount must be a positive whole number of msat.'
)
}
const mint = ctx.deps.getDefaultMint()
if (!mint) {
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
}
let prepared: PreparedMint
try {
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
} catch (err) {
return errResult(
'make_invoice',
'INTERNAL',
err instanceof Error ? err.message : String(err)
)
}
const entry: PendingInvoice = {
invoice: prepared.invoice,
paymentHash: resolvePaymentHash(prepared),
amountMsat: prepared.grossMsat,
createdAt: ctx.nowSeconds(),
prepared,
state: 'pending'
}
if (typeof params.description === 'string' && params.description) {
entry.description = params.description
}
const expiry = Number(params.expiry)
if (Number.isInteger(expiry) && expiry > 0) {
entry.expiresAt = entry.createdAt + expiry
}
ctx.invoices.set(entry.paymentHash, entry)
// phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the claim observes
void settleAndClaim(ctx, entry).catch(err => {
entry.state = 'failed'
ctx.deps.onError?.(err, ctx.connection())
})
return okResult('make_invoice', invoiceResult(entry))
}
const handleLookupInvoice = (
ctx: RequestContext,
params: Record<string, unknown>
): NwcResponse => {
const invoiceParam =
typeof params.invoice === 'string' ? params.invoice : undefined
const hashParam =
typeof params.payment_hash === 'string'
? params.payment_hash.toLowerCase()
: undefined
if (!invoiceParam && !hashParam) {
return errResult(
'lookup_invoice',
'OTHER',
'Provide an invoice or a payment hash.'
)
}
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined
if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) {
entry = candidate
break
}
}
}
if (!entry) {
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.')
}
return okResult('lookup_invoice', invoiceResult(entry))
}
export const dispatch = async (
ctx: RequestContext,
request: NwcRequest
): Promise<NwcResponse> => {
switch (request.method) {
case 'get_info':
return handleGetInfo(ctx)
case 'get_balance':
return handleGetBalance(ctx)
case 'make_invoice':
return await handleMakeInvoice(ctx, request.params)
case 'pay_invoice':
return await handlePayInvoice(ctx, request.params)
case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params)
default:
return errResult(
request.method,
'NOT_IMPLEMENTED',
`Unknown method: ${request.method}.`
)
}
}
+116
View File
@@ -0,0 +1,116 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims
// the minted note (rotating it immediately), and only THEN records the
// settlement the lookup reports.
//
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted
// note's initial secret. NIP-47 clients expect it as the settlement
// receipt, so it is handed out - but only after claimFromPreimage has
// rotated the fresh note, when that secret is burned and worthless. If
// the rotation failed (claimed.rotated === false) the preimage is
// withheld: fund safety over spec comfort.
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops'
import {claimFromPreimage} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared'
import type {PendingInvoice, RequestContext} from './context'
export const DEFAULT_CLAIM_POLL: Required<PollOptions> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000
}
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
// convention) - that suffix is the invoice's real payment hash. A service
// that shapes its verify URL differently gets a wallet-local correlation
// id instead (sha256 of the invoice): still unique and stable for
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
export const resolvePaymentHash = (prepared: PreparedMint): string => {
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
if (fromVerify) return fromVerify.toLowerCase()
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
}
// the NIP-47 transaction object make_invoice and lookup_invoice share
export const invoiceResult = (
entry: PendingInvoice
): Record<string, unknown> => {
const result: Record<string, unknown> = {
type: 'incoming',
state: entry.state,
invoice: entry.invoice,
payment_hash: entry.paymentHash,
amount: entry.amountMsat,
created_at: entry.createdAt,
metadata: {}
}
if (entry.description) result.description = entry.description
if (entry.expiresAt) result.expires_at = entry.expiresAt
if (entry.state === 'settled' && entry.preimage) {
result.preimage = entry.preimage
}
if (entry.settledAt) result.settled_at = entry.settledAt
return result
}
// the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh
// bearer to the caller. Settlement is recorded LAST - after the rotate -
// so a preimage lookup can only ever reveal an already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports
// through deps.onError.
export const settleAndClaim = async (
ctx: RequestContext,
entry: PendingInvoice
): Promise<void> => {
if (!entry.prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
)
}
const result = await pollVerifyUntilSettled(
entry.prepared.verifyUrl,
ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL,
ctx.deps.kit ?? {}
)
// a settled report only means this wallet's invoice was paid if it's
// for the invoice this wallet actually requested
if (!sameInvoice(result.pr, entry.prepared.invoice)) {
throw new Error(
"The service's verify response is for a different invoice than requested."
)
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error(
'The payment settled but the service did not reveal the preimage.'
)
}
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
// other half) - invoked in two steps here because NWC needs the
// preimage, which claimMintedNote deliberately discards
const claimed = await claimFromPreimage(
entry.prepared,
preimage,
ctx.deps.kit ?? {}
)
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
if (claimed.rotated) {
// see the header: only a rotated note makes the preimage a worthless
// secret, safe to hand out as the settlement receipt
entry.preimage = preimage
}
ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice')
}
+169
View File
@@ -0,0 +1,169 @@
// pay_invoice: melt held notes into the client's bolt11, budget-first.
// The budget check and the spend record run inside the connection's
// request queue (service.ts serializes pay_invoice), so no two pays of
// one connection can interleave them. Spends are recorded for settled
// AND unknown-still-pending outcomes - pessimistic on purpose: the money
// may be in flight; a failed payment records nothing.
import {
decodeBolt11AmountMsat,
fetchInvoiceVerification,
isBolt11Invoice,
noteK1
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {PayResult} from '../ops'
import {UncertainOutcomeError, payWithBearers} from '../ops'
import {budgetRemainingMsat, recordSpend} from './budget'
import type {NwcChangeset, RequestContext} from './context'
import type {NwcResponse} from './protocol'
import {errResult, okResult} from './protocol'
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds
// are tracked UNMARKED, rescued secrets are tracked unverified. One
// deliberate divergence: in the exact-match + funds-returned case the
// original bearer (old k1, burned server-side by the classification
// rotate) is left for the next refresh to reconcile, exactly as the UI
// leaves it - the money itself sits in the re-secured note, which IS
// tracked.
export const payChangeset = (
bearers: Bearer[],
result: PayResult
): NwcChangeset => {
const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map(b => b.id)
if (result.carve.change) add.push(result.carve.change)
if (result.outcome === 'failed-funds-returned') {
add.push(result.carve.note)
} else {
// settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match
// carve), lock that bearer; a freshly carved note is never added -
// it was born spent
const carvedK1 = noteK1(result.carve.note.url)
const existing = carvedK1
? bearers.find(b => noteK1(b.url) === carvedK1)
: undefined
if (existing) markSpent.push(existing.id)
}
if (result.rescuedNote) add.push(result.rescuedNote)
return {add, markSpent}
}
export const handlePayInvoice = async (
ctx: RequestContext,
params: Record<string, unknown>
): Promise<NwcResponse> => {
const invoice =
typeof params.invoice === 'string' ? params.invoice.trim() : ''
if (!isBolt11Invoice(invoice)) {
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
}
// the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request
const amountMsat = decodeBolt11AmountMsat(invoice)
if (amountMsat === null || amountMsat <= 0) {
return errResult(
'pay_invoice',
'OTHER',
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
)
}
if (params.amount !== undefined && params.amount !== amountMsat) {
return errResult(
'pay_invoice',
'OTHER',
'The request\'s amount does not match the invoice.'
)
}
if (
amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())
) {
return errResult(
'pay_invoice',
'QUOTA_EXCEEDED',
'This payment exceeds the connection\'s budget.'
)
}
const bearers = ctx.deps.getBearers()
let result: PayResult
try {
result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {},
kit: ctx.deps.kit ?? {}
})
} catch (err) {
if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the
// possible outputs carry fresh secrets that may be the only money
// left - tracked unverified, never dropped
ctx.deps.applyChangeset(
{add: err.possibleOutputs, markSpent: []},
ctx.connection(),
'pay_invoice'
)
return errResult(
'pay_invoice',
'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.'
)
}
const message = err instanceof Error ? err.message : String(err)
return errResult(
'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message
)
}
const spendRecorded = (): void => {
ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now()))
}
switch (result.outcome) {
case 'settled': {
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
// the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one.
let preimage = ''
if (result.verifyUrl) {
try {
const proof = await fetchInvoiceVerification(
result.verifyUrl,
ctx.deps.kit ?? {}
)
preimage = proof.preimage ?? ''
} catch {
// the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome
}
}
return okResult('pay_invoice', {preimage})
}
case 'failed-funds-returned':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.'
)
case 'note-already-spent':
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
return errResult(
'pay_invoice',
'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.'
)
case 'unknown-still-pending':
spendRecorded()
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
return errResult(
'pay_invoice',
'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.'
)
}
}
+241
View File
@@ -0,0 +1,241 @@
// The pure half of the NIP-47 wallet service: the request/response event
// codec - validate, decrypt, build, encrypt - with no relay I/O and no
// wallet logic (dispatch lives in service.ts).
//
// Wire shape (NIP-47):
// - the client sends kind 23194, tagged ['p', wallet-service pubkey], its
// content a JSON {method, params} encrypted to the wallet service with
// NIP-44 v2 (tag ['encryption', 'nip44_v2']) or legacy NIP-04 (tag
// ['encryption', 'nip04'], or NO encryption tag at all)
// - the wallet answers with kind 23195, tagged ['p', client pubkey] and
// ['e', request id], its content a JSON {result_type, error, result}
// encrypted back with the SAME scheme the request used
// - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes
import type {Event as NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
export type {NostrEvent}
export const NWC_REQUEST_KIND = 23194
export const NWC_RESPONSE_KIND = 23195
export const NWC_INFO_KIND = 13194
export const NWC_METHODS = [
'get_info',
'get_balance',
'make_invoice',
'pay_invoice',
'lookup_invoice'
] as const
export type NwcMethod = (typeof NWC_METHODS)[number]
export type NwcErrorCode =
| 'RATE_LIMITED'
| 'NOT_IMPLEMENTED'
| 'INSUFFICIENT_BALANCE'
| 'QUOTA_EXCEEDED'
| 'RESTRICTED'
| 'UNAUTHORIZED'
| 'INTERNAL'
| 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED'
| 'NOT_FOUND'
| 'OTHER'
export type NwcRequest = {
method: string
params: Record<string, unknown>
}
export type NwcResponse = {
result_type: string
error: {code: NwcErrorCode; message: string} | null
result: unknown
}
export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method,
error: null,
result
})
export const errResult = (
method: string,
code: NwcErrorCode,
message: string
): NwcResponse => ({
result_type: method,
error: {code, message},
result: null
})
// the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04'
const conversationKey = (
walletSecretKey: Uint8Array,
clientPubkey: string
): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
export const encryptFor = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
plaintext: string
): string =>
scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
const decryptFrom = (
scheme: NwcEncryption,
walletSecretKey: Uint8Array,
clientPubkey: string,
content: string
): string =>
scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content)
const tagValue = (event: NostrEvent, name: string): string | undefined =>
event.tags.find(t => t[0] === name)?.[1]
// The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can
// mirror it)
// - a respondable failure: the event IS an authorized client's request,
// but its content can't be had or parsed - the client still gets a
// well-formed error answer instead of silence
// - null: not ours to answer at all (wrong kind, wrong author, forged
// signature, expired, undecryptable, or a response event echoed back) -
// dropped silently, exactly what a relay full of strangers' traffic
// demands
export type DecryptedNwcRequest =
| {respond: false; request: NwcRequest; encryption: NwcEncryption}
| {respond: true; response: NwcResponse; encryption: NwcEncryption}
export const decryptRequest = (
walletSecretKey: Uint8Array,
walletServicePubkey: string,
clientPubkey: string,
event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000)
): DecryptedNwcRequest | null => {
if (event.kind !== NWC_REQUEST_KIND) return null
// only the authorized client may talk to this connection, and the
// request must actually be addressed to it
if (event.pubkey !== clientPubkey) return null
if (tagValue(event, 'p') !== walletServicePubkey) return null
if (!verifyEvent(event)) return null
// an expired request is ignored, never answered (NIP-47)
const expiration = tagValue(event, 'expiration')
if (expiration !== undefined && Number(expiration) < nowSeconds) {
return null
}
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
const advertised = tagValue(event, 'encryption')
let encryption: NwcEncryption
if (advertised === undefined || advertised === 'nip04') {
encryption = 'nip04'
} else if (advertised === 'nip44_v2') {
encryption = 'nip44_v2'
} else {
// the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read
return {
respond: true,
encryption: 'nip04',
response: errResult(
'',
'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`
)
}
}
let plaintext: string
try {
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
} catch {
// undecryptable - indistinguishable from relay noise; stay silent
return null
}
let data: unknown
try {
data = JSON.parse(plaintext)
} catch {
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.')
}
}
if (
typeof data !== 'object' ||
data === null ||
typeof (data as NwcRequest).method !== 'string' ||
(data as NwcRequest).method === ''
) {
return {
respond: true,
encryption,
response: errResult('', 'OTHER', 'The request has no method.')
}
}
const request = data as NwcRequest
const params =
typeof request.params === 'object' && request.params !== null
? request.params
: {}
return {respond: false, request: {method: request.method, params}, encryption}
}
// signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag
export const buildResponseEvent = (
walletSecretKey: Uint8Array,
clientPubkey: string,
encryption: NwcEncryption,
requestEventId: string,
response: NwcResponse,
createdAt: number = Math.floor(Date.now() / 1000)
): NostrEvent =>
finalizeEvent(
{
kind: NWC_RESPONSE_KIND,
created_at: createdAt,
tags: [
['p', clientPubkey],
['e', requestEventId],
['encryption', encryption]
],
content: encryptFor(
encryption,
walletSecretKey,
clientPubkey,
JSON.stringify(response)
)
},
walletSecretKey
)
// the replaceable info event advertising this service's capabilities
export const buildInfoEvent = (
walletSecretKey: Uint8Array,
createdAt: number = Math.floor(Date.now() / 1000)
): NostrEvent =>
finalizeEvent(
{
kind: NWC_INFO_KIND,
created_at: createdAt,
tags: [['encryption', 'nip44_v2 nip04']],
content: NWC_METHODS.join(' ')
},
walletSecretKey
)
+190
View File
@@ -0,0 +1,190 @@
// The NWC service runtime: subscribes each connection's relays for NIP-47
// requests, routes them onto the method handlers (dispatch.ts), and
// encrypts the answers back. Framework-free; sockets are only ever opened
// by the injected (or lazily-defaulted) transport inside startService.
//
// pay_invoice requests are serialized per connection through a promise
// queue, so the budget check and the spend record can't interleave;
// every other method dispatches concurrently.
//
// Foreground replay safety: requests older than MAX_REQUEST_AGE_SECONDS
// are dropped unanswered - a pay_invoice replayed after an offline
// stretch must never execute (a client that retries gets a fresh,
// answered request; a stale one answered late could double-pay). The
// foreground-only design itself is documented in the nwc.ts façade
// header.
import type {NwcConnectionRecord} from '../storage/nwcConnections'
import {readNwcConnections} from '../storage/nwcConnections'
import type {NwcConnectionInfo} from './connection'
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
import {dispatch} from './dispatch'
import type {
NostrEvent,
NwcEncryption,
NwcRequest,
NwcResponse
} from './protocol'
import {
NWC_REQUEST_KIND,
buildInfoEvent,
buildResponseEvent,
decryptRequest
} from './protocol'
import type {NwcSubscription, NwcTransport} from './transport'
import {defaultNwcTransport} from './transport'
export type {NwcConnectionInfo}
export type {NwcChangeset, NwcServiceDeps} from './context'
// requests older than this are dropped unanswered (see the header)
const MAX_REQUEST_AGE_SECONDS = 600
type ConnectionRuntime = {
info: NwcConnectionInfo
walletSecret: Uint8Array
// invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet
invoices: Map<string, PendingInvoice>
// serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave
queue: Promise<unknown>
sub: NwcSubscription
}
export type NwcService = {
// a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state
connections: NwcConnectionInfo[]
// closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up
stop: () => void
}
export const startService = async (
linkingPrivKey: Uint8Array,
deps: NwcServiceDeps,
records: NwcConnectionRecord[] = readNwcConnections()
): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport())
const nowSeconds = (): number =>
deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
const publishResponse = async (
runtime: ConnectionRuntime,
requestEventId: string,
encryption: NwcEncryption,
response: NwcResponse
): Promise<void> => {
await transport.publish(
runtime.info.record.relays,
buildResponseEvent(
runtime.walletSecret,
runtime.info.record.clientPubkey,
encryption,
requestEventId,
response,
nowSeconds()
)
)
}
// pay_invoice goes through the connection's queue (budget atomicity);
// everything else dispatches directly
const dispatchSerialized = (
runtime: ConnectionRuntime,
ctx: RequestContext,
request: NwcRequest
): Promise<NwcResponse> => {
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
const run = runtime.queue.then(
() => dispatch(ctx, request),
() => dispatch(ctx, request)
)
runtime.queue = run.catch(() => undefined)
return run
}
const handleEvent = async (
runtime: ConnectionRuntime,
ctx: RequestContext,
event: NostrEvent
): Promise<void> => {
const at = nowSeconds()
// replay safety (see the header): too-old requests are dropped
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
const decrypted = decryptRequest(
runtime.walletSecret,
runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey,
event,
at
)
if (decrypted === null) return
if (decrypted.respond) {
await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response)
return
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, event.id, decrypted.encryption, response)
}
const runtimes = records.map(record => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
const runtime: ConnectionRuntime = {
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
walletSecret,
invoices: new Map(),
queue: Promise.resolve(),
// replaced below, immediately - the field exists because the
// subscription callback closes over the runtime
sub: {close: () => undefined}
}
const ctx: RequestContext = {
deps,
connection: () => runtime.info,
updateRecord: updated => {
runtime.info = {...runtime.info, record: updated}
},
invoices: runtime.invoices,
nowSeconds
}
runtime.sub = transport.subscribe(
record.relays,
{
kinds: [NWC_REQUEST_KIND],
'#p': [runtime.info.walletServicePubkey],
since: nowSeconds()
},
event => {
void handleEvent(runtime, ctx, event).catch(err =>
deps.onError?.(err, runtime.info)
)
}
)
return runtime
})
// info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) {
try {
await transport.publish(
runtime.info.record.relays,
buildInfoEvent(runtime.walletSecret, nowSeconds())
)
} catch (err) {
deps.onError?.(err, runtime.info)
}
}
return {
connections: runtimes.map(r => r.info),
stop: () => {
for (const runtime of runtimes) runtime.sub.close()
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// The relay-facing transport for the NWC service, kept injectable so
// tests never touch a network. Same pattern as nostr/transport.ts: the
// default (nostr-tools' SimplePool) is imported lazily, so merely
// importing the service module never opens (or even references) a
// WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter'
import type {NostrEvent} from './protocol'
export type {NostrFilter}
export type NwcSubscription = {
close: () => void
}
export type NwcTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void>
subscribe: (
relays: string[],
filter: NostrFilter,
onEvent: (event: NostrEvent) => void
) => NwcSubscription
}
export const defaultNwcTransport = async (): Promise<NwcTransport> => {
const {SimplePool} = await import('nostr-tools/pool')
const pool = new SimplePool()
return {
publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event))
// one honest relay accepting is enough - same rule as the backup
if (!results.some(r => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.')
}
},
subscribe: (relays, filter, onEvent) =>
pool.subscribeMany(relays, filter, {onevent: onEvent})
}
}
+109
View File
@@ -0,0 +1,109 @@
// NWC connection persistence: one localStorage record holding every NIP-47
// connection this wallet serves (see nwc.ts). A record is public metadata
// only - the wallet-service key is re-derived from the linking key and the
// client pubkey (nwc/connection.ts's deriveNwcWalletKey), and the CLIENT
// secret is never stored at all (NIP-47: the wallet service should not
// store the secret it generates for the client). The budget spend counter
// lives here so a restart doesn't reset a client's allowance.
export type NwcBudget = {
// the most this connection may pay per period, msat
maxMsat: number
// the period length in milliseconds (e.g. 86_400_000 for daily)
periodMs: number
}
// spend within the current period; rolls over once periodStart is more
// than budget.periodMs in the past
export type NwcBudgetSpend = {
periodStart: number
msat: number
}
export type NwcConnectionRecord = {
// the authorized client's pubkey (the pubkey of the client secret that
// was handed out in the connection string, once, at creation time)
clientPubkey: string
relays: string[]
budget: NwcBudget
spent: NwcBudgetSpend
createdAt: number
}
const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections'
const HEX_64 = /^[0-9a-f]{64}$/i
// strict shape check, same spirit as passkeySlots.ts: localStorage content
// is not trustworthy input, so records are validated before use
const isValidNwcConnectionRecord = (
record: unknown
): record is NwcConnectionRecord => {
if (typeof record !== 'object' || record === null) return false
const r = record as Record<string, unknown>
const budget = r.budget as Record<string, unknown> | null
const spent = r.spent as Record<string, unknown> | null
return (
typeof r.clientPubkey === 'string' &&
HEX_64.test(r.clientPubkey) &&
Array.isArray(r.relays) &&
r.relays.length > 0 &&
r.relays.every(
relay => typeof relay === 'string' && /^wss?:\/\//.test(relay)
) &&
typeof budget === 'object' &&
budget !== null &&
typeof budget.maxMsat === 'number' &&
Number.isInteger(budget.maxMsat) &&
budget.maxMsat > 0 &&
typeof budget.periodMs === 'number' &&
budget.periodMs > 0 &&
typeof spent === 'object' &&
spent !== null &&
typeof spent.periodStart === 'number' &&
typeof spent.msat === 'number' &&
spent.msat >= 0 &&
typeof r.createdAt === 'number'
)
}
// malformed entries are dropped, not thrown on - one corrupted record must
// not take the remaining connections down with it
export const readNwcConnections = (): NwcConnectionRecord[] => {
const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY)
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed)
? parsed.filter(isValidNwcConnectionRecord)
: []
} catch {
return []
}
}
export const writeNwcConnections = (
records: NwcConnectionRecord[]
): void => {
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records))
}
// upsert by client pubkey; returns the stored record. Callers serialize
// read-modify-write cycles themselves (the NWC service serializes per
// connection through its request queue)
export const persistNwcConnection = (
record: NwcConnectionRecord
): NwcConnectionRecord => {
const records = readNwcConnections()
const index = records.findIndex(r => r.clientPubkey === record.clientPubkey)
if (index >= 0) records[index] = record
else records.push(record)
writeNwcConnections(records)
return record
}
export const removeNwcConnection = (clientPubkey: string): void => {
writeNwcConnections(
readNwcConnections().filter(r => r.clientPubkey !== clientPubkey)
)
}