Skip to content

Commit cadec00

Browse files
fix(abstract-substrate): blake2_256 prehash signablePayload over 256 bytes
Substrate signs the raw encoded ExtrinsicPayload when it is at most 256 bytes, but signs the blake2_256 hash of the payload when it exceeds 256 bytes. The signablePayload getter previously always returned the raw bytes, so for large extrinsics (e.g. POLYX nominate with 6+ validators) the BitGoJS user signed the raw payload while the HSM signed the hash, causing TSS signature combination to fail with InvalidSignature. Return blake2_256(raw) (32 bytes) when the payload is larger than 256 bytes, and the raw bytes otherwise, so all downstream consumers (sdk-coin-polyx, sdk-coin-dot, recovery flows) get the correct signableHex without coin-specific logic. Generated with [Linear](https://linear.app/bitgo/issue/SI-911/fix-signablepayload-to-apply-substrate-blake2-256-prehash-when#agent-session-0bfb5d75) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
1 parent 99f90f8 commit cadec00

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1111
import Keyring, { decodeAddress } from '@polkadot/keyring';
1212
import { u8aToBuffer } from '@polkadot/util';
13+
import { blake2AsU8a } from '@polkadot/util-crypto';
1314
import { construct, decode } from '@substrate/txwrapper-polkadot';
1415
import { UnsignedTransaction } from '@substrate/txwrapper-core';
1516
import { TypeRegistry } from '@substrate/txwrapper-core/lib/types';
@@ -533,12 +534,22 @@ export class Transaction extends BaseTransaction {
533534
this._substrateTransaction = tx;
534535
}
535536

536-
/** @inheritdoc **/
537+
/**
538+
* @inheritdoc
539+
*
540+
* Returns the bytes that are actually signed for this transaction. Substrate signs the raw
541+
* encoded `ExtrinsicPayload` when it is at most 256 bytes, but for payloads larger than 256
542+
* bytes it signs the blake2_256 hash of those bytes instead (see Polkadot.js
543+
* `@polkadot/types/extrinsic/util` and the HSM firmware). Returning the raw payload for large
544+
* extrinsics (e.g. nominate with many validators) would make the user and the HSM sign different
545+
* messages, causing TSS signature combination to fail.
546+
*/
537547
get signablePayload(): Buffer {
538548
const extrinsicPayload = this._registry.createType('ExtrinsicPayload', this._substrateTransaction, {
539549
version: EXTRINSIC_VERSION,
540550
});
541-
return u8aToBuffer(extrinsicPayload.toU8a({ method: true }));
551+
const raw = extrinsicPayload.toU8a({ method: true });
552+
return u8aToBuffer(raw.length > 256 ? blake2AsU8a(raw, 256) : raw);
542553
}
543554

544555
/**

modules/sdk-coin-polyx/test/unit/transactionBuilder/nominateBuilder.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,38 @@ describe('Polyx Nominate Builder', function () {
179179
});
180180
});
181181

182+
describe('signablePayload (Substrate 256-byte blake2 rule)', () => {
183+
const buildNominateTx = async (validators: string[]) => {
184+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
185+
const nominateBuilder = factory.getNominateBuilder();
186+
nominateBuilder
187+
.validators(validators)
188+
.sender({ address: senderAddress })
189+
.validity({ firstValid: 3933, maxDuration: 64 })
190+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
191+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 })
192+
.fee({ amount: 0, type: 'tip' })
193+
.material(material);
194+
return nominateBuilder.build();
195+
};
196+
197+
it('should return raw payload bytes when the extrinsic is at most 256 bytes', async () => {
198+
const tx = await buildNominateTx([validatorAddress, validatorAddress2]);
199+
const signablePayload = tx.signablePayload;
200+
// small nominate extrinsic stays under the 256-byte threshold, so it is signed as-is
201+
signablePayload.length.should.be.belowOrEqual(256);
202+
signablePayload.length.should.not.equal(32);
203+
});
204+
205+
it('should return the 32-byte blake2_256 hash when the extrinsic exceeds 256 bytes', async () => {
206+
// 6+ validators (~33 bytes each) pushes the nominate extrinsic over the 256-byte threshold
207+
const manyValidators = Array(8).fill(validatorAddress);
208+
const tx = await buildNominateTx(manyValidators);
209+
const signablePayload = tx.signablePayload;
210+
should.equal(signablePayload.length, 32);
211+
});
212+
});
213+
182214
describe('factory routing', () => {
183215
it('should route raw nominate extrinsic to NominateBuilder', () => {
184216
const resolvedBuilder = factory.from(nominateTx.signed);

0 commit comments

Comments
 (0)