Skip to content

Commit 0547142

Browse files
committed
feat(abstract-eth): override getSignablePayload for ETH coin classes
Override `getSignablePayload` on `AbstractEthLikeCoin` to return the keccak256 hash of the unsigned transaction (via `getMessageToSign(true)`) rather than raw serialized bytes. This exposes the correct bytes AKM needs for its external POST /sign endpoint. Changes: - Add `getSignablePayload(): Buffer` to `EthLikeTransactionData` interface and implement it in `EthTransactionData` using `tx.getMessageToSign(true)` - Add `get signablePayload(): Buffer` to the `Transaction` class, delegating to `EthTransactionData.getSignablePayload()` - Override `getSignablePayload(serializedTx)` in `AbstractEthLikeCoin`, rebuilding via the transaction builder and returning `tx.signablePayload` - Add unit tests covering Legacy and EIP1559 transaction types, and empty-transaction error handling Ticket: CGD-1083
1 parent 235af5f commit 0547142

8 files changed

Lines changed: 124 additions & 1 deletion

File tree

modules/abstract-eth/src/abstractEthLikeCoin.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from '@bitgo/sdk-core';
2828
import BigNumber from 'bignumber.js';
2929

30-
import { isValidEthAddress, KeyPair as EthKeyPair, TransactionBuilder } from './lib';
30+
import { isValidEthAddress, KeyPair as EthKeyPair, Transaction as EthTransaction, TransactionBuilder } from './lib';
3131
import { VerifyEthAddressOptions } from './abstractEthLikeNewCoins';
3232
import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc';
3333

@@ -230,6 +230,14 @@ export abstract class AbstractEthLikeCoin extends BaseCoin {
230230
};
231231
}
232232

233+
/** @inheritDoc */
234+
async getSignablePayload(serializedTx: string): Promise<Buffer> {
235+
const txBuilder = this.getTransactionBuilder();
236+
txBuilder.from(serializedTx);
237+
const tx = (await txBuilder.build()) as EthTransaction;
238+
return tx.signablePayload;
239+
}
240+
233241
/**
234242
* Create a new transaction builder for the current chain
235243
* @return a new transaction builder

modules/abstract-eth/src/ethLikeToken.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,11 @@ export class EthLikeToken extends AbstractEthLikeNewCoins {
399399
return txPrebuild.coin === this.tokenConfig.coin && txPrebuild.token === this.tokenConfig.type;
400400
}
401401

402+
/** @inheritDoc */
403+
async getSignablePayload(serializedTx: string): Promise<Buffer> {
404+
return Buffer.from(serializedTx);
405+
}
406+
402407
/**
403408
* Create a new transaction builder for the current chain
404409
* @return a new transaction builder

modules/abstract-eth/src/lib/iface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export interface EthLikeTransactionData {
9191
* Return the hex string serialization of this transaction
9292
*/
9393
toSerialized(): string;
94+
95+
/**
96+
* Return the keccak256 hash of the unsigned transaction — the 32-byte digest an external signer must sign
97+
*/
98+
getSignablePayload(): Buffer;
9499
}
95100

