Skip to content

Commit 749c3c0

Browse files
committed
feat: added withdraw route
Withdraw route + test cases added TICKET: BTC-2053
1 parent 76e75bd commit 749c3c0

File tree

10 files changed

+354
-3
lines changed

10 files changed

+354
-3
lines changed

modules/abstract-lightning/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
]
4040
},
4141
"dependencies": {
42-
"@bitgo/public-types": "4.17.0",
42+
"@bitgo/public-types": "4.26.0",
4343
"@bitgo/sdk-core": "^32.1.0",
4444
"@bitgo/statics": "^51.7.0",
4545
"@bitgo/utxo-lib": "^11.3.0",

modules/abstract-lightning/src/codecs/api/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './backup';
22
export * from './balance';
33
export * from './invoice';
44
export * from './payment';
5+
export * from './withdraw';
56
export * from './transaction';
67
export * from './wallet';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as t from 'io-ts';
2+
import { LightningOnchainRequest } from '@bitgo/public-types';
3+
import { PendingApprovalData, TxRequestState } from '@bitgo/sdk-core';
4+
5+
// todo:(current) which to keep here which to take to common types
6+
export const LightningOnchainWithdrawParams = t.intersection([
7+
LightningOnchainRequest,
8+
t.type({
9+
// todo:(current) is passphrase required?
10+
// passphrase: t.string,
11+
}),
12+
]);
13+
14+
export type LightningOnchainWithdrawParams = t.TypeOf<typeof LightningOnchainWithdrawParams>;
15+
16+
export type LightningOnchainWithdrawResponse = {
17+
/**
18+
* Unique identifier for the payment request submitted to BitGo.
19+
*/
20+
txRequestId: string;
21+
22+
/**
23+
* Status of the payment request submission to BitGo.
24+
* - `'delivered'`: Successfully received by BitGo, but may or may not have been sent to the Lightning Network yet.
25+
* - For the actual withdraw status, track `transfer`.
26+
*/
27+
txRequestState: TxRequestState;
28+
29+
/**
30+
* Pending approval details, if applicable.
31+
* - If present, withdraw has not been initiated yet.
32+
*/
33+
pendingApproval?: PendingApprovalData;
34+
35+
/**
36+
* Latest transfer details for this withdraw request (if available).
37+
* - Provides the current state of the transfer.
38+
* - To track the latest withdraw status, monitor `transfer` asynchronously.
39+
* This field is absent if approval is required before processing.
40+
*/
41+
transfer?: any;
42+
};

modules/abstract-lightning/src/wallet/lightning.ts

+54
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
TransactionQuery,
2525
PaymentInfo,
2626
PaymentQuery,
27+
LightningOnchainWithdrawParams,
28+
LightningOnchainWithdrawResponse,
2729
} from '../codecs';
2830
import { LightningPaymentIntent, LightningPaymentRequest } from '@bitgo/public-types';
2931

