Skip to content

Commit 657f9de

Browse files
feat: add nodeControl contract + checkout.mintInvoice for WS control plane
Adds schemas/node-control.ts and contracts/node-control.ts exporting the nodeControl oRPC contract (payout, invoice.createBolt11, invoice.createBolt12Offer, events stream via eventIterator). Also adds checkout.mintInvoice to the SDK contract. Bumps to 0.2.0 (not yet published).
1 parent 3ff524b commit 657f9de

4 files changed

Lines changed: 208 additions & 0 deletions

File tree

src/contracts/checkout.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ export const RedeemL402InputSchema = z.object({
118118
});
119119
export type RedeemL402Input = z.infer<typeof RedeemL402InputSchema>;
120120

121+
/**
122+
* Input for mintInvoice. Replaces the legacy two-step "merchant mints locally
123+
* + calls registerInvoice" flow. mdk.com mints the invoice on behalf of the
124+
* merchant by routing the request to whichever node currently holds the WS
125+
* lease for the merchant's app, eliminating dual-node races.
126+
*
127+
* expirySecs is optional; if omitted the server defaults to 15 minutes (the
128+
* value the legacy local-mint paths used).
129+
*/
130+
export const MintInvoiceInputSchema = z.object({
131+
checkoutId: z.string(),
132+
expirySecs: z.number().int().positive().optional(),
133+
});
134+
export type MintInvoice = z.infer<typeof MintInvoiceInputSchema>;
135+
121136
export const RedeemL402OutputSchema = z.object({
122137
redeemed: z.boolean(),
123138
reason: z.string().optional(),
@@ -205,11 +220,16 @@ export const redeemL402Contract = oc
205220
.input(RedeemL402InputSchema)
206221
.output(RedeemL402OutputSchema);
207222

223+
export const mintInvoiceContract = oc
224+
.input(MintInvoiceInputSchema)
225+
.output(CheckoutSchema);
226+
208227
export const checkout = {
209228
get: getCheckoutContract,
210229
create: createCheckoutContract,
211230
confirm: confirmCheckoutContract,
212231
registerInvoice: registerInvoiceContract,
232+
mintInvoice: mintInvoiceContract,
213233
paymentReceived: paymentReceivedContract,
214234
redeemL402: redeemL402Contract,
215235
list: listCheckoutsContract,

src/contracts/node-control.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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. See:
20+
* /Users/martinsaposnic/.claude/plans/delegated-jumping-peach.md
21+
*/
22+
export const payoutContract = oc
23+
.input(PayoutInputSchema)
24+
.output(PayoutResultSchema);
25+
26+
export const invoiceCreateBolt11Contract = oc
27+
.input(InvoiceCreateBolt11InputSchema)
28+
.output(InvoiceBolt11ResultSchema);
29+
30+
export const invoiceCreateBolt12OfferContract = oc
31+
.input(InvoiceCreateBolt12OfferInputSchema)
32+
.output(InvoiceBolt12OfferResultSchema);
33+
34+
/**
35+
* Server-pushed event stream. mdk.com calls this once per session and consumes
36+
* the AsyncIterable for the lifetime of the connection. Single subscriber per
37+
* session, buffered from session start, FIFO.
38+
*/
39+
export const nodeEventsContract = oc
40+
.input(z.void())
41+
.output(eventIterator(NodeEventSchema));
42+
43+
export const nodeControl = {
44+
payout: payoutContract,
45+
invoice: {
46+
createBolt11: invoiceCreateBolt11Contract,
47+
createBolt12Offer: invoiceCreateBolt12OfferContract,
48+
},
49+
events: nodeEventsContract,
50+
};

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
RedeemL402Input,
2325
RedeemL402Output,
2426
} from "./contracts/checkout";
@@ -176,6 +178,30 @@ export const contract = {
176178
subscription,
177179
};
178180

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