From f2a7ea097ad5d2df664da68dd73b67847ff77397 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 20 Apr 2026 14:40:28 -0300 Subject: [PATCH] feat: add nodeControl contract + checkout.mintInvoice for WS control plane Add the oRPC contracts and Zod schemas for the WebSocket-based node control protocol between mdk.com and merchant lightning-js nodes. Includes payout, invoice minting (BOLT11 + BOLT12), and server-pushed event stream contracts. Also adds mintInvoice to the checkout contract, replacing the legacy two-step local-mint + registerInvoice flow. --- src/contracts/checkout.ts | 20 ++++++ src/contracts/node-control.ts | 49 +++++++++++++++ src/index.ts | 26 ++++++++ src/schemas/node-control.ts | 112 ++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/contracts/node-control.ts create mode 100644 src/schemas/node-control.ts diff --git a/src/contracts/checkout.ts b/src/contracts/checkout.ts index c1c2804..f4aa9f2 100644 --- a/src/contracts/checkout.ts +++ b/src/contracts/checkout.ts @@ -113,6 +113,21 @@ export const GetCheckoutInputSchema = z.object({ }); export type GetCheckoutInput = z.infer; +/** + * Input for mintInvoice. Replaces the legacy two-step "merchant mints locally + * + calls registerInvoice" flow. mdk.com mints the invoice on behalf of the + * merchant by routing the request to whichever node currently holds the WS + * lease for the merchant's app, eliminating dual-node races. + * + * expirySecs is optional; if omitted the server defaults to 15 minutes (the + * value the legacy local-mint paths used). + */ +export const MintInvoiceInputSchema = z.object({ + checkoutId: z.string(), + expirySecs: z.number().int().positive().optional(), +}); +export type MintInvoice = z.infer; + export type CreateCheckout = z.infer; export type ConfirmCheckout = z.infer; export type RegisterInvoice = z.infer; @@ -190,11 +205,16 @@ export const getCheckoutDetailContract = oc .input(GetCheckoutInputSchema) .output(CheckoutDetailSchema); +export const mintInvoiceContract = oc + .input(MintInvoiceInputSchema) + .output(CheckoutSchema); + export const checkout = { get: getCheckoutContract, create: createCheckoutContract, confirm: confirmCheckoutContract, registerInvoice: registerInvoiceContract, + mintInvoice: mintInvoiceContract, paymentReceived: paymentReceivedContract, list: listCheckoutsContract, listPaginated: listCheckoutsPaginatedContract, diff --git a/src/contracts/node-control.ts b/src/contracts/node-control.ts new file mode 100644 index 0000000..2497ca8 --- /dev/null +++ b/src/contracts/node-control.ts @@ -0,0 +1,49 @@ +import { eventIterator, oc } from "@orpc/contract"; +import { z } from "zod"; +import { + InvoiceBolt11ResultSchema, + InvoiceBolt12OfferResultSchema, + InvoiceCreateBolt11InputSchema, + InvoiceCreateBolt12OfferInputSchema, + NodeEventSchema, + PayoutInputSchema, + PayoutResultSchema, +} from "../schemas/node-control"; + +/** + * Node control contract used over a WebSocket between mdk.com (RPC client) and + * a merchant's running lightning-js node (RPC handler). + * + * The connection is initiated by the merchant function dialing OUT to mdk.com + * (Vercel does not support inbound WebSockets). mdk.com grants a single-active + * lease per appId via a DB row before the node is constructed. + */ +export const payoutContract = oc + .input(PayoutInputSchema) + .output(PayoutResultSchema); + +export const invoiceCreateBolt11Contract = oc + .input(InvoiceCreateBolt11InputSchema) + .output(InvoiceBolt11ResultSchema); + +export const invoiceCreateBolt12OfferContract = oc + .input(InvoiceCreateBolt12OfferInputSchema) + .output(InvoiceBolt12OfferResultSchema); + +/** + * Server-pushed event stream. mdk.com calls this once per session and consumes + * the AsyncIterable for the lifetime of the connection. Single subscriber per + * session, buffered from session start, FIFO. + */ +export const nodeEventsContract = oc + .input(z.void()) + .output(eventIterator(NodeEventSchema)); + +export const nodeControl = { + payout: payoutContract, + invoice: { + createBolt11: invoiceCreateBolt11Contract, + createBolt12Offer: invoiceCreateBolt12OfferContract, + }, + events: nodeEventsContract, +}; diff --git a/src/index.ts b/src/index.ts index 800563e..31bd28f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { checkout } from "./contracts/checkout"; import { customer } from "./contracts/customer"; +import { nodeControl } from "./contracts/node-control"; import { onboarding } from "./contracts/onboarding"; import { order } from "./contracts/order"; import { products } from "./contracts/products"; @@ -19,6 +20,7 @@ export type { CreateCheckout, PaymentReceived, RegisterInvoice, + MintInvoice, } from "./contracts/checkout"; export { CheckoutStatusSchema, @@ -174,6 +176,30 @@ export const contract = { subscription, }; +// Node control contract (WS only). Used between mdk.com and a running merchant +// lightning-js node for command injection and event push. Not part of `contract` +// because it is a separate transport (WebSocket) and a separate trust boundary +// (mdk.com is the RPC client, the node is the RPC handler). +export { nodeControl }; +export type { + PayoutInput, + PayoutResult, + InvoiceCreateBolt11Input, + InvoiceBolt11Result, + InvoiceCreateBolt12OfferInput, + InvoiceBolt12OfferResult, + NodeEvent, +} from "./schemas/node-control"; +export { + PayoutInputSchema, + PayoutResultSchema, + InvoiceCreateBolt11InputSchema, + InvoiceBolt11ResultSchema, + InvoiceCreateBolt12OfferInputSchema, + InvoiceBolt12OfferResultSchema, + NodeEventSchema, +} from "./schemas/node-control"; + // SDK contract - only the methods the SDK router implements export const sdkContract = { checkout: { diff --git a/src/schemas/node-control.ts b/src/schemas/node-control.ts new file mode 100644 index 0000000..f21aa76 --- /dev/null +++ b/src/schemas/node-control.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +/** + * Input for the payout command. Destination is NOT in the payload — the node-side + * handler reads it from process.env.WITHDRAWAL_DESTINATION. This means mdk.com cannot + * direct funds to an arbitrary destination even if the per-app key is compromised. + * + * amountMsat is required and positive; "drain entire balance" semantics are out of v1. + * If needed later, add a separate explicit command (e.g. payout.drainAll). + */ +export const PayoutInputSchema = z.object({ + amountMsat: z.number().int().positive(), + idempotencyKey: z.string(), +}); +export type PayoutInput = z.infer; + +/** + * Result of a payout command. Returned synchronously after the underlying + * payWhileRunning(_, _, 0) fire-and-forget call. The final outcome (Sent or Failed) + * arrives later as a paymentSent or paymentFailed event over the events() iterator. + */ +export const PayoutResultSchema = z.object({ + accepted: z.literal(true), + paymentId: z.string(), + paymentHash: z.string().nullable(), +}); +export type PayoutResult = z.infer; + +/** + * Input for createBolt11. amountMsat null means a variable-amount JIT invoice. + */ +export const InvoiceCreateBolt11InputSchema = z.object({ + amountMsat: z.number().int().positive().nullable(), + description: z.string(), + expirySecs: z.number().int().positive(), + idempotencyKey: z.string(), +}); +export type InvoiceCreateBolt11Input = z.infer< + typeof InvoiceCreateBolt11InputSchema +>; + +/** + * Result of createBolt11. expiresAt is a unix timestamp in seconds (matches lightning-js). + */ +export const InvoiceBolt11ResultSchema = z.object({ + bolt11: z.string(), + paymentHash: z.string(), + expiresAt: z.number(), + scid: z.string(), +}); +export type InvoiceBolt11Result = z.infer; + +/** + * Input for createBolt12Offer. amountMsat null means a variable-amount offer. + */ +export const InvoiceCreateBolt12OfferInputSchema = z.object({ + amountMsat: z.number().int().positive().nullable(), + description: z.string(), + expirySecs: z.number().int().positive().optional(), + idempotencyKey: z.string(), +}); +export type InvoiceCreateBolt12OfferInput = z.infer< + typeof InvoiceCreateBolt12OfferInputSchema +>; + +export const InvoiceBolt12OfferResultSchema = z.object({ + offer: z.string(), +}); +export type InvoiceBolt12OfferResult = z.infer< + typeof InvoiceBolt12OfferResultSchema +>; + +/** + * Events pushed from the node to mdk.com over the events() AsyncIterable. + * + * - ready: emitted once after node.startReceiving() + setupBolt12Receive() complete. + * mdk.com SHOULD wait for this before sending command RPCs (commands are gated on + * nodeReady server-side and reject with {error:'node-not-ready'} otherwise). + * + * - paymentSent / paymentFailed: outbound payment outcomes. Correlate by paymentId + * returned from the original payout RPC. paymentId is only present for outbound + * payments (per lightning-js PaymentEvent typing); inbound failures clear pending + * claims locally but do not surface here. + * + * - draining: emitted when the node enters its drain window (15s before the + * hardcoded 300s lifetime expires). New command RPCs reject after this. + * + * - leaseReleased: emitted right before the node initiates a graceful shutdown + * (60s of quiet + no in-flight outbound + no pending claims + empty queue). + * Followed by a graceful WS close. + * + * reason on paymentFailed is optional because lightning-js types it optional in + * PaymentEvent (index.d.ts:47); forcing a default would lose signal. + */ +export const NodeEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("ready"), nodeId: z.string() }), + z.object({ + type: z.literal("paymentSent"), + paymentId: z.string(), + paymentHash: z.string(), + preimage: z.string(), + }), + z.object({ + type: z.literal("paymentFailed"), + paymentId: z.string(), + paymentHash: z.string(), + reason: z.string().optional(), + }), + z.object({ type: z.literal("draining") }), + z.object({ type: z.literal("leaseReleased") }), +]); +export type NodeEvent = z.infer;