diff --git a/modules/abstract-substrate/src/abstractSubstrateCoin.ts b/modules/abstract-substrate/src/abstractSubstrateCoin.ts index 27de5e020d..8e47d0ef72 100644 --- a/modules/abstract-substrate/src/abstractSubstrateCoin.ts +++ b/modules/abstract-substrate/src/abstractSubstrateCoin.ts @@ -20,6 +20,7 @@ import { RecoveryTxRequest, SignedTransaction, TssVerifyAddressOptions, + TransactionPrebuild, TxIntentMismatchRecipientError, UnexpectedAddressError, verifyEddsaTssWalletAddress, @@ -147,6 +148,15 @@ export class SubstrateCoin extends BaseCoin { } const factory = this.getBuilder(); + // Use the live chain material embedded in the prebuild (if present) so the factory can decode + // transactions built against a chain spec version newer than the SDK's hardcoded metadata. + // Without this, the factory falls back to its hardcoded material and misidentifies the call + // pallet (e.g. reads a Balances transfer as treasury.disbursement after a chain upgrade). + const prebuildMaterial = (txPrebuild as TransactionPrebuild & { coinSpecific?: { material?: Material } }) + .coinSpecific?.material; + if (prebuildMaterial && typeof factory.material === 'function') { + factory.material(prebuildMaterial); + } const txBuilder = factory.from(txPrebuild.txHex) as unknown as NativeTransferBuilder; const txTo: string = txBuilder['_to']; const txAmount: string = txBuilder['_amount']; diff --git a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts index 83049db1c1..bfee03ef10 100644 --- a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts @@ -11,7 +11,13 @@ import { UnbondBuilder } from './unbondBuilder'; import { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder'; import utils from './utils'; import { Interface, SingletonRegistry, TransactionBuilder } from './'; -import { TxMethod, BatchCallObject, MethodNames, AddAndAffirmWithMediatorsArgs } from './iface'; +import { + TxMethod, + BatchCallObject, + MethodNames, + AddAndAffirmWithMediatorsArgs, + V8AddAndAffirmWithMediatorsArgs, +} from './iface'; import { Transaction as BaseTransaction } from '@bitgo/abstract-substrate'; import { Transaction as PolyxTransaction } from './transaction'; import { PreApproveAssetBuilder } from './preApproveAssetBuilder'; @@ -257,7 +263,25 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { } const methodName = decodedTxn.method?.name; - if (methodName === 'batchAll') { + if (methodName === Interface.MethodNames.TransferWithMemo) { + const args = decodedTxn.method.args as Interface.TransferWithMemoArgs; + if (utils.isNewMemoEncoding(args.memo)) { + return this.getV8HexTransferBuilder(); + } + return this.getV8TransferBuilder(); + } else if (methodName === MethodNames.AddAndAffirmWithMediators) { + const args = decodedTxn.method.args as AddAndAffirmWithMediatorsArgs | V8AddAndAffirmWithMediatorsArgs; + if (utils.isNewMemoEncoding(args.instructionMemo)) { + return this.getV8HexTokenTransferBuilder(); + } + return this.getV8TokenTransferBuilder(); + } else if (methodName === MethodNames.RegisterDidWithCDD) { + return this.getV8RegisterDidWithCDDBuilder(); + } else if (methodName === MethodNames.RegisterDid) { + return this.getV8RegisterDidBuilder(); + } else if (methodName === MethodNames.PreApproveAsset) { + return this.getV8PreApproveAssetBuilder(); + } else if (methodName === 'batchAll') { const args = decodedTxn.method.args as { calls?: BatchCallObject[] }; if (args.calls && args.calls.length === 2) { diff --git a/modules/sdk-coin-polyx/test/unit/polyx.ts b/modules/sdk-coin-polyx/test/unit/polyx.ts index 6b6b7ff5a8..e85af71216 100644 --- a/modules/sdk-coin-polyx/test/unit/polyx.ts +++ b/modules/sdk-coin-polyx/test/unit/polyx.ts @@ -8,8 +8,10 @@ import { coins } from '@bitgo/statics'; import * as sinon from 'sinon'; import * as testData from '../resources/wrwUsers'; import { afterEach } from 'mocha'; -import { genesisHash, specVersion, txVersion, rawTx } from '../resources'; +import { genesisHash, specVersion, txVersion, rawTx, accounts, mockTssSignature } from '../resources'; import { TxIntentMismatchRecipientError } from '@bitgo/sdk-core'; +import { V8TransferBuilder } from '../../src/lib'; +import { testnetV8Material } from '../../src/resources'; describe('Polyx:', function () { let bitgo: TestBitGoAPI; @@ -272,5 +274,66 @@ describe('Polyx:', function () { .should.be.rejectedWith('missing recipients in txParams'); }); }); + + describe('v8 transfer with coinSpecific.material', function () { + const v8Amount = '1000000'; + const v8Receiver = accounts.account2; + const v8Sender = accounts.account1; + let v8SignedTxHex: string; + + before(async function () { + // Build a signed v8 transfer tx using testnetV8Material. + // The server embeds this material in coinSpecific during prebuild so that + // verifyTransaction can decode txHex built against a chain spec newer than + // the SDK's hardcoded v7 material (CECHO-1471). + const builder = new V8TransferBuilder(coins.get('tpolyx')) + .amount(v8Amount) + .to({ address: v8Receiver.address }) + .sender({ address: v8Sender.address }) + .memo('0') + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 1 }) + .fee({ amount: 0, type: 'tip' }); + builder.addSignature({ pub: v8Sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + const tx = await builder.build(); + v8SignedTxHex = tx.toBroadcastFormat(); + }); + + it('verifies a v8 transfer when coinSpecific.material is provided', async function () { + const result = await baseCoin.verifyTransaction({ + txPrebuild: { + txHex: v8SignedTxHex, + coinSpecific: { material: testnetV8Material }, + }, + txParams: { recipients: [{ address: v8Receiver.address, amount: v8Amount }] }, + }); + result.should.be.true(); + }); + + it('throws TxIntentMismatchRecipientError for address mismatch on v8 transfer', async function () { + await baseCoin + .verifyTransaction({ + txPrebuild: { + txHex: v8SignedTxHex, + coinSpecific: { material: testnetV8Material }, + }, + txParams: { recipients: [{ address: wrongAddress, amount: v8Amount }] }, + }) + .should.be.rejectedWith(TxIntentMismatchRecipientError); + }); + + it('throws TxIntentMismatchRecipientError for amount mismatch on v8 transfer', async function () { + await baseCoin + .verifyTransaction({ + txPrebuild: { + txHex: v8SignedTxHex, + coinSpecific: { material: testnetV8Material }, + }, + txParams: { recipients: [{ address: v8Receiver.address, amount: '1' }] }, + }) + .should.be.rejectedWith(TxIntentMismatchRecipientError); + }); + }); }); }); diff --git a/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts index 8ad5f28598..036465e644 100644 --- a/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/test/unit/transactionBuilder/transactionBuilderFactory.ts @@ -1,9 +1,10 @@ import { coins } from '@bitgo/statics'; import should from 'should'; -import { TransactionBuilderFactory, TransferBuilder } from '../../../src/lib'; +import { TransactionBuilderFactory, TransferBuilder, V8TransferBuilder, V8HexTransferBuilder } from '../../../src/lib'; import { Interface } from '../../../src'; import { rawTx, accounts } from '../../resources'; import * as materialData from '../../resources/materialData.json'; +import { buildTestConfig } from './base'; describe('Tao Transaction Builder Factory', function () { const sender = accounts.account1; @@ -41,4 +42,62 @@ describe('Tao Transaction Builder Factory', function () { }); }); }); + + describe('tryGetV8Builder routing', function () { + const receiver = accounts.account2; + + let v8TransferTxHex: string; + let v8HexTransferTxHex: string; + + before(async function () { + const config = buildTestConfig(); + + // Build an unsigned v8 transferWithMemo tx (plain-string memo, not 0x-prefixed) + const v8Tx = await new V8TransferBuilder(config) + .amount('1000000') + .to({ address: receiver.address }) + .sender({ address: sender.address }) + .memo('0') + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 1 }) + .fee({ amount: 0, type: 'tip' }) + .build(); + v8TransferTxHex = v8Tx.toBroadcastFormat(); + + // Build an unsigned v8 hex-memo transferWithMemo tx + const v8HexTx = await new V8HexTransferBuilder(config) + .amount('1000000') + .to({ address: receiver.address }) + .sender({ address: sender.address }) + .memo('56594') + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d') + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 1 }) + .fee({ amount: 0, type: 'tip' }) + .build(); + v8HexTransferTxHex = v8HexTx.toBroadcastFormat(); + }); + + it('routes a v8 transferWithMemo tx to V8TransferBuilder via fallback', function () { + // A v8 txHex encodes transferWithMemo at call index 0x28 (Balances pallet, call 40), + // which does not exist in the v7 SDK metadata. The factory falls back to tryGetV8Builder, + // decodes against v8 metadata, and returns the correct builder. + const factoryInst = new TransactionBuilderFactory(buildTestConfig()); + const builder = factoryInst.from(v8TransferTxHex); + should.ok(builder instanceof V8TransferBuilder, 'expected V8TransferBuilder from tryGetV8Builder fallback'); + }); + + it('routes a v8 hex-memo transferWithMemo tx to V8HexTransferBuilder via fallback', function () { + const factoryInst = new TransactionBuilderFactory(buildTestConfig()); + const builder = factoryInst.from(v8HexTransferTxHex); + should.ok(builder instanceof V8HexTransferBuilder, 'expected V8HexTransferBuilder from tryGetV8Builder fallback'); + }); + + it('routes a v7 transferWithMemo tx to TransferBuilder directly', function () { + const factoryInst = new TransactionBuilderFactory(buildTestConfig()); + const builder = factoryInst.from(rawTx.transfer.signed); + should.ok(builder instanceof TransferBuilder, 'expected TransferBuilder for v7 txHex'); + }); + }); });