Skip to content

Commit f2a7ea0

Browse files
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.
1 parent 2bb631e commit f2a7ea0

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

src/contracts/checkout.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ export const GetCheckoutInputSchema = z.object({
113113
});
114114
export type GetCheckoutInput = z.infer<typeof GetCheckoutInputSchema>;
115115

116+
/**
117+
* Input for mintInvoice. Replaces the legacy two-step "merchant mints locally
118+
* + calls registerInvoice" flow. mdk.com mints the invoice on behalf of the
119+
* merchant by routing the request to whichever node currently holds the WS
120+
* lease for the merchant's app, eliminating dual-node races.
121+
*
122+
* expirySecs is optional; if omitted the server defaults to 15 minutes (the
123+
* value the legacy local-mint paths used).
124+
*/
125+
export const MintInvoiceInputSchema = z.object({
126+
checkoutId: z.string(),
127+
expirySecs: z.number().int().positive().optional(),
128+
});
129+
export type MintInvoice = z.infer<typeof MintInvoiceInputSchema>;
130+
116131
export type CreateCheckout = z.infer<typeof CreateCheckoutInputSchema>;
117132
export type ConfirmCheckout = z.infer<typeof ConfirmCheckoutInputSchema>;
118133
export type RegisterInvoice = z.infer<typeof RegisterInvoiceInputSchema>;
@@ -190,11 +205,16 @@ export const getCheckoutDetailContract = oc
190205
.input(GetCheckoutInputSchema)
191206
.output(CheckoutDetailSchema);
192207

