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
8 changes: 3 additions & 5 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1702,12 +1702,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
);

// lightning - onchain withdrawal
app.post(
'/api/v2/:coin/wallet/:id/lightning/withdraw',
parseBody,
router.post('express.v2.wallet.lightningWithdraw', [
prepareBitGo(config),
promiseWrapper(handleLightningWithdraw)
);
typedPromiseWrapper(handleLightningWithdraw),
]);

// any other API v2 call
app.use('/api/v2/user/*', parseBody, prepareBitGo(config), promiseWrapper(handleV2UserREST));
Expand Down
10 changes: 6 additions & 4 deletions modules/express/src/lightning/lightningWithdrawRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as express from 'express';
import { decodeOrElse } from '@bitgo/sdk-core';
import { getLightningWallet, LightningOnchainWithdrawParams } from '@bitgo/abstract-lightning';
import { ApiResponseError } from '../errors';
import { ExpressApiRouteRequest } from '../typedRoutes/api';

export async function handleLightningWithdraw(req: express.Request): Promise<any> {
export async function handleLightningWithdraw(
req: ExpressApiRouteRequest<'express.v2.wallet.lightningWithdraw', 'post'>
): Promise<any> {
const bitgo = req.bitgo;
const params = decodeOrElse(
LightningOnchainWithdrawParams.name,
Expand All @@ -14,8 +16,8 @@ export async function handleLightningWithdraw(req: express.Request): Promise<any
}
);

const coin = bitgo.coin(req.params.coin);
const wallet = await coin.wallets().get({ id: req.params.id });
const coin = bitgo.coin(req.decoded.coin);
const wallet = await coin.wallets().get({ id: req.decoded.id });
const lightningWallet = getLightningWallet(wallet);

return await lightningWallet.withdrawOnchain(params);
Expand Down
9 changes: 9 additions & 0 deletions modules/express/src/typedRoutes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { PostCoinSign } from './v2/coinSign';
import { PostSendCoins } from './v2/sendCoins';
import { PostGenerateShareTSS } from './v2/generateShareTSS';
import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload';
import { PostLightningWalletWithdraw } from './v2/lightningWithdraw';

// Too large types can cause the following error
//
Expand Down Expand Up @@ -207,6 +208,12 @@ export const ExpressLightningUnlockWalletApiSpec = apiSpec({
},
});

export const ExpressLightningWalletWithdrawApiSpec = apiSpec({
'express.v2.wallet.lightningWithdraw': {
post: PostLightningWalletWithdraw,
},
});

