Skip to content

Commit 6055a47

Browse files
martinsaposnicmdk-bot[bot]
authored andcommitted
feat(sdk): server.payout helper + WS handler + nodeControl wire-up [2/4] (#718)
* feat(sdk): server.payout helper + WS payout handler + nodeControl wire-up Wire programmaticPayout into the exported nodeControl router, add the matching control-WS handler in @moneydevkit/checkout, the server.payout() helper for trusted server-side callers, the ./server subpath export, and the @moneydevkit/nextjs server re-export. Update README. * chore(api-contract): bump to 0.1.26 for nodeControl wire-up * feat(sdk): retryable errors + required idempotencyKey + Edge-friendly server.ts Address PR #718 review feedback: - Make `idempotencyKey` required in both api-contract schema and SDK options. Optional + auto-UUID at the dispatch layer was a double-pay footgun on retry. - Add `retryable: boolean | undefined` and `reason: string | undefined` fields to `MdkError`. SDK classifies known backend codes into retryable / not retryable / unclassified and exposes a short machine-readable `reason` for branching. - Decouple `server.ts` from `./mdk` so importing `@moneydevkit/core/server` does not eagerly load `@moneydevkit/lightning-js`. The helper is a thin oRPC HTTPS call; it only needs `MDK_ACCESS_TOKEN` and `MDK_API_BASE_URL`. Inlining the env reads makes the helper Edge-runtime compatible. - Update README with vibecoder-friendly error handling: retry loop with backoff, reason-code branching, common gotchas, error reference table. - Tests cover validation paths, missing access token, idempotency key required, retryable / non-retryable / unclassified error classification, raw network errors, and a guard that server.ts does not import the lightning-js stack. * docs(payout): fix wrong API key framing, drop speculative codes, mirror to Mintlify - README: rewrite app_scoped_api_key_required guidance. The error fires when the API key isn't tied to a specific app, not because of any "org-level vs app-level" distinction (there's no such concept in the dashboard - every API key is per-app). - README: drop the "acceptance vs settlement" gotcha. - server.ts: align reason mapping with the actual backend codes: PROGRAMMATIC_PAYOUTS_DISABLED (plural) and INVALID_PROGRAMMATIC_PAYOUT_AMOUNT. Drop speculative codes the backend doesn't emit (PROGRAMMATIC_PAYOUT_NODE_OFFLINE, *_TIMEOUT, *_INSUFFICIENT_FEES). All transient failures surface as PROGRAMMATIC_PAYOUT_FAILED today; the underlying cause is in error.message. - docs/nextjs.mdx: mirror the Server-side payouts section so the Mintlify site documents the helper alongside the README.
1 parent ff1f576 commit 6055a47

11 files changed

Lines changed: 675 additions & 7 deletions

File tree

packages/api-contract/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@moneydevkit/api-contract",
3-
"version": "0.1.25",
3+
"version": "0.1.26",
44
"description": "API Contract for moneydevkit",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/api-contract/src/contracts/checkout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const ProgrammaticPayoutInputSchema = z.object({
159159
.max(4096)
160160
// eslint-disable-next-line no-control-regex
161161
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value)),
162-
idempotencyKey: z.string().min(1).optional(),
162+
idempotencyKey: z.string().min(1),
163163
});
164164
export type ProgrammaticPayout = z.infer<
165165
typeof ProgrammaticPayoutInputSchema

packages/api-contract/src/contracts/node-control.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const nodeEventsContract = oc
4141