208+
export const mintInvoiceContract = oc
209+
.input(MintInvoiceInputSchema)
210+
.output(CheckoutSchema);
211+
193212
export const checkout = {
194213
get: getCheckoutContract,
195214
create: createCheckoutContract,
196215
confirm: confirmCheckoutContract,
197216
registerInvoice: registerInvoiceContract,
217+
mintInvoice: mintInvoiceContract,
198218
paymentReceived: paymentReceivedContract,
199219
list: listCheckoutsContract,
200220
listPaginated: listCheckoutsPaginatedContract,

src/contracts/node-control.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { eventIterator, oc } from "@orpc/contract";
2+
import { z } from "zod";
3+
import {
4+
InvoiceBolt11ResultSchema,
5+
InvoiceBolt12OfferResultSchema,
6+
InvoiceCreateBolt11InputSchema,
7+
InvoiceCreateBolt12OfferInputSchema,
8+
NodeEventSchema,
9+
PayoutInputSchema,
10+
PayoutResultSchema,
11+
} from "../schemas/node-control";
12+
13+
/**
14+
* Node control contract used over a WebSocket between mdk.com (RPC client) and
15+
* a merchant's running lightning-js node (RPC handler).
16+
*
17+
* The connection is initiated by the merchant function dialing OUT to mdk.com
18+
* (Vercel does not support inbound WebSockets). mdk.com grants a single-active
19+
* lease per appId via a DB row before the node is constructed.
20+
*/
21+
export const payoutContract = oc
22+
.input(PayoutInputSchema)
23+
.output(PayoutResultSchema);
24+
25+
export const invoiceCreateBolt11Contract = oc
26+
.input(InvoiceCreateBolt11InputSchema)
27+
.output(InvoiceBolt11ResultSchema);
28+
29+
export const invoiceCreateBolt12OfferContract = oc
30+
.input(InvoiceCreateBolt12OfferInputSchema)
31+
.output(InvoiceBolt12OfferResultSchema);
32+
33+
/**
34+
* Server-pushed event stream. mdk.com calls this once per session and consumes
35+
* the AsyncIterable for the lifetime of the connection. Single subscriber per
36+
* session, buffered from session start, FIFO.
37+
*/
38+
export const nodeEventsContract = oc
39+
.input(z.void())
40+
.output(eventIterator(NodeEventSchema));
41+
42+
export const nodeControl = {
43+
payout: payoutContract,
44+
invoice: {
45+
createBolt11: invoiceCreateBolt11Contract,
46+
createBolt12Offer: invoiceCreateBolt12OfferContract,
47+
},
48+
events: nodeEventsContract,
49+
};

src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { checkout } from "./contracts/checkout";
22
import { customer } from "./contracts/customer";
3+
import { nodeControl } from "./contracts/node-control";
34
import { onboarding } from "./contracts/onboarding";
45
import { order } from "./contracts/order";
56
import { products } from "./contracts/products";
@@ -19,6 +20,7 @@ export type {
1920
CreateCheckout,
2021
PaymentReceived,
2122
RegisterInvoice,
23+
MintInvoice,
2224
} from "./contracts/checkout";
2325
export {
2426
CheckoutStatusSchema,
@@ -174,6 +176,30 @@ export const contract = {
174176
subscription,
175177
};
176178

179+
// Node control contract (WS only). Used between mdk.com and a running merchant
180+
// lightning-js node for command injection and event push. Not part of `contract`
181+
// because it is a separate transport (WebSocket) and a separate trust boundary
182+
// (mdk.com is the RPC client, the node is the RPC handler).
183+
export { nodeControl };
184+
export type {
185+
PayoutInput,
186+
PayoutResult,
187+
InvoiceCreateBolt11Input,
188+
InvoiceBolt11Result,
189+
InvoiceCreateBolt12OfferInput,
190+
InvoiceBolt12OfferResult,
191+
NodeEvent,
192+
} from "./schemas/node-control";
193+
export {
194+
PayoutInputSchema,
195+
PayoutResultSchema,
196+
InvoiceCreateBolt11InputSchema,
197+
InvoiceBolt11ResultSchema,
198+
InvoiceCreateBolt12OfferInputSchema,
199+
InvoiceBolt12OfferResultSchema,
200+
NodeEventSchema,
201+
} from "./schemas/node-control";
202+
177203
// SDK contract - only the methods the SDK router implements
178204
export const sdkContract = {
179205
checkout: {

src/schemas/node-control.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Input for the payout command. Destination is NOT in the payload — the node-side
5+
* handler reads it from process.env.WITHDRAWAL_DESTINATION. This means mdk.com cannot
6+
* direct funds to an arbitrary destination even if the per-app key is compromised.
7+
*
8+
* amountMsat is required and positive; "drain entire balance" semantics are out of v1.
9+
* If needed later, add a separate explicit command (e.g. payout.drainAll).
10+
*/
11+
export const PayoutInputSchema = z.object({
12+
amountMsat: z.number().int().positive(),
13+
idempotencyKey: z.string(),
14+
});
15+
export type PayoutInput = z.infer<typeof PayoutInputSchema>;
16+
17+
/**
18+
* Result of a payout command. Returned synchronously after the underlying
19+
* payWhileRunning(_, _, 0) fire-and-forget call. The final outcome (Sent or Failed)
20+
* arrives later as a paymentSent or paymentFailed event over the events() iterator.
21+
*/
22+
export const PayoutResultSchema = z.object({
23+
accepted: z.literal(true),
24+
paymentId: z.string(),
25+
paymentHash: z.string().nullable(),
26+
});
27+
export type PayoutResult = z.infer<typeof PayoutResultSchema>;
28+
29+
/**
30+
* Input for createBolt11. amountMsat null means a variable-amount JIT invoice.
31+
*/
32+
export const InvoiceCreateBolt11InputSchema = z.object({
33+
amountMsat: z.number().int().positive().nullable(),
34+
description: z.string(),
35+
expirySecs: z.number().int().positive(),
36+
idempotencyKey: z.string(),
37+
});
38+
export type InvoiceCreateBolt11Input = z.infer<
39+
typeof InvoiceCreateBolt11InputSchema
40+
>;
41+
42+
/**
43+
* Result of createBolt11. expiresAt is a unix timestamp in seconds (matches lightning-js).
44+
*/
45+
export const InvoiceBolt11ResultSchema = z.object({
46+
bolt11: z.string(),
47+
paymentHash: z.string(),
48+
expiresAt: z.number(),
49+
scid: z.string(),
50+
});
51+
export type InvoiceBolt11Result = z.infer<typeof InvoiceBolt11ResultSchema>;
52+
53+
/**
54+
* Input for createBolt12Offer. amountMsat null means a variable-amount offer.
55+
*/
56+
export const InvoiceCreateBolt12OfferInputSchema = z.object({
57+
amountMsat: z.number().int().positive().nullable(),
58+
description: z.string(),
59+
expirySecs: z.number().int().positive().optional(),
60+
idempotencyKey: z.string(),
61+
});
62+
export type InvoiceCreateBolt12OfferInput = z.infer<
63+
typeof InvoiceCreateBolt12OfferInputSchema
64+
>;
65+
66+
export const InvoiceBolt12OfferResultSchema = z.object({
67+
offer: z.string(),
68+
});
69+
export type InvoiceBolt12OfferResult = z.infer<
70+
typeof InvoiceBolt12OfferResultSchema
71+
>;
72+
73+
/**
74+
* Events pushed from the node to mdk.com over the events() AsyncIterable.
75+
*
76+
* - ready: emitted once after node.startReceiving() + setupBolt12Receive() complete.
77+
* mdk.com SHOULD wait for this before sending command RPCs (commands are gated on
78+
* nodeReady server-side and reject with {error:'node-not-ready'} otherwise).
79+
*
80+
* - paymentSent / paymentFailed: outbound payment outcomes. Correlate by paymentId
81+
* returned from the original payout RPC. paymentId is only present for outbound
82+
* payments (per lightning-js PaymentEvent typing); inbound failures clear pending
83+
* claims locally but do not surface here.
84+
*
85+
* - draining: emitted when the node enters its drain window (15s before the
86+
* hardcoded 300s lifetime expires). New command RPCs reject after this.
87+
*
88+
* - leaseReleased: emitted right before the node initiates a graceful shutdown
89+
* (60s of quiet + no in-flight outbound + no pending claims + empty queue).
90+
* Followed by a graceful WS close.
91+
*
92+
* reason on paymentFailed is optional because lightning-js types it optional in
93+
* PaymentEvent (index.d.ts:47); forcing a default would lose signal.
94+
*/
95+
export const NodeEventSchema = z.discriminatedUnion("type", [
96+
z.object({ type: z.literal("ready"), nodeId: z.string() }),
97+
z.object({
98+
type: z.literal("paymentSent"),
99+
paymentId: z.string(),
100+
paymentHash: z.string(),
101+
preimage: z.string(),
102+
}),
103+
z.object({
104+
type: z.literal("paymentFailed"),
105+
paymentId: z.string(),
106+
paymentHash: z.string(),
107+
reason: z.string().optional(),
108+
}),
109+
z.object({ type: z.literal("draining") }),
110+
z.object({ type: z.literal("leaseReleased") }),
111+
]);
112+
export type NodeEvent = z.infer<typeof NodeEventSchema>;

0 commit comments

Comments
 (0)