Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
228 changes: 228 additions & 0 deletions src/__tests__/payouts-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 2 additions & 2 deletions src/__tests__/payouts/exporters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
41 changes: 23 additions & 18 deletions src/payouts/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines +60 to +63
Copy link

Copilot AI Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use ES6 import syntax instead of CommonJS require() for consistency with TypeScript standards. The require() call should be replaced with a proper import statement or dynamic import.

Copilot generated this review using guidance from repository custom instructions.
// 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;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/payouts/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof wallet.sendTransaction>[0]);
successfulHashes.push(hash);

// Fire progress callback with hash
Expand Down
2 changes: 1 addition & 1 deletion src/payouts/tx-preparer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
10 changes: 3 additions & 7 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,10 @@ export function deepMerge<T extends object>(target: T, source: Partial<T>): 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') {
Expand Down