From 9f2b66ed23c497c9478b8040756d0cc088a64cfa Mon Sep 17 00:00:00 2001 From: Nayan Das Date: Wed, 4 Mar 2026 17:44:33 +0530 Subject: [PATCH] feat(sdk-coin-tempo): implement transaction deserialization, parsing, and verification Ticket: CECHO-286 --- modules/sdk-coin-tempo/src/lib/transaction.ts | 31 +- .../src/lib/transactionBuilder.ts | 178 +++++++++- modules/sdk-coin-tempo/src/lib/utils.ts | 20 +- modules/sdk-coin-tempo/src/tempo.ts | 120 ++++++- .../sdk-coin-tempo/test/resources/tempo.ts | 2 +- modules/sdk-coin-tempo/test/unit/index.ts | 62 +++- .../test/unit/transactionBuilder.ts | 330 +++++++++++++++++- 7 files changed, 717 insertions(+), 26 deletions(-) diff --git a/modules/sdk-coin-tempo/src/lib/transaction.ts b/modules/sdk-coin-tempo/src/lib/transaction.ts index 29ab593657..070921c85b 100644 --- a/modules/sdk-coin-tempo/src/lib/transaction.ts +++ b/modules/sdk-coin-tempo/src/lib/transaction.ts @@ -9,6 +9,7 @@ import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { ethers } from 'ethers'; import { Address, Hex, Tip20Operation } from './types'; +import { amountToTip20Units } from './utils'; /** * TIP-20 Transaction Request Structure @@ -35,6 +36,19 @@ export interface Tip20TransactionRequest { feeToken?: Address; } +export interface TxData { + type: number | string; + chainId: number; + nonce: number; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + gas: string; + callCount: number; + feeToken?: string; + operations: Tip20Operation[]; + signature?: { r: Hex; s: Hex; yParity: number }; +} + export class Tip20Transaction extends BaseTransaction { private txRequest: Tip20TransactionRequest; private _operations: Tip20Operation[]; @@ -44,6 +58,13 @@ export class Tip20Transaction extends BaseTransaction { super(_coinConfig); this.txRequest = request; this._operations = operations; + this._outputs = operations.map((op) => ({ + address: op.to, + value: amountToTip20Units(op.amount).toString(), + coin: op.token, + })); + const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n); + this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }]; } get type(): TransactionType { @@ -190,7 +211,7 @@ export class Tip20Transaction extends BaseTransaction { return this._signature; } - toJson(): Record { + toJson(): TxData { return { type: this.txRequest.type, chainId: this.txRequest.chainId, @@ -209,8 +230,14 @@ export class Tip20Transaction extends BaseTransaction { return await this.serialize(this._signature); } + /** @inheritdoc */ get id(): string { - return 'pending'; + try { + const serialized = this.serializeTransaction(this._signature); + return ethers.utils.keccak256(ethers.utils.arrayify(serialized)); + } catch { + return 'pending'; + } } toString(): string { diff --git a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts index ad68c090fa..0855ea53fa 100644 --- a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts @@ -8,12 +8,31 @@ * - EIP-7702 Account Abstraction (type 0x76) */ -import { TransactionBuilder as AbstractTransactionBuilder, TransferBuilder } from '@bitgo/abstract-eth'; -import { BaseTransaction, BuildTransactionError } from '@bitgo/sdk-core'; +import { + Transaction as EthTransaction, + TransactionBuilder as AbstractTransactionBuilder, + TransferBuilder, +} from '@bitgo/abstract-eth'; +import { + BaseTransaction, + BuildTransactionError, + InvalidTransactionError, + ParseTransactionError, +} from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { ethers } from 'ethers'; import { Address, Hex, Tip20Operation } from './types'; import { Tip20Transaction, Tip20TransactionRequest } from './transaction'; -import { amountToTip20Units, encodeTip20TransferWithMemo, isValidAddress, isValidTip20Amount } from './utils'; +import { + amountToTip20Units, + encodeTip20TransferWithMemo, + isTip20Transaction, + isValidAddress, + isValidMemoId, + isValidTip20Amount, + tip20UnitsToAmount, +} from './utils'; +import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi'; import { AA_TRANSACTION_TYPE } from './constants'; /** @@ -27,6 +46,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { private _gas?: bigint; private _maxFeePerGas?: bigint; private _maxPriorityFeePerGas?: bigint; + private _restoredSignature?: { r: Hex; s: Hex; yParity: number }; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -74,8 +94,133 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { } } + /** @inheritdoc */ + validateRawTransaction(rawTransaction: any): void { + if (typeof rawTransaction === 'string' && isTip20Transaction(rawTransaction)) { + try { + ethers.utils.RLP.decode('0x' + rawTransaction.slice(4)); + return; + } catch (e) { + throw new ParseTransactionError(`Failed to RLP decode TIP-20 transaction: ${e}`); + } + } + super.validateRawTransaction(rawTransaction); + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string, isFirstSigner?: boolean): EthTransaction { + if (!rawTransaction) { + throw new InvalidTransactionError('Raw transaction is empty'); + } + if (isTip20Transaction(rawTransaction)) { + return this.fromTip20Transaction(rawTransaction) as unknown as EthTransaction; + } + return super.fromImplementation(rawTransaction, isFirstSigner); + } + + /** + * Deserialize a type 0x76 transaction and restore builder state. + * RLP field layout mirrors buildBaseRlpData() in transaction.ts. + */ + private fromTip20Transaction(rawTransaction: string): Tip20Transaction { + try { + const rlpHex = '0x' + rawTransaction.slice(4); + const decoded = ethers.utils.RLP.decode(rlpHex) as any[]; + + if (!Array.isArray(decoded) || decoded.length < 13) { + throw new ParseTransactionError('Invalid TIP-20 transaction: unexpected RLP structure'); + } + + const parseBigInt = (hex: string): bigint => (!hex || hex === '0x' ? 0n : BigInt(hex)); + const parseHexInt = (hex: string): number => (!hex || hex === '0x' ? 0 : parseInt(hex, 16)); + + const chainId = parseHexInt(decoded[0] as string); + const maxPriorityFeePerGas = parseBigInt(decoded[1] as string); + const maxFeePerGas = parseBigInt(decoded[2] as string); + const gas = parseBigInt(decoded[3] as string); + const callsTuples = decoded[4] as string[][]; + const nonce = parseHexInt(decoded[7] as string); + const feeTokenRaw = decoded[10] as string; + + const calls: { to: Address; data: Hex; value: bigint }[] = callsTuples.map((tuple) => ({ + to: tuple[0] as Address, + value: parseBigInt(tuple[1] as string), + data: tuple[2] as Hex, + })); + + const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call)); + + let signature: { r: Hex; s: Hex; yParity: number } | undefined; + if (decoded.length >= 14 && decoded[13] && (decoded[13] as string).length > 2) { + const sigBytes = ethers.utils.arrayify(decoded[13] as string); + if (sigBytes.length === 65) { + const r = ethers.utils.hexlify(sigBytes.slice(0, 32)) as Hex; + const s = ethers.utils.hexlify(sigBytes.slice(32, 64)) as Hex; + const v = sigBytes[64]; + const yParity = v > 1 ? v - 27 : v; + signature = { r, s, yParity }; + } + } + + const feeToken = feeTokenRaw && feeTokenRaw !== '0x' ? (feeTokenRaw as Address) : undefined; + + const txRequest: Tip20TransactionRequest = { + type: AA_TRANSACTION_TYPE, + chainId, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + gas, + calls, + accessList: [], + feeToken, + }; + + this._nonce = nonce; + this._gas = gas; + this._maxFeePerGas = maxFeePerGas; + this._maxPriorityFeePerGas = maxPriorityFeePerGas; + this._feeToken = feeToken; + this.operations = operations; + this._restoredSignature = signature; + + const tx = new Tip20Transaction(this._coinConfig, txRequest, operations); + if (signature) { + tx.setSignature(signature); + } + return tx; + } catch (e) { + if (e instanceof ParseTransactionError) throw e; + throw new ParseTransactionError(`Failed to deserialize TIP-20 transaction: ${e}`); + } + } + + /** + * Decode a single AA call's data back into a Tip20Operation. + * Expects the call data to encode transferWithMemo(address, uint256, bytes32). + */ + private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation { + const iface = new ethers.utils.Interface(TIP20_TRANSFER_WITH_MEMO_ABI); + try { + const decoded = iface.decodeFunctionData('transferWithMemo', call.data); + const toAddress = decoded[0] as string; + const amountUnits = BigInt(decoded[1].toString()); + const memoBytes32 = decoded[2] as string; + + const amount = tip20UnitsToAmount(amountUnits); + + const stripped = ethers.utils.stripZeros(memoBytes32); + const memo = stripped.length > 0 ? ethers.utils.toUtf8String(stripped) : undefined; + + return { token: call.to, to: toAddress, amount, memo }; + } catch { + return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) }; + } + } + /** * Build the transaction from configured TIP-20 operations and transaction parameters. + * Signs with _sourceKeyPair if it has been set via sign({ key }). */ protected async buildImplementation(): Promise { if ( @@ -110,7 +255,24 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { feeToken: this._feeToken, }; - return new Tip20Transaction(this._coinConfig, txRequest, this.operations); + const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations); + + if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) { + const prv = this._sourceKeyPair.getKeys().prv!; + const unsignedHex = await tx.serialize(); + const msgHash = ethers.utils.keccak256(ethers.utils.arrayify(unsignedHex)); + const signingKey = new ethers.utils.SigningKey('0x' + prv); + const sig = signingKey.signDigest(ethers.utils.arrayify(msgHash)); + tx.setSignature({ + r: sig.r as Hex, + s: sig.s as Hex, + yParity: sig.recoveryParam ?? 0, + }); + } else if (this._restoredSignature) { + tx.setSignature(this._restoredSignature); + } + + return tx; } /** @@ -234,12 +396,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { throw new BuildTransactionError(`Invalid amount: ${operation.amount}`); } - // Validate memo byte length (handles multi-byte UTF-8 characters) - if (operation.memo) { - const memoByteLength = new TextEncoder().encode(operation.memo).length; - if (memoByteLength > 32) { - throw new BuildTransactionError(`Memo too long: ${memoByteLength} bytes. Maximum 32 bytes.`); - } + if (operation.memo !== undefined && !isValidMemoId(operation.memo)) { + throw new BuildTransactionError(`Invalid memo: must be a non-negative integer`); } } diff --git a/modules/sdk-coin-tempo/src/lib/utils.ts b/modules/sdk-coin-tempo/src/lib/utils.ts index ab07e1f361..be05fb3f86 100644 --- a/modules/sdk-coin-tempo/src/lib/utils.ts +++ b/modules/sdk-coin-tempo/src/lib/utils.ts @@ -6,9 +6,11 @@ import { bip32 } from '@bitgo/secp256k1'; import { ethers } from 'ethers'; -import { TIP20_DECIMALS } from './constants'; +import { AA_TRANSACTION_TYPE, TIP20_DECIMALS } from './constants'; import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi'; +const AA_TX_HEX_REGEX = new RegExp(`^${AA_TRANSACTION_TYPE}[0-9a-f]*$`, 'i'); + type Address = string; type Hex = string; @@ -133,6 +135,20 @@ export function isValidTip20Amount(amount: string): boolean { } } +/** + * Check if a raw transaction string is a Tempo AA transaction (type 0x76) + */ +export function isTip20Transaction(raw: string): boolean { + return AA_TX_HEX_REGEX.test(raw); +} + +/** + * Validate that a memoId is a valid non-negative integer string + */ +export function isValidMemoId(memoId: string): boolean { + return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId); +} + const utils = { isValidAddress, isValidPublicKey, @@ -142,6 +158,8 @@ const utils = { stringToBytes32, encodeTip20TransferWithMemo, isValidTip20Amount, + isTip20Transaction, + isValidMemoId, }; export default utils; diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 24f3d457c2..0265b2b361 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -7,12 +7,25 @@ import { OfflineVaultTxInfo, UnsignedSweepTxMPCv2, TransactionBuilder, + VerifyEthTransactionOptions, + VerifyEthAddressOptions, + TssVerifyEthAddressOptions, optionalDeps, } from '@bitgo/abstract-eth'; import type * as EthLikeCommon from '@ethereumjs/common'; -import { BaseCoin, BitGoBase, InvalidAddressError, InvalidMemoIdError, MPCAlgorithm } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + InvalidAddressError, + InvalidMemoIdError, + MPCAlgorithm, + ParseTransactionOptions, + ParsedTransaction, + UnexpectedAddressError, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; -import { Tip20TransactionBuilder } from './lib'; +import { Tip20Transaction, Tip20TransactionBuilder } from './lib'; +import { amountToTip20Units, isValidMemoId as isValidMemoIdUtil } from './lib/utils'; import * as url from 'url'; import * as querystring from 'querystring'; @@ -155,13 +168,106 @@ export class Tempo extends AbstractEthLikeNewCoins { * @returns true if valid */ isValidMemoId(memoId: string): boolean { - if (typeof memoId !== 'string' || memoId === '') { - return false; + return isValidMemoIdUtil(memoId); + } + + /** + * Tempo uses memoId-based addresses rather than forwarder contracts. + * Verify that the address belongs to this wallet by checking that the + * base (EVM) portion matches the wallet's base address. + */ + async isWalletAddress(params: VerifyEthAddressOptions | TssVerifyEthAddressOptions): Promise { + const { address, baseAddress } = params; + const rootAddress = (params as unknown as Record).rootAddress as string | undefined; + + if (!address) { + throw new InvalidAddressError('address is required'); } - // Must be a non-negative integer (no decimals, no negative, no leading zeros except for "0") - if (!/^(0|[1-9]\d*)$/.test(memoId)) { - return false; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); } + + const { baseAddress: addressBase } = this.getAddressDetails(address); + + const walletBaseAddress = baseAddress || rootAddress; + if (!walletBaseAddress) { + throw new InvalidAddressError('baseAddress or rootAddress is required for verification'); + } + + const { baseAddress: walletBase } = this.getAddressDetails(walletBaseAddress); + + if (addressBase.toLowerCase() !== walletBase.toLowerCase()) { + throw new UnexpectedAddressError(`address validation failure: expected ${walletBase} but got ${addressBase}`); + } + + return true; + } + + /** + * Parse a serialised Tempo transaction and return its operations as SDK outputs. + * @inheritdoc + */ + async parseTransaction(params: ParseTransactionOptions): Promise { + const txHex = (params.txHex || (params as any).halfSigned?.txHex) as string | undefined; + if (!txHex) { + return {}; + } + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txHex); + const tx = (await txBuilder.build()) as Tip20Transaction; + return { + inputs: tx.inputs.map((input) => ({ + address: input.address, + amount: input.value, + coin: this.getChain(), + })), + outputs: tx.outputs.map((output) => ({ + address: output.address, + amount: output.value, + coin: this.getChain(), + })), + }; + } + + /** + * Verify that a Tempo transaction matches the intended recipients and amounts. + * @inheritdoc + */ + async verifyTransaction(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild } = params; + + if (!txPrebuild?.txHex) { + return true; + } + + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const tx = (await txBuilder.build()) as Tip20Transaction; + const operations = tx.getOperations(); + + // If the caller specified explicit recipients, verify they match the operations 1-to-1 + const recipients = txParams?.recipients; + if (recipients && recipients.length > 0) { + if (operations.length !== recipients.length) { + throw new Error( + `Transaction has ${operations.length} operation(s) but ${recipients.length} recipient(s) were requested` + ); + } + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + const recipient = recipients[i]; + if (op.to.toLowerCase() !== recipient.address.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}`); + } + } + } + return true; } diff --git a/modules/sdk-coin-tempo/test/resources/tempo.ts b/modules/sdk-coin-tempo/test/resources/tempo.ts index cc4f26eb2c..4736b56789 100644 --- a/modules/sdk-coin-tempo/test/resources/tempo.ts +++ b/modules/sdk-coin-tempo/test/resources/tempo.ts @@ -74,5 +74,5 @@ export const ERROR_MESSAGES = { missingGas: /Gas limit is required/, missingMaxFeePerGas: /maxFeePerGas is required/, missingMaxPriorityFeePerGas: /maxPriorityFeePerGas is required/, - memoTooLong: /Memo too long/, + invalidMemo: /Invalid memo/, }; diff --git a/modules/sdk-coin-tempo/test/unit/index.ts b/modules/sdk-coin-tempo/test/unit/index.ts index 8236d2e236..0d5f359165 100644 --- a/modules/sdk-coin-tempo/test/unit/index.ts +++ b/modules/sdk-coin-tempo/test/unit/index.ts @@ -2,7 +2,7 @@ import { Tempo } from '../../src/tempo'; import { Ttempo } from '../../src/ttempo'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { BitGoBase, InvalidAddressError, InvalidMemoIdError } from '@bitgo/sdk-core'; +import { BitGoBase, InvalidAddressError, InvalidMemoIdError, UnexpectedAddressError } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import * as should from 'should'; @@ -147,6 +147,66 @@ describe('Tempo Coin', function () { }); }); + describe('isWalletAddress', function () { + const baseAddress = '0x2476602c78e9a5e0563320c78878faa3952b256f'; + + it('should verify a plain address matching the base address', async function () { + const result = await basecoin.isWalletAddress({ + address: baseAddress, + baseAddress, + coinSpecific: {}, + }); + result.should.equal(true); + }); + + it('should verify a memoId address whose base matches the wallet base address', async function () { + const result = await basecoin.isWalletAddress({ + address: `${baseAddress}?memoId=8`, + baseAddress, + coinSpecific: {}, + }); + result.should.equal(true); + }); + + it('should verify using rootAddress when baseAddress is not provided', async function () { + const result = await basecoin.isWalletAddress({ + address: `${baseAddress}?memoId=42`, + rootAddress: baseAddress, + coinSpecific: {}, + }); + result.should.equal(true); + }); + + it('should reject address with mismatched base address', async function () { + await basecoin + .isWalletAddress({ + address: '0x0000000000000000000000000000000000000001?memoId=8', + baseAddress, + coinSpecific: {}, + }) + .should.be.rejectedWith(UnexpectedAddressError); + }); + + it('should reject an invalid address', async function () { + await basecoin + .isWalletAddress({ + address: 'not-a-valid-address', + baseAddress, + coinSpecific: {}, + }) + .should.be.rejectedWith(InvalidAddressError); + }); + + it('should not require forwarderVersion (no forwarder contracts)', async function () { + const result = await basecoin.isWalletAddress({ + address: `${baseAddress}?memoId=100`, + baseAddress, + coinSpecific: { forwarderVersion: undefined }, + }); + result.should.equal(true); + }); + }); + describe('Testnet', function () { let testnetBasecoin; diff --git a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts index d29b8e6b82..1a77c29542 100644 --- a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import { describe, it } from 'mocha'; +import { describe, it, before } from 'mocha'; import { ethers } from 'ethers'; import { Tip20TransactionBuilder } from '../../src/lib/transactionBuilder'; import { Tip20Transaction } from '../../src/lib/transaction'; @@ -12,6 +12,10 @@ import { } from '../../src/lib/utils'; import { TIP20_DECIMALS, AA_TRANSACTION_TYPE } from '../../src/lib/constants'; import { coins } from '@bitgo/statics'; +import { Ttempo } from '../../src/ttempo'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoBase } from '@bitgo/sdk-core'; import { TESTNET_TOKENS, TX_PARAMS, @@ -110,11 +114,11 @@ describe('TIP-20 Transaction Builder', () => { ); }); - it('should throw error for memo longer than 32 bytes', () => { + it('should throw error for invalid (non-numeric) memo', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); assert.throws( - () => builder.addOperation({ token: mockToken, to: mockRecipient, amount: '100', memo: 'a'.repeat(33) }), - /Memo too long/ + () => builder.addOperation({ token: mockToken, to: mockRecipient, amount: '100', memo: 'INV-001' }), + /Invalid memo/ ); }); }); @@ -364,4 +368,322 @@ describe('TIP-20 Transaction Build', () => { assert.strictEqual(json.feeToken, mockFeeToken); }); }); + + describe('Transaction id getter', () => { + it('unsigned and signed transactions should have different ids', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '10' }) + .nonce(1) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + const unsignedId = tx.id; + + tx.setSignature(SIGNATURE_TEST_DATA.validSignature); + const signedId = tx.id; + + assert.notStrictEqual(unsignedId, signedId, 'Signed and unsigned tx should have different ids'); + }); + }); + + describe('outputs / inputs population', () => { + it('should expose outputs with base-unit value and token address as coin', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '1.5', memo: '7' }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + assert.strictEqual(tx.outputs.length, 1); + assert.strictEqual(tx.outputs[0].address, mockRecipient); + // 1.5 tokens * 10^6 = 1_500_000 base units + assert.strictEqual(tx.outputs[0].value, '1500000'); + // coin is the token contract address, not the chain name + assert.strictEqual(tx.outputs[0].coin?.toLowerCase(), mockToken.toLowerCase()); + }); + + it('should expose a single input with the total in base units', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '1.5' }) + .addOperation({ token: mockToken, to: mockRecipient, amount: '3.5' }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + assert.strictEqual(tx.inputs.length, 1); + assert.strictEqual(tx.inputs[0].address, ''); + // (1.5 + 3.5) tokens * 10^6 = 5_000_000 base units + assert.strictEqual(tx.inputs[0].value, '5000000'); + }); + }); + + describe('Round-Trip: Build -> Serialize -> From (deserialization)', () => { + it('should round-trip a single-operation transaction without a signature', async () => { + const operation = { token: mockToken, to: mockRecipient, amount: '25.5', memo: '12345' }; + + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation(operation) + .feeToken(mockFeeToken) + .nonce(7) + .gas(150000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const originalTx = (await builder.build()) as Tip20Transaction; + const serialized = await originalTx.serialize(); + + // Deserialize via from() + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(serialized); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + const ops = restoredTx.getOperations(); + assert.strictEqual(ops.length, 1); + assert.strictEqual(ops[0].token.toLowerCase(), operation.token.toLowerCase()); + assert.strictEqual(ops[0].to.toLowerCase(), operation.to.toLowerCase()); + assert.strictEqual(ops[0].amount, operation.amount); + assert.strictEqual(ops[0].memo, operation.memo); + assert.strictEqual(restoredTx.getFeeToken()?.toLowerCase(), mockFeeToken.toLowerCase()); + }); + + it('should round-trip a batch transaction', async () => { + // Use amounts that match tip20UnitsToAmount output: ethers formatUnits always includes decimal + const operations = [ + { token: TESTNET_TOKENS.alphaUSD.address, to: mockRecipient, amount: '10.0', memo: '1' }, + { token: TESTNET_TOKENS.betaUSD.address, to: mockRecipient, amount: '20.0', memo: '2' }, + ]; + + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation(operations[0]) + .addOperation(operations[1]) + .nonce(42) + .gas(250000n) + .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.getOperationCount(), 2); + assert.ok(restoredTx.isBatch()); + + const json = restoredTx.toJson(); + assert.strictEqual(json.nonce, 42); + assert.strictEqual(json.gas, '250000'); + assert.strictEqual(json.maxFeePerGas, TX_PARAMS.defaultMaxFeePerGas.toString()); + assert.strictEqual(json.maxPriorityFeePerGas, TX_PARAMS.defaultMaxPriorityFeePerGas.toString()); + + const ops = restoredTx.getOperations(); + for (let i = 0; i < operations.length; i++) { + assert.strictEqual(ops[i].token.toLowerCase(), operations[i].token.toLowerCase()); + assert.strictEqual(ops[i].to.toLowerCase(), mockRecipient.toLowerCase()); + assert.strictEqual(ops[i].amount, operations[i].amount); + assert.strictEqual(ops[i].memo, operations[i].memo); + } + }); + + it('should round-trip a signed transaction and preserve the signature', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '5' }) + .nonce(99) + .gas(100000n) + .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(); + + // Deserialize the signed transaction + const builder2 = new Tip20TransactionBuilder(mockCoinConfig); + builder2.from(signedHex); + const restoredTx = (await builder2.build()) as Tip20Transaction; + + const sig = restoredTx.getSignature(); + assert.ok(sig !== undefined, 'Signature should be preserved'); + assert.strictEqual(sig!.yParity, SIGNATURE_TEST_DATA.validSignature.yParity); + }); + + it('should produce the same tx id after a serialization round-trip', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100.0', memo: '99' }) + .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); + + const originalJson = originalTx.toJson(); + const restoredJson = restoredTx.toJson(); + assert.strictEqual(restoredJson.nonce, originalJson.nonce); + assert.strictEqual(restoredJson.gas, originalJson.gas); + assert.strictEqual(restoredJson.maxFeePerGas, originalJson.maxFeePerGas); + assert.strictEqual(restoredJson.maxPriorityFeePerGas, originalJson.maxPriorityFeePerGas); + assert.strictEqual(restoredJson.feeToken, originalJson.feeToken); + assert.strictEqual(restoredJson.callCount, originalJson.callCount); + + const originalOps = originalTx.getOperations(); + const restoredOps = restoredTx.getOperations(); + assert.strictEqual(restoredOps.length, originalOps.length); + assert.strictEqual(restoredOps[0].token.toLowerCase(), originalOps[0].token.toLowerCase()); + assert.strictEqual(restoredOps[0].to.toLowerCase(), originalOps[0].to.toLowerCase()); + assert.strictEqual(restoredOps[0].amount, originalOps[0].amount); + assert.strictEqual(restoredOps[0].memo, originalOps[0].memo); + + const restoredSerialized = await restoredTx.serialize(); + assert.strictEqual(restoredSerialized, serialized); + }); + }); +}); + +describe('Tempo coin - parseTransaction / verifyTransaction', () => { + let bitgo: TestBitGoAPI; + let coin: any; + + const mockToken = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address); + const mockRecipient = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS); + + 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 buildSerializedTx( + operations: { token: string; to: string; amount: string; memo?: string }[], + nonce = 0 + ): Promise { + const builder = new Tip20TransactionBuilder(coins.get('ttempo')); + for (const op of operations) { + builder.addOperation(op); + } + builder + .nonce(nonce) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + const tx = (await builder.build()) as Tip20Transaction; + return tx.serialize(); + } + + describe('parseTransaction', () => { + it('should parse a single-operation transaction into outputs', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '50', memo: '1' }]); + const parsed = await coin.parseTransaction({ txHex }); + assert.ok(Array.isArray(parsed.outputs), 'outputs should be an array'); + assert.strictEqual(parsed.outputs.length, 1); + assert.strictEqual(parsed.outputs[0].address.toLowerCase(), mockRecipient.toLowerCase()); + // 50 tokens * 10^6 = 50_000_000 base units + assert.strictEqual(parsed.outputs[0].amount, '50000000'); + }); + + it('should return empty object when no txHex is provided', async () => { + const parsed = await coin.parseTransaction({}); + assert.deepStrictEqual(parsed, {}); + }); + }); + + describe('verifyTransaction', () => { + it('should return true when no recipients are specified', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '10' }]); + const result = await coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: {}, + }); + assert.strictEqual(result, true); + }); + + it('should return true when recipients match operations', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '100' }]); + const result = await coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [{ address: mockRecipient, amount: '100000000' }], // 100 * 10^6 base units + }, + }); + assert.strictEqual(result, true); + }); + + it('should throw when recipient address does not match', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '100' }]); + const wrongAddress = ethers.utils.getAddress('0x1111111111111111111111111111111111111111'); + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { recipients: [{ address: wrongAddress, amount: '100000000' }] }, + }), + /recipient mismatch/ + ); + }); + + it('should throw when recipient amount does not match', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '100' }]); + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { recipients: [{ address: mockRecipient, amount: '999' }] }, + }), + /amount mismatch/ + ); + }); + + it('should throw when operation count differs from recipient count', async () => { + const txHex = await buildSerializedTx([{ token: mockToken, to: mockRecipient, amount: '10' }]); + await assert.rejects( + () => + coin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [ + { address: mockRecipient, amount: '10000000' }, + { address: mockRecipient, amount: '10000000' }, + ], + }, + }), + /operation\(s\)/ + ); + }); + + it('should return true when no txHex is provided', async () => { + const result = await coin.verifyTransaction({ txPrebuild: {}, txParams: {} }); + assert.strictEqual(result, true); + }); + }); });