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
63 changes: 55 additions & 8 deletions src/__tests__/integration/accelerate.integ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SigningMode } from '../../shared/types';

/**
* Deterministic test keypair derived from Buffer.alloc(64, 0x42) — a public, reproducible seed.
* Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.tbtc.json.
* Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.accelerate.tbtc.json.
*/
const USER_XPUB =
'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH';
Expand All @@ -23,6 +23,22 @@ const accelerateRequestBody = {
maxFee: 10000,
};

interface TransferEntry {
address: string;
value: number;
isChange?: boolean;
}

interface AccelerateResponse {
txid: string;
tx: string;
status: string;
transfer: {
txid: string;
entries: TransferEntry[];
};
}

describe('Accelerate: EXTERNAL signing', () => {
let services: IntegServices;

Expand Down Expand Up @@ -50,19 +66,29 @@ describe('Accelerate: EXTERNAL signing', () => {
);

res.status.should.equal(200);
const body = (await res.json()) as { txid: string; tx: string; status: string };
const body = (await res.json()) as AccelerateResponse;
body.should.have.property('txid', 'test-tx-id');
body.should.have.property('tx', '01000000000101030a0000');
body.should.have.property('status', 'signed');
body.transfer.should.have.property('txid', 'test-tx-id');
body.transfer.entries.should.be.Array().and.have.length(2);

/**
* In external mode, AWM delegates signing to the key provider.
* POST /sign must be called — not POST /key (no local key retrieval for signing).
* The signablePayload for BTC is a PSBT hex.
*/
services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(1);
const signCalls = services.keyProvider.calls.filter((c) => c.path === '/sign');
signCalls.should.have.length(1);
const signBody = signCalls[0].body as { signablePayload: string };
signBody.signablePayload.should.startWith('70736274ff');

services.keyProvider.calls.filter((c) => c.path === '/key').should.have.length(0);

/** BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send */
/**
* BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send.
* cpfpTxIds and txHex both survive the TxSendBody whitelist and appear in tx/send.
*/
const buildCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build'));
buildCalls.should.have.length(1);
const buildBody = buildCalls[0].body as { cpfpTxIds?: string[] };
Expand All @@ -71,7 +97,15 @@ describe('Accelerate: EXTERNAL signing', () => {
services.bitgo.calls
.filter((c) => c.path.endsWith('/public/block/latest'))
.should.have.length(1);
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(1);

const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send'));
sendCalls.should.have.length(1);
const sendBody = sendCalls[0].body as {
cpfpTxIds?: string[];
txHex?: string;
};
sendBody.should.have.property('cpfpTxIds').which.deepEqual([CPFP_TX_ID]);
sendBody.txHex!.should.startWith('70736274ff');
});
});

Expand Down Expand Up @@ -119,10 +153,12 @@ describe('Accelerate: LOCAL signing', () => {
);

res.status.should.equal(200);
const body = (await res.json()) as { txid: string; tx: string; status: string };
const body = (await res.json()) as AccelerateResponse;
body.should.have.property('txid', 'test-tx-id');
body.should.have.property('tx', '01000000000101030a0000');
body.should.have.property('status', 'signed');
body.transfer.should.have.property('txid', 'test-tx-id');
body.transfer.entries.should.be.Array().and.have.length(2);

/**
* In local mode, AWM retrieves the xprv via GET /key/:pub and signs internally.
Expand All @@ -131,7 +167,10 @@ describe('Accelerate: LOCAL signing', () => {
services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(0);
services.keyProvider.calls.filter((c) => c.path.startsWith('/key/')).length.should.be.above(0);

/** BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send */
/**
* BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send.
* cpfpTxIds and txHex both survive the TxSendBody whitelist and appear in tx/send.
*/
const buildCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build'));
buildCalls.should.have.length(1);
const buildBody = buildCalls[0].body as { cpfpTxIds?: string[] };
Expand All @@ -140,6 +179,14 @@ describe('Accelerate: LOCAL signing', () => {
services.bitgo.calls
.filter((c) => c.path.endsWith('/public/block/latest'))
.should.have.length(1);
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(1);

const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send'));
sendCalls.should.have.length(1);
const sendBody = sendCalls[0].body as {
cpfpTxIds?: string[];
txHex?: string;
};
sendBody.should.have.property('cpfpTxIds').which.deepEqual([CPFP_TX_ID]);
sendBody.txHex!.should.startWith('70736274ff');
});
});
200 changes: 200 additions & 0 deletions src/__tests__/integration/consolidateUnspents.integ.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import 'should';
import { startServices, IntegServices } from './helpers/setup';
import { LOCALHOST } from './helpers/servers';
import { SigningMode } from '../../shared/types';

