Skip to content

Commit 18fbb04

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 18fbb04

5 files changed

Lines changed: 114 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
export const DEFAULT_SUBSTRATE_PREFIX = 42;
2+
3+
/**
4+
* Substrate signs the raw encoded `ExtrinsicPayload` only when it is at most this many bytes;
5+
* larger payloads are signed as their blake2_256 hash instead. See `getSubstrateSigningBytes`.
6+
*/
7+
export const MAX_RAW_SIGNING_PAYLOAD_BYTES = 256;

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
} from '@bitgo/sdk-core';
1010
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1111
import Keyring, { decodeAddress } from '@polkadot/keyring';
12-
import { u8aToBuffer } from '@polkadot/util';
1312
import { construct, decode } from '@substrate/txwrapper-polkadot';
1413
import { UnsignedTransaction } from '@substrate/txwrapper-core';
1514
import { TypeRegistry } from '@substrate/txwrapper-core/lib/types';
@@ -533,12 +532,21 @@ export class Transaction extends BaseTransaction {
533532
this._substrateTransaction = tx;
534533
}
535534

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

544552
/**

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { decodeAddress, encodeAddress, Keyring } from '@polkadot/keyring';
44
import { decodePair } from '@polkadot/keyring/pair/decode';
55
import { KeyringPair } from '@polkadot/keyring/types';
66
import { EXTRINSIC_VERSION } from '@polkadot/types/extrinsic/v4/Extrinsic';
7-
import { hexToU8a, isHex, u8aToHex, u8aToU8a } from '@polkadot/util';
8-
import { base64Decode, signatureVerify } from '@polkadot/util-crypto';
7+
import { hexToU8a, isHex, u8aToBuffer, u8aToHex, u8aToU8a } from '@polkadot/util';
8+
import { base64Decode, blake2AsU8a, signatureVerify } from '@polkadot/util-crypto';
99
import { UnsignedTransaction } from '@substrate/txwrapper-core';
1010
import { DecodedSignedTx, DecodedSigningPayload, TypeRegistry } from '@substrate/txwrapper-core/lib/types';
1111
import { construct, decode } from '@substrate/txwrapper-polkadot';
@@ -31,6 +31,7 @@ import {
3131
MoveStakeArgs,
3232
} from './iface';
3333
import { SingletonRegistry } from './singletonRegistry';
34+
import { MAX_RAW_SIGNING_PAYLOAD_BYTES } from './constants';
3435

3536
export class Utils implements BaseUtils {
3637
/** @inheritdoc */
@@ -343,6 +344,22 @@ export class Utils implements BaseUtils {
343344
throw new Error(`Failed to decode transaction: ${error}`);
344345
}
345346
}
347+
348+
/**
349+
* Returns the bytes that Substrate actually signs for a given raw encoded `ExtrinsicPayload`.
350+
*
351+
* Substrate signs the raw payload as-is when it is at most {@link MAX_RAW_SIGNING_PAYLOAD_BYTES}
352+
* bytes, but for larger payloads it signs the 32-byte blake2_256 hash of those bytes instead
353+
* (see Polkadot.js `@polkadot/types/extrinsic/util` and the HSM firmware). Using this helper
354+
* ensures the user and the HSM sign the same message, which is required for TSS signature
355+
* combination to succeed on large extrinsics (e.g. nominate with many validators).
356+
*
357+
* @param {Uint8Array} raw The raw encoded extrinsic payload bytes.
358+
* @returns {Buffer} The bytes to sign: the raw payload, or its blake2_256 hash when oversized.
359+
*/
360+
getSubstrateSigningBytes(raw: Uint8Array): Buffer {
361+
return u8aToBuffer(raw.length > MAX_RAW_SIGNING_PAYLOAD_BYTES ? blake2AsU8a(raw, 256) : raw);
362+
}
346363
}
347364

348365
const utils = new Utils();

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,38 @@ describe('Polyx Batch Builder', function () {
234234
});
235235
});
236236

237+
describe('signablePayload (Substrate 256-byte blake2 rule)', function () {
238+
const buildBatchTx = async (numValidators: number) => {
239+
const batchBuilder = factory.getBatchBuilder();
240+
batchBuilder
241+
.amount(testAmount)
242+
.controller({ address: controllerAddress })
243+
.payee('Staked')
244+
.validators(Array(numValidators).fill(validatorAddress))
245+
.sender({ address: senderAddress })
246+
.validity({ firstValid: 3933, maxDuration: 64 })
247+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
248+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 100 });
249+
batchBuilder.material(utils.getMaterial(coins.get('tpolyx').network.type));
250+
return batchBuilder.build();
251+
};
252+
253+
it('should return raw payload bytes when the batch extrinsic is at most 256 bytes', async () => {
254+
// bond + nominate with a single validator stays under the 256-byte threshold
255+
const tx = await buildBatchTx(1);
256+
const signablePayload = tx.signablePayload;
257+
signablePayload.length.should.be.belowOrEqual(256);
258+
signablePayload.length.should.not.equal(32);
259+
});
260+
261+
it('should return the 32-byte blake2_256 hash when the batch extrinsic exceeds 256 bytes', async () => {
262+
// bond + nominate with many validators pushes the batch over the 256-byte threshold
263+
const tx = await buildBatchTx(6);
264+
const signablePayload = tx.signablePayload;
265+
should.equal(signablePayload.length, 32);
266+
});
267+
});
268+
237269
describe('From Raw Transaction', function () {
238270
it('should rebuild from real batch transaction', async () => {
239271
// First build a transaction to get a real raw transaction

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,52 @@ 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+
// The 256-byte boundary is impractical to hit with a real extrinsic, so exercise the
214+
// raw-vs-hash decision directly through the shared Substrate signing-bytes helper.
215+
it('should keep payloads of exactly 256 bytes raw and only hash strictly larger ones', () => {
216+
const at = utils.getSubstrateSigningBytes(new Uint8Array(256).fill(7));
217+
should.equal(at.length, 256);
218+
at.should.deepEqual(Buffer.alloc(256, 7));
219+
220+
const below = utils.getSubstrateSigningBytes(new Uint8Array(255).fill(7));
221+
should.equal(below.length, 255);
222+
223+
const above = utils.getSubstrateSigningBytes(new Uint8Array(257).fill(7));
224+
should.equal(above.length, 32);
225+
});
226+
});
227+
182228
describe('factory routing', () => {
183229
it('should route raw nominate extrinsic to NominateBuilder', () => {
184230
const resolvedBuilder = factory.from(nominateTx.signed);

0 commit comments

Comments
 (0)