Skip to content

Commit b794c91

Browse files
committed
fix(sdk-core): revert server buildParams forwarding, keep user-param tests
Revert the production changes that forwarded server-returned buildParams through the prebuild→sign→send pipeline. Per BitGo security architecture (mutual distrust principle), the SDK must not forward WP-provided data back to WP — a compromised WP could rewrite recipients. Recipients already appear at the top level of /tx/send via selectParams (the user's original params) for both UTXO and account-based coins. New tests verify this existing behavior. WP-8015, WP-8014 TICKET: WP-8015
1 parent e1c5a9a commit b794c91

3 files changed

Lines changed: 31 additions & 168 deletions

File tree

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 27 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,122 +1848,35 @@ describe('V2 Wallet:', function () {
18481848

18491849
response.isDone().should.be.true();
18501850
});
1851-
1852-
it('should preserve server-returned buildParams on prebuild result', async function () {
1853-
const serverBuildParams = {
1854-
recipients: [{ address: 'addr1', amount: '5000' }],
1855-
feeRate: 10000,
1856-
};
1857-
const params = { offlineVerification: true };
1858-
nock(bgUrl)
1859-
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`, tbtcHotWalletDefaultParams)
1860-
.query(params)
1861-
.reply(200, { buildParams: serverBuildParams });
1862-
const blockHeight = 100;
1863-
sinon.stub(basecoin, 'getLatestBlockHeight').resolves(blockHeight);
1864-
sinon.stub(basecoin, 'postProcessPrebuild').callsFake((params) => Promise.resolve(params));
1865-
const txPrebuild = await wallet.prebuildTransaction({ ...params, reqId: reqId });
1866-
should.exist(txPrebuild.buildParams);
1867-
txPrebuild.buildParams!.should.deepEqual(serverBuildParams);
1868-
});
1869-
1870-
it('should fall back to request params when server does not return buildParams', async function () {
1871-
const params = { offlineVerification: true };
1872-
nock(bgUrl)
1873-
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`, tbtcHotWalletDefaultParams)
1874-
.query(params)
1875-
.reply(200, {});
1876-
const blockHeight = 100;
1877-
sinon.stub(basecoin, 'getLatestBlockHeight').resolves(blockHeight);
1878-
sinon.stub(basecoin, 'postProcessPrebuild').callsFake((params) => Promise.resolve(params));
1879-
const txPrebuild = await wallet.prebuildTransaction({ ...params, reqId: reqId });
1880-
should.exist(txPrebuild.buildParams);
1881-
txPrebuild.buildParams!.should.deepEqual(tbtcHotWalletDefaultParams);
1882-
});
18831851
});
18841852

