diff --git a/modules/sdk-coin-tempo/src/lib/transaction.ts b/modules/sdk-coin-tempo/src/lib/transaction.ts index 941ac2bcf8..5cee4d043d 100644 --- a/modules/sdk-coin-tempo/src/lib/transaction.ts +++ b/modules/sdk-coin-tempo/src/lib/transaction.ts @@ -82,18 +82,25 @@ export class Tip20Transaction extends BaseTransaction { } /** - * Build base RLP data array per Tempo EIP-7702 specification - * @param callsTuples Encoded calls - * @param accessTuples Encoded access list - * @returns RLP-ready array of transaction fields + * Convert bigint to hex string for RLP encoding + * @param value bigint value to convert + * @returns Hex string * @private */ + private bigintToHex(value: bigint): string { + if (value === 0n) { + return '0x'; + } + const hex = value.toString(16); + return '0x' + (hex.length % 2 ? '0' : '') + hex; + } + private buildBaseRlpData(callsTuples: any[], accessTuples: any[]): any[] { return [ ethers.utils.hexlify(this.txRequest.chainId), - this.txRequest.maxPriorityFeePerGas ? ethers.utils.hexlify(this.txRequest.maxPriorityFeePerGas.toString()) : '0x', - ethers.utils.hexlify(this.txRequest.maxFeePerGas.toString()), - ethers.utils.hexlify(this.txRequest.gas.toString()), + this.txRequest.maxPriorityFeePerGas ? this.bigintToHex(this.txRequest.maxPriorityFeePerGas) : '0x', + this.bigintToHex(this.txRequest.maxFeePerGas), + this.bigintToHex(this.txRequest.gas), callsTuples, accessTuples, '0x', // nonceKey (reserved for 2D nonce system) diff --git a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts index ba9008ed2b..7402138f85 100644 --- a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts @@ -46,9 +46,13 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { } /** - * Build the transaction from configured TIP-20 operations and transaction parameters + * Validate the transaction has all required fields for Tempo AA transactions. + * Overrides parent class validation since AA transactions use a different model + * (operations-based rather than single contract address). + * + * @throws BuildTransactionError if validation fails */ - protected async buildImplementation(): Promise { + validateTransaction(): void { if (this.operations.length === 0) { throw new BuildTransactionError('At least one operation is required to build a transaction'); } @@ -68,6 +72,24 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { if (this._maxPriorityFeePerGas === undefined) { throw new BuildTransactionError('maxPriorityFeePerGas is required to build a transaction'); } + } + + /** + * Build the transaction from configured TIP-20 operations and transaction parameters. + * Validation is performed by validateTransaction() which is called by build() before this method. + */ + protected async buildImplementation(): Promise { + // These checks satisfy TypeScript's type narrowing. + // validateTransaction() already ensures these are defined, but TypeScript + // doesn't track that across method boundaries. + if ( + this._nonce === undefined || + this._gas === undefined || + this._maxFeePerGas === undefined || + this._maxPriorityFeePerGas === undefined + ) { + throw new BuildTransactionError('Transaction validation failed: missing required fields'); + } const calls = this.operations.map((op) => this.operationToCall(op)); @@ -140,6 +162,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { throw new BuildTransactionError(`Invalid gas limit: ${gas}`); } this._gas = gasValue; + this.updateEip1559Fee(); return this; } @@ -155,6 +178,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { throw new BuildTransactionError(`Invalid maxFeePerGas: ${maxFeePerGas}`); } this._maxFeePerGas = feeValue; + this.updateEip1559Fee(); return this; } @@ -170,9 +194,27 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder { throw new BuildTransactionError(`Invalid maxPriorityFeePerGas: ${maxPriorityFeePerGas}`); } this._maxPriorityFeePerGas = feeValue; + this.updateEip1559Fee(); return this; } + /** + * Update the parent class fee structure with EIP-1559 parameters + * @private + */ + private updateEip1559Fee(): void { + if (this._maxFeePerGas !== undefined && this._maxPriorityFeePerGas !== undefined && this._gas !== undefined) { + this.fee({ + fee: this._maxFeePerGas.toString(), + gasLimit: this._gas.toString(), + eip1559: { + maxFeePerGas: this._maxFeePerGas.toString(), + maxPriorityFeePerGas: this._maxPriorityFeePerGas.toString(), + }, + }); + } + } + /** * Get all operations in this transaction * @returns Array of TIP-20 operations diff --git a/modules/sdk-coin-tempo/test/integration/tip20.ts b/modules/sdk-coin-tempo/test/integration/tip20.ts index 1d83621ab6..6f9f37b31f 100644 --- a/modules/sdk-coin-tempo/test/integration/tip20.ts +++ b/modules/sdk-coin-tempo/test/integration/tip20.ts @@ -1,121 +1,213 @@ +/** + * TIP-20 Integration Tests + * + * End-to-end flows for TIP-20 token transactions on Tempo using testnet token addresses. + */ + +import assert from 'assert'; import { describe, it } from 'mocha'; +import { ethers } from 'ethers'; +import { coins } from '@bitgo/statics'; import { Tip20TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { Tip20Transaction } from '../../src/lib/transaction'; import { Address } from '../../src/lib/types'; -import { coins } from '@bitgo/statics'; +import { AA_TRANSACTION_TYPE, TEMPO_CHAIN_IDS } from '../../src/lib/constants'; +import { TESTNET_TOKENS, TX_PARAMS, SIGNATURE_TEST_DATA, TEST_RECIPIENT_ADDRESS } from '../resources/tempo'; const mockCoinConfig = coins.get('ttempo'); +const ALPHA_USD_TOKEN = TESTNET_TOKENS.alphaUSD.address as Address; +const BETA_USD_TOKEN = TESTNET_TOKENS.betaUSD.address as Address; +const THETA_USD_TOKEN = TESTNET_TOKENS.thetaUSD.address as Address; +const RECEIVER_ADDRESS = TEST_RECIPIENT_ADDRESS as Address; + describe('TIP-20 Integration Tests', () => { - const ALPHA_USD_TOKEN = '0x...' as Address; - const BETA_USD_TOKEN = '0x...' as Address; - const THETA_USD_TOKEN = '0x...' as Address; - const RECEIVER_ADDRESS = '0x...' as Address; + describe('Single Transfer', () => { + it('should build and serialize single TIP-20 transfer', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); - describe.skip('Single Transfer', () => { - it('should build single TIP-20 transfer without memo', async () => { + builder + .addOperation({ + token: ALPHA_USD_TOKEN, + to: RECEIVER_ADDRESS, + amount: '1.0', + memo: 'INV-2025-001', + }) + .feeToken(ALPHA_USD_TOKEN) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + + assert.ok(tx instanceof Tip20Transaction); + assert.strictEqual(tx.getOperationCount(), 1); + assert.strictEqual(tx.isBatch(), false); + assert.strictEqual(tx.getOperations()[0].memo, 'INV-2025-001'); + + const serialized = await tx.serialize(); + assert.ok(serialized.startsWith(AA_TRANSACTION_TYPE)); + }); + }); + + describe('Batch Transfer', () => { + it('should build multi-token batch transfer', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '1.0', - }); - builder.feeToken(ALPHA_USD_TOKEN); - // TODO: const tx = await builder.build(); + + builder + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.5', memo: 'multi-alpha' }) + .addOperation({ token: BETA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '2.0', memo: 'multi-beta' }) + .addOperation({ token: THETA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '0.75', memo: 'multi-theta' }) + .feeToken(BETA_USD_TOKEN) + .nonce(10) + .gas(400000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + + assert.strictEqual(tx.getOperationCount(), 3); + assert.strictEqual(tx.isBatch(), true); + assert.strictEqual(tx.getFeeToken(), BETA_USD_TOKEN); + + const operations = tx.getOperations(); + assert.strictEqual(operations[0].token, ALPHA_USD_TOKEN); + assert.strictEqual(operations[1].token, BETA_USD_TOKEN); + assert.strictEqual(operations[2].token, THETA_USD_TOKEN); + }); + }); + + describe('Transaction Signing', () => { + it('should produce unsigned transaction with correct RLP structure', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + builder + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.0' }) + .feeToken(ALPHA_USD_TOKEN) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + const unsignedHex = await tx.serialize(); + + assert.ok(unsignedHex.startsWith(AA_TRANSACTION_TYPE)); + assert.strictEqual(tx.getSignature(), undefined); + + // Verify RLP structure (13 fields for unsigned) + const rlpPart = '0x' + unsignedHex.slice(4); + const decoded = ethers.utils.RLP.decode(rlpPart); + assert.strictEqual(decoded.length, 13); }); - it('should build single TIP-20 transfer with memo', async () => { + it('should apply signature and produce broadcast format', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '1.0', - memo: '12345', - }); - builder.feeToken(ALPHA_USD_TOKEN); - // TODO: const tx = await builder.build(); + + builder + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.0' }) + .feeToken(ALPHA_USD_TOKEN) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .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(); + + // Verify it includes signature (14 fields) + assert.ok(signedHex.startsWith(AA_TRANSACTION_TYPE)); + const rlpPart = '0x' + signedHex.slice(4); + const decoded = ethers.utils.RLP.decode(rlpPart); + assert.strictEqual(decoded.length, 14); + + const storedSig = tx.getSignature(); + assert.ok(storedSig); + assert.strictEqual(storedSig?.r, SIGNATURE_TEST_DATA.validSignature.r); }); }); - describe.skip('Batch Transfer', () => { - it('should build batch transfer with multiple memos', async () => { + describe('Fee Token Selection', () => { + it('should pay fees with different token than transfer', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder - .addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '0.5', - memo: '1001', - }) - .addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '0.3', - memo: '1002', - }) - .addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '0.2', - memo: '1003', - }); - builder.feeToken(ALPHA_USD_TOKEN); - // TODO: const tx = await builder.build(); + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.0' }) + .feeToken(BETA_USD_TOKEN) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + + assert.strictEqual(tx.getOperations()[0].token, ALPHA_USD_TOKEN); + assert.strictEqual(tx.getFeeToken(), BETA_USD_TOKEN); }); - it('should build multi-token batch transfer', async () => { + it('should build transaction without explicit fee token', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder - .addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '1.5', - memo: '2001', - }) - .addOperation({ - token: BETA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '2.0', - memo: '2002', - }) - .addOperation({ - token: THETA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '0.75', - memo: '2003', - }); - builder.feeToken(BETA_USD_TOKEN); - // TODO: const tx = await builder.build(); + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.0' }) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + + assert.strictEqual(tx.getFeeToken(), undefined); + const serialized = await tx.serialize(); + assert.ok(serialized.startsWith(AA_TRANSACTION_TYPE)); }); }); - describe.skip('Transaction Signing', () => { - it('should sign and serialize transaction', async () => { + describe('Transaction JSON', () => { + it('should produce complete JSON representation', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '1.0', - memo: '9999', - }); - builder.feeToken(ALPHA_USD_TOKEN); - // TODO: Implement signing with ethers.js Wallet - // TODO: const tx = await builder.build(); - // TODO: tx.setSignature(signature); - // TODO: const serialized = await tx.toBroadcastFormat(); + + builder + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '123.456', memo: 'json-test' }) + .feeToken(BETA_USD_TOKEN) + .nonce(42) + .gas(150000n) + .maxFeePerGas(3000000000n) + .maxPriorityFeePerGas(1500000000n); + + const tx = (await builder.build()) as Tip20Transaction; + const json = tx.toJson(); + + assert.strictEqual(json.type, AA_TRANSACTION_TYPE); + assert.strictEqual(json.chainId, TEMPO_CHAIN_IDS.TESTNET); + assert.strictEqual(json.nonce, 42); + assert.strictEqual(json.gas, '150000'); + assert.strictEqual(json.feeToken, BETA_USD_TOKEN); + + const ops = json.operations as any[]; + assert.strictEqual(ops[0].token, ALPHA_USD_TOKEN); + assert.strictEqual(ops[0].amount, '123.456'); + assert.strictEqual(ops[0].memo, 'json-test'); }); }); - describe.skip('Fee Token Selection', () => { - it('should pay fees with different token than transfer', async () => { + describe('canBroadcast Check', () => { + it('should return true for valid transaction', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.addOperation({ - token: ALPHA_USD_TOKEN, - to: RECEIVER_ADDRESS, - amount: '1.0', - memo: '5555', - }); - builder.feeToken(BETA_USD_TOKEN); - // TODO: const tx = await builder.build(); + + builder + .addOperation({ token: ALPHA_USD_TOKEN, to: RECEIVER_ADDRESS, amount: '1.0' }) + .feeToken(ALPHA_USD_TOKEN) + .nonce(0) + .gas(TX_PARAMS.defaultGas) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; + assert.strictEqual(tx.canBroadcast(), true); }); }); }); diff --git a/modules/sdk-coin-tempo/test/resources/tempo.ts b/modules/sdk-coin-tempo/test/resources/tempo.ts new file mode 100644 index 0000000000..cc4f26eb2c --- /dev/null +++ b/modules/sdk-coin-tempo/test/resources/tempo.ts @@ -0,0 +1,78 @@ +/** + * Test resources for Tempo SDK + */ + +// ============================================================================ +// TIP-20 Token Addresses (Tempo Testnet) +// ============================================================================ + +export const TESTNET_TOKENS = { + pathUSD: { + address: '0x20c0000000000000000000000000000000000000', + name: 'ttempo:pathusd', + }, + alphaUSD: { + address: '0x20c0000000000000000000000000000000000001', + name: 'ttempo:alphausd', + }, + betaUSD: { + address: '0x20c0000000000000000000000000000000000002', + name: 'ttempo:betausd', + }, + thetaUSD: { + address: '0x20c0000000000000000000000000000000000003', + name: 'ttempo:thetausd', + }, +}; + +// Valid checksummed test recipient address +export const TEST_RECIPIENT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + +// ============================================================================ +// Transaction Parameters +// ============================================================================ + +export const TX_PARAMS = { + defaultGas: BigInt('100000'), + defaultMaxFeePerGas: BigInt('2000000000'), + defaultMaxPriorityFeePerGas: BigInt('1000000000'), +}; + +// ============================================================================ +// Signature Test Data +// ============================================================================ + +export const SIGNATURE_TEST_DATA = { + validSignature: { + r: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as `0x${string}`, + s: '0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321' as `0x${string}`, + yParity: 0, + }, +}; + +// ============================================================================ +// Memo Test Cases +// ============================================================================ + +export const MEMO_TEST_CASES = { + valid: [ + { input: '12345', description: 'numeric' }, + { input: 'INV-001', description: 'alphanumeric with dash' }, + { input: 'a'.repeat(32), description: 'max length 32 bytes' }, + { input: '', description: 'empty string' }, + ], + invalid: [{ input: 'a'.repeat(33), description: '33 bytes - exceeds limit' }], +}; + +// ============================================================================ +// Error Messages +// ============================================================================ + +export const ERROR_MESSAGES = { + noOperations: /At least one operation is required/, + missingNonce: /Nonce is required/, + missingGas: /Gas limit is required/, + missingMaxFeePerGas: /maxFeePerGas is required/, + missingMaxPriorityFeePerGas: /maxPriorityFeePerGas is required/, + memoTooLong: /Memo too long/, +}; diff --git a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts index 171d4f8290..092dcc9608 100644 --- a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import { describe, it } from 'mocha'; import { ethers } from 'ethers'; import { Tip20TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { Tip20Transaction } from '../../src/lib/transaction'; import { amountToTip20Units, tip20UnitsToAmount, @@ -9,8 +10,15 @@ import { encodeTip20TransferWithMemo, isValidTip20Amount, } from '../../src/lib/utils'; -import { TIP20_DECIMALS } from '../../src/lib/constants'; +import { TIP20_DECIMALS, AA_TRANSACTION_TYPE } from '../../src/lib/constants'; import { coins } from '@bitgo/statics'; +import { + TESTNET_TOKENS, + TX_PARAMS, + ERROR_MESSAGES, + SIGNATURE_TEST_DATA, + TEST_RECIPIENT_ADDRESS, +} from '../resources/tempo'; const mockCoinConfig = coins.get('ttempo'); @@ -38,49 +46,28 @@ describe('TIP-20 Utilities', () => { describe('stringToBytes32', () => { it('should convert string to bytes32', () => { const result = stringToBytes32('12345'); - assert.strictEqual(typeof result, 'string'); assert.strictEqual(result.length, 66); assert.ok(result.startsWith('0x')); }); it('should throw error for string longer than 32 bytes', () => { - const longString = 'a'.repeat(33); - assert.throws(() => stringToBytes32(longString), /Memo too long/); + assert.throws(() => stringToBytes32('a'.repeat(33)), /Memo too long/); }); }); describe('encodeTip20TransferWithMemo', () => { it('should encode transferWithMemo call', () => { const to = ethers.utils.getAddress('0x742d35cc6634c0532925a3b844bc9e7595f0beb1'); - const amount = 1500000n; - const memo = '12345'; - - const encoded = encodeTip20TransferWithMemo(to, amount, memo); - assert.strictEqual(typeof encoded, 'string'); + const encoded = encodeTip20TransferWithMemo(to, 1500000n, '12345'); assert.ok(encoded.startsWith('0x')); assert.ok(encoded.length > 10); }); - - it('should encode transferWithMemo without memo', () => { - const to = ethers.utils.getAddress('0x742d35cc6634c0532925a3b844bc9e7595f0beb1'); - const amount = 1500000n; - - const encoded = encodeTip20TransferWithMemo(to, amount); - assert.strictEqual(typeof encoded, 'string'); - assert.ok(encoded.startsWith('0x')); - }); }); describe('isValidTip20Amount', () => { - it('should validate correct amounts', () => { + it('should validate amounts correctly', () => { assert.strictEqual(isValidTip20Amount('1.5'), true); - assert.strictEqual(isValidTip20Amount('100'), true); - assert.strictEqual(isValidTip20Amount('0.000001'), true); - }); - - it('should invalidate incorrect amounts', () => { assert.strictEqual(isValidTip20Amount('invalid'), false); - assert.strictEqual(isValidTip20Amount(''), false); assert.strictEqual(isValidTip20Amount('-5'), false); }); }); @@ -92,207 +79,289 @@ describe('TIP-20 Transaction Builder', () => { const mockFeeToken = ethers.utils.getAddress('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); describe('addOperation', () => { - it('should add a single operation without memo', () => { + it('should add operation with and without memo', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - - builder.addOperation({ - token: mockToken, - to: mockRecipient, - amount: '100.5', - }); + builder.addOperation({ token: mockToken, to: mockRecipient, amount: '100.5' }); + builder.addOperation({ token: mockToken, to: mockRecipient, amount: '50.0', memo: '12345' }); const operations = builder.getOperations(); - assert.strictEqual(operations.length, 1); - assert.strictEqual(operations[0].token, mockToken); - assert.strictEqual(operations[0].to, mockRecipient); - assert.strictEqual(operations[0].amount, '100.5'); + assert.strictEqual(operations.length, 2); assert.strictEqual(operations[0].memo, undefined); + assert.strictEqual(operations[1].memo, '12345'); }); - it('should add a single operation with memo', () => { + it('should throw error for invalid addresses', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - - builder.addOperation({ - token: mockToken, - to: mockRecipient, - amount: '100.5', - memo: '202501', - }); - - const operations = builder.getOperations(); - assert.strictEqual(operations.length, 1); - assert.strictEqual(operations[0].memo, '202501'); + assert.throws( + () => builder.addOperation({ token: '0xinvalid' as any, to: mockRecipient, amount: '100' }), + /Invalid token address/ + ); + assert.throws( + () => builder.addOperation({ token: mockToken, to: '0xinvalid' as any, amount: '100' }), + /Invalid recipient address/ + ); }); - it('should add multiple operations (batch)', () => { + it('should throw error for invalid amount', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws( + () => builder.addOperation({ token: mockToken, to: mockRecipient, amount: 'invalid-amount' }), + /Invalid amount/ + ); + }); - builder - .addOperation({ - token: mockToken, - to: mockRecipient, - amount: '50.0', - memo: '1001', - }) - .addOperation({ - token: mockToken, - to: mockRecipient, - amount: '30.0', - memo: '1002', - }) - .addOperation({ - token: mockToken, - to: mockRecipient, - amount: '20.0', - memo: '1003', - }); - - const operations = builder.getOperations(); - assert.strictEqual(operations.length, 3); - assert.strictEqual(operations[0].memo, '1001'); - assert.strictEqual(operations[1].memo, '1002'); - assert.strictEqual(operations[2].memo, '1003'); + it('should throw error for memo longer than 32 bytes', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws( + () => builder.addOperation({ token: mockToken, to: mockRecipient, amount: '100', memo: 'a'.repeat(33) }), + /Memo too long/ + ); }); + }); - it('should throw error for invalid token address', () => { + describe('feeToken', () => { + it('should set and get fee token', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.feeToken(mockFeeToken); + assert.strictEqual(builder.getFeeToken(), mockFeeToken); + }); - assert.throws(() => { - builder.addOperation({ - token: '0xinvalid' as any, - to: mockRecipient, - amount: '100', - }); - }, /Invalid token address/); + it('should throw error for invalid fee token address', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => builder.feeToken('invalid-address'), /Invalid fee token address/); }); + }); - it('should throw error for invalid recipient address', () => { + describe('Transaction parameters', () => { + it('should set nonce, gas, maxFeePerGas, and maxPriorityFeePerGas', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.nonce(42).gas(500000n).maxFeePerGas(1000000000n).maxPriorityFeePerGas(500000000n); - assert.throws(() => { - builder.addOperation({ - token: mockToken, - to: '0xinvalid' as any, - amount: '100', - }); - }, /Invalid recipient address/); + assert.strictEqual((builder as any)._nonce, 42); + assert.strictEqual((builder as any)._gas, 500000n); + assert.strictEqual((builder as any)._maxFeePerGas, 1000000000n); + assert.strictEqual((builder as any)._maxPriorityFeePerGas, 500000000n); }); - it('should throw error for invalid amount', () => { + it('should throw error for invalid nonce and gas', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - - assert.throws(() => { - builder.addOperation({ - token: mockToken, - to: mockRecipient, - amount: 'invalid-amount', - }); - }, /Invalid amount/); + assert.throws(() => builder.nonce(-1), /Invalid nonce/); + assert.throws(() => builder.gas(0n), /Invalid gas limit/); }); + }); - it('should throw error for memo longer than 32 bytes', () => { + describe('Method chaining', () => { + it('should support fluent interface', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - const longMemo = 'a'.repeat(33); - - assert.throws(() => { - builder.addOperation({ - token: mockToken, - to: mockRecipient, - amount: '100', - memo: longMemo, - }); - }, /Memo too long/); + const result = builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100', memo: '9999' }) + .feeToken(mockFeeToken) + .nonce(10) + .gas(400000n) + .maxFeePerGas(2000000000n) + .maxPriorityFeePerGas(1000000000n); + + assert.strictEqual(result, builder); }); }); +}); - describe('feeToken', () => { - it('should set fee token', () => { +describe('TIP-20 Constants', () => { + it('should have correct decimal places', () => { + assert.strictEqual(TIP20_DECIMALS, 6); + }); +}); + +describe('TIP-20 Transaction Build', () => { + const mockToken = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address); + const mockRecipient = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS); + const mockFeeToken = ethers.utils.getAddress(TESTNET_TOKENS.betaUSD.address); + + describe('Build Transaction', () => { + it('should build single-operation transaction', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100.5', memo: 'test-memo' }) + .feeToken(mockFeeToken) + .nonce(0) + .gas(100000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); - builder.feeToken(mockFeeToken); + const tx = (await builder.build()) as Tip20Transaction; - assert.strictEqual(builder.getFeeToken(), mockFeeToken); + assert.ok(tx instanceof Tip20Transaction); + assert.strictEqual(tx.getOperationCount(), 1); + assert.strictEqual(tx.getFeeToken(), mockFeeToken); }); - it('should throw error for invalid fee token address', () => { + it('should build batch transaction with multiple operations', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: TESTNET_TOKENS.alphaUSD.address, to: mockRecipient, amount: '10.0', memo: 'batch-1' }) + .addOperation({ token: TESTNET_TOKENS.betaUSD.address, to: mockRecipient, amount: '20.0', memo: 'batch-2' }) + .addOperation({ token: TESTNET_TOKENS.thetaUSD.address, to: mockRecipient, amount: '30.0', memo: 'batch-3' }) + .feeToken(mockFeeToken) + .nonce(42) + .gas(300000n) + .maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas) + .maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas); + + const tx = (await builder.build()) as Tip20Transaction; - assert.throws(() => { - builder.feeToken('invalid-address'); - }, /Invalid fee token address/); + assert.ok(tx instanceof Tip20Transaction); + assert.strictEqual(tx.getOperationCount(), 3); + assert.strictEqual(tx.isBatch(), true); }); - }); - describe('Transaction parameters', () => { - it('should set nonce', () => { + it('should build transaction without fee token', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.nonce(42); - assert.strictEqual((builder as any)._nonce, 42); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '50.0' }) + .nonce(1) + .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.getFeeToken(), undefined); }); + }); - it('should throw error for negative nonce', () => { + describe('Build Validation Errors', () => { + it('should throw error when no operations added', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - assert.throws(() => { - builder.nonce(-1); - }, /Invalid nonce/); + builder.nonce(0).gas(100000n).maxFeePerGas(1000000000n).maxPriorityFeePerGas(500000000n); + await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.noOperations); }); - it('should set gas limit', () => { + it('should throw error when nonce not set', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.gas(500000n); - assert.strictEqual((builder as any)._gas, 500000n); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .gas(100000n) + .maxFeePerGas(1000000000n) + .maxPriorityFeePerGas(500000000n); + await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.missingNonce); }); - it('should set gas limit from string', () => { + it('should throw error when gas not set', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.gas('500000'); - assert.strictEqual((builder as any)._gas, 500000n); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .nonce(0) + .maxFeePerGas(1000000000n) + .maxPriorityFeePerGas(500000000n); + await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.missingGas); }); - it('should throw error for invalid gas limit', () => { + it('should throw error when maxFeePerGas not set', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - assert.throws(() => { - builder.gas(0n); - }, /Invalid gas limit/); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .nonce(0) + .gas(100000n) + .maxPriorityFeePerGas(500000000n); + await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.missingMaxFeePerGas); }); - it('should set maxFeePerGas', () => { + it('should throw error when maxPriorityFeePerGas not set', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.maxFeePerGas(1000000000n); - assert.strictEqual((builder as any)._maxFeePerGas, 1000000000n); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100' }) + .nonce(0) + .gas(100000n) + .maxFeePerGas(1000000000n); + await assert.rejects(async () => await builder.build(), ERROR_MESSAGES.missingMaxPriorityFeePerGas); }); + }); + + describe('Round-Trip: Build -> Serialize -> Operations Check', () => { + it('should preserve operation data through build', async () => { + const operations = [{ token: mockToken, to: mockRecipient, amount: '123.456', memo: 'memo-test' }]; - it('should set maxPriorityFeePerGas', () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - builder.maxPriorityFeePerGas(500000000n); - assert.strictEqual((builder as any)._maxPriorityFeePerGas, 500000000n); + builder + .addOperation(operations[0]) + .feeToken(mockFeeToken) + .nonce(10) + .gas(150000n) + .maxFeePerGas(2000000000n) + .maxPriorityFeePerGas(1000000000n); + + const tx = (await builder.build()) as Tip20Transaction; + const txOps = tx.getOperations(); + + assert.strictEqual(txOps.length, 1); + assert.strictEqual(txOps[0].token, operations[0].token); + assert.strictEqual(txOps[0].to, operations[0].to); + assert.strictEqual(txOps[0].amount, operations[0].amount); + assert.strictEqual(txOps[0].memo, operations[0].memo); }); }); - describe('Method chaining', () => { - it('should support fluent interface', () => { + describe('Build -> Serialize -> Sign Flow', () => { + it('should produce serializable unsigned transaction', async () => { const builder = new Tip20TransactionBuilder(mockCoinConfig); - - const result = builder - .addOperation({ - token: mockToken, - to: mockRecipient, - amount: '100', - memo: '9999', - }) + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '50.0', memo: 'serial-test' }) .feeToken(mockFeeToken) - .nonce(10) - .gas(400000n) + .nonce(5) + .gas(100000n) .maxFeePerGas(2000000000n) .maxPriorityFeePerGas(1000000000n); - assert.strictEqual(result, builder); + const tx = (await builder.build()) as Tip20Transaction; + const unsignedHex = await tx.serialize(); + + assert.ok(unsignedHex.startsWith(AA_TRANSACTION_TYPE)); + assert.ok(/^0x[0-9a-f]+$/i.test(unsignedHex)); + }); + + it('should support full sign and broadcast flow', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '75.0', memo: 'full-flow' }) + .feeToken(mockFeeToken) + .nonce(123) + .gas(120000n) + .maxFeePerGas(2500000000n) + .maxPriorityFeePerGas(1200000000n); + + const tx = (await builder.build()) as Tip20Transaction; + const unsignedHex = await tx.serialize(); + + tx.setSignature(SIGNATURE_TEST_DATA.validSignature); + + const broadcastHex = await tx.toBroadcastFormat(); + assert.ok(broadcastHex.startsWith(AA_TRANSACTION_TYPE)); + assert.ok(broadcastHex.length > unsignedHex.length, 'Signed should be longer'); }); }); -}); -describe('TIP-20 Constants', () => { - it('should have correct decimal places', () => { - assert.strictEqual(TIP20_DECIMALS, 6); + describe('Transaction JSON Representation', () => { + it('should produce consistent JSON from built transaction', async () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder + .addOperation({ token: mockToken, to: mockRecipient, amount: '100.0', memo: 'json-test' }) + .feeToken(mockFeeToken) + .nonce(50) + .gas(200000n) + .maxFeePerGas(3000000000n) + .maxPriorityFeePerGas(1500000000n); + + const tx = (await builder.build()) as Tip20Transaction; + const json = tx.toJson(); + + assert.strictEqual(json.type, AA_TRANSACTION_TYPE); + assert.strictEqual(json.nonce, 50); + assert.strictEqual(json.gas, '200000'); + assert.strictEqual(json.maxFeePerGas, '3000000000'); + assert.strictEqual(json.maxPriorityFeePerGas, '1500000000'); + assert.strictEqual(json.callCount, 1); + assert.strictEqual(json.feeToken, mockFeeToken); + }); }); });