/**
* Deterministic test keypair derived from Buffer.alloc(64, 0x42) — a public, reproducible seed.
* Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.consolidate.tbtc.json.
*/
const USER_XPUB =
'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH';
const USER_XPRV =
'xprv9s21ZrQH143K2SDwr5LpPcLbsf4FZJmnbzXKnqURCoqGwoR965apxWYoS2DKu2ivcMTB9uTK6XhZDEPfTeNXGf7mmACuMN6cFS5ttmrpZ3i';

const WALLET_ID = 'test-wallet-id';

const consolidateRequestBody = {
pubkey: USER_XPUB,
source: 'user' as const,
feeRate: 1000,
maxFeeRate: 2000,
minValue: 1000,
};

interface TransferEntry {
address: string;
value: number;
isChange?: boolean;
}

interface ConsolidateResponse {
txid: string;
tx: string;
status: string;
transfer: {
txid: string;
status: string;
entries: TransferEntry[];
};
}

describe('Consolidate unspents: EXTERNAL signing', () => {
let services: IntegServices;

before(async () => {
services = await startServices({ signingMode: SigningMode.EXTERNAL });
});

after(async () => {
await services.teardown();
});

beforeEach(() => {
services.keyProvider.calls.length = 0;
services.bitgo.calls.length = 0;
});

it('consolidates tbtc unspents via external key provider', async () => {
const res = await fetch(
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/${WALLET_ID}/consolidateunspents`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify(consolidateRequestBody),
},
);

res.status.should.equal(200);
const body = (await res.json()) as ConsolidateResponse;
body.should.have.property('txid', 'test-tx-id');
body.should.have.property('tx', '01000000000101030a0000');
body.should.have.property('status', 'signed');
body.transfer.should.have.property('txid', 'test-tx-id');
body.transfer.entries.should.be.Array().and.have.length(2);

/**
* In external mode, AWM delegates signing to the key provider.
* POST /sign must be called — not POST /key (no local key retrieval for signing).
* The signablePayload for BTC is a PSBT hex (starts with PSBT magic bytes 70736274ff).
*/
const signCalls = services.keyProvider.calls.filter((c) => c.path === '/sign');
signCalls.should.have.length(1);
const signBody = signCalls[0].body as { signablePayload: string };
signBody.signablePayload.should.startWith('70736274ff');

services.keyProvider.calls.filter((c) => c.path === '/key').should.have.length(0);

/**
* BitGo must receive consolidateUnspents (not tx/build) with the consolidation params,
* then tx/send with type: 'consolidate'. tx/build is only used by sendMany and accelerate.
*/
const consolidateCalls = services.bitgo.calls.filter((c) =>
c.path.endsWith('/consolidateUnspents'),
);
consolidateCalls.should.have.length(1);
const consolidateBody = consolidateCalls[0].body as {
feeRate?: number;
maxFeeRate?: number;
minValue?: number;
txFormat?: string;
};
consolidateBody.should.have.property('feeRate', 1000);
consolidateBody.should.have.property('maxFeeRate', 2000);
consolidateBody.should.have.property('minValue', 1000);
consolidateBody.should.have.property('txFormat', 'psbt-lite');

const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send'));
sendCalls.should.have.length(1);
const sendBody = sendCalls[0].body as { type?: string };
sendBody.should.have.property('type', 'consolidate');

services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0);
});
});

describe('Consolidate unspents: LOCAL signing', () => {
let services: IntegServices;

before(async () => {
services = await startServices({ signingMode: SigningMode.LOCAL });

/**
* Seed the mock key provider with a known xprv so AWM can retrieve it
* via GET /key/:pub and sign the PSBT locally. The xpub must match
* getKeychain.user.json and the bip32Derivation in prebuildTx.consolidate.tbtc.json.
*/
await fetch(`http://127.0.0.1:${services.keyProvider.port}/key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pub: USER_XPUB,
prv: USER_XPRV,
coin: 'tbtc',
source: 'user',
type: 'independent',
}),
});
});

after(async () => {
await services.teardown();
});

beforeEach(() => {
services.keyProvider.calls.length = 0;
services.bitgo.calls.length = 0;
});

