// 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 {PollAbortedError} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices' import {handlePayInvoice} from './pay' import type {NwcRequest, NwcResponse} from './protocol' import {NWC_METHODS, errResult, okResult} from './protocol' const MAX_PENDING_MINT_OUTPUTS = 20 // 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, ): Promise => { 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.') } const now = ctx.nowSeconds() if ( ctx.deps .getBearers() .some( (bearer) => !bearer.spent && bearer.pendingMint?.retireAfter !== undefined && bearer.pendingMint.retireAfter <= now, ) ) { await ctx.deps.recoverPendingMints(ctx.assertOwner) } const pendingOutputs = ctx.deps .getBearers() .filter((bearer) => bearer.pendingMint && !bearer.spent).length if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) { return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.') } let prepared: PreparedMint let stagedOutput: Bearer | undefined try { prepared = await prepareMint(mint, amountMsat, { ...(ctx.deps.kit ?? {}), assertOwner: ctx.assertOwner, allocateOutputSecrets: (server, count) => ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), persistOutput: async (note) => { stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner) }, }) } catch (err) { if (stagedOutput) { try { await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) } catch (cleanupError) { return errResult( 'make_invoice', 'INTERNAL', cleanupError instanceof Error ? cleanupError.message : String(cleanupError), ) } } return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) } if (!stagedOutput) { return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.') } const retireAfter = invoiceRetireAfter(prepared.invoice) if (retireAfter !== null) { try { await ctx.deps.setMintOutputRetirement( stagedOutput, retireAfter, ctx.assertOwner, ) } catch (error) { try { await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) } catch (cleanupError) { return errResult( 'make_invoice', 'INTERNAL', cleanupError instanceof Error ? cleanupError.message : String(cleanupError), ) } return errResult( 'make_invoice', 'INTERNAL', error instanceof Error ? error.message : String(error), ) } } const entry: PendingInvoice = { invoice: prepared.invoice, paymentHash: resolvePaymentHash(prepared), amountMsat: prepared.grossMsat, createdAt: ctx.nowSeconds(), prepared, stagedOutput, 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 service-owned claim observes const started = ctx.startBackground(() => settleAndClaim(ctx, entry).catch((err) => { entry.state = 'failed' // an interrupted claim poll is normal service teardown (stop // aborted it), not a background failure worth surfacing if (err instanceof PollAbortedError) return ctx.deps.onError?.(err, ctx.connection()) }), ) if (!started) { entry.state = 'failed' return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.') } return okResult('make_invoice', invoiceResult(entry)) } const handleLookupInvoice = (ctx: RequestContext, params: Record): 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 => { 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}.`) } }