Skip to content

Commit e2befa7

Browse files
committed
feat: verify psbt withdraw lightning
TICKET: BTC-2470
1 parent f5f91d4 commit e2befa7

File tree

4 files changed

+187
-1
lines changed

4 files changed

+187
-1
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as t from 'io-ts';
22
import { LightningOnchainRequest, optionalString } from '@bitgo/public-types';
33
import { PendingApprovalData, TxRequestState } from '@bitgo/sdk-core';
4+
import { Bip32Derivation } from 'bip174/src/lib/interfaces';
45

56
export const WithdrawStatusDelivered = 'delivered';
67
export const WithdrawStatusFailed = 'failed';
@@ -124,3 +125,10 @@ export const SendPsbtResponse = t.intersection(
124125
'SendPsbtResponse'
125126
);
126127
export type SendPsbtResponse = t.TypeOf<typeof SendPsbtResponse>;
128+
129+
export type WithdrawBaseOutputUTXO<TNumber extends number | bigint = number> = {
130+
value: TNumber;
131+
change: boolean;
132+
address: string;
133+
bip32Derivation?: Bip32Derivation;
134+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './signableJson';
22
export * from './signature';
33
export * from './lightningUtils';
4+
export * from './parseWithdrawPsbt';
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { Psbt } from '@bitgo/utxo-lib';
3+
import { WatchOnlyAccount, WithdrawBaseOutputUTXO } from '../codecs';
4+
import { LightningOnchainRecipient } from '@bitgo/public-types';
5+
import { Bip32Derivation } from 'bip174/src/lib/interfaces';
6+
7+
function parseDerivationPath(derivationPath: string): {
8+
purpose: number;
9+
change: number;
10+
addressIndex: number;
11+
} {
12+
const pathSegments = derivationPath.split('/');
13+
const purpose = Number(pathSegments[1].replace(/'/g, ''));
14+
const change = Number(pathSegments[pathSegments.length - 2]);
15+
const addressIndex = Number(pathSegments[pathSegments.length - 1]);
16+
return { purpose, change, addressIndex };
17+
}
18+
19+
function parsePsbtOutputs(psbt: Psbt, network: utxolib.Network): WithdrawBaseOutputUTXO<bigint>[] {
20+
const parsedOutputs: WithdrawBaseOutputUTXO<bigint>[] = [];
21+
let bip32Derivation: Bip32Derivation | undefined;
22+
23+
for (let i = 0; i < psbt.data.outputs.length; i++) {
24+
const output = psbt.data.outputs[i];
25+
const txOutput = psbt.txOutputs[i];
26+
27+
let address = '';
28+
const value = txOutput.value;
29+
let isChange = false;
30+
31+
if (output.bip32Derivation && output.bip32Derivation.length > 0) {
32+
isChange = true;
33+
bip32Derivation = output.bip32Derivation[0];
34+
}
35+
if (txOutput.script) {
36+
address = utxolib.address.fromOutputScript(txOutput.script, network);
37+
}
38+
const valueBigInt = BigInt(value);
39+
40+
parsedOutputs.push({
41+
address,
42+
value: valueBigInt,
43+
change: isChange,
44+
bip32Derivation,
45+
});
46+
}
47+
48+
return parsedOutputs;
49+
}
50+
51+
function verifyChangeAddress(
52+
output: WithdrawBaseOutputUTXO<bigint>,
53+
accounts: WatchOnlyAccount[],
54+
network: utxolib.Network
55+
): void {
56+
if (!output.bip32Derivation || !output.bip32Derivation.path) {
57+
throw new Error(`bip32Derivation path not found for change address`);
58+
}
59+
// derivation path example: m/84'/0'/0'/1/0
60+
const { purpose, change, addressIndex } = parseDerivationPath(output.bip32Derivation.path);
61+
62+
// Find the corresponding account using the purpose
63+
const account = accounts.find((acc) => acc.purpose === purpose);
64+
if (!account) {
65+
throw new Error(`Account not found for purpose: ${purpose}`);
66+
}
67+
68+
// Create a BIP32 node from the xpub
69+
const xpubNode = utxolib.bip32.fromBase58(account.xpub, network);
70+
71+
// Derive the public key from the xpub using the change and address index
72+
const derivedPubkey = xpubNode.derive(change).derive(addressIndex).publicKey;
73+
74+
if (derivedPubkey.toString('hex') !== output.bip32Derivation.pubkey.toString('hex')) {
75+
throw new Error(
76+
`Derived pubkey does not match for address: ${output.address}, derived: ${derivedPubkey.toString(
77+
'hex'
78+
)}, expected: ${output.bip32Derivation.pubkey.toString('hex')}`
79+
);
80+
}
81+
82+
// Determine the correct payment type based on the purpose
83+
let derivedAddress: string | undefined;
84+
switch (purpose) {
85+
case 49: // P2SH-P2WPKH (Nested SegWit)
86+
derivedAddress = utxolib.payments.p2sh({
87+
redeem: utxolib.payments.p2wpkh({
88+
pubkey: derivedPubkey,
89+
network,
90+
}),
91+
network,
92+
}).address;
93+
break;
94+
case 84: // P2WPKH (Native SegWit)
95+
derivedAddress = utxolib.payments.p2wpkh({
96+
pubkey: derivedPubkey,
97+
network,
98+
}).address;
99+
break;
100+
case 86: // P2TR (Taproot)
101+
derivedAddress = utxolib.payments.p2tr({
102+
pubkey: derivedPubkey,
103+
network,
104+
}).address;
105+
break;
106+
default:
107+
throw new Error(`Unsupported purpose: ${purpose}`);
108+
}
109+
110+
if (derivedAddress !== output.address) {
111+
throw new Error(`invalid change address: expected ${derivedAddress}, got ${output.address}`);
112+
}
113+
}
114+
115+
/**
116+
* Validates the funded psbt before creating the signatures for withdraw.
117+
*/
118+
export function validatePsbtForWithdraw(
119+
psbtHex: string,
120+
network: utxolib.Network,
121+
recipients: LightningOnchainRecipient[],
122+
accounts: WatchOnlyAccount[]
123+
): void {
124+
const parsedPsbt = Psbt.fromHex(psbtHex, { network: network });
125+
const outputs = parsePsbtOutputs(parsedPsbt, network);
126+
outputs.forEach((output) => {
127+
if (output.change) {
128+
try {
129+
verifyChangeAddress(output, accounts, network);
130+
} catch (e: any) {
131+
throw new Error(`Unable to verify change address: ${e}`);
132+
}
133+
} else {
134+
let match = false;
135+
recipients.forEach((recipient) => {
136+
if (recipient.address === output.address && BigInt(recipient.amountSat) === output.value) {
137+
match = true;
138+
}
139+
});
140+
if (!match) {
141+
throw new Error(`PSBT output ${output.address} with value ${output.value} does not match any recipient`);
142+
}
143+
}
144+
});
145+
}

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
decodeOrElse,
1111
} from '@bitgo/sdk-core';
1212
import * as t from 'io-ts';
13-
import { createMessageSignature, unwrapLightningCoinSpecific } from '../lightning';
13+
import {
14+
createMessageSignature,
15+
getUtxolibNetwork,
16+
unwrapLightningCoinSpecific,
17+
validatePsbtForWithdraw,
18+
} from '../lightning';
1419
import {
1520
CreateInvoiceBody,
1621
Invoice,
@@ -28,6 +33,7 @@ import {
2833
ListInvoicesResponse,
2934
ListPaymentsResponse,
3035
LndCreateWithdrawResponse,
36+
WatchOnly,
3137
} from '../codecs';
3238
import { LightningPaymentIntent, LightningPaymentRequest } from '@bitgo/public-types';
3339

@@ -364,6 +370,32 @@ export class LightningWallet implements ILightningWallet {
364370
throw new Error(`serialized txHex is missing`);
365371
}
366372

373+
const walletData = this.wallet.toJSON();
374+
if (!walletData.coinSpecific.watchOnlyAccounts) {
375+
throw new Error(`wallet is missing watch only accounts`);
376+
}
377+
378+
const watchOnlyAccountDetails = decodeOrElse(
379+
WatchOnly.name,
380+
WatchOnly,
381+
walletData.coinSpecific.watchOnlyAccounts,
382+
(errors) => {
383+
throw new Error(`invalid watch only accounts, error: ${errors}`);
384+
}
385+
);
386+
const network = getUtxolibNetwork(this.wallet.coin());
387+
388+
try {
389+
validatePsbtForWithdraw(
390+
transactionRequestCreate.transactions[0].unsignedTx.serializedTxHex,
391+
network,
392+
params.recipients,
393+
watchOnlyAccountDetails.accounts
394+
);
395+
} catch (err: any) {
396+
throw new Error(`error validating withdraw psbt: ${err}`);
397+
}
398+
367399
const { userAuthKey } = await getLightningAuthKeychains(this.wallet);
368400
const userAuthKeyEncryptedPrv = userAuthKey.encryptedPrv;
369401
if (!userAuthKeyEncryptedPrv) {

0 commit comments

Comments
 (0)