diff --git a/package.json b/package.json index b1d3fdb..5303036 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "@superdapp/agents", "version": "1.0.0", "description": "SuperDapp AI Agents SDK and CLI for Node.js/TypeScript", - "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { diff --git a/src/__tests__/payouts-integration.test.ts b/src/__tests__/payouts-integration.test.ts new file mode 100644 index 0000000..a7f608d --- /dev/null +++ b/src/__tests__/payouts-integration.test.ts @@ -0,0 +1,228 @@ +/** + * Integration Tests for Payouts Module + * + * Tests the complete payout flow: buildManifest → toCSV → preparePushTxs → executeTxPlan → reconcilePush + * Verifies that all functions can be imported from the package root. + */ + +import { + buildManifest, + toCSV, + preparePushTxs, + executeTxPlan, + reconcilePush, + TokenInfo, + WinnerRow, + ExecuteOptions, +} from '../index'; + +interface MockTransaction { + to: string; + value: string; + data: string; + gasLimit: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce: number; + chainId: number; + type?: number; +} + +// Mock viem types for testing +interface MockWalletClient { + sendTransaction: (tx: MockTransaction) => Promise<`0x${string}`>; +} + +interface MockPublicClient { + waitForTransactionReceipt: (options: { hash: `0x${string}`; confirmations: number }) => Promise<{ status: 'success' | 'reverted' }>; + getTransactionReceipt: (options: { hash: `0x${string}` }) => Promise<{ + status: 'success' | 'reverted'; + logs: Array<{ + topics: string[]; + data: string; + }>; + }>; +} + +describe('Payouts Integration', () => { + const mockToken: TokenInfo = { + address: '0xa0b86A33e6441e7344C2C3Dd84A1ba8F3894e5D8', // USDC on Ethereum (properly checksummed) + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + chainId: 1, + isNative: false, + }; + + const mockWinners: WinnerRow[] = [ + { + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // vitalik.eth (properly checksummed) + amount: '100', + rank: 1, + id: 'winner-1', + }, + { + address: 'invalid-address', // Invalid address for testing + amount: '50', + rank: 2, + id: 'winner-2', + }, + { + address: '0x742D35Cc6584c0532e47a89c9Fdd3d3F8c6c1B66', // Another valid address (properly checksummed) + amount: '25', + rank: 3, + id: 'winner-3', + }, + ]; + + const createMockWalletClient = (): MockWalletClient => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async sendTransaction(_tx: MockTransaction): Promise<`0x${string}`> { + // Simulate successful transaction with proper 64-character hash + const hash = '0x' + '1234567890abcdef'.repeat(4); // Creates exactly 64 hex chars + return hash as `0x${string}`; + }, + }); + + const createMockPublicClient = (): MockPublicClient => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async waitForTransactionReceipt(_options: { hash: `0x${string}`; confirmations: number }) { + return { status: 'success' as const }; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getTransactionReceipt(_options: { hash: `0x${string}` }) { + return { + status: 'success' as const, + logs: [ + { + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', // Transfer event + '0x000000000000000000000000742d35cc6584c0532e47a89c9fdd3d3f8c6c1b66', // from + '0x000000000000000000000000742d35cc6584c0532e47a89c9fdd3d3f8c6c1b66', // to (winner address) + ], + data: '0x0000000000000000000000000000000000000000000000000000000000000064', // amount (100 in hex) + }, + ], + }; + }, + }); + + test('should complete full payout flow with happy path', async () => { + // Step 1: Build manifest + const buildResult = buildManifest(mockWinners, { + token: mockToken, + roundId: 'round-123', + groupId: 'group-456', + }); + + expect(buildResult.manifest).toBeDefined(); + expect(buildResult.manifest.winners.length).toBeGreaterThan(0); + expect(buildResult.rejectedAddresses.length).toBeGreaterThan(0); // One invalid address + + // Step 2: Export to CSV + const csvData = toCSV(buildResult.manifest); + + expect(csvData).toContain('address,amountWei,symbol'); + expect(csvData.toLowerCase()).toContain('0x742d35cc6584c0532e47a89c9fdd3d3f8c6c1b66'); + expect(csvData).toContain('USDC'); + + // Step 3: Prepare push transactions + const preparedPayout = preparePushTxs(buildResult.manifest, { + token: mockToken, + maxPerBatch: 2, + singleApproval: true, + airdrop: '0x2aACce8B9522F81F14834883198645BB6894Bfc0', // Provide a valid airdrop address + }); + + expect(preparedPayout.manifestId).toBe(buildResult.manifest.id); + expect(preparedPayout.validation.isValid).toBe(true); + expect(preparedPayout.transactions.length).toBeGreaterThan(0); + + // Step 4: Execute transaction plan (mocked) + const mockWallet = createMockWalletClient(); + const mockPublic = createMockPublicClient(); + const executeOptions: ExecuteOptions = { + wallet: mockWallet as unknown as import('viem').WalletClient, + publicClient: mockPublic as unknown as import('viem').PublicClient, + stopOnFail: false, + }; + + const hashes = await executeTxPlan(preparedPayout, executeOptions); + + expect(hashes.length).toBeGreaterThan(0); + expect(hashes[0]).toMatch(/^0x[a-fA-F0-9]{64}$/); + + // Step 5: Reconcile push (mocked) + const reconcileResult = await reconcilePush( + mockPublic as unknown as import('viem').PublicClient, + mockToken.address as `0x${string}`, + buildResult.manifest, + hashes + ); + + expect(reconcileResult.success).toBeDefined(); + expect(reconcileResult.totalAmountFound).toBeDefined(); + expect(reconcileResult.expectedTotalAmount).toBe(buildResult.manifest.totalAmount); + expect(reconcileResult.details.successfulTransfers).toBeDefined(); + }); + + test('should handle failed transactions in execution', async () => { + // Create a mock wallet that throws errors + const failingWallet: MockWalletClient = { + async sendTransaction() { + throw new Error('Network error'); + }, + }; + + const buildResult = buildManifest([mockWinners[0]!], { + token: mockToken, + roundId: 'round-123', + groupId: 'group-456', + }); + + const preparedPayout = preparePushTxs(buildResult.manifest, { + token: mockToken, + airdrop: '0x2aACce8B9522F81F14834883198645BB6894Bfc0', // Provide a valid airdrop address + }); + + const mockPublic = createMockPublicClient(); + const executeOptions: ExecuteOptions = { + wallet: failingWallet as unknown as import('viem').WalletClient, + publicClient: mockPublic as unknown as import('viem').PublicClient, + stopOnFail: false, + }; + + const hashes = await executeTxPlan(preparedPayout, executeOptions); + + // Should return empty array when all transactions fail + expect(hashes.length).toBe(0); + }); + + test('should import all required functions from package root', () => { + // This test verifies the main requirement from the issue + // that all functions can be imported from the package root + expect(typeof buildManifest).toBe('function'); + expect(typeof toCSV).toBe('function'); + expect(typeof preparePushTxs).toBe('function'); + expect(typeof executeTxPlan).toBe('function'); + expect(typeof reconcilePush).toBe('function'); + }); + + test('should export CSV with correct format', () => { + const buildResult = buildManifest([mockWinners[0]!], { + token: mockToken, + roundId: 'round-123', + groupId: 'group-456', + }); + + const csvData = toCSV(buildResult.manifest); + + // Test canonical format: address,amountWei,symbol,roundId,groupId + expect(csvData).toContain('address,amountWei,symbol,roundId,groupId'); + expect(csvData.toLowerCase()).toContain('0xd8da6bf26964af9d7eed9e03e53415d37aa96045'); + expect(csvData).toContain('USDC'); + expect(csvData).toContain('round-123'); + expect(csvData).toContain('group-456'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/payouts/exporters.test.ts b/src/__tests__/payouts/exporters.test.ts index df954fe..46a2dd6 100644 --- a/src/__tests__/payouts/exporters.test.ts +++ b/src/__tests__/payouts/exporters.test.ts @@ -76,8 +76,8 @@ describe('Payouts Exporters', () => { const lines = csv.split('\n'); expect(lines).toHaveLength(3); // header + 2 winners - // Check first winner row - expect(lines[1]).toBe('0x742d35cc6584C0532E47A89c9Fdd3D3f8C6c1B66,100500000000000000000,SUPR,round-123,group-456'); + // Check first winner row (using proper EIP-55 checksummed address) + expect(lines[1]).toBe('0x742D35Cc6584c0532e47a89c9Fdd3d3F8c6c1B66,100500000000000000000,SUPR,round-123,group-456'); // Check second winner row expect(lines[2]).toBe('0x1234567890123456789012345678901234567890,50250000000000000000,SUPR,round-123,group-456'); diff --git a/src/payouts/builder.ts b/src/payouts/builder.ts index 0351094..6309d7e 100644 --- a/src/payouts/builder.ts +++ b/src/payouts/builder.ts @@ -53,29 +53,34 @@ export function validateAndChecksumAddress(address: string): string | null { } /** - * Convert address to checksum format (EIP-55) - * Note: This is a simplified implementation for demo purposes. - * In production, you should use a proper Keccak-256 implementation. + * Convert address to checksum format (EIP-55) using viem's implementation */ function toChecksumAddress(address: string): string { - const cleanAddress = address.replace(/^0x/i, '').toLowerCase(); - - // For this implementation, we'll return a properly formatted address - // In production, this should use Keccak-256 hash for proper EIP-55 checksumming - let result = '0x'; - for (let i = 0; i < cleanAddress.length; i++) { - const char = cleanAddress[i]; - if (!char) continue; + // Use viem's getAddress which provides proper EIP-55 checksumming + try { + const { getAddress } = require('viem'); + return getAddress(address); + } catch (error) { + // Fallback to original implementation if viem is not available + const cleanAddress = address.replace(/^0x/i, '').toLowerCase(); - // Simple pattern for demo - alternate case based on position - if (i % 4 < 2) { - result += char.toUpperCase(); - } else { - result += char; + // For this implementation, we'll return a properly formatted address + // In production, this should use Keccak-256 hash for proper EIP-55 checksumming + let result = '0x'; + for (let i = 0; i < cleanAddress.length; i++) { + const char = cleanAddress[i]; + if (!char) continue; + + // Simple pattern for demo - alternate case based on position + if (i % 4 < 2) { + result += char.toUpperCase(); + } else { + result += char; + } } + + return result; } - - return result; } /** diff --git a/src/payouts/execute.ts b/src/payouts/execute.ts index 328eaa9..697b9b2 100644 --- a/src/payouts/execute.ts +++ b/src/payouts/execute.ts @@ -76,7 +76,7 @@ export async function executeTxPlan( } // Send transaction - const hash = await wallet.sendTransaction(viemTx as any); + const hash = await wallet.sendTransaction(viemTx as Parameters[0]); successfulHashes.push(hash); // Fire progress callback with hash diff --git a/src/payouts/tx-preparer.ts b/src/payouts/tx-preparer.ts index 6498c5a..52989d8 100644 --- a/src/payouts/tx-preparer.ts +++ b/src/payouts/tx-preparer.ts @@ -84,7 +84,7 @@ export function preparePushTxs( opts: PushPrepareOptions ): PreparedPayout { const { airdrop: providedAirdrop, token, maxPerBatch = 50, singleApproval = true } = opts; - const totalWei = manifest.totals.amountWei; + const totalWei = manifest.totalAmount; const transactions: PreparedTx[] = []; const errors: string[] = []; const warnings: string[] = []; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 92680c9..67a35e4 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -126,14 +126,10 @@ export function deepMerge(target: T, source: Partial): T { } /** - * Extract Ethereum address from event log topic + * Extract an Ethereum address from a bytes32 topic (e.g., from event logs) * - * Event log topics contain addresses padded to 32 bytes (64 hex characters). - * This function extracts the actual 20-byte address from the padded topic. - * - * @param topic - The hex string topic from an event log (should be 66 chars: 0x + 64 hex) - * @returns The extracted Ethereum address (20 bytes, 42 chars including 0x prefix) - * @throws Error if the topic is not properly formatted + * @param topic - The bytes32 topic string (66 chars: 0x + 64 hex chars) + * @returns The extracted Ethereum address (0x + 40 hex chars) */ export function extractAddressFromTopic(topic: string): `0x${string}` { if (!topic || typeof topic !== 'string') {