From 50cae11b2e375f4a9cb3d5f6e971cbb6cded5f52 Mon Sep 17 00:00:00 2001 From: Abhijit Madhusudan <136959837+abhijit0943@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:16:55 +0000 Subject: [PATCH] feat(abstract-substrate): expose rawExtrinsicPayload separate from signablePayload Add a `rawExtrinsicPayload` getter that returns the full raw encoded `ExtrinsicPayload` bytes, and refactor `signablePayload` to derive from it via `utils.getSubstrateSigningBytes` (no duplicated 256-byte blake2 logic). Surface the raw payload as `rawSignableHex` on the polyx-specific `TxData`, populated in the polyx `toJson` output, so consumers that need the raw bytes (e.g. the HSM `polyx/signtx` path) can use them for large extrinsics where `signablePayload` is the blake2_256 hash. The field is kept on the polyx coin interface rather than the shared one, so other Substrate coins are unaffected. Generated with [Linear](https://linear.app/bitgo/issue/SI-926/polyx-expose-raw-extrinsicpayload-separately-from-signable-signing#agent-session-0ac3bf90) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- .../abstract-substrate/src/lib/transaction.ts | 20 ++++++++++++--- modules/sdk-coin-polyx/src/lib/iface.ts | 7 ++++++ modules/sdk-coin-polyx/src/lib/transaction.ts | 5 +++- .../transactionBuilder/nominateBuilder.ts | 25 ++++++++++++++++--- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/modules/abstract-substrate/src/lib/transaction.ts b/modules/abstract-substrate/src/lib/transaction.ts index 091b4d3597..fe6eb047c2 100644 --- a/modules/abstract-substrate/src/lib/transaction.ts +++ b/modules/abstract-substrate/src/lib/transaction.ts @@ -532,6 +532,21 @@ export class Transaction extends BaseTransaction { this._substrateTransaction = tx; } + /** + * Returns the full raw encoded `ExtrinsicPayload` bytes (including the method) for this + * transaction, without applying the Substrate 256-byte blake2_256 signing rule. + * + * These are the bytes the HSM signs on the `polyx/signtx` path. They differ from + * {@link signablePayload} once the payload exceeds 256 bytes, where {@link signablePayload} + * is the blake2_256 hash instead of the raw bytes. + */ + get rawExtrinsicPayload(): Uint8Array { + const extrinsicPayload = this._registry.createType('ExtrinsicPayload', this._substrateTransaction, { + version: EXTRINSIC_VERSION, + }); + return extrinsicPayload.toU8a({ method: true }); + } + /** * @inheritdoc * @@ -543,10 +558,7 @@ export class Transaction extends BaseTransaction { * messages, causing TSS signature combination to fail. */ get signablePayload(): Buffer { - const extrinsicPayload = this._registry.createType('ExtrinsicPayload', this._substrateTransaction, { - version: EXTRINSIC_VERSION, - }); - return utils.getSubstrateSigningBytes(extrinsicPayload.toU8a({ method: true })); + return utils.getSubstrateSigningBytes(this.rawExtrinsicPayload); } /** diff --git a/modules/sdk-coin-polyx/src/lib/iface.ts b/modules/sdk-coin-polyx/src/lib/iface.ts index 65cb3b549d..7393b5d533 100644 --- a/modules/sdk-coin-polyx/src/lib/iface.ts +++ b/modules/sdk-coin-polyx/src/lib/iface.ts @@ -14,6 +14,13 @@ export interface TxData extends Interface.TxData { toDID?: string; instructionId?: string; portfolioDID?: string; + /** + * Hex of the full raw encoded `ExtrinsicPayload` (see `Transaction.rawExtrinsicPayload`). + * Surfaced alongside the MPC/combine `signableHex` so consumers that need the raw payload + * (e.g. the HSM `polyx/signtx` path) can use it for extrinsics larger than 256 bytes, where + * the signing bytes are the blake2_256 hash rather than the raw payload. + */ + rawSignableHex?: string; } /** diff --git a/modules/sdk-coin-polyx/src/lib/transaction.ts b/modules/sdk-coin-polyx/src/lib/transaction.ts index 434d0bb49d..756474a961 100644 --- a/modules/sdk-coin-polyx/src/lib/transaction.ts +++ b/modules/sdk-coin-polyx/src/lib/transaction.ts @@ -71,6 +71,7 @@ export class Transaction extends SubstrateTransaction { eraPeriod: decodedTx.eraPeriod, chainName: this._chainName, tip: decodedTx.tip ? Number(decodedTx.tip) : 0, + rawSignableHex: Buffer.from(this.rawExtrinsicPayload).toString('hex'), }; const txMethod = decodedTx.method.args; @@ -99,7 +100,9 @@ export class Transaction extends SubstrateTransaction { result.portfolioDID = rejectInstructionArgs.portfolio.did as string; result.amount = '0'; // Reject instruction does not transfer any value } else { - return super.toJson() as TxData; + const baseResult = super.toJson() as TxData; + baseResult.rawSignableHex = result.rawSignableHex; + return baseResult; } return result; diff --git a/modules/sdk-coin-polyx/test/unit/transactionBuilder/nominateBuilder.ts b/modules/sdk-coin-polyx/test/unit/transactionBuilder/nominateBuilder.ts index 3423fe8c0c..7a7129b9c2 100644 --- a/modules/sdk-coin-polyx/test/unit/transactionBuilder/nominateBuilder.ts +++ b/modules/sdk-coin-polyx/test/unit/transactionBuilder/nominateBuilder.ts @@ -1,7 +1,7 @@ import { decode } from '@substrate/txwrapper-polkadot'; import { coins } from '@bitgo/statics'; import should from 'should'; -import { TransactionBuilderFactory, NominateBuilder, BatchBuilder } from '../../../src/lib'; +import { TransactionBuilderFactory, NominateBuilder, BatchBuilder, PolyxTransaction } from '../../../src/lib'; import { TransactionType } from '@bitgo/sdk-core'; import utils from '../../../src/lib/utils'; import { accounts, nominateTx, nominateValidators, stakingTx } from '../../resources'; @@ -191,7 +191,7 @@ describe('Polyx Nominate Builder', function () { .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 }) .fee({ amount: 0, type: 'tip' }) .material(material); - return nominateBuilder.build(); + return (await nominateBuilder.build()) as PolyxTransaction; }; it('should return raw payload bytes when the extrinsic is at most 256 bytes', async () => { @@ -200,14 +200,31 @@ describe('Polyx Nominate Builder', function () { // small nominate extrinsic stays under the 256-byte threshold, so it is signed as-is signablePayload.length.should.be.belowOrEqual(256); signablePayload.length.should.not.equal(32); + // for a sub-256-byte payload, signablePayload is exactly the raw extrinsic payload + const rawExtrinsicPayload = Buffer.from(tx.rawExtrinsicPayload); + rawExtrinsicPayload.should.deepEqual(signablePayload); + // toJson surfaces the full raw payload as rawSignableHex even when it equals signablePayload + should.equal(tx.toJson().rawSignableHex, rawExtrinsicPayload.toString('hex')); }); it('should return the 32-byte blake2_256 hash when the extrinsic exceeds 256 bytes', async () => { - // 6+ validators (~33 bytes each) pushes the nominate extrinsic over the 256-byte threshold - const manyValidators = Array(8).fill(validatorAddress); + // Each nominate target adds a 33-byte MultiAddress (1-byte variant + 32-byte account id) on + // top of 79 bytes of fixed signing-payload overhead (call index, era, nonce, tip, + // spec/transaction versions, genesis + block hash). So 5 targets stay raw at 244 bytes and + // 6 already cross to 277 bytes (hashed). 8 would also exceed the threshold; this uses 9 to + // mirror the multi-nomination scenario from SI-926 with margin (376 bytes). + const manyValidators = Array(9).fill(validatorAddress); const tx = await buildNominateTx(manyValidators); const signablePayload = tx.signablePayload; should.equal(signablePayload.length, 32); + // the raw extrinsic payload stays full-length and diverges from the hashed signablePayload + const rawExtrinsicPayload = Buffer.from(tx.rawExtrinsicPayload); + rawExtrinsicPayload.length.should.be.greaterThan(256); + rawExtrinsicPayload.should.not.deepEqual(signablePayload); + // signablePayload is the blake2_256 hash of the raw extrinsic payload + utils.getSubstrateSigningBytes(tx.rawExtrinsicPayload).should.deepEqual(signablePayload); + // toJson surfaces the full raw payload as rawSignableHex for the HSM signing path + should.equal(tx.toJson().rawSignableHex, rawExtrinsicPayload.toString('hex')); }); // The 256-byte boundary is impractical to hit with a real extrinsic, so exercise the