1885-
describe('sendMany buildParams pass-through for UTXO coins', function () {
1886-
it('should include recipients from server buildParams in /tx/send request body', async function () {
1887-
const recipients = [{ address: 'addr1', amount: '5000' }];
1888-
const serverBuildParams = { recipients, feeRate: 10000 };
1889-
const prebuildReturn = { txHex: 'deadbeef', buildParams: serverBuildParams };
1890-
sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(prebuildReturn);
1891-
sinon.stub(basecoin, 'getExtraPrebuildParams').resolves({});
1892-
1893-
const path = `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`;
1894-
const sendNock = nock(bgUrl)
1895-
.post(path, _.matches({ txHex: 'deadbeef', recipients, feeRate: 10000 }))
1896-
.reply(200, { status: 'signed' });
1897-
1898-
const result = await wallet.sendMany({ recipients });
1899-
sendNock.isDone().should.be.true();
1900-
result.should.have.property('status', 'signed');
1901-
});
1902-
1903-
it('should include RBF fields from server buildParams in /tx/send request body', async function () {
1904-
const recipients = [{ address: 'addr1', amount: '5000' }];
1905-
const serverBuildParams = {
1906-
recipients,
1907-
rbfTxIds: ['txid1'],
1908-
maxFee: 50000,
1909-
feeMultiplier: 2,
1910-
};
1911-
const prebuildReturn = { txHex: 'deadbeef', buildParams: serverBuildParams };
1912-
sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(prebuildReturn);
1913-
sinon.stub(basecoin, 'getExtraPrebuildParams').resolves({});
1914-
1915-
const path = `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`;
1916-
const sendNock = nock(bgUrl)
1917-
.post(path, _.matches({ txHex: 'deadbeef', rbfTxIds: ['txid1'], maxFee: 50000, feeMultiplier: 2 }))
1918-
.reply(200, { status: 'signed' });
1919-
1920-
const result = await wallet.sendMany({ recipients });
1921-
sendNock.isDone().should.be.true();
1922-
result.should.have.property('status', 'signed');
1923-
});
1924-
1925-
it('should work when server returns no buildParams in build response', async function () {
1853+
describe('sendMany includes user-provided recipients at top level in /tx/send', function () {
1854+
it('should include top-level recipients from user params for UTXO coins', async function () {
19261855
const recipients = [{ address: 'addr1', amount: '5000' }];
19271856
const prebuildReturn = { txHex: 'deadbeef' };
19281857
sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(prebuildReturn);
19291858
sinon.stub(basecoin, 'getExtraPrebuildParams').resolves({});
19301859

19311860
const path = `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`;
19321861
const sendNock = nock(bgUrl)
1933-
.post(path, _.matches({ txHex: 'deadbeef' }))
1862+
.post(path, (body) => {
1863+
// recipients from user params must appear at top level via selectParams
1864+
return (
1865+
body.recipients !== undefined &&
1866+
body.recipients[0].address === recipients[0].address &&
1867+
body.txHex === 'deadbeef'
1868+
);
1869+
})
19341870
.reply(200, { status: 'signed' });
19351871

19361872
const result = await wallet.sendMany({ recipients });
19371873
sendNock.isDone().should.be.true();
19381874
result.should.have.property('status', 'signed');
19391875
});
19401876

1941-
it('should let explicit user params override server buildParams', async function () {
1942-
const userRecipients = [{ address: 'user-addr', amount: '9999' }];
1943-
const serverRecipients = [{ address: 'server-addr', amount: '5000' }];
1944-
const serverBuildParams = { recipients: serverRecipients, feeRate: 10000 };
1945-
const prebuildReturn = { txHex: 'deadbeef', buildParams: serverBuildParams };
1946-
sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(prebuildReturn);
1947-
sinon.stub(basecoin, 'getExtraPrebuildParams').resolves({});
1948-
1949-
const path = `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`;
1950-
const sendNock = nock(bgUrl)
1951-
.post(path, _.matches({ txHex: 'deadbeef', recipients: userRecipients }))
1952-
.reply(200, { status: 'signed' });
1953-
1954-
const result = await wallet.sendMany({ recipients: userRecipients });
1955-
sendNock.isDone().should.be.true();
1956-
result.should.have.property('status', 'signed');
1957-
});
1958-
});
1959-
1960-
describe('sendMany buildParams pass-through for account-based coins', function () {
1961-
let ethBasecoin;
1962-
let ethWalletForBuildParams: Wallet;
1963-
1964-
before(function () {
1965-
ethBasecoin = bitgo.coin('teth');
1966-
ethWalletForBuildParams = new Wallet(bitgo, ethBasecoin, {
1877+
it('should include top-level recipients alongside halfSigned for account-based coins', async function () {
1878+
const ethBasecoin = bitgo.coin('teth');
1879+
const ethWalletLocal = new Wallet(bitgo, ethBasecoin, {
19671880
id: '598f606cd8fc24710d2ebadb1d9459bb',
19681881
coin: 'teth',
19691882
keys: [
@@ -1974,27 +1887,22 @@ describe('V2 Wallet:', function () {
19741887
multisigType: 'onchain',
19751888
type: 'hot',
19761889
});
1977-
});
1978-
1979-
it('should include top-level recipients alongside halfSigned in /tx/send for ETH', async function () {
19801890
const recipients = [{ address: '0x5208d8e80c6d1aef9be37b4bd19a9cf75ed93dc8', amount: '200000000000000' }];
1981-
const serverBuildParams = { recipients };
19821891
const prebuildReturn = {
19831892
halfSigned: {
19841893
recipients,
19851894
txHex: '0xdeadbeef',
19861895
expireTime: 1234567890,
19871896
contractSequenceId: 1001,
19881897
},
1989-
buildParams: serverBuildParams,
19901898
};
1991-
sinon.stub(ethWalletForBuildParams, 'prebuildAndSignTransaction').resolves(prebuildReturn);
1899+
sinon.stub(ethWalletLocal, 'prebuildAndSignTransaction').resolves(prebuildReturn);
19921900
sinon.stub(ethBasecoin, 'getExtraPrebuildParams').resolves({});
19931901

1994-
const path = `/api/v2/${ethWalletForBuildParams.coin()}/wallet/${ethWalletForBuildParams.id()}/tx/send`;
1902+
const path = `/api/v2/${ethWalletLocal.coin()}/wallet/${ethWalletLocal.id()}/tx/send`;
19951903
const sendNock = nock(bgUrl)
19961904
.post(path, (body) => {
1997-
// recipients must be at top level (not just inside halfSigned)
1905+
// recipients from user params must be at top level (not just inside halfSigned)
19981906
return (
19991907
body.recipients !== undefined &&
20001908
body.recipients[0].address === recipients[0].address &&
@@ -2003,66 +1911,30 @@ describe('V2 Wallet:', function () {
20031911
})
20041912
.reply(200, { status: 'signed' });
20051913

2006-
const result = await ethWalletForBuildParams.sendMany({ recipients });
1914+
const result = await ethWalletLocal.sendMany({ recipients });
20071915
sendNock.isDone().should.be.true();
20081916
result.should.have.property('status', 'signed');
20091917
});
20101918

2011-
it('should include top-level recipients from selectParams even without server buildParams for ETH', async function () {
2012-
const recipients = [{ address: '0x5208d8e80c6d1aef9be37b4bd19a9cf75ed93dc8', amount: '200000000000000' }];
2013-
// No buildParams — simulates old server that doesn't return buildParams
2014-
const prebuildReturn = {
2015-
halfSigned: {
2016-
recipients,
2017-
txHex: '0xdeadbeef',
2018-
expireTime: 1234567890,
2019-
contractSequenceId: 1001,
2020-
},
2021-
};
2022-
sinon.stub(ethWalletForBuildParams, 'prebuildAndSignTransaction').resolves(prebuildReturn);
2023-
sinon.stub(ethBasecoin, 'getExtraPrebuildParams').resolves({});
1919+
it('should not forward server-returned buildParams to /tx/send', async function () {
1920+
const userRecipients = [{ address: 'user-addr', amount: '9999' }];
1921+
const prebuildReturn = { txHex: 'deadbeef' };
1922+
sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(prebuildReturn);
1923+
sinon.stub(basecoin, 'getExtraPrebuildParams').resolves({});
20241924

2025-
const path = `/api/v2/${ethWalletForBuildParams.coin()}/wallet/${ethWalletForBuildParams.id()}/tx/send`;
1925+
const path = `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`;
20261926
const sendNock = nock(bgUrl)
20271927
.post(path, (body) => {
2028-
// recipients should still be at top level via selectParams (user's original params)
1928+
// only user-provided recipients should appear, no server buildParams key
20291929
return (
20301930
body.recipients !== undefined &&
2031-
body.recipients[0].address === recipients[0].address &&
2032-
body.halfSigned !== undefined
1931+
body.recipients[0].address === 'user-addr' &&
1932+
body.buildParams === undefined
20331933
);
20341934
})
20351935
.reply(200, { status: 'signed' });
20361936

2037-
const result = await ethWalletForBuildParams.sendMany({ recipients });
2038-
sendNock.isDone().should.be.true();
2039-
result.should.have.property('status', 'signed');
2040-
});
2041-
2042-
it('should include eip1559 and nonce from server buildParams at top level for ETH', async function () {
2043-
const recipients = [{ address: '0x5208d8e80c6d1aef9be37b4bd19a9cf75ed93dc8', amount: '200000000000000' }];
2044-
const eip1559 = { maxPriorityFeePerGas: '57500000', maxFeePerGas: '510214356' };
2045-
const serverBuildParams = { recipients, nonce: 42, memo: { value: 'test' } };
2046-
const prebuildReturn = {
2047-
halfSigned: {
2048-
recipients,
2049-
txHex: '0xdeadbeef',
2050-
eip1559,
2051-
contractSequenceId: 3371928,
2052-
},
2053-
buildParams: serverBuildParams,
2054-
};
2055-
sinon.stub(ethWalletForBuildParams, 'prebuildAndSignTransaction').resolves(prebuildReturn);
2056-
sinon.stub(ethBasecoin, 'getExtraPrebuildParams').resolves({});
2057-
2058-
const path = `/api/v2/${ethWalletForBuildParams.coin()}/wallet/${ethWalletForBuildParams.id()}/tx/send`;
2059-
const sendNock = nock(bgUrl)
2060-
.post(path, (body) => {
2061-
return body.recipients !== undefined && body.halfSigned !== undefined && body.nonce === 42;
2062-
})
2063-
.reply(200, { status: 'signed' });
2064-
2065-
const result = await ethWalletForBuildParams.sendMany({ recipients });
1937+
const result = await wallet.sendMany({ recipients: userRecipients });
20661938
sendNock.isDone().should.be.true();
20671939
result.should.have.property('status', 'signed');
20681940
});

modules/sdk-coin-eth/test/unit/ethWallet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ describe('Ethereum Hop Transactions', function () {
282282
should.exist(res.hopTransaction.id);
283283
should.exist(res.hopTransaction.signature);
284284
should.not.exist(res.wallet);
285-
should.exist(res.buildParams);
285+
should.not.exist(res.buildParams);
286286
feeScope.isDone().should.equal(true);
287287
const feeReq = (feeScope as any).interceptors[0].req;
288288
feeReq.path.should.containEql('hop=true');

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,16 +2021,14 @@ export class Wallet implements IWallet {
20212021
if (!_.isUndefined(blockHeight)) {
20222022
buildResponse.blockHeight = blockHeight;
20232023
}
2024-
const serverBuildParams = buildResponse.buildParams;
20252024
let prebuild: TransactionPrebuild = (await this.baseCoin.postProcessPrebuild(
20262025
Object.assign(buildResponse, {
20272026
wallet: this,
20282027
buildParams: whitelistedParams,
20292028
})
20302029
)) as any;
20312030
delete prebuild.wallet;
2032-
// Preserve buildParams: prefer server-validated; fall back to request params
2033-
prebuild.buildParams = serverBuildParams ?? whitelistedParams;
2031+
delete prebuild.buildParams;
20342032
prebuild = _.extend({}, prebuild, { walletId: this.id() });
20352033
if (this._wallet && this._wallet.coinSpecific && !params.walletContractAddress) {
20362034
prebuild = _.extend({}, prebuild, {
@@ -2505,11 +2503,7 @@ export class Wallet implements IWallet {
25052503
}
25062504

25072505
try {
2508-
const signedTransaction = await this.signTransaction(signingParams);
2509-
if (txPrebuild.buildParams) {
2510-
(signedTransaction as any).buildParams = txPrebuild.buildParams;
2511-
}
2512-
return signedTransaction;
2506+
return await this.signTransaction(signingParams);
25132507
} catch (error) {
25142508
if (error.message.includes('insufficient funds')) {
25152509
error.code = 'insufficient_funds';
@@ -2893,11 +2887,8 @@ export class Wallet implements IWallet {
28932887
}
28942888

28952889
const halfSignedTransaction = await this.prebuildAndSignTransaction(params);
2896-
const { buildParams: prebuildBuildParams, ...signedTxFields } = halfSignedTransaction as any;
28972890
const extraParams = await this.baseCoin.getExtraPrebuildParams(Object.assign(params, { wallet: this }));
2898-
// Merge order: signed tx fields, then server buildParams, then user selectParams, then extraParams.
2899-
// selectParams overrides buildParams so explicit user values take precedence.
2900-
const finalTxParams = _.extend({}, signedTxFields, prebuildBuildParams, selectParams, extraParams);
2891+
const finalTxParams = _.extend({}, halfSignedTransaction, selectParams, extraParams);
29012892
return this.sendTransaction(finalTxParams, reqId);
29022893
}
29032894

0 commit comments

Comments
 (0)