Skip to content

Commit fa5006e

Browse files
authored
feat(express): migrated lightningPayment to type route
2 parents 392d153 + f1f065d commit fa5006e

File tree

6 files changed

+1269
-10
lines changed

6 files changed

+1269
-10
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,12 +1694,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
16941694
);
16951695

16961696
// lightning - pay invoice
1697-
app.post(
1698-
'/api/v2/:coin/wallet/:id/lightning/payment',
1699-
parseBody,
1697+
router.post('express.v2.wallet.lightningPayment', [
17001698
prepareBitGo(config),
1701-
promiseWrapper(handlePayLightningInvoice)
1702-
);
1699+
typedPromiseWrapper(handlePayLightningInvoice),
1700+
]);
17031701

17041702
// lightning - onchain withdrawal
17051703
router.post('express.v2.wallet.lightningWithdraw', [

modules/express/src/lightning/lightningInvoiceRoutes.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as express from 'express';
22
import { ApiResponseError } from '../errors';
33
import { CreateInvoiceBody, getLightningWallet, Invoice, SubmitPaymentParams } from '@bitgo/abstract-lightning';
44
import { decodeOrElse } from '@bitgo/sdk-core';
5+
import { ExpressApiRouteRequest } from '../typedRoutes/api';
56

67
export async function handleCreateLightningInvoice(req: express.Request): Promise<any> {
78
const bitgo = req.bitgo;
@@ -17,14 +18,16 @@ export async function handleCreateLightningInvoice(req: express.Request): Promis
1718
return Invoice.encode(await lightningWallet.createInvoice(params));
1819
}
1920

20-
export async function handlePayLightningInvoice(req: express.Request): Promise<any> {
21+
export async function handlePayLightningInvoice(
22+
req: ExpressApiRouteRequest<'express.v2.wallet.lightningPayment', 'post'>
23+
): Promise<any> {
2124
const bitgo = req.bitgo;
2225
const params = decodeOrElse(SubmitPaymentParams.name, SubmitPaymentParams, req.body, (error) => {
2326
throw new ApiResponseError(`Invalid request body to pay lightning invoice`, 400);
2427
});
2528

26-
const coin = bitgo.coin(req.params.coin);
27-
const wallet = await coin.wallets().get({ id: req.params.id });
29+
const coin = bitgo.coin(req.decoded.coin);
30+
const wallet = await coin.wallets().get({ id: req.decoded.id });
2831
const lightningWallet = getLightningWallet(wallet);
2932

3033
return await lightningWallet.payInvoice(params);

modules/express/src/typedRoutes/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { PostCoinSign } from './v2/coinSign';
4141
import { PostSendCoins } from './v2/sendCoins';
4242
import { PostGenerateShareTSS } from './v2/generateShareTSS';
4343
import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload';
44+
import { PostLightningWalletPayment } from './v2/lightningPayment';
4445
import { PostLightningWalletWithdraw } from './v2/lightningWithdraw';
4546

4647
// Too large types can cause the following error
@@ -190,6 +191,12 @@ export const ExpressKeychainChangePasswordApiSpec = apiSpec({
190191
},
191192
});
192193

194+
export const ExpressLightningWalletPaymentApiSpec = apiSpec({
195+
'express.v2.wallet.lightningPayment': {
196+
post: PostLightningWalletPayment,
197+
},
198+
});
199+
193200
export const ExpressLightningGetStateApiSpec = apiSpec({
194201
'express.lightning.getState': {
195202
get: GetLightningState,
@@ -285,6 +292,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
285292
typeof ExpressV2WalletCreateAddressApiSpec &
286293
typeof ExpressKeychainLocalApiSpec &
287294
typeof ExpressKeychainChangePasswordApiSpec &
295+
typeof ExpressLightningWalletPaymentApiSpec &
288296
typeof ExpressLightningGetStateApiSpec &
289297
typeof ExpressLightningInitWalletApiSpec &
290298
typeof ExpressLightningUnlockWalletApiSpec &
@@ -319,6 +327,7 @@ export const ExpressApi: ExpressApi = {
319327
...ExpressV2WalletCreateAddressApiSpec,
320328
...ExpressKeychainLocalApiSpec,
321329
...ExpressKeychainChangePasswordApiSpec,
330+
...ExpressLightningWalletPaymentApiSpec,
322331
...ExpressLightningGetStateApiSpec,
323332
...ExpressLightningInitWalletApiSpec,
324333
...ExpressLightningUnlockWalletApiSpec,
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { BigIntFromString } from 'io-ts-types';
4+
import { BitgoExpressError } from '../../schemas/error';
5+
6+
/**
7+
* Path parameters for lightning payment API
8+
*/
9+
export const LightningPaymentParams = {
10+
/** The coin identifier (e.g., 'tlnbtc', 'lnbtc') */
11+
coin: t.string,
12+
/** The wallet ID */
13+
id: t.string,
14+
} as const;
15+
16+
/**
17+
* Request body for paying a lightning invoice
18+
*/
19+
export const LightningPaymentRequestBody = {
20+
/** The BOLT #11 encoded lightning invoice to pay */
21+
invoice: t.string,
22+
/** The wallet passphrase to decrypt signing keys */
23+
passphrase: t.string,
24+
/** Amount to pay in millisatoshis (required for zero-amount invoices) */
25+
amountMsat: optional(BigIntFromString),
26+
/** Maximum fee limit in millisatoshis */
27+
feeLimitMsat: optional(BigIntFromString),
28+
/** Fee limit as a ratio of payment amount (e.g., 0.01 for 1%) */
29+
feeLimitRatio: optional(t.number),
30+
/** Custom sequence ID for tracking this payment */
31+
sequenceId: optional(t.string),
32+
/** Comment or memo for this payment (not sent to recipient) */
33+
comment: optional(t.string),
34+
} as const;
35+
36+
/**
37+
* Payment status on the Lightning Network
38+
*/
39+
const PaymentStatus = t.union([t.literal('in_flight'), t.literal('settled'), t.literal('failed')]);
40+
41+
/**
42+
* Payment failure reasons
43+
*/
44+
const PaymentFailureReason = t.union([
45+
t.literal('TIMEOUT'),
46+
t.literal('NO_ROUTE'),
47+
t.literal('ERROR'),
48+
t.literal('INCORRECT_PAYMENT_DETAILS'),
49+
t.literal('INSUFFICIENT_BALANCE'),
50+
t.literal('INSUFFICIENT_WALLET_BALANCE'),
51+
t.literal('EXCESS_WALLET_BALANCE'),
52+
t.literal('INVOICE_EXPIRED'),
53+
t.literal('PAYMENT_ALREADY_SETTLED'),
54+
t.literal('PAYMENT_ALREADY_IN_FLIGHT'),
55+
t.literal('TRANSIENT_ERROR_RETRY_LATER'),
56+
t.literal('CANCELED'),
57+
t.literal('FORCE_FAILED'),
58+
]);
59+
60+
/**
61+
* Lightning Network payment status details
62+
*/
63+
const LndCreatePaymentResponse = t.intersection([
64+
t.type({
65+
/** Current payment status */
66+
status: PaymentStatus,
67+
/** Payment hash identifying this payment */
68+
paymentHash: t.string,
69+
}),
70+
t.partial({
71+
/** Internal BitGo payment ID */
72+
paymentId: t.string,
73+
/** Payment preimage (present when settled) */
74+
paymentPreimage: t.string,
75+
/** Actual amount paid in millisatoshis */
76+
amountMsat: t.string,
77+
/** Actual fee paid in millisatoshis */
78+
feeMsat: t.string,
79+
/** Failure reason (present when failed) */
80+
failureReason: PaymentFailureReason,
81+
}),
82+
]);
83+
84+
/**
85+
* Transaction request state
86+
*/
87+
const TxRequestState = t.union([
88+
t.literal('pendingCommitment'),
89+
t.literal('pendingApproval'),
90+
t.literal('canceled'),
91+
t.literal('rejected'),
92+
t.literal('initialized'),
93+
t.literal('pendingDelivery'),
94+
t.literal('delivered'),
95+
t.literal('pendingUserSignature'),
96+
t.literal('signed'),
97+
]);
98+
99+
/**
100+
* Pending approval state
101+
*/
102+
const PendingApprovalState = t.union([
103+
t.literal('pending'),
104+
t.literal('awaitingSignature'),
105+
t.literal('pendingBitGoAdminApproval'),
106+
t.literal('pendingIdVerification'),
107+
t.literal('pendingCustodianApproval'),
108+
t.literal('pendingFinalApproval'),
109+
t.literal('approved'),
110+
t.literal('processing'),
111+
t.literal('rejected'),
112+
]);
113+
114+
/**
115+
* Pending approval type
116+
*/
117+
const PendingApprovalType = t.union([
118+
t.literal('userChangeRequest'),
119+
t.literal('transactionRequest'),
120+
t.literal('policyRuleRequest'),
121+
t.literal('updateApprovalsRequiredRequest'),
122+
t.literal('transactionRequestFull'),
123+
]);
124+
125+
/**
126+
* Transaction request details within pending approval info
127+
*/
128+
const TransactionRequestDetails = t.intersection([
129+
t.type({
130+
/** Coin-specific transaction details */
131+
coinSpecific: t.record(t.string, t.unknown),
132+
/** Recipients of the transaction */
133+
recipients: t.unknown,
134+
/** Build parameters for the transaction */
135+
buildParams: t.intersection([
136+
t.partial({
137+
/** Type of transaction */
138+
type: t.union([t.literal('fanout'), t.literal('consolidate')]),
139+
}),
140+
t.record(t.string, t.unknown),
141+
]),
142+
}),
143+
t.partial({
144+
/** Source wallet for the transaction */
145+
sourceWallet: t.string,
146+
}),
147+
]);
148+
149+
/**
150+
* Pending approval information
151+
*/
152+
const PendingApprovalInfo = t.intersection([
153+
t.type({
154+
/** Type of pending approval */
155+
type: PendingApprovalType,
156+
}),
157+
t.partial({
158+
/** Transaction request details (for transaction-related approvals) */
159+
transactionRequest: TransactionRequestDetails,
160+
}),
161+
]);
162+
163+
/**
164+
* Pending approval details
165+
*/
166+
const PendingApproval = t.intersection([
167+
t.type({
168+
/** Pending approval ID */
169+
id: t.string,
170+
/** Approval state */
171+
state: PendingApprovalState,
172+
/** User ID of the approval creator */
173+
creator: t.string,
174+
/** Pending approval information */
175+
info: PendingApprovalInfo,
176+
}),
177+
t.partial({
178+
/** Wallet ID (for wallet-level approvals) */
179+
wallet: t.string,
180+
/** Enterprise ID (for enterprise-level approvals) */
181+
enterprise: t.string,
182+
/** Number of approvals required */
183+
approvalsRequired: t.number,
184+
/** Associated transaction request ID */
185+
txRequestId: t.string,
186+
}),
187+
]);
188+
189+
/**
190+
* Response for paying a lightning invoice
191+
*/
192+
export const LightningPaymentResponse = t.intersection([
193+
t.type({
194+
/** Payment request ID for tracking */
195+
txRequestId: t.string,
196+
/** Status of the payment request ('delivered', 'pendingApproval', etc.) */
197+
txRequestState: TxRequestState,
198+
}),
199+
t.partial({
200+
/** Pending approval details (present when approval is required) */
201+
pendingApproval: PendingApproval,
202+
/** Payment status on the Lightning Network (absent when pending approval) */
203+
paymentStatus: LndCreatePaymentResponse,
204+
}),
205+
]);
206+
207+
/**
208+
* Response status codes
209+
*/
210+
export const LightningPaymentResponseObj = {
211+
/** Successfully submitted payment */
212+
200: LightningPaymentResponse,
213+
/** Invalid request */
214+
400: BitgoExpressError,
215+
} as const;
216+
217+
/**
218+
* Pay a Lightning Invoice
219+
*
220+
* Submits a payment for a BOLT #11 lightning invoice. The payment is signed with the user's
221+
* authentication key and submitted to BitGo. If the payment requires additional approvals
222+
* (based on wallet policy), returns pending approval details. Otherwise, the payment is
223+
* immediately submitted to the Lightning Network.
224+
*
225+
* Fee limits can be controlled using either `feeLimitMsat` (absolute limit) or `feeLimitRatio`
226+
* (as a ratio of payment amount). If both are provided, the more restrictive limit applies.
227+
*
228+
* For zero-amount invoices (invoices without a specified amount), the `amountMsat` field is required.
229+
*
230+
* @operationId express.v2.wallet.lightningPayment
231+
* @tag express
232+
*/
233+
export const PostLightningWalletPayment = httpRoute({
234+
path: '/api/v2/{coin}/wallet/{id}/lightning/payment',
235+
method: 'POST',
236+
request: httpRequest({
237+
params: LightningPaymentParams,
238+
body: LightningPaymentRequestBody,
239+
}),
240+
response: LightningPaymentResponseObj,
241+
});

modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ describe('Lightning Invoice Routes', () => {
1616
req.params = params.params || {};
1717
req.query = params.query || {};
1818
req.bitgo = params.bitgo;
19+
// Add decoded property with both path params and body for typed routes
20+
(req as any).decoded = {
21+
coin: params.params?.coin,
22+
id: params.params?.id,
23+
...params.body,
24+
};
1925
return req as express.Request;
2026
};
2127

@@ -168,7 +174,9 @@ describe('Lightning Invoice Routes', () => {
168174
});
169175
req.bitgo = bitgo;
170176

171-
await should(handlePayLightningInvoice(req)).be.rejectedWith('Invalid request body to pay lightning invoice');
177+
await should(handlePayLightningInvoice(req as any)).be.rejectedWith(
178+
'Invalid request body to pay lightning invoice'
179+
);
172180
});
173181

174182
it('should throw an error if the invoice is missing in the request params', async () => {
@@ -183,7 +191,9 @@ describe('Lightning Invoice Routes', () => {
183191
});
184192
req.bitgo = bitgo;
185193

186-
await should(handlePayLightningInvoice(req)).be.rejectedWith(/^Invalid request body to pay lightning invoice/);
194+
await should(handlePayLightningInvoice(req as any)).be.rejectedWith(
195+
/^Invalid request body to pay lightning invoice/
196+
);
187197
});
188198
});
189199
});

0 commit comments

Comments
 (0)