export const ExpressOfcSignPayloadApiSpec = apiSpec({
'express.ofc.signPayload': {
post: PostOfcSignPayload,
Expand Down Expand Up @@ -281,6 +288,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
typeof ExpressLightningGetStateApiSpec &
typeof ExpressLightningInitWalletApiSpec &
typeof ExpressLightningUnlockWalletApiSpec &
typeof ExpressLightningWalletWithdrawApiSpec &
typeof ExpressV2WalletSendManyApiSpec &
typeof ExpressV2WalletSendCoinsApiSpec &
typeof ExpressOfcSignPayloadApiSpec &
Expand Down Expand Up @@ -314,6 +322,7 @@ export const ExpressApi: ExpressApi = {
...ExpressLightningGetStateApiSpec,
...ExpressLightningInitWalletApiSpec,
...ExpressLightningUnlockWalletApiSpec,
...ExpressLightningWalletWithdrawApiSpec,
...ExpressV2WalletSendManyApiSpec,
...ExpressV2WalletSendCoinsApiSpec,
...ExpressOfcSignPayloadApiSpec,
Expand Down
217 changes: 217 additions & 0 deletions modules/express/src/typedRoutes/api/v2/lightningWithdraw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import * as t from 'io-ts';
import { BigIntFromString } from 'io-ts-types';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { BitgoExpressError } from '../../schemas/error';

/**
* Path parameters for lightning withdraw
*/
export const LightningWithdrawParams = {
/** The coin identifier (e.g., 'lnbtc', 'tlnbtc') */
coin: t.string,
/** The ID of the wallet */
id: t.string,
} as const;

/**
* Lightning onchain recipient
*/
const LightningOnchainRecipient = t.type({
/** Amount in satoshis (as string that will be converted to BigInt) */
amountSat: BigIntFromString,
/** Bitcoin address to send to */
address: t.string,
});

/**
* Request body for lightning onchain withdrawal
*/
export const LightningWithdrawRequestBody = {
/** Array of recipients to pay */
recipients: t.array(LightningOnchainRecipient),
/** Wallet passphrase for signing */
passphrase: t.string,
/** Fee rate in satoshis per virtual byte (as string that will be converted to BigInt) */
satsPerVbyte: optional(BigIntFromString),
/** Target number of blocks for confirmation */
numBlocks: optional(t.number),
/** Optional sequence ID for the withdraw transfer */
sequenceId: optional(t.string),
/** Optional comment for the withdraw transfer */
comment: optional(t.string),
} as const;

/**
* Withdraw status codec
*/
const WithdrawStatus = t.union([t.literal('delivered'), t.literal('failed')]);

/**
* LND create withdraw response
*/
const LndCreateWithdrawResponse = t.intersection([
t.type({
/** Status of the withdrawal */
status: WithdrawStatus,
}),
t.partial({
/** Transaction ID (txid) if delivered */
txid: t.string,
/** Failure reason if failed */
failureReason: t.string,
}),
]);

/**
* Transaction request state
*/
const TxRequestState = t.union([
t.literal('pendingCommitment'),
t.literal('pendingApproval'),
t.literal('canceled'),
t.literal('rejected'),
t.literal('initialized'),
t.literal('pendingDelivery'),
t.literal('delivered'),
t.literal('pendingUserSignature'),
t.literal('signed'),
]);

/**
* Pending approval state
*/
const PendingApprovalState = t.union([
t.literal('pending'),
t.literal('awaitingSignature'),
t.literal('pendingBitGoAdminApproval'),
t.literal('pendingIdVerification'),
t.literal('pendingCustodianApproval'),
t.literal('pendingFinalApproval'),
t.literal('approved'),
t.literal('processing'),
t.literal('rejected'),
]);

/**
* Pending approval type
*/
const PendingApprovalType = t.union([
t.literal('userChangeRequest'),
t.literal('transactionRequest'),
t.literal('policyRuleRequest'),
t.literal('updateApprovalsRequiredRequest'),
t.literal('transactionRequestFull'),
]);

/**
* Transaction request details in pending approval info
* When transactionRequest is present, coinSpecific, recipients, and buildParams are REQUIRED
* Only sourceWallet is optional
*/
const TransactionRequestDetails = t.intersection([
t.type({
/** Coin-specific transaction details - REQUIRED */
coinSpecific: t.record(t.string, t.unknown),
/** Recipients of the transaction - REQUIRED */
recipients: t.unknown,
/** Build parameters for the transaction - REQUIRED */
buildParams: t.intersection([
t.partial({
/** Type of transaction (fanout or consolidate) - OPTIONAL */
type: t.union([t.literal('fanout'), t.literal('consolidate')]),
}),
t.record(t.string, t.unknown),
]),
}),
t.partial({
/** Source wallet for the transaction - OPTIONAL */
sourceWallet: t.string,
}),
]);

/**
* Pending approval info nested object
*/
const PendingApprovalInfo = t.intersection([
t.type({
/** Type of pending approval */
type: PendingApprovalType,
}),
t.partial({
/** Transaction request associated with approval */
transactionRequest: TransactionRequestDetails,
}),
]);

/**
* Pending approval data
*/
const PendingApproval = t.intersection([
t.type({
/** Pending approval ID */
id: t.string,
/** State of the pending approval */
state: PendingApprovalState,
/** Creator of the pending approval */
creator: t.string,
/** Information about the pending approval */
info: PendingApprovalInfo,
}),
t.partial({
/** Wallet ID if wallet-specific */
wallet: t.string,
/** Enterprise ID if enterprise-specific */
enterprise: t.string,
/** Number of approvals required */
approvalsRequired: t.number,
/** Associated transaction request ID */
txRequestId: t.string,
}),
]);

/**
* Response for lightning onchain withdrawal
*/
const LightningWithdrawResponse = t.intersection([
t.type({
/** Unique identifier for withdraw request submitted to BitGo */
txRequestId: t.string,
/** Status of withdraw request submission to BitGo */
txRequestState: TxRequestState,
}),
t.partial({
/** Pending approval details, if applicable */
pendingApproval: PendingApproval,
/** Current snapshot of withdraw status (if available) */
withdrawStatus: LndCreateWithdrawResponse,
}),
]);

/**
* Response type mapping for lightning withdraw
*/
export const LightningWithdrawResponseType = {
200: LightningWithdrawResponse,
400: BitgoExpressError,
401: BitgoExpressError,
404: BitgoExpressError,
500: BitgoExpressError,
} as const;

/**
* Lightning Onchain Withdrawal API
*
* Withdraws lightning balance to an onchain Bitcoin address
*
* @operationId express.v2.wallet.lightningWithdraw
* @tag express
*/
export const PostLightningWalletWithdraw = httpRoute({
path: '/api/v2/{coin}/wallet/{id}/lightning/withdraw',
method: 'POST',
request: httpRequest({
params: LightningWithdrawParams,
body: LightningWithdrawRequestBody,
}),
response: LightningWithdrawResponseType,
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ describe('Lightning Withdraw Routes', () => {
req.params = params.params || {};
req.query = params.query || {};
req.bitgo = params.bitgo;
// Add decoded property with both path params and body for typed routes
(req as any).decoded = {
coin: params.params?.coin,
id: params.params?.id,
...params.body,
};
return req as express.Request;
};

Expand Down Expand Up @@ -149,10 +155,10 @@ describe('Lightning Withdraw Routes', () => {
const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
bitgo,
});
req.bitgo = bitgo;

await should(handleLightningWithdraw(req)).be.rejectedWith(
await should(handleLightningWithdraw(req as any)).be.rejectedWith(
'Invalid request body for withdrawing on chain lightning balance'
);
});
Expand All @@ -171,10 +177,10 @@ describe('Lightning Withdraw Routes', () => {
const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
bitgo,
});
req.bitgo = bitgo;

await should(handleLightningWithdraw(req)).be.rejectedWith(
await should(handleLightningWithdraw(req as any)).be.rejectedWith(
'Invalid request body for withdrawing on chain lightning balance'
);
});
Expand Down
Loading