From f2dc0ee012942e46bc4597d3a3c3ddb262b7f327 Mon Sep 17 00:00:00 2001 From: N V Rakesh Reddy Date: Wed, 22 Jul 2026 20:56:25 +0530 Subject: [PATCH] fix(sdk-coin-polyx): forward decoded specVersion to v8 transfer builders in tryGetV8Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tryGetV8Builder() now builds v8MaterialWithDecodedSpec from the decoded transaction bytes instead of using the static placeholder (specVersion 8000000). All four v8 transfer paths — V8TransferBuilder, V8HexTransferBuilder, V8TokenTransferBuilder, V8HexTokenTransferBuilder — receive .material(v8MaterialWithDecodedSpec) so createBaseTxInfo() encodes the live chain specVersion (e.g. 8000020) in the rebuilt signing payload. Without this fix verifySignature fails for any chain specVersion above 8000000 because the additional bytes in the rebuilt payload differ from the bytes the HSM signed. Adds a regression test that asserts signablePayload round-trips correctly when the factory is given a live material with specVersion 8000020. specVersion is present in signing payloads (unsigned) but not in signed extrinsics — falls back to v8Material.specVersion for signed txes so validateBaseFields does not throw. References: SI-1034 Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Cursor --- .../src/lib/transactionBuilderFactory.ts | 26 +++++++++-- .../transactionBuilderFactory.ts | 45 ++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts index 5813d30a7b..370eece0ad 100644 --- a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts @@ -291,18 +291,36 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { } const methodName = decodedTxn.method?.name; + // Build a merged material that keeps the v8 metadata/registry but overrides specVersion and + // txVersion with the values decoded from the actual transaction bytes. The static v8 material + // has a placeholder specVersion (8000000); the live chain may have a higher value (e.g. + // 8000020). `createBaseTxInfo()` reads specVersion and txVersion from `this._material`, so + // using the static placeholder causes the rebuilt signable payload to have wrong additional + // bytes — verifySignature then fails even though the TSS signature is valid. Taking the + // decoded values from the transaction itself (rather than forwarding `this._material`, which + // may be v7) ensures the rebuild always matches the bytes the HSM signed. See SI-1034 for the + // analogous fix in getV8BatchStakingBuilder. + // specVersion is present in signing payloads (unsigned) but NOT in signed extrinsics + // (decodeSignedTx doesn't return it). Fall back to the static v8 value for signed txes — + // the signing payload path (used by getSignedTx/verifySignature) always decodes specVersion. + const v8MaterialWithDecodedSpec = { + ...v8Material, + specVersion: decodedTxn.specVersion ?? v8Material.specVersion, + txVersion: decodedTxn.transactionVersion ?? v8Material.txVersion, + } as Interface.Material; + if (methodName === Interface.MethodNames.TransferWithMemo) { const args = decodedTxn.method.args as Interface.TransferWithMemoArgs; if (utils.isNewMemoEncoding(args.memo)) { - return this.getV8HexTransferBuilder(); + return this.getV8HexTransferBuilder().material(v8MaterialWithDecodedSpec); } - return this.getV8TransferBuilder(); + return this.getV8TransferBuilder().material(v8MaterialWithDecodedSpec); } else if (methodName === MethodNames.AddAndAffirmWithMediators) { const args = decodedTxn.method.args as AddAndAffirmWithMediatorsArgs | V8AddAndAffirmWithMediatorsArgs; if (utils.isNewMemoEncoding(args.instructionMemo)) { - return this.getV8HexTokenTransferBuilder(); + return this.getV8HexTokenTransferBuilder().material(v8MaterialWithDecodedSpec); } - return this.getV8TokenTransferBuilder(); + return this.getV8TokenTransferBuilder().material(v8MaterialWithDecodedSpec); } else if (methodName === MethodNames.RegisterDidWithCDD) { return this.getV8RegisterDidWithCDDBuilder(); } else if (methodName === MethodNames.RegisterDid) { diff --git a/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts index 8fd09985f4..12d6fcbb12 100644 --- a/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts @@ -1,4 +1,4 @@ -import { coins } from '@bitgo/statics'; +import { coins, NetworkType } from '@bitgo/statics'; import should from 'should'; import { TransactionBuilderFactory, @@ -6,6 +6,7 @@ import { V8TransferBuilder, V8HexTransferBuilder, V8TokenTransferBuilder, + utils, } from '../../../src/lib'; import { Interface } from '../../../src'; import { rawTx, accounts, mockTssSignature } from '../../resources'; @@ -203,5 +204,47 @@ describe('Tao Transaction Builder Factory', function () { should.equal(json.toDID, TO_DID); should.equal(rebuilt.toBroadcastFormat(), signedHex, 'signed round-trip hex must match original'); }); + + it('forwards factory live material specVersion to V8TokenTransferBuilder (SI-XXXX regression)', async function () { + // Regression for the bug where getV8TokenTransferBuilder() was called without + // .material(this._material), causing the builder to use the static placeholder + // specVersion (8000000) instead of the live chain specVersion. This produced a + // rebuilt signablePayload with wrong additional bytes, failing verifySignature. + const liveSpecVersion = 8000020; + const liveMaterial = { + ...utils.getV8Material(NetworkType.TESTNET), + specVersion: liveSpecVersion, + } as Interface.Material; + + // Build an unsigned tx with the live material — its signable payload encodes liveSpecVersion. + const originalTx = await new V8TokenTransferBuilder(buildTestConfig()) + .material(liveMaterial) + .assetId(ASSET_ID) + .amount('1000000') + .fromDID(FROM_DID) + .toDID(TO_DID) + .memo('0') + .sender({ address: sender.address }) + .validity(VALIDITY) + .referenceBlock(REF_BLOCK) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 1 }) + .fee({ amount: 0, type: 'tip' }) + .build(); + const originalSignable = originalTx.signablePayload.toString('hex'); + const signingPayloadHex = originalTx.toBroadcastFormat(); + + // Factory with the same live material must forward it to the created V8TokenTransferBuilder + // so the rebuild uses liveSpecVersion, not the static 8000000 placeholder. + const factoryInst = new TransactionBuilderFactory(buildTestConfig()).material(liveMaterial); + const rebuiltBuilder = factoryInst.from(signingPayloadHex); + rebuiltBuilder.sender({ address: sender.address }).validity(VALIDITY).referenceBlock(REF_BLOCK); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal( + rebuiltTx.signablePayload.toString('hex'), + originalSignable, + 'signablePayload must match: factory must forward live material to V8TokenTransferBuilder' + ); + }); }); });