Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/contracts/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ export const GetCheckoutInputSchema = z.object({
});
export type GetCheckoutInput = z.infer<typeof GetCheckoutInputSchema>;

/**
* 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<typeof MintInvoiceInputSchema>;

export type CreateCheckout = z.infer<typeof CreateCheckoutInputSchema>;
export type ConfirmCheckout = z.infer<typeof ConfirmCheckoutInputSchema>;
export type RegisterInvoice = z.infer<typeof RegisterInvoiceInputSchema>;
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire mintInvoice into sdkContract checkout map

This adds checkout.mintInvoice to the core contract, but sdkContract.checkout still exports only registerInvoice in src/index.ts (lines 205-210), so any SDK router/client that is typed from sdkContract cannot adopt the new single-step mint flow and will stay pinned to the legacy path. That mismatch makes this feature unavailable in the SDK-facing contract surface even though it is now publicly defined.

Useful? React with 👍 / 👎.

paymentReceived: paymentReceivedContract,
list: listCheckoutsContract,
listPaginated: listCheckoutsPaginatedContract,
Expand Down
49 changes: 49 additions & 0 deletions src/contracts/node-control.ts
Original file line number Diff line number Diff line change
@@ -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,
};
26 changes: 26 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +20,7 @@ export type {
CreateCheckout,
PaymentReceived,
RegisterInvoice,
MintInvoice,
} from "./contracts/checkout";
export {
CheckoutStatusSchema,
Expand Down Expand Up @@ -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: {
Expand Down
112 changes: 112 additions & 0 deletions src/schemas/node-control.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PayoutInputSchema>;

/**
* 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<typeof PayoutResultSchema>;

/**
* 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<typeof InvoiceBolt11ResultSchema>;

/**
* 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<typeof NodeEventSchema>;
Loading