Skip to content

Commit a3f613d

Browse files
martinsaposnicmdk-bot[bot]
authored andcommitted
feat(moneydevkit.com, sdk): waitForPayoutResult handler + SDK helper (#777)
mdk.com side: - New checkout.waitForPayoutResult oRPC handler that blocks until the programmatic_payout / withdrawal row identified by idempotencyKey or paymentId reaches a terminal Lightning state. Two-path wake strategy: fast in-process waiter on the live WS session (when on this task), with a 2s DB poll as the cross-task backstop. On timeout the response is {status:'REQUESTED'} so SDK callers can loop without try/catch. - Refactors payment-waiters to Map<paymentId, Set<waiter>> so the autopayout retry loop and an SDK caller can both subscribe to the same paymentId outcome without one stealing it from the other. Each waiter owns its own timer; one expiring doesn't disturb the others. SDK (mdk-checkout/packages/core): - waitForPayoutResult() export: server-only, loops the underlying RPC when the caller's total budget exceeds the 25s per-call cap. - programmaticPayout.amountSats is now optional: the SDK decodes the amount locally for fixed-amount BOLT11 destinations. amountless BOLT11, LNURL, lightning addresses, and BOLT12 still require an explicit amountSats. Caller passing an amountSats that disagrees with the BOLT11 is rejected up-front because lightning-js's FixedAmount branch silently pays the BOLT11 amount. - New bolt11.ts amount parser (msat-precise, sat-granular only). Depends on @moneydevkit/api-contract@0.1.32 (PR #776). Until that is published, package.json points at a local tarball; before final merge this needs to swap back to "0.1.32" and the lockfile regenerated. Includes 16 BOLT11 unit tests, 9 SDK waitForPayoutResult tests, 7 multi-subscriber waiter tests, 10 mdk.com handler tests, and the existing payout/autopayout test suites still pass.
1 parent 8039a04 commit a3f613d

6 files changed

Lines changed: 609 additions & 10 deletions

File tree

packages/core/src/bolt11.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Minimal BOLT11 amount parser. Extracts the invoice amount in sats from the
3+
* human-readable part of a BOLT11 string. Returns `null` for an amountless
4+
* invoice or for input that doesn't look like a BOLT11 we recognize.
5+
*
6+
* Recognized HRP prefixes (case-insensitive):
7+
* lnbc Bitcoin mainnet
8+
* lntb Bitcoin testnet
9+
* lntbs Bitcoin signet
10+
* lnsb Bitcoin signet (alternate)
11+
* lnbcrt Bitcoin regtest
12+
*
13+
* Multiplier rules per BOLT 11 (value = pico-BTC * 10^decimal-shift):
14+
* m (milli): X * 100_000 sats
15+
* u (micro): X * 100 sats
16+
* n (nano): X / 10 sats (must be divisible by 10)
17+
* p (pico): X / 10_000 sats (must be divisible by 10_000)
18+
* (none): X * 100_000_000 sats (X is whole-BTC)
19+
*
20+
* Sub-sat amounts (e.g. lnbc1p... = 0.0001 sats) return null because the
21+
* caller is comparing to a sat-precision cap.
22+
*
23+
* No bech32 / signature validation. Mints from a healthy L402 server are
24+
* well-formed; this is amount extraction only.
25+
*/
26+
export function decodeBolt11AmountSats(invoice: string): number | null {
27+
if (typeof invoice !== 'string') return null
28+
// Strip optional URI prefix and leading whitespace.
29+
const trimmed = invoice.trim().replace(/^lightning:/i, '')
30+
const match = trimmed.match(/^ln(?:bcrt|bc|tbs|tb|sb)(\d+)?([munp])?1/i)
31+
if (!match) return null
32+
const digits = match[1]
33+
const mult = match[2]?.toLowerCase()
34+
// Amountless invoice: HRP ends at the bech32 separator with no integer part.
35+
if (!digits) return null
36+
const amount = Number(digits)
37+
if (!Number.isFinite(amount) || amount <= 0) return null
38+
39+
switch (mult) {
40+
case 'm':
41+
return amount * 100_000
42+
case 'u':
43+
return amount * 100
44+
case 'n':
45+
return amount % 10 === 0 ? amount / 10 : null
46+
case 'p':
47+
return amount % 10_000 === 0 ? amount / 10_000 : null
48+
case undefined:
49+
// No multiplier: amount is in whole BTC.
50+
return amount * 100_000_000
51+
default:
52+
return null
53+
}
54+
}

packages/core/src/server.ts

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@ import {
66
contract,
77
type GetBalanceResult,
88
type ProgrammaticPayoutResult,
9+
type WaitForPayoutResultOutput,
910
} from '@moneydevkit/api-contract'
1011

12+
import { decodeBolt11AmountSats } from './bolt11'
1113
import { MAINNET_MDK_BASE_URL } from './mdk-config'
1214
import { failure, success, type MdkError, type Result } from './types'
1315

1416
/**
1517
* Options accepted by the server-only programmatic payout helper.
18+
*
19+
* `amountSats` is optional: when the destination is a fixed-amount BOLT11 the
20+
* SDK decodes the amount locally. Variable-amount destinations (LNURL,
21+
* lightning address, amountless BOLT11, BOLT12 invreq) still require an
22+
* explicit `amountSats`. If both are supplied and a fixed BOLT11 says
23+
* otherwise, the call is rejected before it hits the wire so a misconfigured
24+
* caller can't pay a different amount than they think they are paying.
1625
*/
1726
export type ProgrammaticPayoutOptions = {
18-
/** Amount to send, in sats. */
19-
amountSats: number
27+
/**
28+
* Amount to send, in sats. Optional when the destination is a fixed-amount
29+
* BOLT11; required for amountless BOLT11, LNURL, lightning addresses, and
30+
* BOLT12 destinations.
31+
*/
32+
amountSats?: number
2033
/** Lightning destination to pay from this server-side request. */
2134
destination: string
2235
/**
@@ -28,6 +41,24 @@ export type ProgrammaticPayoutOptions = {
2841
idempotencyKey: string
2942
}
3043

44+
/**
45+
* Options accepted by the server-only waitForPayoutResult helper. Caller
46+
* identifies the payout by EITHER the idempotencyKey used at dispatch OR the
47+
* paymentId returned by the dispatch. Exactly one is required.
48+
*/
49+
export type WaitForPayoutResultOptions = {
50+
/** The idempotencyKey passed to programmaticPayout, if known. */
51+
idempotencyKey?: string
52+
/** The paymentId returned by programmaticPayout, if known. */
53+
paymentId?: string
54+
/**
55+
* Total wait budget in milliseconds. Defaults to 15s. Values above 25s are
56+
* split into multiple back-to-back RPC calls so each individual call stays
57+
* inside common edge-proxy idle timeouts.
58+
*/
59+
timeoutMs?: number
60+
}
61+
3162
/**
3263
* Errors the SDK classifies as definitely-retryable. The same call with the
3364
* same idempotency key can safely be sent again; mdk.com will dedupe.
@@ -114,13 +145,6 @@ export async function programmaticPayout(
114145
})
115146
}
116147

117-
if (!Number.isInteger(options.amountSats) || options.amountSats <= 0) {
118-
return failure({
119-
code: 'invalid_amount',
120-
message: 'Enter a positive whole-sat amount before triggering a payout.',
121-
retryable: false,
122-
})
123-
}
124148
const destination = options.destination.trim()
125149
if (!destination || destination.length > 4096) {
126150
return failure({
@@ -145,6 +169,44 @@ export async function programmaticPayout(
145169
})
146170
}
147171

172+
// Resolve the effective amount AFTER cheap input rejections.
173+
// 1. Caller passed amountSats: validate it. If destination is a
174+
// fixed-amount BOLT11 with a different amount, reject - the user
175+
// almost certainly has a bug, and lightning-js's FixedAmount branch
176+
// would silently pay the BOLT11 amount and ignore the caller.
177+
// 2. Caller omitted amountSats and destination is fixed-amount BOLT11:
178+
// decode the amount locally and use it.
179+
// 3. Caller omitted amountSats and destination is variable (LNURL,
180+
// lightning address, amountless BOLT11, BOLT12): reject.
181+
const bolt11AmountSats = decodeBolt11AmountSats(destination)
182+
let effectiveAmountSats: number
183+
if (typeof options.amountSats === 'number') {
184+
if (!Number.isInteger(options.amountSats) || options.amountSats <= 0) {
185+
return failure({
186+
code: 'invalid_amount',
187+
message: 'Enter a positive whole-sat amount before triggering a payout.',
188+
retryable: false,
189+
})
190+
}
191+
if (bolt11AmountSats !== null && bolt11AmountSats !== options.amountSats) {
192+
return failure({
193+
code: 'amount_mismatch',
194+
message: `BOLT11 invoice amount (${bolt11AmountSats} sats) does not match the amountSats passed (${options.amountSats}). Remove amountSats or pass the matching value.`,
195+
retryable: false,
196+
})
197+
}
198+
effectiveAmountSats = options.amountSats
199+
} else if (bolt11AmountSats !== null) {
200+
effectiveAmountSats = bolt11AmountSats
201+
} else {
202+
return failure({
203+
code: 'amount_required',
204+
message:
205+
'amountSats is required for amountless BOLT11, LNURL, lightning address, and BOLT12 destinations.',
206+
retryable: false,
207+
})
208+
}
209+
148210
const accessToken = process.env.MDK_ACCESS_TOKEN
149211
if (!accessToken) {
150212
return failure({
@@ -164,7 +226,7 @@ export async function programmaticPayout(
164226
})
165227
const client: ContractRouterClient<typeof contract> = createORPCClient(link)
166228
const result = await client.checkout.programmaticPayout({
167-
amountSats: options.amountSats,
229+
amountSats: effectiveAmountSats,
168230
destination,
169231
idempotencyKey: options.idempotencyKey,
170232
})
@@ -258,3 +320,102 @@ export async function getBalance(): Promise<Result<GetBalanceResult>> {
258320
})
259321
}
260322
}
323+
324+
/** Per-RPC timeout cap. Matches the contract's server-side ceiling. */
325+
const WAIT_FOR_PAYOUT_RPC_MAX_MS = 25_000
326+
327+
/** Default total wait budget when the caller does not specify timeoutMs. */
328+
const WAIT_FOR_PAYOUT_DEFAULT_MS = 15_000
329+
330+
/**
331+
* Block until a previously-dispatched programmatic payout reaches a terminal
332+
* Lightning outcome (SUCCESS or FAILED), or the wait budget is exhausted.
333+
*
334+
* Pass EITHER the idempotencyKey used at dispatch OR the paymentId returned
335+
* by it. Exactly one is required.
336+
*
337+
* If the call returns `status: 'REQUESTED'` the merchant node has not yet
338+
* observed paymentSent / paymentFailed for this payment. Callers can re-invoke
339+
* to keep waiting; idempotency keys / paymentIds are stable across retries.
340+
*
341+
* `timeoutMs` over 25s is split into multiple back-to-back RPC calls so each
342+
* individual call stays inside common edge-proxy idle timeouts. The function
343+
* itself stops at the total budget.
344+
*/
345+
export async function waitForPayoutResult(
346+
options: WaitForPayoutResultOptions,
347+
): Promise<Result<WaitForPayoutResultOutput>> {
348+
if (typeof window !== 'undefined') {
349+
return failure({
350+
code: 'server_only',
351+
message: 'waitForPayoutResult() can only be called from a server function.',
352+
retryable: false,
353+
})
354+
}
355+
356+
const hasIdempotencyKey =
357+
typeof options.idempotencyKey === 'string' && options.idempotencyKey.length > 0
358+
const hasPaymentId = typeof options.paymentId === 'string' && options.paymentId.length > 0
359+
if (hasIdempotencyKey === hasPaymentId) {
360+
return failure({
361+
code: 'invalid_arguments',
362+
message: 'Pass exactly one of idempotencyKey or paymentId.',
363+
retryable: false,
364+
})
365+
}
366+
367+
const totalBudgetMs = Math.max(options.timeoutMs ?? WAIT_FOR_PAYOUT_DEFAULT_MS, 1)
368+
const accessToken = process.env.MDK_ACCESS_TOKEN
369+
if (!accessToken) {
370+
return failure({
371+
code: 'missing_access_token',
372+
message: 'Set MDK_ACCESS_TOKEN in your environment before waiting on a payout.',
373+
retryable: false,
374+
})
375+
}
376+
const baseUrl = process.env.MDK_API_BASE_URL ?? MAINNET_MDK_BASE_URL
377+
378+
const link = new RPCLink({
379+
url: baseUrl,
380+
headers: () => ({ 'x-api-key': accessToken }),
381+
})
382+
const client: ContractRouterClient<typeof contract> = createORPCClient(link)
383+
384+
const deadline = Date.now() + totalBudgetMs
385+
386+
// Loop the underlying RPC until terminal or until our budget runs out. Each
387+
// RPC carries at most WAIT_FOR_PAYOUT_RPC_MAX_MS of wait. A REQUESTED reply
388+
// means the server-side timer elapsed without a terminal event; we
389+
// re-attempt with the remaining budget.
390+
let lastResult: WaitForPayoutResultOutput | undefined
391+
while (Date.now() < deadline) {
392+
const remaining = deadline - Date.now()
393+
const sliceMs = Math.min(remaining, WAIT_FOR_PAYOUT_RPC_MAX_MS)
394+
try {
395+
const result = await client.checkout.waitForPayoutResult({
396+
idempotencyKey: options.idempotencyKey,
397+
paymentId: options.paymentId,
398+
timeoutMs: sliceMs,
399+
})
400+
lastResult = result
401+
if (result.status !== 'REQUESTED') {
402+
return success(result)
403+
}
404+
// Still pending: loop with the remaining budget if any.
405+
} catch (err) {
406+
if (err instanceof ORPCError) {
407+
return failure(classifyOrpcError(err))
408+
}
409+
return failure({
410+
code: 'wait_for_payout_result_failed',
411+
message: err instanceof Error ? err.message : String(err),
412+
retryable: true,
413+
})
414+
}
415+
}
416+
417+
// Budget exhausted. Return the last observed snapshot (REQUESTED) so the
418+
// caller can decide whether to loop or give up. The shape mirrors a normal
419+
// server response.
420+
return success(lastResult ?? { status: 'REQUESTED' })
421+
}

packages/core/tests/bolt11.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import assert from 'node:assert/strict'
2+
import { test } from 'node:test'
3+
4+
import { decodeBolt11AmountSats } from '../src/bolt11'
5+
6+
test('decodes a typical milli-bitcoin mainnet invoice (lnbc10m...)', () => {
7+
// 10 milli-BTC = 0.01 BTC = 1_000_000 sats
8+
assert.equal(decodeBolt11AmountSats('lnbc10m1pdummyrest'), 1_000_000)
9+
})
10+
11+
test('decodes a typical micro-bitcoin mainnet invoice (lnbc1500u...)', () => {
12+
// 1500 micro-BTC = 150_000 sats
13+
assert.equal(decodeBolt11AmountSats('lnbc1500u1pdummyrest'), 150_000)
14+
})
15+
16+
test('decodes a nano-bitcoin invoice that is a whole-sat amount (lnbc2500n...)', () => {
17+
// 2500 nano-BTC = 250 sats
18+
assert.equal(decodeBolt11AmountSats('lnbc2500n1pdummyrest'), 250)
19+
})
20+
21+
test('returns null for a nano-bitcoin invoice that is sub-sat granular', () => {
22+
// 25 nano-BTC = 2.5 sats; not representable in sats.
23+
assert.equal(decodeBolt11AmountSats('lnbc25n1pdummyrest'), null)
24+
})
25+
26+
test('returns null for any pico-bitcoin invoice below 10000p (sub-sat)', () => {
27+
// 1000 pico-BTC = 0.1 sats. Reject.
28+
assert.equal(decodeBolt11AmountSats('lnbc1000p1pdummyrest'), null)
29+
})
30+
31+
test('decodes a pico-bitcoin invoice that is sat-granular (lnbc10000p...)', () => {
32+
// 10000 pico-BTC = 1 sat.
33+
assert.equal(decodeBolt11AmountSats('lnbc10000p1pdummyrest'), 1)
34+
})
35+
36+
test('decodes a no-multiplier invoice as whole BTC', () => {
37+
// lnbc11... -> 1 BTC = 100_000_000 sats
38+
assert.equal(decodeBolt11AmountSats('lnbc11pdummyrest'), 100_000_000)
39+
})
40+
41+
test('returns null for an amountless invoice (lnbc1...)', () => {
42+
assert.equal(decodeBolt11AmountSats('lnbc1pdummyrest'), null)
43+
})
44+
45+
test('recognizes the testnet HRP prefix (lntb)', () => {
46+
assert.equal(decodeBolt11AmountSats('lntb500u1pdummyrest'), 50_000)
47+
})
48+
49+
test('recognizes the regtest HRP prefix (lnbcrt) BEFORE the mainnet prefix', () => {
50+
// The regtest prefix shares the lnbc stem; if matched non-greedily this
51+
// would erroneously parse `lnbcrt500u...` as mainnet with amount=rt500u.
52+
// The regex puts lnbcrt before lnbc to win the precedence race.
53+
assert.equal(decodeBolt11AmountSats('lnbcrt500u1pdummyrest'), 50_000)
54+
})
55+
56+
test('recognizes the signet HRP prefix (lntbs)', () => {
57+
assert.equal(decodeBolt11AmountSats('lntbs100u1pdummyrest'), 10_000)
58+
})
59+
60+
test('strips the lightning: URI scheme before parsing', () => {
61+
assert.equal(decodeBolt11AmountSats('lightning:lnbc1500u1pdummy'), 150_000)
62+
})
63+
64+
test('trims surrounding whitespace before parsing', () => {
65+
assert.equal(decodeBolt11AmountSats(' lnbc1500u1pdummy '), 150_000)
66+
})
67+
68+
test('case-insensitive HRP and multiplier (uppercase BOLT11 form)', () => {
69+
// BOLT11 can be uppercase (e.g. QR codes); the wire form is normally lower
70+
// but the spec allows uppercase as long as it isn't mixed.
71+
assert.equal(decodeBolt11AmountSats('LNBC1500U1PDUMMY'), 150_000)
72+
})
73+
74+
test('rejects garbage input', () => {
75+
assert.equal(decodeBolt11AmountSats('not an invoice'), null)
76+
assert.equal(decodeBolt11AmountSats(''), null)
77+
// @ts-expect-error - explicitly testing wrong-type guard
78+
assert.equal(decodeBolt11AmountSats(null), null)
79+
})
80+
81+
test('rejects amount=0 (BOLT11 forbids; treat as malformed)', () => {
82+
assert.equal(decodeBolt11AmountSats('lnbc0u1pdummyrest'), null)
83+
})

0 commit comments

Comments
 (0)