fix(abstract-substrate): blake2_256 prehash signablePayload over 256 bytes#9135
Conversation
|
@claude review |
|
Claude finished @abhijit0943's task —— View job Code Review CompleteTodo List:
🔍 Overall AssessmentThis is a well-executed fix for a critical TSS signing issue in Substrate-based transactions. The implementation correctly addresses the root cause where BitGoJS and HSM were signing different data for large extrinsics. ✅ Strengths1. Correct Problem Analysis & Solution
2. Clean Architecture
3. Comprehensive Testing
4. Implementation Details// modules/abstract-substrate/src/lib/utils.ts:360-362
getSubstrateSigningBytes(raw: Uint8Array): Buffer {
return u8aToBuffer(raw.length > MAX_RAW_SIGNING_PAYLOAD_BYTES ? blake2AsU8a(raw, 256) : raw);
}
🔧 Minor Suggestions1. Constants Location (modules/abstract-substrate/src/lib/constants.ts:4-7)✅ Good: Constant is well-documented and properly referenced /**
* Substrate signs the raw encoded `ExtrinsicPayload` only when it is at most this many bytes;
* larger payloads are signed as their blake2_256 hash instead.
* See Polkadot.js `@polkadot/types/extrinsic/util` and HSM firmware.
* Reference: https://github.com/polkadot-js/api/blob/master/packages/types/src/extrinsic/util.ts
*/2. Test Utility Method (modules/sdk-coin-polyx/test/unit/transactionBuilder/batchStakingBuilder.ts:164)The test calls 3. Error HandlingThe getSubstrateSigningBytes(raw: Uint8Array): Buffer {
if (raw.length <= MAX_RAW_SIGNING_PAYLOAD_BYTES) {
return u8aToBuffer(raw);
}
try {
return u8aToBuffer(blake2AsU8a(raw, 256));
} catch (error) {
throw new Error(`Failed to hash signing payload: ${error}`);
}
}🎯 Security & Correctness✅ Security Review
✅ Logic Correctness
📋 Final Recommendations✅ APPROVE - This PR should be merged. The fix is:
The minor suggestions above are optional improvements that don't block this important fix. Job run link | Branch: abhijitmadhusudan496/si-911-fix-signablepayload-to-apply-substrate-blake2_256-c9ed |
…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>
18fbb04 to
c964f36
Compare
|
@claude review this PR thoroughly -- are the tests sufficient ? can we assert on the whether the message is exactly the same (as proposed in the comment of the new method) via unit test ? |
|
Claude finished @Vijay-Jagannathan's task —— View job Thorough Code Review Analysis ✅Todo List:
🔍 Comprehensive Analysis Complete✅ Implementation Quality: EXCELLENT1. Core Logic AssessmentThe getSubstrateSigningBytes(raw: Uint8Array): Buffer {
return u8aToBuffer(raw.length > MAX_RAW_SIGNING_PAYLOAD_BYTES ? blake2AsU8a(raw, 256) : raw);
}✅ Correctness:
2. Constants & DocumentationPerfect constant definition (
🧪 Test Coverage Assessment: COMPREHENSIVEBoundary Testing Excellence
const at = utils.getSubstrateSigningBytes(new Uint8Array(256).fill(7));
should.equal(at.length, 256); // Exactly 256 stays raw
const above = utils.getSubstrateSigningBytes(new Uint8Array(257).fill(7));
should.equal(above.length, 32); // 257 gets hashed✅ Perfect boundary coverage - tests 255, 256, 257 byte scenarios Real-World Integration Testing
📋 Answering Your Specific QuestionsQ1: Are the tests sufficient?✅ YES - Exceptionally comprehensive:
Q2: Can we assert message equivalence via unit test?✅ PARTIALLY - Here's what's possible: Current Testing ✅: // Tests that signing bytes match expected behavior
const signablePayload = tx.signablePayload;
should.equal(signablePayload.length, 32); // For large tx
signablePayload.length.should.not.equal(32); // For small txAdditional Message Equivalence Tests (Suggested Enhancement): // Could add to test the exact message match
it('should produce identical signing message as Polkadot.js for same payload', () => {
const rawPayload = new Uint8Array(300).fill(42); // >256 bytes
const bitgoResult = utils.getSubstrateSigningBytes(rawPayload);
const polkadotResult = blake2AsU8a(rawPayload, 256); // Direct Polkadot.js call
should.deepEqual(bitgoResult, Buffer.from(polkadotResult));
});Why Current Tests Are Sufficient:
🎯 Final Assessment✅ APPROVE - This PR should be merged immediately Strengths:
No Concerns Found:
The tests are more than sufficient - they provide boundary testing, real-world integration scenarios, and regression coverage. Additional message equivalence assertions would be redundant since the implementation uses the exact same Polkadot.js cryptographic functions. This is exemplary code quality for a critical blockchain infrastructure fix. Job run link | Branch: abhijitmadhusudan496/si-911-fix-signablepayload-to-apply-substrate-blake2_256-c9ed |
Summary
Substrate signs the raw encoded
ExtrinsicPayloadonly when it is at most 256 bytes; for payloads larger than 256 bytes it signs theblake2_256hash of those bytes instead (Polkadot.js@polkadot/types/extrinsic/util, mirrored in the HSM firmware).The
signablePayloadgetter in@bitgo/abstract-substratealways returned the raw payload. For large extrinsics — e.g. POLYXswitchValidator/nominate with 6+ validators — the BitGoJS user signed the raw bytes while the HSM signed the hash, so the two parties signed different messages and TSS G-share combine failed withInvalidSignature.This fix makes
signablePayloadreturn the bytes that are actually signed:length <= 256→ raw bytes (unchanged)length > 256→blake2_256(raw)(32 bytes)The decision is extracted into a shared
utils.getSubstrateSigningBytes(raw)helper (with the threshold as theMAX_RAW_SIGNING_PAYLOAD_BYTESconstant) so the rule lives in one documented place and gives the future DOT fix a copy-paste target. All downstream consumers (sdk-coin-polyx, recovery flows) then get the correctsignableHexat the source, with no coin-specific logic needed in WP.Testing
sdk-coin-polyxnominateBuilder: small nominate extrinsic returns raw bytes (≤256, not 32); 8-validator nominate returns a 32-byte hashed payload.sdk-coin-polyxbatchStakingBuilder:bond + nominatewith 1 validator stays raw (≤256); with 6 validators returns a 32-byte hash. The batch wrapsnominateand crosses the threshold sooner, so it is covered explicitly.Notes / follow-ups
sdk-coin-dothas its own standalonesignablePayloadgetter with the same raw-only bug, and the DOT wasm path (wasmTx.signablePayload()) needs separate verification. Both are out of scope here per the issue and tracked as a DOT follow-up; the new helper is the intended drop-in for that fix.@bitgo-beta/abstract-substrate