@@ -156,6 +158,15 @@ export interface ILightningWallet {
156158
* @returns {Promise<PayInvoiceResponse>} Payment result containing transaction request details and payment status
157159
*/
158160
payInvoice(params: SubmitPaymentParams): Promise<PayInvoiceResponse>;
161+
162+
/**
163+
* On chain withdrawal
164+
* @param {LightningOnchainWithdrawParams} params - Withdraw parameters
165+
* @param {LightningOnchainRecipient[]} params.recipients - The recipients to pay
166+
* @param {bigint} [params.satsPerVbyte] - Optional value for sats per virtual byte
167+
* @returns {Promise<LightningOnchainWithdrawResponse>} Withdraw result containing transaction request details and status
168+
*/
169+
withdrawOnchain(params: LightningOnchainWithdrawParams): Promise<LightningOnchainWithdrawResponse>;
159170
/**
160171
* Get payment details by payment hash
161172
* @param {string} paymentHash - Payment hash to lookup
@@ -301,6 +312,49 @@ export class LightningWallet implements ILightningWallet {
301312
};
302313
}
303314

315+
async withdrawOnchain(params: LightningOnchainWithdrawParams): Promise<LightningOnchainWithdrawResponse> {
316+
const reqId = new RequestTracer();
317+
this.wallet.bitgo.setRequestTracer(reqId);
318+
319+
const paymentIntent: { intent: LightningPaymentIntent } = {
320+
intent: {
321+
onchainRequest: {
322+
recipients: params.recipients,
323+
satsPerVbyte: params.satsPerVbyte,
324+
},
325+
intentType: 'payment',
326+
},
327+
};
328+
329+
const transactionRequestCreate = (await this.wallet.bitgo
330+
.post(this.wallet.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests', 2))
331+
.send(t.type({ intent: LightningPaymentIntent }).encode(paymentIntent))
332+
.result()) as TxRequest;
333+
334+
if (transactionRequestCreate.state === 'pendingApproval') {
335+
const pendingApprovals = new PendingApprovals(this.wallet.bitgo, this.wallet.baseCoin);
336+
const pendingApproval = await pendingApprovals.get({ id: transactionRequestCreate.pendingApprovalId });
337+
return {
338+
pendingApproval: pendingApproval.toJSON(),
339+
txRequestId: transactionRequestCreate.txRequestId,
340+
txRequestState: transactionRequestCreate.state,
341+
};
342+
}
343+
344+
const transactionRequestSend = await commonTssMethods.sendTxRequest(
345+
this.wallet.bitgo,
346+
this.wallet.id(),
347+
transactionRequestCreate.txRequestId,
348+
RequestType.tx,
349+
reqId
350+
);
351+
352+
return {
353+
txRequestId: transactionRequestCreate.txRequestId,
354+
txRequestState: transactionRequestSend.state,
355+
};
356+
}
357+
304358
async getPayment(paymentHash: string): Promise<PaymentInfo> {
305359
const response = await this.wallet.bitgo
306360
.get(this.wallet.bitgo.url(`/wallet/${this.wallet.id()}/lightning/payment/${paymentHash}`, 2))

modules/bitgo/test/v2/unit/lightning/lightningWallets.ts

+93
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getLightningKeychain,
1616
getLightningAuthKeychains,
1717
updateWalletCoinSpecific,
18+
LightningOnchainWithdrawParams,
1819
} from '@bitgo/abstract-lightning';
1920

2021
import { BitGo, common, GenerateLightningWalletOptions, Wallet, Wallets } from '../../../../src';
@@ -651,4 +652,96 @@ describe('Lightning wallets', function () {
651652
assert.strictEqual(signedRequest.passphrase, undefined, 'passphrase should not exist in request');
652653
});
653654
});
655+
describe('On chain withdrawal', function () {
656+
let wallet: LightningWallet;
657+
beforeEach(function () {
658+
wallet = getLightningWallet(
659+
new Wallet(bitgo, basecoin, {
660+
id: 'walletId',
661+
coin: 'tlnbtc',
662+
subType: 'lightningCustody',
663+
coinSpecific: { keys: ['def', 'ghi'] },
664+
})
665+
) as LightningWallet;
666+
});
667+
it('should withdraw on chain', async function () {
668+
const params: LightningOnchainWithdrawParams = {
669+
recipients: [
670+
{
671+
amountSat: 500000n,
672+
address: 'bcrt1qjq48cqk2u80hewdcndf539m8nnnvt845nl68x7',
673+
},
674+
],
675+
satsPerVbyte: 15n,
676+
};
677+
678+
const txRequestResponse = {
679+
txRequestId: 'txReq123',
680+
state: 'pendingDelivery',
681+
};
682+
683+
const finalPaymentResponse = {
684+
txRequestId: 'txReq123',
685+
state: 'delivered',
686+
};
687+
688+
const createTxRequestNock = nock(bgUrl)
689+
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests`)
690+
.reply(200, txRequestResponse);
691+
692+
const sendTxRequestNock = nock(bgUrl)
693+
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests/${txRequestResponse.txRequestId}/transactions/0/send`)
694+
.reply(200, finalPaymentResponse);
695+
696+
const response = await wallet.withdrawOnchain(params);
697+
assert.strictEqual(response.txRequestId, 'txReq123');
698+
assert.strictEqual(response.txRequestState, 'delivered');
699+
700+
createTxRequestNock.done();
701+
sendTxRequestNock.done();
702+
});
703+
704+
it('should handle pending approval when withdrawing onchain', async function () {
705+
const params: LightningOnchainWithdrawParams = {
706+
recipients: [
707+
{
708+
amountSat: 500000n,
709+
address: 'bcrt1qjq48cqk2u80hewdcndf539m8nnnvt845nl68x7',
710+
},
711+
],
712+
satsPerVbyte: 15n,
713+
};
714+
715+
const txRequestResponse = {
716+
txRequestId: 'txReq123',
717+
state: 'pendingApproval',
718+
pendingApprovalId: 'approval123',
719+
};
720+
721+
const pendingApprovalData: PendingApprovalData = {
722+
id: 'approval123',
723+
state: State.PENDING,
724+
creator: 'user123',
725+
info: {
726+
type: Type.TRANSACTION_REQUEST,
727+
},
728+
};
729+
730+
const createTxRequestNock = nock(bgUrl)
731+
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests`)
732+
.reply(200, txRequestResponse);
733+
734+
const getPendingApprovalNock = nock(bgUrl)
735+
.get(`/api/v2/${coinName}/pendingapprovals/${txRequestResponse.pendingApprovalId}`)
736+
.reply(200, pendingApprovalData);
737+
738+
const response = await wallet.withdrawOnchain(params);
739+
assert.strictEqual(response.txRequestId, 'txReq123');
740+
assert.strictEqual(response.txRequestState, 'pendingApproval');
741+
assert(response.pendingApproval);
742+
743+
createTxRequestNock.done();
744+
getPendingApprovalNock.done();
745+
});
746+
});
654747
});

modules/express/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"superagent": "^9.0.1"
5757
},
5858
"devDependencies": {
59-
"@bitgo/public-types": "4.17.0",
59+
"@bitgo/public-types": "4.26.0",
6060
"@bitgo/sdk-lib-mpc": "^10.2.0",
6161
"@bitgo/sdk-test": "^8.0.80",
6262
"@types/argparse": "^1.0.36",

modules/express/src/clientRoutes.ts

+9
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes';
5757
import { handleUpdateLightningWalletCoinSpecific } from './lightning/lightningWalletRoutes';
5858
import { ProxyAgent } from 'proxy-agent';
5959
import { isLightningCoinName } from '@bitgo/abstract-lightning';
60+
import { handleLightningWithdraw } from './lightning/lightningWithdrawRoutes';
6061

6162
const { version } = require('bitgo/package.json');
6263
const pjson = require('../package.json');
@@ -1721,6 +1722,14 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
17211722
promiseWrapper(handlePayLightningInvoice)
17221723
);
17231724

1725+
// lightning - onchain withdrawal
1726+
app.post(
1727+
'/api/v2/:coin/wallet/:id/lightning/withdraw',
1728+
parseBody,
1729+
prepareBitGo(config),
1730+
promiseWrapper(handleLightningWithdraw)
1731+
);
1732+
17241733
// any other API v2 call
17251734
app.use('/api/v2/user/*', parseBody, prepareBitGo(config), promiseWrapper(handleV2UserREST));
17261735
app.use('/api/v2/:coin/*', parseBody, prepareBitGo(config), promiseWrapper(handleV2CoinSpecificREST));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as express from 'express';
2+
import { decodeOrElse } from '@bitgo/sdk-core';
3+
import { getLightningWallet, LightningOnchainWithdrawParams } from '@bitgo/abstract-lightning';
4+
import { ApiResponseError } from '../errors';
5+
6+
export async function handleLightningWithdraw(req: express.Request): Promise<any> {
7+
const bitgo = req.bitgo;
8+
const params = decodeOrElse(
9+
LightningOnchainWithdrawParams.name,
10+
LightningOnchainWithdrawParams,
11+
req.body,
12+
(error) => {
13+
throw new ApiResponseError(`Invalid request body for withdrawing on chain lightning balance`, 400);
14+
}
15+
);
16+
17+
const coin = bitgo.coin(req.params.coin);
18+
const wallet = await coin.wallets().get({ id: req.params.id });
19+
const lightningWallet = getLightningWallet(wallet);
20+
21+
return await lightningWallet.withdrawOnchain(params);
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as sinon from 'sinon';
2+
import * as should from 'should';
3+
import * as express from 'express';
4+
import { PayInvoiceResponse } from '@bitgo/abstract-lightning';
5+
import { BitGo } from 'bitgo';
6+
import { handleLightningWithdraw } from '../../../src/lightning/lightningWithdrawRoutes';
7+
8+
describe('Lightning Withdraw Routes', () => {
9+
let bitgo;
10+
const coin = 'tlnbtc';
11+
12+
const mockRequestObject = (params: { body?: any; params?: any; query?: any; bitgo?: any }) => {
13+
const req: Partial<express.Request> = {};
14+
req.body = params.body || {};
15+
req.params = params.params || {};
16+
req.query = params.query || {};
17+
req.bitgo = params.bitgo;
18+
return req as express.Request;
19+
};
20+
21+
afterEach(() => {
22+
sinon.restore();
23+
});
24+
25+
describe('On chain withdrawal', () => {
26+
it('should successfully make a on chain withdrawal', async () => {
27+
const inputParams = {
28+
recipients: [
29+
{
30+
amountSat: '500000',
31+
address: 'bcrt1qjq48cqk2u80hewdcndf539m8nnnvt845nl68x7',
32+
},
33+
],
34+
satsPerVbyte: '15',
35+
};
36+
37+
const expectedResponse: PayInvoiceResponse = {
38+
txRequestState: 'delivered',
39+
txRequestId: '123',
40+
};
41+
42+
const onchainWithdrawStub = sinon.stub().resolves(expectedResponse);
43+
const mockLightningWallet = {
44+
withdrawOnchain: onchainWithdrawStub,
45+
};
46+
47+
// Mock the module import
48+
const proxyquire = require('proxyquire');
49+
const lightningWithdrawRoutes = proxyquire('../../../src/lightning/lightningWithdrawRoutes', {
50+
'@bitgo/abstract-lightning': {
51+
getLightningWallet: () => mockLightningWallet,
52+
},
53+
});
54+
55+
const walletStub = {};
56+
const coinStub = {
57+
wallets: () => ({ get: sinon.stub().resolves(walletStub) }),
58+
};
59+
const stubBitgo = sinon.createStubInstance(BitGo as any, { coin: coinStub });
60+
61+
const req = mockRequestObject({
62+
params: { id: 'testWalletId', coin },
63+
body: inputParams,
64+
bitgo: stubBitgo,
65+
});
66+
67+
const result = await lightningWithdrawRoutes.handleLightningWithdraw(req);
68+
69+
should(result).deepEqual(expectedResponse);
70+
should(onchainWithdrawStub).be.calledOnce();
71+
const [firstArg] = onchainWithdrawStub.getCall(0).args;
72+
73+
const decodedRecipients = inputParams.recipients.map((recipient) => {
74+
return {
75+
...recipient,
76+
amountSat: BigInt(recipient.amountSat),
77+
};
78+
});
79+
80+
// we decode the amountMsat string to bigint, it should be in bigint format when passed to payInvoice
81+
should(firstArg).have.property('recipients', decodedRecipients);
82+
should(firstArg).have.property('satsPerVbyte', BigInt(inputParams.satsPerVbyte));
83+
});
84+
85+
it('should throw an error if the passphrase is missing in the request params', async () => {
86+
const inputParams = {
87+
satsPerVbyte: '15',
88+
};
89+
90+
const req = mockRequestObject({
91+
params: { id: 'testWalletId', coin },
92+
body: inputParams,
93+
});
94+
req.bitgo = bitgo;
95+
96+
await should(handleLightningWithdraw(req)).be.rejectedWith(
97+
'Invalid request body for withdrawing on chain lightning balance'
98+
);
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)