Skip to content
Open
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
35 changes: 35 additions & 0 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you confirmed whether both fields are required to be passed here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The server-side auto-estimation only triggers when both fields are missing. If only one field is provided, the server won't auto-estimate, it will fail downstream. Better to reject early with a clear error.

}
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));
Expand All @@ -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') {
Expand Down
13 changes: 9 additions & 4 deletions modules/express/src/typedRoutes/api/v2/sendmany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Copy link
Contributor

@alextse-bg alextse-bg Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

export const EIP1559Params = t.union([
  t.type({}),
  t.type({
    /** Maximum priority fee per gas (in wei) - REQUIRED */
    maxPriorityFeePerGas: t.union([t.number, t.string]),
    /** Maximum fee per gas (in wei) - REQUIRED */
    maxFeePerGas: t.union([t.number, t.string]),
  })
]);

/** 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]),
});

Expand Down
115 changes: 100 additions & 15 deletions modules/express/test/unit/typedRoutes/sendCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
64 changes: 64 additions & 0 deletions modules/express/test/unit/typedRoutes/sendmany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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);
});
Expand Down