Skip to content

Commit 218b063

Browse files
authored
feat(express): migrated lightningWithdraw to type route
2 parents 391d9f9 + 1feb50c commit 218b063

File tree

6 files changed

+769
-13
lines changed

6 files changed

+769
-13
lines changed

modules/express/src/clientRoutes.ts

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

17041704
// lightning - onchain withdrawal
1705-
app.post(
1706-
'/api/v2/:coin/wallet/:id/lightning/withdraw',
1707-
parseBody,
1705+
router.post('express.v2.wallet.lightningWithdraw', [
17081706
prepareBitGo(config),
1709-
promiseWrapper(handleLightningWithdraw)
1710-
);
1707+
typedPromiseWrapper(handleLightningWithdraw),
1708+
]);
17111709

17121710
// any other API v2 call
17131711
app.use('/api/v2/user/*', parseBody, prepareBitGo(config), promiseWrapper(handleV2UserREST));

modules/express/src/lightning/lightningWithdrawRoutes.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import * as express from 'express';
21
import { decodeOrElse } from '@bitgo/sdk-core';
32
import { getLightningWallet, LightningOnchainWithdrawParams } from '@bitgo/abstract-lightning';
43
import { ApiResponseError } from '../errors';
4+
import { ExpressApiRouteRequest } from '../typedRoutes/api';
55