it('consolidates tbtc unspents using locally stored xprv', async () => {
const res = await fetch(
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/${WALLET_ID}/consolidateunspents`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify(consolidateRequestBody),
},
);

res.status.should.equal(200);
const body = (await res.json()) as ConsolidateResponse;
body.should.have.property('txid', 'test-tx-id');
body.should.have.property('tx', '01000000000101030a0000');
body.should.have.property('status', 'signed');
body.transfer.should.have.property('txid', 'test-tx-id');
body.transfer.entries.should.be.Array().and.have.length(2);

/**
* In local mode, AWM retrieves the xprv via GET /key/:pub and signs internally.
* POST /sign must NOT be called — signing happens inside AWM, not in the key provider.
*/
services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(0);
services.keyProvider.calls.filter((c) => c.path.startsWith('/key/')).length.should.be.above(0);

/**
* BitGo must receive consolidateUnspents (not tx/build) with the consolidation params,
* then tx/send with type: 'consolidate'.
*/
const consolidateCalls = services.bitgo.calls.filter((c) =>
c.path.endsWith('/consolidateUnspents'),
);
consolidateCalls.should.have.length(1);
const consolidateBody = consolidateCalls[0].body as {
feeRate?: number;
maxFeeRate?: number;
minValue?: number;
txFormat?: string;
};
consolidateBody.should.have.property('feeRate', 1000);
consolidateBody.should.have.property('maxFeeRate', 2000);
consolidateBody.should.have.property('minValue', 1000);
consolidateBody.should.have.property('txFormat', 'psbt-lite');

const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send'));
sendCalls.should.have.length(1);
const sendBody = sendCalls[0].body as { type?: string };
sendBody.should.have.property('type', 'consolidate');

services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"txHex": "70736274ff01000a01000000000000000000000000",
"txInfo": { "nP2SHInputs": 0, "nSegwitInputs": 0, "nOutputs": 0 }
"txHex": "70736274ff01005e0100000001015736beea8d00e1145b39e6e16505877e84961ffbb64071b4ed2556c53c27e20000000000ffffffff01905f010000000000220020b41d3a1e7ae51e81e1f5ac6518d6a4c449f1e1744461b9a298452913c7c14a9e000000004f010488b21e0000000000000000001860687e74faf798e258d66855e725343e64978b899441c761e05f321eece137025a0fef9917548521cc62dc96f7d55da6392e001fe6f3087f201310eb83f7bb8a04460c65944f010488b21e00000000000000000025de30d2eda67a684e7290fb06abb26e29488db6d6522a081c2e1c09d125a4c20335929e0c0d34bfec5318221142dbfa31dda3915480909706272e196974d76283046bf51aa84f010488b21e00000000000000000035ed9ee074c71886a1a6fa216a6fde3f285e7dea25044ed1d96fba451081690f0338681e310aeea9e95ee4d629655a77b72c022ec5c8b673647cea4b2f82ef67c604d3b12b170001012ba08601000000000022002008dbf93c4d3788ce02d95d4b5c6932a93df1fab66c6522c27d27bd5366e0a64a0103040100000001056952210293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a210316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a742102fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572353ae22060293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a146bf51aa800000000000000001500000000000000220602fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572314d3b12b170000000000000000150000000000000022060316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a7414460c6594000000000000000015000000000000000001016952210295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d682021038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf542102020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410153ae220202020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410114d3b12b170000000000000000140000000000000022020295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d6820146bf51aa8000000000000000014000000000000002202038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf5414460c65940000000000000000140000000000000000",
"txInfo": {
"nP2SHInputs": 0,
"nSegwitInputs": 1,
"nOutputs": 1,
"changeAddresses": [
"tb1qkswn58n6u50grc0443j3344yc3ylrct5g3smng5cg553837pf20qgcd3u9"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"txHex": "70736274ff0100870100000002a6758b462440d328f24027763756b0e0bc094741a5ceb0416a199dac2acc1a180000000000ffffffffe0538936ed9a8e57700a33dd4881174c625eb9189abdae3bbfa21aed9c60c7ed0100000000ffffffff01d07e010000000000220020b41d3a1e7ae51e81e1f5ac6518d6a4c449f1e1744461b9a298452913c7c14a9e000000004f010488b21e0000000000000000001860687e74faf798e258d66855e725343e64978b899441c761e05f321eece137025a0fef9917548521cc62dc96f7d55da6392e001fe6f3087f201310eb83f7bb8a04460c65944f010488b21e00000000000000000025de30d2eda67a684e7290fb06abb26e29488db6d6522a081c2e1c09d125a4c20335929e0c0d34bfec5318221142dbfa31dda3915480909706272e196974d76283046bf51aa84f010488b21e00000000000000000035ed9ee074c71886a1a6fa216a6fde3f285e7dea25044ed1d96fba451081690f0338681e310aeea9e95ee4d629655a77b72c022ec5c8b673647cea4b2f82ef67c604d3b12b170001012b50c300000000000022002008dbf93c4d3788ce02d95d4b5c6932a93df1fab66c6522c27d27bd5366e0a64a0103040100000001056952210293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a210316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a742102fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572353ae22060293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a146bf51aa800000000000000001500000000000000220602fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572314d3b12b170000000000000000150000000000000022060316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a7414460c6594000000000000000015000000000000000001012b50c3000000000000220020b180ecd6ef26da91ec5f739c2e7d631a525f914d3839a3d0e04a9f15a2798346010304010000000105695221024d7e8c3413c9410e9685f0b4aaeb24ef8933b7de4156a8cf07da9c7bd879151d21033b5c5699c1ae79ce9b3c6e52069b96a5c8814f6124e13655be3637006d599f4f21028e334ee478c3650865833c96e83b93579277be05a2e89d2a50e53cbdf21c732753ae2206024d7e8c3413c9410e9685f0b4aaeb24ef8933b7de4156a8cf07da9c7bd879151d146bf51aa8000000000000000015000000010000002206028e334ee478c3650865833c96e83b93579277be05a2e89d2a50e53cbdf21c732714d3b12b17000000000000000015000000010000002206033b5c5699c1ae79ce9b3c6e52069b96a5c8814f6124e13655be3637006d599f4f14460c6594000000000000000015000000010000000001016952210295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d682021038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf542102020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410153ae220202020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410114d3b12b170000000000000000140000000000000022020295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d6820146bf51aa8000000000000000014000000000000002202038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf5414460c65940000000000000000140000000000000000",
"txInfo": {
"nP2SHInputs": 0,
"nSegwitInputs": 2,
"nOutputs": 1,
"changeAddresses": [
"tb1qkswn58n6u50grc0443j3344yc3ylrct5g3smng5cg553837pf20qgcd3u9"
]
}
}
12 changes: 11 additions & 1 deletion src/__tests__/integration/helpers/mockBitgoServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ type SendManyFixtureMethod = 'getWallet' | 'prebuildTx' | 'sendTx';
type SupportedCoin = 'hteth' | 'tbtc';
type CoinToFixtures<C extends SupportedCoin> = {
[K in SendManyFixtureMethod]: `${K}.${C}`;
} & { acceleratePrebuildTx: `prebuildTx.accelerate.${C}` | `prebuildTx.${C}` };
} & {
acceleratePrebuildTx: `prebuildTx.accelerate.${C}` | `prebuildTx.${C}`;
consolidatePrebuildTx: `prebuildTx.consolidate.${C}` | `prebuildTx.${C}`;
};

/** Registry — add a new coin here to support it across all sendMany integ test routes */
const COIN_FIXTURES: { [C in SupportedCoin]: CoinToFixtures<C> } = {
Expand All @@ -34,12 +37,14 @@ const COIN_FIXTURES: { [C in SupportedCoin]: CoinToFixtures<C> } = {
prebuildTx: 'prebuildTx.hteth',
sendTx: 'sendTx.hteth',
acceleratePrebuildTx: 'prebuildTx.hteth', // CPFP/RBF not applicable to EVM; reuses standard prebuild
consolidatePrebuildTx: 'prebuildTx.hteth', // consolidateUnspents not applicable to EVM; reuses standard prebuild
},
tbtc: {
getWallet: 'getWallet.tbtc',
prebuildTx: 'prebuildTx.tbtc',
sendTx: 'sendTx.tbtc',
acceleratePrebuildTx: 'prebuildTx.accelerate.tbtc',
consolidatePrebuildTx: 'prebuildTx.consolidate.tbtc',
},
};

Expand Down Expand Up @@ -117,6 +122,11 @@ export async function startMockBitgoServer(): Promise<MockBitgoServer> {
res.json(loadFixture(fixtureName));
});

/** Consolidate unspents prebuild — coin-specific PSBT-lite fixture */
app.post('/api/v2/:coin/wallet/:walletId/consolidateUnspents', (req, res) => {
res.json(loadFixture(coinFixtures(req.params.coin).consolidatePrebuildTx));
});

/** Transaction submit — coin-specific fixture */
app.post('/api/v2/:coin/wallet/:walletId/tx/send', (req, res) => {
res.json(loadFixture(coinFixtures(req.params.coin).sendTx));
Expand Down
Loading