From 347c2c6e1edee791387e6af562d09a284ca4a4e2 Mon Sep 17 00:00:00 2001 From: Rakesh Reddy Date: Wed, 31 Dec 2025 10:33:18 +0530 Subject: [PATCH] feat(sdk-coin-tempo): add token transaction builder TICKET: WIN-8479 --- modules/sdk-coin-tempo/package.json | 3 +- modules/sdk-coin-tempo/src/lib/constants.ts | 24 +- modules/sdk-coin-tempo/src/lib/index.ts | 3 + modules/sdk-coin-tempo/src/lib/tip20Abi.ts | 74 +++-- modules/sdk-coin-tempo/src/lib/transaction.ts | 91 ++++++ .../src/lib/transactionBuilder.ts | 229 ++++++++++++++ modules/sdk-coin-tempo/src/lib/types.ts | 17 + modules/sdk-coin-tempo/src/lib/utils.ts | 82 ++++- modules/sdk-coin-tempo/src/tip20Token.ts | 158 ++++++++-- .../sdk-coin-tempo/test/integration/tip20.ts | 121 +++++++ .../test/unit/transactionBuilder.ts | 297 ++++++++++++++++++ yarn.lock | 33 ++ 12 files changed, 1084 insertions(+), 48 deletions(-) create mode 100644 modules/sdk-coin-tempo/src/lib/transaction.ts create mode 100644 modules/sdk-coin-tempo/src/lib/transactionBuilder.ts create mode 100644 modules/sdk-coin-tempo/src/lib/types.ts create mode 100644 modules/sdk-coin-tempo/test/integration/tip20.ts create mode 100644 modules/sdk-coin-tempo/test/unit/transactionBuilder.ts diff --git a/modules/sdk-coin-tempo/package.json b/modules/sdk-coin-tempo/package.json index e1c13e2b77..4ba29bf228 100644 --- a/modules/sdk-coin-tempo/package.json +++ b/modules/sdk-coin-tempo/package.json @@ -43,7 +43,8 @@ "@bitgo/abstract-eth": "^24.19.4", "@bitgo/sdk-core": "^36.25.0", "@bitgo/secp256k1": "^1.8.0", - "@bitgo/statics": "^58.19.0" + "@bitgo/statics": "^58.19.0", + "viem": "^2.21.0" }, "devDependencies": { "@bitgo/sdk-api": "^1.72.2", diff --git a/modules/sdk-coin-tempo/src/lib/constants.ts b/modules/sdk-coin-tempo/src/lib/constants.ts index 72a37182f8..309f8b5aba 100644 --- a/modules/sdk-coin-tempo/src/lib/constants.ts +++ b/modules/sdk-coin-tempo/src/lib/constants.ts @@ -5,5 +5,25 @@ export const MAINNET_COIN = 'tempo'; export const TESTNET_COIN = 'ttempo'; -export const VALID_ADDRESS_REGEX = /^[A-Za-z0-9]+$/; // Update with actual address format -export const VALID_PUBLIC_KEY_REGEX = /^[A-Fa-f0-9]{64}$/; // Update with actual public key format +export const VALID_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; +export const VALID_PUBLIC_KEY_REGEX = /^[A-Fa-f0-9]{64}$/; + +/** + * Tempo Chain IDs + */ +export const TEMPO_CHAIN_IDS = { + TESTNET: 42429, // Andantino testnet + // MAINNET: TBD +} as const; + +/** + * TIP-20 Token Standard + * TIP-20 uses 6 decimals (unlike ERC-20's standard 18 decimals) + */ +export const TIP20_DECIMALS = 6; + +/** + * AA Transaction Type + * Tempo uses EIP-7702 Account Abstraction with transaction type 0x76 + */ +export const AA_TRANSACTION_TYPE = '0x76' as const; diff --git a/modules/sdk-coin-tempo/src/lib/index.ts b/modules/sdk-coin-tempo/src/lib/index.ts index fd30f20d17..828e74be16 100644 --- a/modules/sdk-coin-tempo/src/lib/index.ts +++ b/modules/sdk-coin-tempo/src/lib/index.ts @@ -3,3 +3,6 @@ export * from './utils'; export * from './constants'; export * from './iface'; export * from './tip20Abi'; +export * from './types'; +export * from './transactionBuilder'; +export * from './transaction'; diff --git a/modules/sdk-coin-tempo/src/lib/tip20Abi.ts b/modules/sdk-coin-tempo/src/lib/tip20Abi.ts index d373ba3a23..8aa7a6f5b7 100644 --- a/modules/sdk-coin-tempo/src/lib/tip20Abi.ts +++ b/modules/sdk-coin-tempo/src/lib/tip20Abi.ts @@ -1,32 +1,60 @@ /** - * TIP20 Token Standard ABI (Skeleton) + * TIP-20 Token Standard ABI * - * TODO: Update this file when TIP20 ABI becomes available + * TIP-20 is Tempo's token standard, similar to ERC-20 but with: + * - 6 decimal places (instead of 18) + * - transferWithMemo function for attaching metadata */ /** - * Placeholder TIP20 ABI - * This is an empty array that should be replaced with the actual ABI + * TIP-20 transferWithMemo ABI + * Standard function for TIP-20 token transfers with memo field */ -export const TIP20_ABI = [] as const; +export const TIP20_TRANSFER_WITH_MEMO_ABI = [ + { + type: 'function', + name: 'transferWithMemo', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'memo', type: 'bytes32' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const; /** - * Placeholder for TIP20 Factory ABI + * Standard TIP-20 token ABI (similar to ERC-20) */ -export const TIP20_FACTORY_ABI = [] as const; - -/** - * Get the method signature for TIP20 transfer - * TODO: Update with actual method name if different from ERC20 - */ -export function getTip20TransferSignature(): string { - return 'transfer(address,uint256)'; -} - -/** - * Get the method signature for TIP20 transferFrom - * TODO: Update with actual method name if different from ERC20 - */ -export function getTip20TransferFromSignature(): string { - return 'transferFrom(address,address,uint256)'; -} +export const TIP20_ABI = [ + { + type: 'function', + name: 'balanceOf', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transferFrom', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, + ...TIP20_TRANSFER_WITH_MEMO_ABI, +] as const; diff --git a/modules/sdk-coin-tempo/src/lib/transaction.ts b/modules/sdk-coin-tempo/src/lib/transaction.ts new file mode 100644 index 0000000000..fc44d73bbb --- /dev/null +++ b/modules/sdk-coin-tempo/src/lib/transaction.ts @@ -0,0 +1,91 @@ +/** + * TIP-20 Transaction + * + * Represents a Tempo Account Abstraction (AA) transaction (type 0x76) + * Supports single or batch TIP-20 token transfers with memos + */ + +import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import type { Address, Hex } from 'viem'; +import { Tip20Operation } from './types'; + +export class Tip20Transaction extends BaseTransaction { + private txRequest: any; + private _operations: Tip20Operation[]; + private _signature?: { r: Hex; s: Hex; yParity: number }; + + constructor(_coinConfig: Readonly, request: any, operations: Tip20Operation[] = []) { + super(_coinConfig); + this.txRequest = request; + this._operations = operations; + } + + get type(): TransactionType { + return TransactionType.Send; + } + + canSign(): boolean { + return true; + } + + async serialize(signature?: { r: Hex; s: Hex; yParity: number }): Promise { + // TODO: Implement viem EIP-7702 transaction serialization + throw new ParseTransactionError('Transaction serialization not yet implemented'); + } + + getOperations(): Tip20Operation[] { + return [...this._operations]; + } + + getFeeToken(): Address | undefined { + return this.txRequest.feeToken; + } + + getOperationCount(): number { + return this.txRequest.calls.length; + } + + isBatch(): boolean { + return this.txRequest.calls.length > 1; + } + + setSignature(signature: { r: Hex; s: Hex; yParity: number }): void { + this._signature = signature; + } + + getSignature(): { r: Hex; s: Hex; yParity: number } | undefined { + return this._signature; + } + + toJson(): Record { + return { + type: this.txRequest.type, + chainId: this.txRequest.chainId, + nonce: this.txRequest.nonce, + maxFeePerGas: this.txRequest.maxFeePerGas.toString(), + maxPriorityFeePerGas: this.txRequest.maxPriorityFeePerGas.toString(), + gas: this.txRequest.gas.toString(), + callCount: this.txRequest.calls.length, + feeToken: this.txRequest.feeToken, + operations: this._operations, + signature: this._signature, + }; + } + + async toBroadcastFormat(): Promise { + return await this.serialize(this._signature); + } + + get id(): string { + return 'pending'; + } + + toString(): string { + return JSON.stringify(this.toJson(), null, 2); + } + + canBroadcast(): boolean { + return this.txRequest.calls.length > 0 && this.txRequest.chainId > 0; + } +} diff --git a/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts new file mode 100644 index 0000000000..df8672ca78 --- /dev/null +++ b/modules/sdk-coin-tempo/src/lib/transactionBuilder.ts @@ -0,0 +1,229 @@ +/** + * TIP-20 Transaction Builder + * + * Unified builder for TIP-20 transactions supporting: + * - Single or batch operations + * - Per-operation memos for tracking + * - Custom fee token selection + * - EIP-7702 Account Abstraction (type 0x76) + */ + +import { TransactionBuilder as AbstractTransactionBuilder, TransferBuilder } from '@bitgo/abstract-eth'; +import { BaseTransaction, BuildTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import type { Address, Hex } from 'viem'; +import { Tip20Operation } from './types'; +import { Tip20Transaction } from './transaction'; +import { amountToTip20Units, encodeTip20TransferWithMemo, isValidAddress, isValidTip20Amount } from './utils'; +import { AA_TRANSACTION_TYPE } from './constants'; + +/** + * Transaction Builder for TIP-20 tokens on Tempo blockchain + * Extends abstract-eth TransactionBuilder with Tempo-specific features + */ +export class Tip20TransactionBuilder extends AbstractTransactionBuilder { + private operations: Tip20Operation[] = []; + private _feeToken?: Address; + private _nonce?: number; + private _gas?: bigint; + private _maxFeePerGas?: bigint; + private _maxPriorityFeePerGas?: bigint; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** + * Implement the transfer method from abstract class + * Note: For TIP-20 transactions, use addOperation() instead + */ + transfer(data?: string, isFirstSigner?: boolean): TransferBuilder { + const transferBuilder = new TransferBuilder(undefined, isFirstSigner); + if (data) { + transferBuilder.data(data); + } + this._transfer = transferBuilder; + return transferBuilder; + } + + /** + * Initialize the transaction builder + */ + protected async buildImplementation(): Promise { + if (this.operations.length === 0) { + throw new BuildTransactionError('At least one operation is required to build a transaction'); + } + + if (this._nonce === undefined) { + throw new BuildTransactionError('Nonce is required to build a transaction'); + } + + if (this._gas === undefined) { + throw new BuildTransactionError('Gas limit is required to build a transaction'); + } + + if (this._maxFeePerGas === undefined) { + throw new BuildTransactionError('maxFeePerGas is required to build a transaction'); + } + + if (this._maxPriorityFeePerGas === undefined) { + throw new BuildTransactionError('maxPriorityFeePerGas is required to build a transaction'); + } + + const calls = this.operations.map((op) => this.operationToCall(op)); + + const txRequest = { + type: AA_TRANSACTION_TYPE, + chainId: this._common.chainIdBN().toNumber(), + nonce: this._nonce, + maxFeePerGas: this._maxFeePerGas, + maxPriorityFeePerGas: this._maxPriorityFeePerGas, + gas: this._gas, + calls, + accessList: [], + feeToken: this._feeToken, + }; + + return new Tip20Transaction(this._coinConfig, txRequest, this.operations); + } + + /** + * Add a single operation to the transaction + * Can be called multiple times to create batch transactions + * + * @param operation - TIP-20 operation with token, recipient, amount, and optional memo + * @returns this builder instance for chaining + */ + addOperation(operation: Tip20Operation): this { + this.validateOperation(operation); + this.operations.push(operation); + return this; + } + + /** + * Set which TIP-20 token will be used to pay transaction fees + * This is a global setting for the entire transaction + * + * @param tokenAddress - Address of the TIP-20 token to use for fees + * @returns this builder instance for chaining + */ + feeToken(tokenAddress: string): this { + if (!isValidAddress(tokenAddress)) { + throw new BuildTransactionError(`Invalid fee token address: ${tokenAddress}`); + } + this._feeToken = tokenAddress as Address; + return this; + } + + /** + * Set the transaction nonce + * + * @param nonce - Transaction nonce + * @returns this builder instance for chaining + */ + nonce(nonce: number): this { + if (nonce < 0) { + throw new BuildTransactionError(`Invalid nonce: ${nonce}`); + } + this._nonce = nonce; + return this; + } + + /** + * Set the gas limit for the transaction + * + * @param gas - Gas limit + * @returns this builder instance for chaining + */ + gas(gas: string | bigint): this { + const gasValue = typeof gas === 'string' ? BigInt(gas) : gas; + if (gasValue <= 0n) { + throw new BuildTransactionError(`Invalid gas limit: ${gas}`); + } + this._gas = gasValue; + return this; + } + + /** + * Set the maximum fee per gas (EIP-1559) + * + * @param maxFeePerGas - Maximum fee per gas in wei + * @returns this builder instance for chaining + */ + maxFeePerGas(maxFeePerGas: string | bigint): this { + const feeValue = typeof maxFeePerGas === 'string' ? BigInt(maxFeePerGas) : maxFeePerGas; + if (feeValue < 0n) { + throw new BuildTransactionError(`Invalid maxFeePerGas: ${maxFeePerGas}`); + } + this._maxFeePerGas = feeValue; + return this; + } + + /** + * Set the maximum priority fee per gas (EIP-1559) + * + * @param maxPriorityFeePerGas - Maximum priority fee per gas in wei + * @returns this builder instance for chaining + */ + maxPriorityFeePerGas(maxPriorityFeePerGas: string | bigint): this { + const feeValue = typeof maxPriorityFeePerGas === 'string' ? BigInt(maxPriorityFeePerGas) : maxPriorityFeePerGas; + if (feeValue < 0n) { + throw new BuildTransactionError(`Invalid maxPriorityFeePerGas: ${maxPriorityFeePerGas}`); + } + this._maxPriorityFeePerGas = feeValue; + return this; + } + + /** + * Get all operations in this transaction + * @returns Array of TIP-20 operations + */ + getOperations(): Tip20Operation[] { + return [...this.operations]; + } + + /** + * Get the fee token address if set + * @returns Fee token address or undefined + */ + getFeeToken(): Address | undefined { + return this._feeToken; + } + + /** + * Validate a single operation + * @param operation - Operation to validate + * @throws BuildTransactionError if invalid + */ + private validateOperation(operation: Tip20Operation): void { + if (!isValidAddress(operation.token)) { + throw new BuildTransactionError(`Invalid token address: ${operation.token}`); + } + + if (!isValidAddress(operation.to)) { + throw new BuildTransactionError(`Invalid recipient address: ${operation.to}`); + } + + if (!isValidTip20Amount(operation.amount)) { + throw new BuildTransactionError(`Invalid amount: ${operation.amount}`); + } + + if (operation.memo && operation.memo.length > 32) { + throw new BuildTransactionError(`Memo too long: ${operation.memo.length} bytes. Maximum 32 bytes.`); + } + } + + /** + * Convert a TIP-20 operation to an AA call + */ + private operationToCall(op: Tip20Operation): { to: Address; data: Hex; value: bigint } { + const amountInUnits = amountToTip20Units(op.amount); + const data = encodeTip20TransferWithMemo(op.to, amountInUnits, op.memo); + + return { + to: op.token, + data, + value: 0n, + }; + } +} diff --git a/modules/sdk-coin-tempo/src/lib/types.ts b/modules/sdk-coin-tempo/src/lib/types.ts new file mode 100644 index 0000000000..d453c329cb --- /dev/null +++ b/modules/sdk-coin-tempo/src/lib/types.ts @@ -0,0 +1,17 @@ +import type { Address, Hex, TransactionSerializedEIP7702 } from 'viem'; + +/** + * TIP-20 Operation with optional memo + * Represents a single transfer operation in a transaction + */ +export interface Tip20Operation { + token: Address; + to: Address; + amount: string; + memo?: string; +} + +/** + * Re-export viem types for convenience + */ +export type { Address, Hex, TransactionSerializedEIP7702 }; diff --git a/modules/sdk-coin-tempo/src/lib/utils.ts b/modules/sdk-coin-tempo/src/lib/utils.ts index 131462d4a2..1a14689fac 100644 --- a/modules/sdk-coin-tempo/src/lib/utils.ts +++ b/modules/sdk-coin-tempo/src/lib/utils.ts @@ -2,11 +2,12 @@ * Tempo Utility Functions * * Since Tempo is EVM-compatible, we can reuse Ethereum utilities - */ import { bip32 } from '@bitgo/secp256k1'; -import { VALID_ADDRESS_REGEX } from './constants'; +import { parseUnits, formatUnits, encodeFunctionData, pad, toHex, type Address, type Hex } from 'viem'; +import { VALID_ADDRESS_REGEX, TIP20_DECIMALS } from './constants'; +import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi'; /** * Check if address is valid Ethereum-style address @@ -51,10 +52,87 @@ export function isValidPrivateKey(privateKey: string): boolean { } } +/** + * TIP-20 Utility Functions + */ + +/** + * Convert human-readable amount to TIP-20 units (6 decimals) + * @param amount - Human-readable amount (e.g., "1.5") + * @returns Amount in TIP-20 smallest units as bigint + * @example amountToTip20Units("1.5") => 1500000n + */ +export function amountToTip20Units(amount: string): bigint { + try { + return parseUnits(amount, TIP20_DECIMALS); + } catch (error) { + throw new Error(`Invalid amount format: ${amount}. Expected decimal string.`); + } +} + +/** + * Convert TIP-20 units (6 decimals) to human-readable amount + * @param units - Amount in TIP-20 smallest units + * @returns Human-readable amount string + * @example tip20UnitsToAmount(1500000n) => "1.5" + */ +export function tip20UnitsToAmount(units: bigint): string { + return formatUnits(units, TIP20_DECIMALS); +} + +/** + * Convert string to bytes32 for memo field + * @param memo - Memo string to encode + * @returns Hex-encoded bytes32 value + * @example stringToBytes32("INVOICE-001") => "0x494e564f4943452d30303100..." + */ +export function stringToBytes32(memo: string): Hex { + if (memo.length > 32) { + throw new Error(`Memo too long: ${memo.length} bytes. Maximum 32 bytes.`); + } + return pad(toHex(memo), { size: 32 }); +} + +/** + * Encode TIP-20 transferWithMemo function call using viem + * @param to - Recipient address + * @param amount - Amount in TIP-20 units (bigint) + * @param memo - Optional memo string + * @returns Encoded function call data + */ +export function encodeTip20TransferWithMemo(to: Address, amount: bigint, memo?: string): Hex { + const memoBytes = memo ? stringToBytes32(memo) : pad('0x', { size: 32 }); + + return encodeFunctionData({ + abi: TIP20_TRANSFER_WITH_MEMO_ABI, + functionName: 'transferWithMemo', + args: [to, amount, memoBytes], + }); +} + +/** + * Validate TIP-20 amount format + * @param amount - Amount string to validate + * @returns true if valid, false otherwise + */ +export function isValidTip20Amount(amount: string): boolean { + try { + const parsed = parseUnits(amount, TIP20_DECIMALS); + return parsed >= 0n; + } catch { + return false; + } +} + const utils = { isValidAddress, isValidPublicKey, isValidPrivateKey, + amountToTip20Units, + tip20UnitsToAmount, + stringToBytes32, + encodeTip20TransferWithMemo, + isValidTip20Amount, }; export default utils; diff --git a/modules/sdk-coin-tempo/src/tip20Token.ts b/modules/sdk-coin-tempo/src/tip20Token.ts index e1e022798a..69431314d4 100644 --- a/modules/sdk-coin-tempo/src/tip20Token.ts +++ b/modules/sdk-coin-tempo/src/tip20Token.ts @@ -4,7 +4,9 @@ import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; import { coins } from '@bitgo/statics'; import { GetSendMethodArgsOptions, SendMethodArgs } from '@bitgo/abstract-eth'; +import type { Address } from 'viem'; import { Tempo } from './tempo'; +import { encodeTip20TransferWithMemo, amountToTip20Units, isValidAddress, isValidTip20Amount } from './lib/utils'; /** * TIP20 Token Configuration Interface @@ -122,48 +124,164 @@ export class Tip20Token extends Tempo { } /** - * Placeholder: Verify coin and token match - * TODO: Implement when transaction logic is added + * Verify that the transaction coin/token matches this token + * @param txPrebuild - Transaction prebuild object + * @returns true if valid, false otherwise */ - verifyCoin(txPrebuild: unknown): boolean { - return true; + verifyCoin(txPrebuild: { coin?: string; token?: string }): boolean { + if (!txPrebuild) { + return false; + } + + // Check if the coin or token matches this token's configuration + const coinMatch = txPrebuild.coin === this.tokenConfig.coin || txPrebuild.coin === this.tokenConfig.type; + const tokenMatch = + !txPrebuild.token || + txPrebuild.token === this.tokenConfig.tokenContractAddress || + txPrebuild.token === this.tokenConfig.type; + + return coinMatch && tokenMatch; } /** - * Placeholder: Get send method arguments - * TODO: Implement for token transfers + * Get send method arguments for TIP-20 token transfer with memo + * @param txInfo - Transaction information including recipient and amount + * @returns Array of send method arguments for ABI encoding */ getSendMethodArgs(txInfo: GetSendMethodArgsOptions): SendMethodArgs[] { - // TODO: Implement for token transfers - // Return empty array to prevent downstream services from breaking - return []; + const { recipient } = txInfo; + + if (!recipient) { + throw new Error('Recipient is required for token transfer'); + } + + if (!isValidAddress(recipient.address)) { + throw new Error(`Invalid recipient address: ${recipient.address}`); + } + + if (!isValidTip20Amount(recipient.amount)) { + throw new Error(`Invalid amount: ${recipient.amount}`); + } + + const memo = (recipient as { memo?: string }).memo; + const amountInUnits = amountToTip20Units(recipient.amount); + const data = encodeTip20TransferWithMemo(recipient.address as Address, amountInUnits, memo); + + return [ + { + name: 'toAddress', + type: 'address', + value: recipient.address, + }, + { + name: 'value', + type: 'uint', + value: recipient.amount, + }, + { + name: 'tokenContractAddress', + type: 'address', + value: this.tokenConfig.tokenContractAddress, + }, + { + name: 'data', + type: 'bytes', + value: Buffer.from(data.slice(2), 'hex'), + }, + { + name: 'expireTime', + type: 'uint', + value: txInfo.expireTime, + }, + { + name: 'sequenceId', + type: 'uint', + value: txInfo.contractSequenceId, + }, + { + name: 'signature', + type: 'bytes', + value: Buffer.from(txInfo.signature.replace('0x', ''), 'hex'), + }, + ]; } /** - * Placeholder: Get operation for token transfer - * TODO: Implement for token transfers + * Get operation object for TIP-20 token transfer (for batch transactions) + * @param recipient - Recipient information with address, amount, and optional memo + * @param expireTime - Transaction expiration time (not used in Tempo AA transactions) + * @param contractSequenceId - Contract sequence ID (not used in Tempo AA transactions) + * @returns Operation array for ABI encoding */ getOperation( - recipient: { address: string; amount: string }, + recipient: { address: string; amount: string; memo?: string }, expireTime: number, contractSequenceId: number ): (string | Buffer)[][] { - // TODO: Implement for token transfers - // Return empty array to prevent downstream services from breaking - return []; + if (!isValidAddress(recipient.address)) { + throw new Error(`Invalid recipient address: ${recipient.address}`); + } + + if (!isValidTip20Amount(recipient.amount)) { + throw new Error(`Invalid amount: ${recipient.amount}`); + } + + const amountInUnits = amountToTip20Units(recipient.amount); + const data = encodeTip20TransferWithMemo(recipient.address as Address, amountInUnits, recipient.memo); + + // Return format compatible with parent class for ABI encoding + return [ + ['address', 'bytes'], + [this.tokenConfig.tokenContractAddress, Buffer.from(data.slice(2), 'hex')], + ]; } /** - * Placeholder: Query token balance - * TODO: Implement using Tempo block explorer or RPC + * Query token balance for an address + * @param tokenContractAddress - Token contract address + * @param walletAddress - Wallet address to query + * @param _apiKey - Optional API key for RPC access (reserved for future use) + * @returns Balance as a string in human-readable format (6 decimals) + * + * Note: This requires access to Tempo RPC or block explorer API + * TODO: Implement using viem publicClient when RPC details are available */ async queryAddressTokenBalance( tokenContractAddress: string, walletAddress: string, - apiKey?: string + _apiKey?: string ): Promise { - // TODO: Implement using Tempo block explorer or RPC - // Return 0 balance to prevent downstream services from breaking + // Validate inputs + if (!isValidAddress(tokenContractAddress)) { + throw new Error(`Invalid token contract address: ${tokenContractAddress}`); + } + + if (!isValidAddress(walletAddress)) { + throw new Error(`Invalid wallet address: ${walletAddress}`); + } + + // TODO: Implement actual balance query using viem publicClient + // TODO: Use _apiKey when RPC access is implemented + // Example implementation: + // ```typescript + // const publicClient = createPublicClient({ + // chain: tempo({ feeToken: tokenContractAddress }), + // transport: http(TEMPO_RPC_URL) + // }); + // + // const balance = await publicClient.readContract({ + // address: tokenContractAddress as Address, + // abi: TIP20_ABI, + // functionName: 'balanceOf', + // args: [walletAddress as Address], + // }); + // + // return tip20UnitsToAmount(balance as bigint); + // ``` + + // For now, return '0' as placeholder + // This should be implemented when RPC endpoint configuration is available + console.warn('queryAddressTokenBalance not yet implemented - returning 0 balance'); return '0'; } } diff --git a/modules/sdk-coin-tempo/test/integration/tip20.ts b/modules/sdk-coin-tempo/test/integration/tip20.ts new file mode 100644 index 0000000000..51aa0dec60 --- /dev/null +++ b/modules/sdk-coin-tempo/test/integration/tip20.ts @@ -0,0 +1,121 @@ +import { describe, it } from 'mocha'; +import { Tip20TransactionBuilder } from '../../src/lib/transactionBuilder'; +import type { Address } from 'viem'; +import { coins } from '@bitgo/statics'; + +const mockCoinConfig = coins.get('ttempo'); + +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.skip('Single Transfer', () => { + it('should build single TIP-20 transfer without memo', 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(); + }); + + it('should build single TIP-20 transfer with memo', 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(); + }); + }); + + describe.skip('Batch Transfer', () => { + it('should build batch transfer with multiple memos', 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(); + }); + + it('should build multi-token batch transfer', 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(); + }); + }); + + describe.skip('Transaction Signing', () => { + it('should sign and serialize transaction', 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 viem privateKeyToAccount + // TODO: const tx = await builder.build(); + // TODO: tx.setSignature(signature); + // TODO: const serialized = await tx.toBroadcastFormat(); + }); + }); + + describe.skip('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: '1.0', + memo: '5555', + }); + builder.feeToken(BETA_USD_TOKEN); + // TODO: const tx = await builder.build(); + }); + }); +}); diff --git a/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts new file mode 100644 index 0000000000..9debf8ae42 --- /dev/null +++ b/modules/sdk-coin-tempo/test/unit/transactionBuilder.ts @@ -0,0 +1,297 @@ +import assert from 'assert'; +import { describe, it } from 'mocha'; +import { Tip20TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { + amountToTip20Units, + tip20UnitsToAmount, + stringToBytes32, + encodeTip20TransferWithMemo, + isValidTip20Amount, +} from '../../src/lib/utils'; +import { TIP20_DECIMALS } from '../../src/lib/constants'; +import { coins } from '@bitgo/statics'; + +const mockCoinConfig = coins.get('ttempo'); + +describe('TIP-20 Utilities', () => { + describe('amountToTip20Units', () => { + it('should convert decimal amount to 6-decimal units', () => { + assert.strictEqual(amountToTip20Units('1.5'), 1500000n); + assert.strictEqual(amountToTip20Units('100'), 100000000n); + assert.strictEqual(amountToTip20Units('0.000001'), 1n); + }); + + it('should throw error for invalid amount', () => { + assert.throws(() => amountToTip20Units('invalid'), /Invalid amount format/); + }); + }); + + describe('tip20UnitsToAmount', () => { + it('should convert 6-decimal units to human-readable amount', () => { + assert.strictEqual(tip20UnitsToAmount(1500000n), '1.5'); + assert.strictEqual(tip20UnitsToAmount(100000000n), '100'); + assert.strictEqual(tip20UnitsToAmount(1n), '0.000001'); + }); + }); + + 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/); + }); + }); + + describe('encodeTip20TransferWithMemo', () => { + it('should encode transferWithMemo call', () => { + const to = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const amount = 1500000n; + const memo = '12345'; + + const encoded = encodeTip20TransferWithMemo(to, amount, memo); + assert.strictEqual(typeof encoded, 'string'); + assert.ok(encoded.startsWith('0x')); + assert.ok(encoded.length > 10); + }); + + it('should encode transferWithMemo without memo', () => { + const to = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + 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', () => { + 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); + }); + }); +}); + +describe('TIP-20 Transaction Builder', () => { + const mockToken = '0x1234567890123456789012345678901234567890'; + const mockRecipient = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockFeeToken = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + + describe('addOperation', () => { + it('should add a single operation without memo', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + builder.addOperation({ + token: mockToken, + to: mockRecipient, + amount: '100.5', + }); + + 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[0].memo, undefined); + }); + + it('should add a single operation with memo', () => { + 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'); + }); + + it('should add multiple operations (batch)', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + 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 invalid token address', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + assert.throws(() => { + builder.addOperation({ + token: '0xinvalid' as any, + to: mockRecipient, + amount: '100', + }); + }, /Invalid token address/); + }); + + it('should throw error for invalid recipient address', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + assert.throws(() => { + builder.addOperation({ + token: mockToken, + to: '0xinvalid' as any, + amount: '100', + }); + }, /Invalid recipient address/); + }); + + 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/); + }); + + it('should throw error for memo longer than 32 bytes', () => { + 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/); + }); + }); + + describe('feeToken', () => { + it('should set fee token', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + builder.feeToken(mockFeeToken); + + assert.strictEqual(builder.getFeeToken(), mockFeeToken); + }); + + it('should throw error for invalid fee token address', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + assert.throws(() => { + builder.feeToken('invalid-address'); + }, /Invalid fee token address/); + }); + }); + + describe('Transaction parameters', () => { + it('should set nonce', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.nonce(42); + assert.strictEqual((builder as any)._nonce, 42); + }); + + it('should throw error for negative nonce', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => { + builder.nonce(-1); + }, /Invalid nonce/); + }); + + it('should set gas limit', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.gas(500000n); + assert.strictEqual((builder as any)._gas, 500000n); + }); + + it('should set gas limit from string', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.gas('500000'); + assert.strictEqual((builder as any)._gas, 500000n); + }); + + it('should throw error for invalid gas limit', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + assert.throws(() => { + builder.gas(0n); + }, /Invalid gas limit/); + }); + + it('should set maxFeePerGas', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.maxFeePerGas(1000000000n); + assert.strictEqual((builder as any)._maxFeePerGas, 1000000000n); + }); + + it('should set maxPriorityFeePerGas', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + builder.maxPriorityFeePerGas(500000000n); + assert.strictEqual((builder as any)._maxPriorityFeePerGas, 500000000n); + }); + }); + + describe('Method chaining', () => { + it('should support fluent interface', () => { + const builder = new Tip20TransactionBuilder(mockCoinConfig); + + 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('TIP-20 Constants', () => { + it('should have correct decimal places', () => { + assert.strictEqual(TIP20_DECIMALS, 6); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0488b34f9e..f59a2969ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6957,6 +6957,11 @@ abitype@1.1.0, abitype@^1.0.6, abitype@^1.0.9: resolved "https://registry.npmjs.org/abitype/-/abitype-1.1.0.tgz" integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== +abitype@1.2.3, abitype@^1.2.3: + version "1.2.3" + resolved "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz#bec3e09dea97d99ef6c719140bee663a329ad1f4" + integrity sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg== + accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" @@ -16353,6 +16358,20 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +ox@0.11.1: + version "0.11.1" + resolved "https://registry.npmjs.org/ox/-/ox-0.11.1.tgz#ee1d1b88e794533db244e90bf7fb3863293169f3" + integrity sha512-1l1gOLAqg0S0xiN1dH5nkPna8PucrZgrIJOfS49MLNiMevxu07Iz4ZjuJS9N+xifvT+PsZyIptS7WHM8nC+0+A== + dependencies: + "@adraffy/ens-normalize" "^1.11.0" + "@noble/ciphers" "^1.3.0" + "@noble/curves" "1.9.1" + "@noble/hashes" "^1.8.0" + "@scure/bip32" "^1.7.0" + "@scure/bip39" "^1.6.0" + abitype "^1.2.3" + eventemitter3 "5.0.1" + ox@0.9.3: version "0.9.3" resolved "https://registry.npmjs.org/ox/-/ox-0.9.3.tgz" @@ -20851,6 +20870,20 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +viem@^2.21.0: + version "2.43.3" + resolved "https://registry.npmjs.org/viem/-/viem-2.43.3.tgz#fa3e089f8f094db0be7a9218e8e6d6a7d40ee074" + integrity sha512-zM251fspfSjENCtfmT7cauuD+AA/YAlkFU7cksdEQJxj7wDuO0XFRWRH+RMvfmTFza88B9kug5cKU+Wk2nAjJg== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.2.3" + isows "1.0.7" + ox "0.11.1" + ws "8.18.3" + viem@^2.21.45: version "2.37.2" resolved "https://registry.npmjs.org/viem/-/viem-2.37.2.tgz"