Skip to content

Commit bdce58d

Browse files
committed
fix(express): restore backward compatibility for empty eip1559 object
TICKET: WP-7882
1 parent 2c2e523 commit bdce58d

File tree

4 files changed

+200
-19
lines changed

4 files changed

+200
-19
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,19 @@ async function handleV2SendOne(req: ExpressApiRouteRequest<'express.v2.wallet.se
937937
const wallet = await coin.wallets().get({ id: req.decoded.id, reqId });
938938
req.body.reqId = reqId;
939939

940+
// Validate eip1559: must have both fields or neither
941+
if (req.body.eip1559) {
942+
const { maxFeePerGas, maxPriorityFeePerGas } = req.body.eip1559;
943+
const hasMax = maxFeePerGas !== undefined;
944+
const hasPriority = maxPriorityFeePerGas !== undefined;
945+
if (hasMax && !hasPriority) {
946+
throw new ApiResponseError('eip1559 missing maxPriorityFeePerGas', 400);
947+
}
948+
if (hasPriority && !hasMax) {
949+
throw new ApiResponseError('eip1559 missing maxFeePerGas', 400);
950+
}
951+
}
952+
940953
let result;
941954
try {
942955
result = await wallet.send(createSendParams(req));
@@ -960,6 +973,20 @@ async function handleV2SendMany(req: ExpressApiRouteRequest<'express.v2.wallet.s
960973
const reqId = new RequestTracer();
961974
const wallet = await coin.wallets().get({ id: req.decoded.id, reqId });
962975
req.body.reqId = reqId;
976+
977+
// Validate eip1559: must have both fields or neither
978+
if (req.body.eip1559) {
979+
const { maxFeePerGas, maxPriorityFeePerGas } = req.body.eip1559;
980+
const hasMax = maxFeePerGas !== undefined;
981+
const hasPriority = maxPriorityFeePerGas !== undefined;
982+
if (hasMax && !hasPriority) {
983+
throw new ApiResponseError('eip1559 missing maxPriorityFeePerGas', 400);
984+
}
985+
if (hasPriority && !hasMax) {
986+
throw new ApiResponseError('eip1559 missing maxFeePerGas', 400);
987+
}
988+
}
989+
963990
let result;
964991
try {
965992
if (wallet._wallet.multisigType === 'tss') {

modules/express/src/typedRoutes/api/v2/sendmany.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ export const SendManyRequestParams = {
1616

1717
/**
1818
* EIP-1559 fee parameters for Ethereum transactions
19-
* When eip1559 object is present, both fields are REQUIRED
19+
*
20+
* Accepts:
21+
* - Empty object {} - triggers automatic fee estimation (backward compatible)
22+
* - Object with both maxFeePerGas AND maxPriorityFeePerGas - uses provided values
23+
*
24+
* Note: Partial objects (only one field) pass validation but backend handles them
2025
*/
21-
export const EIP1559Params = t.type({
22-
/** Maximum priority fee per gas (in wei) - REQUIRED */
26+
export const EIP1559Params = t.partial({
27+
/** Maximum priority fee per gas (in wei) */
2328
maxPriorityFeePerGas: t.union([t.number, t.string]),
24-
/** Maximum fee per gas (in wei) - REQUIRED */
29+
/** Maximum fee per gas (in wei) */
2530
maxFeePerGas: t.union([t.number, t.string]),
2631
});
2732

modules/express/test/unit/typedRoutes/sendCoins.ts

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,77 @@ describe('SendCoins V2 codec tests', function () {
336336
assert.strictEqual(callArgs.gasLimit, 21000);
337337
});
338338

339+
it('should allow empty eip1559 object for backward compatibility', async function () {
340+
const requestBody = {
341+
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
342+
amount: '1000000000000000000',
343+
walletPassphrase: 'test_passphrase_12345',
344+
eip1559: {},
345+
};
346+
347+
const mockWallet = {
348+
send: sinon.stub().resolves(mockSendResponse),
349+
_wallet: { multisigType: 'onchain' },
350+
};
351+
352+
const walletsGetStub = sinon.stub().resolves(mockWallet);
353+
const mockCoin = {
354+
wallets: sinon.stub().returns({
355+
get: walletsGetStub,
356+
}),
357+
};
358+
359+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
360+
361+
const result = await agent
362+
.post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`)
363+
.set('Authorization', 'Bearer test_access_token_12345')
364+
.set('Content-Type', 'application/json')
365+
.send(requestBody);
366+
367+
assert.strictEqual(result.status, 200);
368+
369+
// Verify empty eip1559 was passed to SDK (backend handles auto fee estimation)
370+
const callArgs = mockWallet.send.firstCall.args[0];
371+
assert.deepStrictEqual(callArgs.eip1559, {});
372+
});
373+
374+
it('should reject partial eip1559 object with 400 error', async function () {
375+
const requestBody = {
376+
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
377+
amount: '1000000000000000000',
378+
walletPassphrase: 'test_passphrase_12345',
379+
eip1559: {
380+
maxFeePerGas: 100000000000,
381+
// maxPriorityFeePerGas intentionally missing
382+
},
383+
};
384+
385+
const mockWallet = {
386+
send: sinon.stub().resolves(mockSendResponse),
387+
_wallet: { multisigType: 'onchain' },
388+
};
389+
390+
const walletsGetStub = sinon.stub().resolves(mockWallet);
391+
const mockCoin = {
392+
wallets: sinon.stub().returns({
393+
get: walletsGetStub,
394+
}),
395+
};
396+
397+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
398+
399+
const result = await agent
400+
.post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`)
401+
.set('Authorization', 'Bearer test_access_token_12345')
402+
.set('Content-Type', 'application/json')
403+
.send(requestBody);
404+
405+
// Partial eip1559 should be rejected with 400 error
406+
assert.strictEqual(result.status, 400);
407+
assert.ok(result.body.error.includes('eip1559 missing maxPriorityFeePerGas'));
408+
});
409+
339410
it('should successfully send with memo (XRP/Stellar)', async function () {
340411
const requestBody = {
341412
address: 'GDSAMPLE123456789',
@@ -787,10 +858,39 @@ describe('SendCoins V2 codec tests', function () {
787858

788859
const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody);
789860
assert.ok(decoded.eip1559);
861+
assert.ok('maxPriorityFeePerGas' in decoded.eip1559);
862+
assert.ok('maxFeePerGas' in decoded.eip1559);
790863
assert.strictEqual(decoded.eip1559.maxPriorityFeePerGas, 2000000000);
791864
assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000);
792865
});
793866

867+
it('should allow empty eip1559 object for backward compatibility', function () {
868+
const validBody = {
869+
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
870+
amount: '1000000000000000000',
871+
eip1559: {},
872+
};
873+
874+
const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody);
875+
assert.ok(decoded.eip1559);
876+
assert.deepStrictEqual(decoded.eip1559, {});
877+
});
878+
879+
it('should pass schema validation for partial eip1559 (controller rejects)', function () {
880+
const partialBody = {
881+
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
882+
amount: '1000000000000000000',
883+
eip1559: {
884+
maxFeePerGas: 100000000000,
885+
},
886+
};
887+
888+
// Partial objects pass schema validation; controller validates and rejects
889+
const decoded = assertDecode(t.type(SendCoinsRequestBody), partialBody);
890+
assert.ok(decoded.eip1559);
891+
assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000);
892+
});
893+
794894
it('should validate body with memo', function () {
795895
const validBody = {
796896
address: 'GDSAMPLE',
@@ -860,21 +960,6 @@ describe('SendCoins V2 codec tests', function () {
860960
});
861961
});
862962

863-
it('should reject body with incomplete eip1559 params', function () {
864-
const invalidBody = {
865-
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
866-
amount: '1000000000000000000',
867-
eip1559: {
868-
maxPriorityFeePerGas: 2000000000,
869-
// Missing maxFeePerGas
870-
},
871-
};
872-
873-
assert.throws(() => {
874-
assertDecode(t.type(SendCoinsRequestBody), invalidBody);
875-
});
876-
});
877-
878963
it('should reject body with incomplete memo params', function () {
879964
const invalidBody = {
880965
address: 'GDSAMPLE',

modules/express/test/unit/typedRoutes/sendmany.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,68 @@ describe('SendMany V2 codec tests', function () {
456456
assert.strictEqual(callArgs.gasLimit, 21000);
457457
});
458458

459+
it('should allow empty eip1559 object for backward compatibility', async function () {
460+
const requestBody = {
461+
recipients: [{ address: '0x1234567890123456789012345678901234567890', amount: 1000 }],
462+
walletPassphrase: 'test_passphrase_12345',
463+
eip1559: {},
464+
};
465+
466+
const mockWallet = {
467+
sendMany: sinon.stub().resolves(mockSendManyResponse),
468+
_wallet: { multisigType: 'onchain' },
469+
};
470+
471+
const walletsGetStub = sinon.stub().resolves(mockWallet);
472+
const mockCoin = {
473+
wallets: sinon.stub().returns({
474+
get: walletsGetStub,
475+
}),
476+
};
477+
478+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
479+
480+
const result = await agent
481+
.post(`/api/v2/${coin}/wallet/${walletId}/sendmany`)
482+
.set('Authorization', 'Bearer test_access_token_12345')
483+
.set('Content-Type', 'application/json')
484+
.send(requestBody);
485+
486+
assert.strictEqual(result.status, 200);
487+
assert.deepStrictEqual(mockWallet.sendMany.firstCall.args[0].eip1559, {});
488+
});
489+
490+
it('should reject partial eip1559 with 400', async function () {
491+
const requestBody = {
492+
recipients: [{ address: '0x1234567890123456789012345678901234567890', amount: 1000 }],
493+
walletPassphrase: 'test_passphrase_12345',
494+
eip1559: { maxFeePerGas: 100000000000 },
495+
};
496+
497+
const mockWallet = {
498+
sendMany: sinon.stub().resolves(mockSendManyResponse),
499+
_wallet: { multisigType: 'onchain' },
500+
};
501+
502+
const walletsGetStub = sinon.stub().resolves(mockWallet);
503+
const mockCoin = {
504+
wallets: sinon.stub().returns({
505+
get: walletsGetStub,
506+
}),
507+
};
508+
509+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
510+
511+
const result = await agent
512+
.post(`/api/v2/${coin}/wallet/${walletId}/sendmany`)
513+
.set('Authorization', 'Bearer test_access_token_12345')
514+
.set('Content-Type', 'application/json')
515+
.send(requestBody);
516+
517+
assert.strictEqual(result.status, 400);
518+
assert.ok(result.body.error.includes('eip1559 missing'));
519+
});
520+
459521
it('should support memo parameters for Stellar/XRP', async function () {
460522
const requestBody = {
461523
recipients: [
@@ -1058,6 +1120,8 @@ describe('SendMany V2 codec tests', function () {
10581120

10591121
const decoded = assertDecode(t.type(SendManyRequestBody), validBody);
10601122
assert.ok(decoded.eip1559);
1123+
assert.ok('maxPriorityFeePerGas' in decoded.eip1559);
1124+
assert.ok('maxFeePerGas' in decoded.eip1559);
10611125
assert.strictEqual(decoded.eip1559.maxPriorityFeePerGas, 2000000000);
10621126
assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000);
10631127
});

0 commit comments

Comments
 (0)