6-
export async function handleLightningWithdraw(req: express.Request): Promise<any> {
6+
export async function handleLightningWithdraw(
7+
req: ExpressApiRouteRequest<'express.v2.wallet.lightningWithdraw', 'post'>
8+
): Promise<any> {
79
const bitgo = req.bitgo;
810
const params = decodeOrElse(
911
LightningOnchainWithdrawParams.name,
@@ -14,8 +16,8 @@ export async function handleLightningWithdraw(req: express.Request): Promise<any
1416
}
1517
);
1618

17-
const coin = bitgo.coin(req.params.coin);
18-
const wallet = await coin.wallets().get({ id: req.params.id });
19+
const coin = bitgo.coin(req.decoded.coin);
20+
const wallet = await coin.wallets().get({ id: req.decoded.id });
1921
const lightningWallet = getLightningWallet(wallet);
2022

2123
return await lightningWallet.withdrawOnchain(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 { PostLightningWalletWithdraw } from './v2/lightningWithdraw';
4445

4546
// Too large types can cause the following error
4647
//
@@ -207,6 +208,12 @@ export const ExpressLightningUnlockWalletApiSpec = apiSpec({
207208
},
208209
});
209210

211+
export const ExpressLightningWalletWithdrawApiSpec = apiSpec({
212+
'express.v2.wallet.lightningWithdraw': {
213+
post: PostLightningWalletWithdraw,
214+
},
215+
});
216+
210217
export const ExpressOfcSignPayloadApiSpec = apiSpec({
211218
'express.ofc.signPayload': {
212219
post: PostOfcSignPayload,
@@ -281,6 +288,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
281288
typeof ExpressLightningGetStateApiSpec &
282289
typeof ExpressLightningInitWalletApiSpec &
283290
typeof ExpressLightningUnlockWalletApiSpec &
291+
typeof ExpressLightningWalletWithdrawApiSpec &
284292
typeof ExpressV2WalletSendManyApiSpec &
285293
typeof ExpressV2WalletSendCoinsApiSpec &
286294
typeof ExpressOfcSignPayloadApiSpec &
@@ -314,6 +322,7 @@ export const ExpressApi: ExpressApi = {
314322
...ExpressLightningGetStateApiSpec,
315323
...ExpressLightningInitWalletApiSpec,
316324
...ExpressLightningUnlockWalletApiSpec,
325+
...ExpressLightningWalletWithdrawApiSpec,
317326
...ExpressV2WalletSendManyApiSpec,
318327
...ExpressV2WalletSendCoinsApiSpec,
319328
...ExpressOfcSignPayloadApiSpec,
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import * as t from 'io-ts';
2+
import { BigIntFromString } from 'io-ts-types';
3+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
4+
import { BitgoExpressError } from '../../schemas/error';
5+
6+
/**
7+
* Path parameters for lightning withdraw
8+
*/
9+
export const LightningWithdrawParams = {
10+
/** The coin identifier (e.g., 'lnbtc', 'tlnbtc') */
11+
coin: t.string,
12+
/** The ID of the wallet */
13+
id: t.string,
14+
} as const;
15+
16+
/**
17+
* Lightning onchain recipient
18+
*/
19+
const LightningOnchainRecipient = t.type({
20+
/** Amount in satoshis (as string that will be converted to BigInt) */
21+
amountSat: BigIntFromString,
22+
/** Bitcoin address to send to */
23+
address: t.string,
24+
});
25+
26+
/**
27+
* Request body for lightning onchain withdrawal
28+
*/
29+
export const LightningWithdrawRequestBody = {
30+
/** Array of recipients to pay */
31+
recipients: t.array(LightningOnchainRecipient),
32+
/** Wallet passphrase for signing */
33+
passphrase: t.string,
34+
/** Fee rate in satoshis per virtual byte (as string that will be converted to BigInt) */
35+
satsPerVbyte: optional(BigIntFromString),
36+
/** Target number of blocks for confirmation */
37+
numBlocks: optional(t.number),
38+
/** Optional sequence ID for the withdraw transfer */
39+
sequenceId: optional(t.string),
40+
/** Optional comment for the withdraw transfer */
41+
comment: optional(t.string),
42+
} as const;
43+
44+
/**
45+
* Withdraw status codec
46+
*/
47+
const WithdrawStatus = t.union([t.literal('delivered'), t.literal('failed')]);
48+
49+
/**
50+
* LND create withdraw response
51+
*/
52+
const LndCreateWithdrawResponse = t.intersection([
53+
t.type({
54+
/** Status of the withdrawal */
55+
status: WithdrawStatus,
56+
}),
57+
t.partial({
58+
/** Transaction ID (txid) if delivered */
59+
txid: t.string,
60+
/** Failure reason if failed */
61+
failureReason: t.string,
62+
}),
63+
]);
64+
65+
/**
66+
* Transaction request state
67+
*/
68+
const TxRequestState = t.union([
69+
t.literal('pendingCommitment'),
70+
t.literal('pendingApproval'),
71+
t.literal('canceled'),
72+
t.literal('rejected'),
73+
t.literal('initialized'),
74+
t.literal('pendingDelivery'),
75+
t.literal('delivered'),
76+
t.literal('pendingUserSignature'),
77+
t.literal('signed'),
78+
]);
79+
80+
/**
81+
* Pending approval state
82+
*/
83+
const PendingApprovalState = t.union([
84+
t.literal('pending'),
85+
t.literal('awaitingSignature'),
86+
t.literal('pendingBitGoAdminApproval'),
87+
t.literal('pendingIdVerification'),
88+
t.literal('pendingCustodianApproval'),
89+
t.literal('pendingFinalApproval'),
90+
t.literal('approved'),
91+
t.literal('processing'),
92+
t.literal('rejected'),
93+
]);
94+
95+
/**
96+
* Pending approval type
97+
*/
98+
const PendingApprovalType = t.union([
99+
t.literal('userChangeRequest'),
100+
t.literal('transactionRequest'),
101+
t.literal('policyRuleRequest'),
102+
t.literal('updateApprovalsRequiredRequest'),
103+
t.literal('transactionRequestFull'),
104+
]);
105+
106+
/**
107+
* Transaction request details in pending approval info
108+
* When transactionRequest is present, coinSpecific, recipients, and buildParams are REQUIRED
109+
* Only sourceWallet is optional
110+
*/
111+
const TransactionRequestDetails = t.intersection([
112+
t.type({
113+
/** Coin-specific transaction details - REQUIRED */
114+
coinSpecific: t.record(t.string, t.unknown),
115+
/** Recipients of the transaction - REQUIRED */
116+
recipients: t.unknown,
117+
/** Build parameters for the transaction - REQUIRED */
118+
buildParams: t.intersection([
119+
t.partial({
120+
/** Type of transaction (fanout or consolidate) - OPTIONAL */
121+
type: t.union([t.literal('fanout'), t.literal('consolidate')]),
122+
}),
123+
t.record(t.string, t.unknown),
124+
]),
125+
}),
126+
t.partial({
127+
/** Source wallet for the transaction - OPTIONAL */
128+
sourceWallet: t.string,
129+
}),
130+
]);
131+
132+
/**
133+
* Pending approval info nested object
134+
*/
135+
const PendingApprovalInfo = t.intersection([
136+
t.type({
137+
/** Type of pending approval */
138+
type: PendingApprovalType,
139+
}),
140+
t.partial({
141+
/** Transaction request associated with approval */
142+
transactionRequest: TransactionRequestDetails,
143+
}),
144+
]);
145+
146+
/**
147+
* Pending approval data
148+
*/
149+
const PendingApproval = t.intersection([
150+
t.type({
151+
/** Pending approval ID */
152+
id: t.string,
153+
/** State of the pending approval */
154+
state: PendingApprovalState,
155+
/** Creator of the pending approval */
156+
creator: t.string,
157+
/** Information about the pending approval */
158+
info: PendingApprovalInfo,
159+
}),
160+
t.partial({
161+
/** Wallet ID if wallet-specific */
162+
wallet: t.string,
163+
/** Enterprise ID if enterprise-specific */
164+
enterprise: t.string,
165+
/** Number of approvals required */
166+
approvalsRequired: t.number,
167+
/** Associated transaction request ID */
168+
txRequestId: t.string,
169+
}),
170+
]);
171+
172+
/**
173+
* Response for lightning onchain withdrawal
174+
*/
175+
const LightningWithdrawResponse = t.intersection([
176+
t.type({
177+
/** Unique identifier for withdraw request submitted to BitGo */
178+
txRequestId: t.string,
179+
/** Status of withdraw request submission to BitGo */
180+
txRequestState: TxRequestState,
181+
}),
182+
t.partial({
183+
/** Pending approval details, if applicable */
184+
pendingApproval: PendingApproval,
185+
/** Current snapshot of withdraw status (if available) */
186+
withdrawStatus: LndCreateWithdrawResponse,
187+
}),
188+
]);
189+
190+
/**
191+
* Response type mapping for lightning withdraw
192+
*/
193+
export const LightningWithdrawResponseType = {
194+
200: LightningWithdrawResponse,
195+
400: BitgoExpressError,
196+
401: BitgoExpressError,
197+
404: BitgoExpressError,
198+
500: BitgoExpressError,
199+
} as const;
200+
201+
/**
202+
* Lightning Onchain Withdrawal API
203+
*
204+
* Withdraws lightning balance to an onchain Bitcoin address
205+
*
206+
* @operationId express.v2.wallet.lightningWithdraw
207+
* @tag express
208+
*/
209+
export const PostLightningWalletWithdraw = httpRoute({
210+
path: '/api/v2/{coin}/wallet/{id}/lightning/withdraw',
211+
method: 'POST',
212+
request: httpRequest({
213+
params: LightningWithdrawParams,
214+
body: LightningWithdrawRequestBody,
215+
}),
216+
response: LightningWithdrawResponseType,
217+
});

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

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

@@ -149,10 +155,10 @@ describe('Lightning Withdraw Routes', () => {
149155
const req = mockRequestObject({
150156
params: { id: 'testWalletId', coin },
151157
body: inputParams,
158+
bitgo,
152159
});
153-
req.bitgo = bitgo;
154160

155-
await should(handleLightningWithdraw(req)).be.rejectedWith(
161+
await should(handleLightningWithdraw(req as any)).be.rejectedWith(
156162
'Invalid request body for withdrawing on chain lightning balance'
157163
);
158164
});
@@ -171,10 +177,10 @@ describe('Lightning Withdraw Routes', () => {
171177
const req = mockRequestObject({
172178
params: { id: 'testWalletId', coin },
173179
body: inputParams,
180+
bitgo,
174181
});
175-
req.bitgo = bitgo;
176182

177-
await should(handleLightningWithdraw(req)).be.rejectedWith(
183+
await should(handleLightningWithdraw(req as any)).be.rejectedWith(
178184
'Invalid request body for withdrawing on chain lightning balance'
179185
);
180186
});

0 commit comments

Comments
 (0)