From 5465b8404292e280f022bd3206ebb815895e2e1f Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Mon, 18 May 2026 09:17:29 +0000 Subject: [PATCH] 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 Session-Id: 03bf61c0-ea3a-464d-b3ed-0dd7164321b9 Task-Id: 67bd271e-8a0f-4f83-b91f-f2454642289e --- .../abstract-eth/src/abstractEthLikeCoin.ts | 10 +++- modules/abstract-eth/src/lib/iface.ts | 5 ++ modules/abstract-eth/src/lib/transaction.ts | 8 +++ modules/abstract-eth/src/lib/types.ts | 4 ++ modules/abstract-eth/test/unit/transaction.ts | 15 ++++++ modules/sdk-coin-eth/test/unit/eth.ts | 54 +++++++++++++++++++ modules/sdk-coin-eth/test/unit/transaction.ts | 16 ++++++ 7 files changed, 111 insertions(+), 1 deletion(-) diff --git a/modules/abstract-eth/src/abstractEthLikeCoin.ts b/modules/abstract-eth/src/abstractEthLikeCoin.ts index b8e75995b7..4192ba52c6 100644 --- a/modules/abstract-eth/src/abstractEthLikeCoin.ts +++ b/modules/abstract-eth/src/abstractEthLikeCoin.ts @@ -27,7 +27,7 @@ import { } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; -import { isValidEthAddress, KeyPair as EthKeyPair, TransactionBuilder } from './lib'; +import { isValidEthAddress, KeyPair as EthKeyPair, Transaction as EthTransaction, TransactionBuilder } from './lib'; import { VerifyEthAddressOptions } from './abstractEthLikeNewCoins'; import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc'; @@ -230,6 +230,14 @@ export abstract class AbstractEthLikeCoin extends BaseCoin { }; } + /** @inheritDoc */ + async getSignablePayload(serializedTx: string): Promise { + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(serializedTx); + const tx = (await txBuilder.build()) as EthTransaction; + return tx.signablePayload; + } + /** * Create a new transaction builder for the current chain * @return a new transaction builder diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index a97a67eb0f..c6c308739a 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -91,6 +91,11 @@ export interface EthLikeTransactionData { * Return the hex string serialization of this transaction */ toSerialized(): string; + + /** + * Return the keccak256 hash of the unsigned transaction — the bytes AKM must sign + */ + getSignablePayload(): Buffer; } export interface SignatureParts { diff --git a/modules/abstract-eth/src/lib/transaction.ts b/modules/abstract-eth/src/lib/transaction.ts index 221ccad20f..971211e4ed 100644 --- a/modules/abstract-eth/src/lib/transaction.ts +++ b/modules/abstract-eth/src/lib/transaction.ts @@ -169,6 +169,14 @@ export class Transaction extends BaseTransaction { this._signatures.push(toStringSig({ v: txData.v!, r: txData.r!, s: txData.s! })); } + /** @inheritdoc */ + get signablePayload(): Buffer { + if (!this._transactionData) { + throw new InvalidTransactionError('No transaction data to sign'); + } + return this._transactionData.getSignablePayload(); + } + /** @inheritdoc */ toBroadcastFormat(): string { if (this._transactionData) { diff --git a/modules/abstract-eth/src/lib/types.ts b/modules/abstract-eth/src/lib/types.ts index 993d79d640..707a7ca0e3 100644 --- a/modules/abstract-eth/src/lib/types.ts +++ b/modules/abstract-eth/src/lib/types.ts @@ -91,6 +91,10 @@ export class EthTransactionData implements EthLikeTransactionData { this.tx = this.tx.sign(privateKey); } + getSignablePayload(): Buffer { + return Buffer.from(this.tx.getMessageToSign(true)); + } + /** @inheritdoc */ toJson(): TxData { const result: BaseTxData = { diff --git a/modules/abstract-eth/test/unit/transaction.ts b/modules/abstract-eth/test/unit/transaction.ts index e259beaff5..b41cf336c2 100644 --- a/modules/abstract-eth/test/unit/transaction.ts +++ b/modules/abstract-eth/test/unit/transaction.ts @@ -57,5 +57,20 @@ export function runTransactionTests(coinName: string, testData: any, common: Com should.equal(tx.toBroadcastFormat(), testData.ENCODED_TRANSACTION); }); }); + + describe('signablePayload', () => { + it('should throw on an empty transaction', () => { + const tx = getTransaction(); + should.throws(() => tx.signablePayload); + }); + + it('should return a 32-byte keccak256 hash for an unsigned transaction', () => { + const tx = getTransaction(); + tx.setTransactionData(testData.TXDATA); + const payload = tx.signablePayload; + payload.should.be.instanceof(Buffer); + payload.length.should.equal(32); + }); + }); }); } diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index d3aee49106..8c533c4419 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -2522,4 +2522,58 @@ describe('ETH:', function () { }); }); }); + + describe('getSignablePayload', function () { + let coin: Teth; + + before(function () { + coin = bitgo.coin('teth') as Teth; + }); + + it('should return a 32-byte keccak256 hash for a Legacy transaction', async function () { + const txBuilder = getBuilder('teth') as TransactionBuilder; + txBuilder.type(TransactionType.Send); + txBuilder.fee({ fee: '10000000000', gasLimit: '7000000' }); + txBuilder.counter(1); + txBuilder.contract('0x8Ce59c2d1702844F8EdED451AA103961bC37B4e8'); + const transferBuilder = txBuilder.transfer() as TransferBuilder; + transferBuilder + .coin('teth') + .expirationTime(Math.floor(Date.now() / 1000) + 3600) + .amount('100000') + .to('0xeeaf0F05f37891ab4a21208B105A0687d12c5aF7') + .contractSequenceId(1); + const tx = await txBuilder.build(); + const serializedTx = tx.toBroadcastFormat(); + + const payload = await coin.getSignablePayload(serializedTx); + assert.ok(Buffer.isBuffer(payload)); + assert.strictEqual(payload.length, 32); + }); + + it('should return a 32-byte keccak256 hash for an EIP1559 transaction', async function () { + const txBuilder = getBuilder('teth') as TransactionBuilder; + txBuilder.type(TransactionType.Send); + txBuilder.fee({ + fee: '280000000000', + gasLimit: '7000000', + eip1559: { maxFeePerGas: '7593123', maxPriorityFeePerGas: '150' }, + }); + txBuilder.counter(1); + txBuilder.contract('0x8Ce59c2d1702844F8EdED451AA103961bC37B4e8'); + const transferBuilder = txBuilder.transfer() as TransferBuilder; + transferBuilder + .coin('teth') + .expirationTime(Math.floor(Date.now() / 1000) + 3600) + .amount('100000') + .to('0xeeaf0F05f37891ab4a21208B105A0687d12c5aF7') + .contractSequenceId(1); + const tx = await txBuilder.build(); + const serializedTx = tx.toBroadcastFormat(); + + const payload = await coin.getSignablePayload(serializedTx); + assert.ok(Buffer.isBuffer(payload)); + assert.strictEqual(payload.length, 32); + }); + }); }); diff --git a/modules/sdk-coin-eth/test/unit/transaction.ts b/modules/sdk-coin-eth/test/unit/transaction.ts index fcc9ba37c5..f9fd5f28c7 100644 --- a/modules/sdk-coin-eth/test/unit/transaction.ts +++ b/modules/sdk-coin-eth/test/unit/transaction.ts @@ -61,4 +61,20 @@ describe('ETH Transaction', () => { }); }); }); + + describe('signablePayload', () => { + it('should throw on an empty transaction', () => { + const tx = getTransaction(); + assert.throws(() => tx.signablePayload); + }); + + testParams.map(([txnType, txData]) => { + it(`should return a 32-byte keccak256 hash for a ${txnType} transaction`, () => { + const tx = getTransaction(txData); + const payload = tx.signablePayload; + assert.ok(Buffer.isBuffer(payload)); + assert.strictEqual(payload.length, 32); + }); + }); + }); });