Skip to content

Commit 6f89e61

Browse files
committed
Merge remote-tracking branch 'origin/master' into WP-5447-express-migrate-api-v2-coin-wallet-id-state-to-typed-routes
TICKET: WP-5447
2 parents e74d662 + f5dd019 commit 6f89e61

File tree

82 files changed

+3430
-418
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+3430
-418
lines changed

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
engine-strict=true

CODEOWNERS

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,6 @@
109109
/modules/sdk-core/src/bitgo/address-book/ @BitGo/prime
110110
/modules/sdk-core/src/bitgo/trading/ @BitGo/prime
111111

112-
# Asset Metadata Service
113-
/modules/statics/src/coins/botTokens.ts @BitGo/ams
114-
/modules/statics/src/coins/botOfcTokens.ts @BitGo/ams
115-
116112
# Core Modules
117113
/modules/bitgo/ @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform
118114
/modules/bitgo/test/v2/unit/lightning/ @BitGo/btc-team
@@ -141,6 +137,10 @@
141137
/types/ @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform
142138
/webpack/ @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform
143139

140+
# Asset Metadata Service
141+
/modules/statics/src/coins/botTokens.ts @BitGo/ams
142+
/modules/statics/src/coins/botOfcTokens.ts @BitGo/ams
143+
144144
/.eslintrc.json @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform @BitGo/developer-experience
145145
/.prettierrc.yml @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform @BitGo/developer-experience
146146
/check-package-versions.js @BitGo/coins @BitGo/web-experience @BitGo/wallet-platform @BitGo/developer-experience

