diff --git a/modules/sdk-coin-tempo/src/lib/transaction.ts b/modules/sdk-coin-tempo/src/lib/transaction.ts index 34060c992a..58a886a487 100644 --- a/modules/sdk-coin-tempo/src/lib/transaction.ts +++ b/modules/sdk-coin-tempo/src/lib/transaction.ts @@ -8,7 +8,7 @@ import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { ethers } from 'ethers'; -import { Address, Hex, Tip20Operation } from './types'; +import { Address, Hex, RawContractCall, Tip20Operation } from './types'; import { amountToTip20Units } from './utils'; /** @@ -46,23 +46,38 @@ export interface TxData { callCount: number; feeToken?: string; operations: Tip20Operation[]; + rawCalls: RawContractCall[]; signature?: { r: Hex; s: Hex; yParity: number }; } export class Tip20Transaction extends BaseTransaction { private txRequest: Tip20TransactionRequest; private _operations: Tip20Operation[]; + private _rawCalls: RawContractCall[]; private _signature?: { r: Hex; s: Hex; yParity: number }; - constructor(_coinConfig: Readonly, request: Tip20TransactionRequest, operations: Tip20Operation[] = []) { + constructor( + _coinConfig: Readonly, + request: Tip20TransactionRequest, + operations: Tip20Operation[] = [], + rawCalls: RawContractCall[] = [] + ) { super(_coinConfig); this.txRequest = request; this._operations = operations; - this._outputs = operations.map((op) => ({ - address: op.to, - value: amountToTip20Units(op.amount).toString(), - coin: op.token, - })); + this._rawCalls = rawCalls; + this._outputs = [ + ...operations.map((op) => ({ + address: op.to, + value: amountToTip20Units(op.amount).toString(), + coin: op.token, + })), + ...rawCalls.map((call) => ({ + address: call.to, + value: call.value || '0', + coin: _coinConfig.name, + })), + ]; const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n); this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }]; } @@ -191,12 +206,16 @@ export class Tip20Transaction extends BaseTransaction { return [...this._operations]; } + getRawCalls(): RawContractCall[] { + return [...this._rawCalls]; + } + getFeeToken(): Address | undefined { return this.txRequest.feeToken; } getOperationCount(): number { - return this.txRequest.calls.length; + return this._operations.length + this._rawCalls.length; } isBatch(): boolean { @@ -240,6 +259,7 @@ export class Tip20Transaction extends BaseTransaction { callCount: this.txRequest.calls.length, feeToken: this.txRequest.feeToken, operations: this._operations, + rawCalls: this._rawCalls, signature: this._signature, }; } diff --git a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts index 0855ea53fa..f856ebd84d 100644 --- a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts @@ -21,13 +21,14 @@ import { } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { ethers } from 'ethers'; -import { Address, Hex, Tip20Operation } from './types'; +import { Address, Hex, RawContractCall, Tip20Operation } from './types'; import { Tip20Transaction, Tip20TransactionRequest } from './transaction'; import { amountToTip20Units, encodeTip20TransferWithMemo, isTip20Transaction, isValidAddress, + isValidHexData, isValidMemoId, isValidTip20Amount, tip20UnitsToAmount, @@ -41,6 +42,7 @@ import { AA_TRANSACTION_TYPE } from './constants'; */ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { private operations: Tip20Operation[] = []; + private rawCalls: RawContractCall[] = []; private _feeToken?: Address; private _nonce?: number; private _gas?: bigint; @@ -73,8 +75,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { * @throws BuildTransactionError if validation fails */ validateTransaction(): void { - if (this.operations.length === 0) { - throw new BuildTransactionError('At least one operation is required to build a transaction'); + if (this.operations.length === 0 && this.rawCalls.length === 0) { + throw new BuildTransactionError('At least one operation or raw call is required to build a transaction'); } if (this._nonce === undefined) { @@ -148,7 +150,16 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { data: tuple[2] as Hex, })); - const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call)); + const operations: Tip20Operation[] = []; + const decodedRawCalls: RawContractCall[] = []; + for (const call of calls) { + const op = this.decodeCallToOperation(call); + if (op !== null) { + operations.push(op); + } else { + decodedRawCalls.push({ to: call.to, data: call.data, value: call.value.toString() }); + } + } let signature: { r: Hex; s: Hex; yParity: number } | undefined; if (decoded.length >= 14 && decoded[13] && (decoded[13] as string).length > 2) { @@ -182,9 +193,10 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { this._maxPriorityFeePerGas = maxPriorityFeePerGas; this._feeToken = feeToken; this.operations = operations; + this.rawCalls = decodedRawCalls; this._restoredSignature = signature; - const tx = new Tip20Transaction(this._coinConfig, txRequest, operations); + const tx = new Tip20Transaction(this._coinConfig, txRequest, operations, decodedRawCalls); if (signature) { tx.setSignature(signature); } @@ -197,9 +209,10 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { /** * Decode a single AA call's data back into a Tip20Operation. - * Expects the call data to encode transferWithMemo(address, uint256, bytes32). + * Returns null if the call is not a transferWithMemo — it will be stored as a RawContractCall instead. + * This preserves calldata fidelity for arbitrary smart contract interactions. */ - private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation { + private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation | null { const iface = new ethers.utils.Interface(TIP20_TRANSFER_WITH_MEMO_ABI); try { const decoded = iface.decodeFunctionData('transferWithMemo', call.data); @@ -214,7 +227,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { return { token: call.to, to: toAddress, amount, memo }; } catch { - return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) }; + // Not a transferWithMemo call — caller will store as RawContractCall + return null; } } @@ -243,6 +257,14 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { const calls = this.operations.map((op) => this.operationToCall(op)); + for (const rawCall of this.rawCalls) { + calls.push({ + to: rawCall.to as Address, + data: rawCall.data as Hex, + value: rawCall.value ? BigInt(rawCall.value) : 0n, + }); + } + const txRequest: Tip20TransactionRequest = { type: AA_TRANSACTION_TYPE, chainId: this._common.chainIdBN().toNumber(), @@ -255,7 +277,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { feeToken: this._feeToken, }; - const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations); + const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations, this.rawCalls); if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) { const prv = this._sourceKeyPair.getKeys().prv!; @@ -288,6 +310,32 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { return this; } + /** + * Add a raw smart contract call with pre-encoded calldata + * Use this for arbitrary contract interactions where the UI provides ABI-encoded calldata + * + * @param call - Raw contract call with target address and pre-encoded calldata + * @returns this builder instance for chaining + */ + addRawCall(call: RawContractCall): this { + if (!isValidAddress(call.to)) { + throw new BuildTransactionError(`Invalid contract address: ${call.to}`); + } + if (!isValidHexData(call.data)) { + throw new BuildTransactionError(`Invalid calldata: must be a non-empty 0x-prefixed hex string`); + } + this.rawCalls.push(call); + return this; + } + + /** + * Get all raw contract calls in this transaction + * @returns Array of raw contract calls + */ + getRawCalls(): RawContractCall[] { + return [...this.rawCalls]; + } + /** * Set which TIP-20 token will be used to pay transaction fees * This is a global setting for the entire transaction diff --git a/modules/sdk-coin-tempo/src/lib/types.ts b/modules/sdk-coin-tempo/src/lib/types.ts index 4dbdc139d1..84f26bca17 100644 --- a/modules/sdk-coin-tempo/src/lib/types.ts +++ b/modules/sdk-coin-tempo/src/lib/types.ts @@ -15,3 +15,14 @@ export interface Tip20Operation { amount: string; memo?: string; } + +/** + * Raw smart contract call with pre-encoded calldata + * Used for arbitrary contract interactions (e.g., mint(), approve()) + * where the caller provides the full ABI-encoded calldata + */ +export interface RawContractCall { + to: Address; + data: Hex; + value?: string; +} diff --git a/modules/sdk-coin-tempo/src/lib/utils.ts b/modules/sdk-coin-tempo/src/lib/utils.ts index be05fb3f86..77a610bf82 100644 --- a/modules/sdk-coin-tempo/src/lib/utils.ts +++ b/modules/sdk-coin-tempo/src/lib/utils.ts @@ -149,6 +149,14 @@ export function isValidMemoId(memoId: string): boolean { return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId); } +/** + * Validate that a string is a non-empty 0x-prefixed hex string + * Used to validate pre-encoded calldata for raw contract calls + */ +export function isValidHexData(data: string): boolean { + return typeof data === 'string' && /^0x[0-9a-fA-F]+$/.test(data) && data.length > 2; +} + const utils = { isValidAddress, isValidPublicKey, @@ -160,6 +168,7 @@ const utils = { isValidTip20Amount, isTip20Transaction, isValidMemoId, + isValidHexData, }; export default utils; diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 76e32e819c..b26d6d1d95 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -256,26 +256,49 @@ export class Tempo extends AbstractEthLikeNewCoins { txBuilder.from(txHex); const tx = (await txBuilder.build()) as Tip20Transaction; const operations = tx.getOperations(); + const rawCalls = tx.getRawCalls(); - // If the caller specified explicit recipients, verify they match the operations 1-to-1 + // If the caller specified explicit recipients, verify they match operations and raw calls 1-to-1 const recipients = txParams?.recipients; if (recipients && recipients.length > 0) { - if (operations.length !== recipients.length) { + const totalCallCount = operations.length + rawCalls.length; + if (totalCallCount !== recipients.length) { throw new Error( - `Transaction has ${operations.length} operation(s) but ${recipients.length} recipient(s) were requested` + `Transaction has ${totalCallCount} call(s) but ${recipients.length} recipient(s) were requested` ); } - for (let i = 0; i < operations.length; i++) { - const op = operations[i]; + + let opIndex = 0; + let rawIndex = 0; + for (let i = 0; i < recipients.length; i++) { const recipient = recipients[i]; - const recipientBaseAddress = recipient.address.split('?')[0]; - if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) { - throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`); - } - // Compare amounts in base units (smallest denomination) - const opAmountBaseUnits = amountToTip20Units(op.amount).toString(); - if (opAmountBaseUnits !== recipient.amount.toString()) { - throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`); + if (recipient.data) { + // Contract call recipient — verify against rawCalls + const rawCall = rawCalls[rawIndex++]; + if (!rawCall) { + throw new Error(`Missing raw call for recipient ${i}`); + } + if (rawCall.to.toLowerCase() !== recipient.address.split('?')[0].toLowerCase()) { + throw new Error(`Raw call ${i} address mismatch: expected ${recipient.address}, got ${rawCall.to}`); + } + if (rawCall.data !== recipient.data) { + throw new Error(`Raw call ${i} calldata mismatch`); + } + } else { + // Token transfer recipient — verify against operations + const op = operations[opIndex++]; + if (!op) { + throw new Error(`Missing operation for recipient ${i}`); + } + const recipientBaseAddress = recipient.address.split('?')[0]; + if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) { + throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`); + } + // Compare amounts in base units (smallest denomination) + const opAmountBaseUnits = amountToTip20Units(op.amount).toString(); + if (opAmountBaseUnits !== recipient.amount.toString()) { + throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`); + } } } } diff --git a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts index 66364720ff..2a250903a6 100644 --- a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts @@ -238,7 +238,7 @@ describe('TIP-20 Transaction Build', () => { it('should throw error when no operations added', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); builder.nonce(0).gas(100000n).maxFeePerGas(1000000000n).maxPriorityFeePerGas(500000000n); - await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.noOperations); + await assert.rejects(async () => await builder.build(), /At least one operation or raw call is required/); }); it('should throw error when nonce not set', async () => { @@ -677,7 +677,7 @@ describe('Tempo coin - parseTransaction / verifyTransaction', () => { ], }, }), - /operation\(s\)/ + /call\(s\)/ ); }); @@ -776,3 +776,307 @@ describe('Tempo coin - parseTransaction / verifyTransaction', () => { }); }); }); + +describe('Raw Contract Call Builder', () => { + const mockContract = ethers.utils.getAddress('0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const mockCalldata = + '0xa9059cbb000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e0000000000000000000000000000000000000000000000000000000000989680'; + + describe('addRawCall', () => { + it('should add a raw call and return it via getRawCalls()', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.addRawCall({ to: mockContract, data: mockCalldata }); + + const rawCalls = builder.getRawCalls(); + assert.strictEqual(rawCalls.length, 1); + assert.strictEqual(rawCalls[0].to, mockContract); + assert.strictEqual(rawCalls[0].data, mockCalldata); + }); + + it('should support chaining addRawCall', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + const result = builder.addRawCall({ to: mockContract, data: mockCalldata }); + assert.strictEqual(result, builder); + }); + + it('should throw for invalid contract address', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => builder.addRawCall({ to: '0xinvalid', data: mockCalldata }), /Invalid contract address/); + }); + + it('should throw for empty calldata', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => builder.addRawCall({ to: mockContract, data: '0x' }), /Invalid calldata/); + }); + + it('should throw for non-hex calldata', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => builder.addRawCall({ to: mockContract, data: 'not-hex-data' }), /Invalid calldata/); + }); + + it('should throw for calldata missing 0x prefix', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => builder.addRawCall({ to: mockContract, data: 'a9059cbb' }), /Invalid calldata/); + }); + + it('should allow raw-call-only transaction (no operations)', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + assert.ok(tx instanceof Tip20Transaction); + assert.strictEqual(tx.getOperations().length, 0); + assert.strictEqual(tx.getRawCalls().length, 1); + assert.strictEqual(tx.getOperationCount(), 1); + }); + }); + + describe('Round-Trip: Build -> Serialize -> From (raw call deserialization)', () => { + it('should preserve raw call calldata through a round-trip', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(5) + .gas(150000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const originalTx = (await builder.build()) as Tip20Transaction; + const serialized = await originalTx.serialize(); + + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(serialized); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + assert.strictEqual(restoredTx.getOperations().length, 0); + const rawCalls = restoredTx.getRawCalls(); + assert.strictEqual(rawCalls.length, 1); + assert.strictEqual(rawCalls[0].to.toLowerCase(), mockContract.toLowerCase()); + assert.strictEqual(rawCalls[0].data.toLowerCase(), mockCalldata.toLowerCase()); + }); + + it('should preserve calldata through a signed round-trip', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(7) + .gas(120000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + tx.setSignature(SIGNATURE_TEST_DATA.validSignature); + const signedHex = await tx.toBroadcastFormat(); + + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(signedHex); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + const rawCalls = restoredTx.getRawCalls(); + assert.strictEqual(rawCalls.length, 1); + assert.strictEqual(rawCalls[0].data.toLowerCase(), mockCalldata.toLowerCase()); + const sig = restoredTx.getSignature(); + assert.ok(sig !== undefined); + assert.strictEqual(sig!.yParity, SIGNATURE_TEST_DATA.validSignature.yParity); + }); + + it('should preserve raw call tx id through a round-trip', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(3) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const originalTx = (await builder.build()) as Tip20Transaction; + const serialized = await originalTx.serialize(); + const originalId = originalTx.id; + + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(serialized); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + assert.strictEqual(restoredTx.id, originalId); + }); + }); + + describe('Mixed: operations + raw calls', () => { + it('should build and round-trip a transaction with both operations and raw calls', async () => { + const tokenAddress = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address); + const recipientAddress = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS); + + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: tokenAddress, to: recipientAddress, amount: '10.0', memo: '1' }) + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(1) + .gas(200000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const originalTx = (await builder.build()) as Tip20Transaction; + assert.strictEqual(originalTx.getOperations().length, 1); + assert.strictEqual(originalTx.getRawCalls().length, 1); + assert.strictEqual(originalTx.getOperationCount(), 2); + + const serialized = await originalTx.serialize(); + + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(serialized); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + assert.strictEqual(restoredTx.getOperations().length, 1); + assert.strictEqual(restoredTx.getRawCalls().length, 1); + assert.strictEqual(restoredTx.getOperationCount(), 2); + + const ops = restoredTx.getOperations(); + assert.strictEqual(ops[0].to.toLowerCase(), recipientAddress.toLowerCase()); + assert.strictEqual(ops[0].amount, '10.0'); + + const rawCalls = restoredTx.getRawCalls(); + assert.strictEqual(rawCalls[0].to.toLowerCase(), mockContract.toLowerCase()); + assert.strictEqual(rawCalls[0].data.toLowerCase(), mockCalldata.toLowerCase()); + }); + + it('should expose outputs for both operations and raw calls', async () => { + const tokenAddress = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address); + const recipientAddress = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS); + + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: tokenAddress, to: recipientAddress, amount: '5.0' }) + .addRawCall({ to: mockContract, data: mockCalldata, value: '0' }) + .nonce(2) + .gas(200000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + assert.strictEqual(tx.outputs.length, 2); + assert.strictEqual(tx.outputs[0].address.toLowerCase(), recipientAddress.toLowerCase()); + assert.strictEqual(tx.outputs[1].address.toLowerCase(), mockContract.toLowerCase()); + }); + + it('should include rawCalls in toJson() output', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addRawCall({ to: mockContract, data: mockCalldata }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + const json = tx.toJson(); + + assert.ok(Array.isArray(json.rawCalls)); + assert.strictEqual(json.rawCalls.length, 1); + assert.strictEqual(json.rawCalls[0].to, mockContract); + assert.strictEqual(json.rawCalls[0].data, mockCalldata); + }); + }); +}); + +describe('Tempo coin - verifyTransaction with raw calls', () => { + let bitgo: TestBitGoAPI; + let coin: any; + + const mockContract = ethers.utils.getAddress('0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const mockCalldata = + '0xa9059cbb000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e0000000000000000000000000000000000000000000000000000000000989680'; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('ttempo', (bg: BitGoBase) => { + const mockStaticsCoin = { + name: 'ttempo', + fullName: 'Testnet Tempo', + network: { type: 'testnet' }, + features: [], + } as any; + return Ttempo.createInstance(bg, mockStaticsCoin); + }); + bitgo.initializeTestVars(); + coin = bitgo.coin('ttempo'); + }); + + async function buildRawCallTx(contract: string, calldata: string, nonce = 0): Promise { + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + builder + .addRawCall({ to: contract, data: calldata }) + .nonce(nonce) + .gas(150000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + return tx.serialize(); + } + + it('should verify a raw call transaction when recipient data matches', async () => { + const txHex = await buildRawCallTx(mockContract, mockCalldata); + const result = await coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [{ address: mockContract, amount: '0', data: mockCalldata }], + }, + }); + assert.strictEqual(result, true); + }); + + it('should throw when raw call address does not match recipient', async () => { + const txHex = await buildRawCallTx(mockContract, mockCalldata); + const wrongAddress = ethers.utils.getAddress('0x1111111111111111111111111111111111111111'); + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { recipients: [{ address: wrongAddress, amount: '0', data: mockCalldata }] }, + }), + /address mismatch/ + ); + }); + + it('should throw when raw call calldata does not match', async () => { + const txHex = await buildRawCallTx(mockContract, mockCalldata); + const wrongCalldata = '0xdeadbeef01020304'; + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { recipients: [{ address: mockContract, amount: '0', data: wrongCalldata }] }, + }), + /calldata mismatch/ + ); + }); + + it('should throw when recipient count does not match call count', async () => { + const txHex = await buildRawCallTx(mockContract, mockCalldata); + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [ + { address: mockContract, amount: '0', data: mockCalldata }, + { address: mockContract, amount: '0', data: mockCalldata }, + ], + }, + }), + /call\(s\)/ + ); + }); + + it('should parse a raw call transaction and expose the contract as an output', async () => { + const txHex = await buildRawCallTx(mockContract, mockCalldata); + const parsed = await coin.parseTransaction({ txHex }); + assert.ok(Array.isArray(parsed.outputs)); + assert.strictEqual(parsed.outputs.length, 1); + assert.strictEqual(parsed.outputs[0].address.toLowerCase(), mockContract.toLowerCase()); + }); +});