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