modules/abstract-eth/src/ethLikeToken.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export class EthLikeToken extends AbstractEthLikeNewCoins {
4343
static createTokenConstructors(
4444
coinNames: CoinNames,
4545
tokenConfigs: EthLikeTokenConfig[] = [
46-
...tokens.bitcoin[coinNames.Mainnet].tokens,
47-
...tokens.testnet[coinNames.Mainnet].tokens,
46+
...(tokens.bitcoin[coinNames.Mainnet]?.tokens || []),
47+
...(tokens.testnet[coinNames.Mainnet]?.tokens || []),
4848
]
4949
): NamedCoinConstructor[] {
5050
const tokensCtors: NamedCoinConstructor[] = [];

modules/abstract-eth/src/lib/transactionBuilder.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { Transaction } from './transaction';
4949
import { TransferBuilder } from './transferBuilder';
5050

5151
const DEFAULT_M = 3;
52+
const RAW_TX_HEX_REGEX = /^(0x)?[0-9a-f]{1,}$/;
5253

5354
/**
5455
* EthereumLike transaction builder.
@@ -165,7 +166,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
165166
/** @inheritdoc */
166167
protected fromImplementation(rawTransaction: string, isFirstSigner?: boolean): Transaction {
167168
let tx: Transaction;
168-
if (/^0x?[0-9a-f]{1,}$/.test(rawTransaction.toLowerCase())) {
169+
if (RAW_TX_HEX_REGEX.test(rawTransaction.toLowerCase())) {
169170
tx = Transaction.fromSerialized(this._coinConfig, this._common, rawTransaction, isFirstSigner);
170171
this.loadBuilderInput(tx.toJson(), isFirstSigner);
171172
} else {
@@ -343,7 +344,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
343344
throw new InvalidTransactionError('Raw transaction is empty');
344345
}
345346
if (typeof rawTransaction === 'string') {
346-
if (/^0x?[0-9a-f]{1,}$/.test(rawTransaction.toLowerCase())) {
347+
if (RAW_TX_HEX_REGEX.test(rawTransaction.toLowerCase())) {
347348
const txBytes = ethUtil.toBuffer(ethUtil.addHexPrefix(rawTransaction.toLowerCase()));
348349
if (!this.isEip1559Txn(txBytes) && !this.isRLPDecodable(txBytes)) {
349350
throw new ParseTransactionError('There was error in decoding the hex string');

modules/abstract-lightning/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@bitgo/sdk-core": "^36.8.0",
4444
"@bitgo/statics": "^57.8.0",
4545
"@bitgo/utxo-lib": "^11.10.0",
46+
"bip174": "npm:@bitgo-forks/[email protected]",
4647
"bs58check": "^2.1.2",
4748
"fp-ts": "^2.12.2",
4849
"io-ts": "npm:@bitgo-forks/[email protected]",

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) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { validatePsbtForWithdraw } from '../../../src';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
import assert from 'assert';
4+
5+
describe('parseWithdrawPsbt', () => {
6+
const unsignedPsbtHex =
7+
'70736274ff01007d02000000015e50b8d96cebdc3273d9a100eb68392d980d5b934b8170c80a23488b595268ca0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15affa80a000000000016001480a06f2e6b77e817fd5de6e41ea512c563c26cb800000000000100ea02000000000101a158d806735bb7c54e4c701d4f5821cd5342d48d5e1fcbed1169e6e45aa444be0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15a6a310c000000000016001478a5d98c7160484b9b00f1782803c58edfc49b9a024730440220407d9162f52371df246dcfa2943d40fbdcb0d4b6768f7682c65193378b2845a60220101c7bc460c93d2976961ac23400f0f10c145efb989a3addb7f03ebaaa2200950121037e17444c85c8b7da07f12fd53cb2ca142c2b4932d0f898649c4b5be0021da0980000000001030401000000220602e57146e5b4762a7ff374adf4072047b67ef115ad46a34189bdeb6a4f88db9b0818000000005400008000000080000000800100000006000000000022020379abbe44004ff7e527bdee3dd8d95e5cd250053f35ee92258b97aa83dfa93c621800000000540000800000008000000080010000005000000000';
8+
const network = utxolib.networks.testnet;
9+
const recipients = [
10+
{
11+
amountSat: 100000n,
12+
address: 'tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k',
13+
},
14+
];
15+
const accounts = [
16+
{
17+
xpub: 'tpubDCmiWMkTJrZ24t1Z6ECR3HyynCyZ9zGsWqhcLh6H4yFK2CDozSszD1pP2Li4Nx1YYtRcvmNbdb3nD1SzFejYtPFfTocTv2EaAgJCg4zpJpA',
18+
purpose: 49,
19+
coin_type: 0,
20+
account: 0,
21+
},
22+
{
23+
xpub: 'tpubDCFN7bsxR9UTKggdH2pmv5HeHGQNiDrJwa1EZFtP9sH5PF28i37FHpoYSYARQkKZ6Mi98pkp7oypDcxFmE4dQGq8jV8Gv3L6gmWBeRwPxkP',
24+
purpose: 84,
25+
coin_type: 0,
26+
account: 0,
27+
},
28+
];
29+
it('should parse a valid withdraw PSBT', () => {
30+
validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, accounts);
31+
});
32+
it('should throw for invalid PSBT', () => {
33+
assert.throws(() => {
34+
validatePsbtForWithdraw('asdasd', network, recipients, accounts);
35+
}, /ERR_BUFFER_OUT_OF_BOUNDS/);
36+
});
37+
it('should throw for invalid recipient address', () => {
38+
const differentRecipients = [
39+
{
40+
...recipients[0],
41+
address: 'tb1qxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyz',
42+
},
43+
];
44+
assert.throws(() => {
45+
validatePsbtForWithdraw(unsignedPsbtHex, network, differentRecipients, accounts);
46+
}, /PSBT output tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k with value 100000 does not match any recipient/);
47+
});
48+
it('should throw for invalid recipient value', () => {
49+
const differentRecipients = [
50+
{
51+
...recipients[0],
52+
amountSat: 99999n,
53+
},
54+
];
55+
assert.throws(() => {
56+
validatePsbtForWithdraw(unsignedPsbtHex, network, differentRecipients, accounts);
57+
}, /PSBT output tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k with value 100000 does not match any recipient/);
58+
});
59+
it('should throw for account not found', () => {
60+
const incompatibleAccounts = [];
61+
assert.throws(() => {
62+
validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, incompatibleAccounts);
63+
}, /Account not found for purpose/);
64+
});
65+
it('should throw for invalid pubkey', () => {
66+
const incompatibleAccounts = [
67+
{
68+
...accounts[1],
69+
xpub: 'tpubDCmiWMkTJrZ24t1Z6ECR3HyynCyZ9zGsWqhcLh6H4yFK2CDozSszD1pP2Li4Nx1YYtRcvmNbdb3nD1SzFejYtPFfTocTv2EaAgJCg4zpJpA',
70+
},
71+
];
72+
assert.throws(() => {
73+
validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, incompatibleAccounts);
74+
}, /Derived pubkey does not match for address/);
75+
});
76+
it('should throw for invalid purpose', () => {
77+
const incompatibleAccounts = [
78+
{
79+
...accounts[1],
80+
purpose: 1017,
81+
},
82+
];
83+
const incompatiblePsbt = `70736274ff01007d02000000015e50b8d96cebdc3273d9a100eb68392d980d5b934b8170c80a23488b595268ca0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15affa80a000000000016001480a06f2e6b77e817fd5de6e41ea512c563c26cb800000000000100ea02000000000101a158d806735bb7c54e4c701d4f5821cd5342d48d5e1fcbed1169e6e45aa444be0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15a6a310c000000000016001478a5d98c7160484b9b00f1782803c58edfc49b9a024730440220407d9162f52371df246dcfa2943d40fbdcb0d4b6768f7682c65193378b2845a60220101c7bc460c93d2976961ac23400f0f10c145efb989a3addb7f03ebaaa2200950121037e17444c85c8b7da07f12fd53cb2ca142c2b4932d0f898649c4b5be0021da0980000000001030401000000220602e57146e5b4762a7ff374adf4072047b67ef115ad46a34189bdeb6a4f88db9b0818000000005400008000000080000000800100000006000000000022020379abbe44004ff7e527bdee3dd8d95e5cd250053f35ee92258b97aa83dfa93c621800000000f90300800000008000000080010000005000000000`;
84+
assert.throws(() => {
85+
validatePsbtForWithdraw(incompatiblePsbt, network, recipients, incompatibleAccounts);
86+
}, /Unsupported purpose/);
87+
});
88+
});

0 commit comments

Comments
 (0)