diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f6916737b2..8e6ee42e63 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -937,6 +937,23 @@ async function handleV2SendOne(req: ExpressApiRouteRequest<'express.v2.wallet.se const wallet = await coin.wallets().get({ id: req.decoded.id, reqId }); req.body.reqId = reqId; + // Validate eip1559: reject partial objects, normalize empty objects to undefined + if (req.body.eip1559) { + const { maxFeePerGas, maxPriorityFeePerGas } = req.body.eip1559; + const hasMax = maxFeePerGas !== undefined; + const hasPriority = maxPriorityFeePerGas !== undefined; + if (hasMax && !hasPriority) { + throw new ApiResponseError('eip1559 missing maxPriorityFeePerGas', 400); + } + if (hasPriority && !hasMax) { + throw new ApiResponseError('eip1559 missing maxFeePerGas', 400); + } + // Normalize empty object to undefined for server-side auto-estimation + if (!hasMax && !hasPriority) { + req.body.eip1559 = undefined; + } + } + let result; try { result = await wallet.send(createSendParams(req)); @@ -960,6 +977,24 @@ async function handleV2SendMany(req: ExpressApiRouteRequest<'express.v2.wallet.s const reqId = new RequestTracer(); const wallet = await coin.wallets().get({ id: req.decoded.id, reqId }); req.body.reqId = reqId; + + // Validate eip1559: reject partial objects, normalize empty objects to undefined + if (req.body.eip1559) { + const { maxFeePerGas, maxPriorityFeePerGas } = req.body.eip1559; + const hasMax = maxFeePerGas !== undefined; + const hasPriority = maxPriorityFeePerGas !== undefined; + if (hasMax && !hasPriority) { + throw new ApiResponseError('eip1559 missing maxPriorityFeePerGas', 400); + } + if (hasPriority && !hasMax) { + throw new ApiResponseError('eip1559 missing maxFeePerGas', 400); + } + // Normalize empty object to undefined for server-side auto-estimation + if (!hasMax && !hasPriority) { + req.body.eip1559 = undefined; + } + } + let result; try { if (wallet._wallet.multisigType === 'tss') { diff --git a/modules/express/src/typedRoutes/api/v2/sendmany.ts b/modules/express/src/typedRoutes/api/v2/sendmany.ts index 800610de4f..fb3ae0a302 100644 --- a/modules/express/src/typedRoutes/api/v2/sendmany.ts +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -16,12 +16,17 @@ export const SendManyRequestParams = { /** * EIP-1559 fee parameters for Ethereum transactions - * When eip1559 object is present, both fields are REQUIRED + * + * Accepts: + * - Empty object {} - triggers automatic fee estimation (backward compatible) + * - Object with both maxFeePerGas AND maxPriorityFeePerGas - uses provided values + * + * Note: Partial objects (only one field) pass validation but backend handles them */ -export const EIP1559Params = t.type({ - /** Maximum priority fee per gas (in wei) - REQUIRED */ +export const EIP1559Params = t.partial({ + /** Maximum priority fee per gas (in wei) */ maxPriorityFeePerGas: t.union([t.number, t.string]), - /** Maximum fee per gas (in wei) - REQUIRED */ + /** Maximum fee per gas (in wei) */ maxFeePerGas: t.union([t.number, t.string]), }); diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index 47aa8fde87..1ca6ba6f9a 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -336,6 +336,77 @@ describe('SendCoins V2 codec tests', function () { assert.strictEqual(callArgs.gasLimit, 21000); }); + it('should normalize empty eip1559 object to undefined for server-side auto-estimation', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + walletPassphrase: 'test_passphrase_12345', + eip1559: {}, + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify empty eip1559 is normalized to undefined for server-side auto-estimation + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.eip1559, undefined); + }); + + it('should reject partial eip1559 object with 400 error', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + walletPassphrase: 'test_passphrase_12345', + eip1559: { + maxFeePerGas: 100000000000, + // maxPriorityFeePerGas intentionally missing + }, + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Partial eip1559 should be rejected with 400 error + assert.strictEqual(result.status, 400); + assert.ok(result.body.error.includes('eip1559 missing maxPriorityFeePerGas')); + }); + it('should successfully send with memo (XRP/Stellar)', async function () { const requestBody = { address: 'GDSAMPLE123456789', @@ -787,10 +858,39 @@ describe('SendCoins V2 codec tests', function () { const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.ok(decoded.eip1559); + assert.ok('maxPriorityFeePerGas' in decoded.eip1559); + assert.ok('maxFeePerGas' in decoded.eip1559); assert.strictEqual(decoded.eip1559.maxPriorityFeePerGas, 2000000000); assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); }); + it('should allow empty eip1559 object for backward compatibility', function () { + const validBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + eip1559: {}, + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.ok(decoded.eip1559); + assert.deepStrictEqual(decoded.eip1559, {}); + }); + + it('should pass schema validation for partial eip1559 (controller rejects)', function () { + const partialBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + eip1559: { + maxFeePerGas: 100000000000, + }, + }; + + // Partial objects pass schema validation; controller validates and rejects + const decoded = assertDecode(t.type(SendCoinsRequestBody), partialBody); + assert.ok(decoded.eip1559); + assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); + }); + it('should validate body with memo', function () { const validBody = { address: 'GDSAMPLE', @@ -860,21 +960,6 @@ describe('SendCoins V2 codec tests', function () { }); }); - it('should reject body with incomplete eip1559 params', function () { - const invalidBody = { - address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - amount: '1000000000000000000', - eip1559: { - maxPriorityFeePerGas: 2000000000, - // Missing maxFeePerGas - }, - }; - - assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); - }); - }); - it('should reject body with incomplete memo params', function () { const invalidBody = { address: 'GDSAMPLE', diff --git a/modules/express/test/unit/typedRoutes/sendmany.ts b/modules/express/test/unit/typedRoutes/sendmany.ts index d2da4fb892..e9f537f96e 100644 --- a/modules/express/test/unit/typedRoutes/sendmany.ts +++ b/modules/express/test/unit/typedRoutes/sendmany.ts @@ -456,6 +456,68 @@ describe('SendMany V2 codec tests', function () { assert.strictEqual(callArgs.gasLimit, 21000); }); + it('should normalize empty eip1559 object to undefined for server-side auto-estimation', async function () { + const requestBody = { + recipients: [{ address: '0x1234567890123456789012345678901234567890', amount: 1000 }], + walletPassphrase: 'test_passphrase_12345', + eip1559: {}, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.sendMany.firstCall.args[0].eip1559, undefined); + }); + + it('should reject partial eip1559 with 400', async function () { + const requestBody = { + recipients: [{ address: '0x1234567890123456789012345678901234567890', amount: 1000 }], + walletPassphrase: 'test_passphrase_12345', + eip1559: { maxFeePerGas: 100000000000 }, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + assert.ok(result.body.error.includes('eip1559 missing')); + }); + it('should support memo parameters for Stellar/XRP', async function () { const requestBody = { recipients: [ @@ -1058,6 +1120,8 @@ describe('SendMany V2 codec tests', function () { const decoded = assertDecode(t.type(SendManyRequestBody), validBody); assert.ok(decoded.eip1559); + assert.ok('maxPriorityFeePerGas' in decoded.eip1559); + assert.ok('maxFeePerGas' in decoded.eip1559); assert.strictEqual(decoded.eip1559.maxPriorityFeePerGas, 2000000000); assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); });