mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
feat: nip-47 nwc wallet service engine with per-connection budgets
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user