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
+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})
}
}