96101
export interface SignatureParts {

modules/abstract-eth/src/lib/transaction.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export class Transaction extends BaseTransaction {
169169
this._signatures.push(toStringSig({ v: txData.v!, r: txData.r!, s: txData.s! }));
170170
}
171171

172+
/** @inheritdoc */
173+
get signablePayload(): Buffer {
174+
if (!this._transactionData) {
175+
throw new InvalidTransactionError('No transaction data to sign');
176+
}
177+
return this._transactionData.getSignablePayload();
178+
}
179+
172180
/** @inheritdoc */
173181
toBroadcastFormat(): string {
174182
if (this._transactionData) {

modules/abstract-eth/src/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export class EthTransactionData implements EthLikeTransactionData {
9191
this.tx = this.tx.sign(privateKey);
9292
}
9393

94+
getSignablePayload(): Buffer {
95+
return Buffer.from(this.tx.getMessageToSign(true));
96+
}
97+
9498
/** @inheritdoc */
9599
toJson(): TxData {
96100
const result: BaseTxData = {

modules/abstract-eth/test/unit/transaction.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,20 @@ export function runTransactionTests(coinName: string, testData: any, common: Com
5757
should.equal(tx.toBroadcastFormat(), testData.ENCODED_TRANSACTION);
5858
});
5959
});
60+
61+
describe('signablePayload', () => {
62+
it('should throw on an empty transaction', () => {
63+
const tx = getTransaction();
64+
should.throws(() => tx.signablePayload);
65+
});
66+
67+
it('should return a 32-byte keccak256 hash for an unsigned transaction', () => {
68+
const tx = getTransaction();
69+
tx.setTransactionData(testData.TXDATA);
70+
const payload = tx.signablePayload;
71+
payload.should.be.instanceof(Buffer);
72+
payload.length.should.equal(32);
73+
});
74+
});
6075
});
6176
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2593,4 +2593,58 @@ describe('ETH:', function () {
25932593
});
25942594
});
25952595
});
2596+
2597+
describe('getSignablePayload', function () {
2598+
let coin: Teth;
2599+
2600+
before(function () {
2601+
coin = bitgo.coin('teth') as Teth;
2602+
});
2603+
2604+
it('should return a 32-byte keccak256 hash for a Legacy transaction', async function () {
2605+
const txBuilder = getBuilder('teth') as TransactionBuilder;
2606+
txBuilder.type(TransactionType.Send);
2607+
txBuilder.fee({ fee: '10000000000', gasLimit: '7000000' });
2608+
txBuilder.counter(1);
2609+
txBuilder.contract('0x8Ce59c2d1702844F8EdED451AA103961bC37B4e8');
2610+
const transferBuilder = txBuilder.transfer() as TransferBuilder;
2611+
transferBuilder
2612+
.coin('teth')
2613+
.expirationTime(Math.floor(Date.now() / 1000) + 3600)
2614+
.amount('100000')
2615+
.to('0xeeaf0F05f37891ab4a21208B105A0687d12c5aF7')
2616+
.contractSequenceId(1);
2617+
const tx = await txBuilder.build();
2618+
const serializedTx = tx.toBroadcastFormat();
2619+
2620+
const payload = await coin.getSignablePayload(serializedTx);
2621+
assert.ok(Buffer.isBuffer(payload));
2622+
assert.strictEqual(payload.length, 32);
2623+
});
2624+
2625+
it('should return a 32-byte keccak256 hash for an EIP1559 transaction', async function () {
2626+
const txBuilder = getBuilder('teth') as TransactionBuilder;
2627+
txBuilder.type(TransactionType.Send);
2628+
txBuilder.fee({
2629+
fee: '280000000000',
2630+
gasLimit: '7000000',
2631+
eip1559: { maxFeePerGas: '7593123', maxPriorityFeePerGas: '150' },
2632+
});
2633+
txBuilder.counter(1);
2634+
txBuilder.contract('0x8Ce59c2d1702844F8EdED451AA103961bC37B4e8');
2635+
const transferBuilder = txBuilder.transfer() as TransferBuilder;
2636+
transferBuilder
2637+
.coin('teth')
2638+
.expirationTime(Math.floor(Date.now() / 1000) + 3600)
2639+
.amount('100000')
2640+
.to('0xeeaf0F05f37891ab4a21208B105A0687d12c5aF7')
2641+
.contractSequenceId(1);
2642+
const tx = await txBuilder.build();
2643+
const serializedTx = tx.toBroadcastFormat();
2644+
2645+
const payload = await coin.getSignablePayload(serializedTx);
2646+
assert.ok(Buffer.isBuffer(payload));
2647+
assert.strictEqual(payload.length, 32);
2648+
});
2649+
});
25962650
});

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,28 @@ describe('ETH Transaction', () => {
6161
});
6262
});
6363
});
64+
65+
describe('signablePayload', () => {
66+
it('should throw on an empty transaction', () => {
67+
const tx = getTransaction();
68+
assert.throws(() => tx.signablePayload);
69+
});
70+
71+
testParams.map(([txnType, txData]) => {
72+
it(`should return the keccak256 signing hash for a ${txnType} transaction`, () => {
73+
const tx = getTransaction(txData);
74+
const payload = tx.signablePayload;
75+
assert.ok(Buffer.isBuffer(payload));
76+
assert.strictEqual(payload.length, 32);
77+
// txData.id is set from getMessageToSign() in toJson() — must match the signing payload
78+
assert.strictEqual('0x' + payload.toString('hex'), txData.id);
79+
});
80+
});
81+
82+
it('should produce distinct payloads for Legacy and EIP1559 transactions', () => {
83+
const legacy = getTransaction(testData.LEGACY_TXDATA).signablePayload;
84+
const eip1559 = getTransaction(testData.EIP1559_TXDATA).signablePayload;
85+
assert.notStrictEqual(legacy.toString('hex'), eip1559.toString('hex'));
86+
});
87+
});
6488
});

0 commit comments

Comments
 (0)