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' + ); + }); }); });