4242
export const nodeControl = {
4343
payout: payoutContract,
44+
programmaticPayout: programmaticPayoutContract,
4445
invoice: {
4546
createBolt11: invoiceCreateBolt11Contract,
4647
createBolt12Offer: invoiceCreateBolt12OfferContract,

packages/core/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
"import": "./dist/route.js",
2929
"default": "./dist/route.js"
3030
},
31+
"./server": {
32+
"types": "./dist/server.d.ts",
33+
"import": "./dist/server.js",
34+
"default": "./dist/server.js"
35+
},
3136
"./mdk402": {
3237
"types": "./dist/mdk402/index.d.ts",
3338
"import": "./dist/mdk402/index.js",

packages/core/src/control/handlers.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,36 @@ const payoutImpl = impl.payout.handler(({ input, context }) => {
4949
}>((resolve, reject) => {
5050
context.queue.push({
5151
kind: 'payout',
52-
destination: destination as string,
52+
destination: destination.trim(),
53+
amountMsat: input.amountMsat,
54+
resolve,
55+
reject,
56+
})
57+
})
58+
})
59+
60+
/**
61+
* Programmatic payout RPC handler for trusted server-supplied destinations.
62+
*/
63+
const programmaticPayoutImpl = impl.programmaticPayout.handler(({ input, context }) => {
64+
if (!context.sessionState.nodeReady) {
65+
rejectWith('NODE_NOT_READY', 'node has not finished startReceiving yet')
66+
}
67+
if (context.sessionState.draining) {
68+
rejectWith('DRAINING', 'node is in drain window; retry on next session')
69+
}
70+
const destination = input.destination.trim()
71+
if (!destination) {
72+
rejectWith('PROGRAMMATIC_PAYOUT_DESTINATION_UNSET', 'payout destination is required')
73+
}
74+
return new Promise<{
75+
accepted: true
76+
paymentId: string
77+
paymentHash: string | null
78+
}>((resolve, reject) => {
79+
context.queue.push({
80+
kind: 'payout',
81+
destination,
5382
amountMsat: input.amountMsat,
5483
resolve,
5584
reject,
@@ -115,6 +144,7 @@ const eventsImpl = impl.events.handler(async function* ({ context }) {
115144

116145
export const nodeControlRouter = impl.router({
117146
payout: payoutImpl,
147+
programmaticPayout: programmaticPayoutImpl,
118148
invoice: {
119149
createBolt11: createBolt11Impl,
120150
createBolt12Offer: createBolt12OfferImpl,

packages/core/src/server.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { ORPCError } from '@orpc/client'
2+
import { createORPCClient } from '@orpc/client'
3+
import { RPCLink } from '@orpc/client/fetch'
4+
import type { ContractRouterClient } from '@orpc/contract'
5+
import { contract, type ProgrammaticPayoutResult } from '@moneydevkit/api-contract'
6+
7+
import { MAINNET_MDK_BASE_URL } from './mdk-config'
8+
import { failure, success, type MdkError, type Result } from './types'
9+
10+
/**
11+
* Options accepted by the server-only programmatic payout helper.
12+
*/
13+
export type ProgrammaticPayoutOptions = {
14+
/** Amount to send, in sats. */
15+
amountSats: number
16+
/** Lightning destination to pay from this server-side request. */
17+
destination: string
18+
/**
19+
* Idempotency key used to deduplicate retries of the same logical payout.
20+
* Pass the same value on retry to avoid double-pays. Typically your own
21+
* orderId / withdrawalId / requestId. Must be a stable string per logical
22+
* payout, not a fresh value generated on each call.
23+
*/
24+
idempotencyKey: string
25+
}
26+
27+
/**
28+
* Errors the SDK classifies as definitely-retryable. The same call with the
29+
* same idempotency key can safely be sent again; mdk.com will dedupe.
30+
*/
31+
const RETRYABLE_CODES = new Set([
32+
'PROGRAMMATIC_PAYOUT_FAILED',
33+
'PROGRAMMATIC_PAYOUT_DAILY_LIMIT_EXCEEDED',
34+
])
35+
36+
/**
37+
* Errors the SDK classifies as not retryable without changing inputs or config.
38+
*/
39+
const NON_RETRYABLE_CODES = new Set([
40+
'PROGRAMMATIC_PAYOUT_APP_KEY_REQUIRED',
41+
'PROGRAMMATIC_PAYOUTS_DISABLED',
42+
'PROGRAMMATIC_PAYOUT_TOO_LARGE',
43+
'INVALID_PROGRAMMATIC_PAYOUT_AMOUNT',
44+
'VALIDATION_ERROR',
45+
'NOT_FOUND',
46+
])
47+
48+
/**
49+
* Map a backend error code to a short, actionable reason string the caller
50+
* can branch on. Returns undefined for unrecognized codes.
51+
*/
52+
function reasonForCode(code: string | undefined): string | undefined {
53+
if (!code) return undefined
54+
switch (code) {
55+
case 'PROGRAMMATIC_PAYOUT_DAILY_LIMIT_EXCEEDED':
56+
return 'daily_limit_exceeded'
57+
case 'PROGRAMMATIC_PAYOUT_TOO_LARGE':
58+
return 'amount_too_large'
59+
case 'PROGRAMMATIC_PAYOUTS_DISABLED':
60+
return 'programmatic_payouts_disabled'
61+
case 'PROGRAMMATIC_PAYOUT_APP_KEY_REQUIRED':
62+
return 'app_scoped_api_key_required'
63+
case 'PROGRAMMATIC_PAYOUT_FAILED':
64+
return 'payout_dispatch_failed'
65+
case 'INVALID_PROGRAMMATIC_PAYOUT_AMOUNT':
66+
return 'amount_invalid'
67+
default:
68+
return undefined
69+
}
70+
}
71+
72+
function classifyOrpcError(err: ORPCError<string, unknown>): MdkError {
73+
const data = err.data as { code?: string } | undefined
74+
const code = data?.code
75+
const retryable = code
76+
? RETRYABLE_CODES.has(code)
77+
? true
78+
: NON_RETRYABLE_CODES.has(code)
79+
? false
80+
: undefined
81+
: undefined
82+
return {
83+
code: code ?? err.code ?? 'payout_failed',
84+
message: err.message,
85+
status: err.status,
86+
retryable,
87+
reason: reasonForCode(code),
88+
}
89+
}
90+
91+
/**
92+
* Trigger a payout from a server function through mdk.com's control plane.
93+
*
94+
* This helper accepts a destination because it is intended for trusted server
95+
* functions. Never expose it through a client-controlled route without your
96+
* own authorization and business rules.
97+
*
98+
* The returned result distinguishes retryable failures (e.g. transient
99+
* dispatch failures, daily limit) from terminal ones (e.g. app config, validation).
100+
* Use `result.error.retryable` and `result.error.reason` to drive retries.
101+
*/
102+
export async function programmaticPayout(
103+
options: ProgrammaticPayoutOptions,
104+
): Promise<Result<ProgrammaticPayoutResult>> {
105+
if (typeof window !== 'undefined') {
106+
return failure({
107+
code: 'server_only',
108+
message: 'programmaticPayout() can only be called from a server function.',
109+
retryable: false,
110+
})
111+
}
112+
113+
if (!Number.isInteger(options.amountSats) || options.amountSats <= 0) {
114+
return failure({
115+
code: 'invalid_amount',
116+
message: 'Enter a positive whole-sat amount before triggering a payout.',
117+
retryable: false,
118+
})
119+
}
120+
const destination = options.destination.trim()
121+
if (!destination || destination.length > 4096) {
122+
return failure({
123+
code: 'invalid_destination',
124+
message: 'Enter a valid Lightning destination before triggering a payout.',
125+
retryable: false,
126+
})
127+
}
128+
if (/[\u0000-\u001f\u007f]/.test(destination)) {
129+
return failure({
130+
code: 'invalid_destination',
131+
message: 'Enter a valid Lightning destination before triggering a payout.',
132+
retryable: false,
133+
})
134+
}
135+
if (typeof options.idempotencyKey !== 'string' || options.idempotencyKey.length === 0) {
136+
return failure({
137+
code: 'invalid_idempotency_key',
138+
message:
139+
'Pass a stable idempotencyKey (e.g. your orderId) so retries do not double-pay.',
140+
retryable: false,
141+
})
142+
}
143+
144+
const accessToken = process.env.MDK_ACCESS_TOKEN
145+
if (!accessToken) {
146+
return failure({
147+
code: 'missing_access_token',
148+
message: 'Set MDK_ACCESS_TOKEN in your environment before triggering a payout.',
149+
retryable: false,
150+
})
151+
}
152+
const baseUrl = process.env.MDK_API_BASE_URL ?? MAINNET_MDK_BASE_URL
153+
154+
try {
155+
const link = new RPCLink({
156+
url: baseUrl,
157+
headers: () => ({
158+
'x-api-key': accessToken,
159+
}),
160+
})
161+
const client: ContractRouterClient<typeof contract> = createORPCClient(link)
162+
const result = await client.checkout.programmaticPayout({
163+
amountSats: options.amountSats,
164+
destination,
165+
idempotencyKey: options.idempotencyKey,
166+
})
167+
return success(result)
168+
} catch (err) {
169+
if (err instanceof ORPCError) {
170+
return failure(classifyOrpcError(err))
171+
}
172+
return failure({
173+
code: 'payout_failed',
174+
message: err instanceof Error ? err.message : String(err),
175+
retryable: true,
176+
})
177+
}
178+
}

packages/core/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ export type MdkError = {
44
details?: Array<{ message?: string; path?: Array<string | number> }>
55
suggestion?: string
66
status?: number
7+
/**
8+
* True when the failure is transient and the same call can be retried later.
9+
* False when retrying without changing inputs or configuration will fail again.
10+
* Absent when the SDK can't classify the failure; treat as not retryable.
11+
*/
12+
retryable?: boolean
13+
/**
14+
* Short machine-readable reason for the failure (e.g. 'insufficient_fees',
15+
* 'daily_limit_exceeded'). Present for failures the SDK can categorize.
16+
*/
17+
reason?: string
718
}
819

920
export type Result<T> =

packages/core/tests/control-handlers.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,55 @@ test('payout rejects when draining', async () => {
3535
assert.equal(ctx.queue.size, 0)
3636
})
3737

38-
test('payout rejects when WITHDRAWAL_DESTINATION is unset', async () => {
38+
test('payout rejects when neither destination nor WITHDRAWAL_DESTINATION is set', async () => {
3939
const ctx = makeContext({ env: { WITHDRAWAL_DESTINATION: undefined } })
4040
await assert.rejects(
4141
call(nodeControlRouter.payout, { amountMsat: 1000, idempotencyKey: 'k1' }, { context: ctx }),
4242
/WITHDRAWAL_DESTINATION/,
4343
)
4444
})
4545

46-
test('payout enqueues with env-derived destination (NOT from input)', async () => {
46+
test('payout ignores arbitrary input destination and uses WITHDRAWAL_DESTINATION', async () => {
47+
const ctx = makeContext({ env: { WITHDRAWAL_DESTINATION: 'lnurl-pre-configured' } })
48+
const pending = call(
49+
nodeControlRouter.payout,
50+
{ amountMsat: 12345, destination: ' lnbc-attacker ', idempotencyKey: 'k1' } as never,
51+
{ context: ctx },
52+
)
53+
await new Promise((r) => setImmediate(r))
54+
const cmd = ctx.queue.shift()
55+
assert.ok(cmd)
56+
assert.equal(cmd?.kind, 'payout')
57+
if (cmd?.kind === 'payout') {
58+
assert.equal(cmd.destination, 'lnurl-pre-configured')
59+
assert.equal(cmd.amountMsat, 12345, 'msats round-trip with no 1000x conversion')
60+
cmd.resolve({ accepted: true, paymentId: 'pay-id-1', paymentHash: 'hash-1' })
61+
}
62+
const result = await pending
63+
assert.deepEqual(result, { accepted: true, paymentId: 'pay-id-1', paymentHash: 'hash-1' })
64+
})
65+
66+
test('programmaticPayout enqueues with explicit input destination', async () => {
67+
const ctx = makeContext({ env: { WITHDRAWAL_DESTINATION: 'lnurl-pre-configured' } })
68+
const pending = call(
69+
nodeControlRouter.programmaticPayout,
70+
{ amountMsat: 12345, destination: ' lnbc-programmatic ', idempotencyKey: 'k1' },
71+
{ context: ctx },
72+
)
73+
await new Promise((r) => setImmediate(r))
74+
const cmd = ctx.queue.shift()
75+
assert.ok(cmd)
76+
assert.equal(cmd?.kind, 'payout')
77+
if (cmd?.kind === 'payout') {
78+
assert.equal(cmd.destination, 'lnbc-programmatic')
79+
assert.equal(cmd.amountMsat, 12345, 'msats round-trip with no 1000x conversion')
80+
cmd.resolve({ accepted: true, paymentId: 'pay-id-1', paymentHash: 'hash-1' })
81+
}
82+
const result = await pending
83+
assert.deepEqual(result, { accepted: true, paymentId: 'pay-id-1', paymentHash: 'hash-1' })
84+
})
85+
86+
test('payout falls back to env-derived destination when input omits one', async () => {
4787
const ctx = makeContext({ env: { WITHDRAWAL_DESTINATION: 'lnurl-pre-configured' } })
4888
const pending = call(
4989
nodeControlRouter.payout,
@@ -56,7 +96,7 @@ test('payout enqueues with env-derived destination (NOT from input)', async () =
5696
assert.ok(cmd)
5797
assert.equal(cmd?.kind, 'payout')
5898
if (cmd?.kind === 'payout') {
59-
assert.equal(cmd.destination, 'lnurl-pre-configured', 'destination from env, not input')
99+
assert.equal(cmd.destination, 'lnurl-pre-configured', 'destination falls back to env')
60100
assert.equal(cmd.amountMsat, 12345, 'msats round-trip with no 1000x conversion')
61101
cmd.resolve({ accepted: true, paymentId: 'pay-id-1', paymentHash: 'hash-1' })
62102
}

0 commit comments

Comments
 (0)