diff --git a/agents/airdrop-distributor/.env.example b/agents/airdrop-distributor/.env.example new file mode 100644 index 0000000..83217a5 --- /dev/null +++ b/agents/airdrop-distributor/.env.example @@ -0,0 +1,26 @@ +# ── PayPol Community Agent Configuration ────────────────── +# +# Copy this file to .env and fill in your values: +# cp .env.example .env + +# Your agent's public port (default: 3002) +AGENT_PORT=3002 + +# Your wallet address (receives AlphaUSD payments via NexusV2 escrow) +OWNER_WALLET=0xYourWalletAddress + +# Your GitHub username (shown on marketplace) +GITHUB_HANDLE=your-github-username + +# PayPol marketplace URL (default: http://localhost:3000) +PAYPOL_MARKETPLACE_URL=http://localhost:3000 + +# Your agent's publicly reachable URL (for marketplace webhook calls) +# In local dev, use ngrok or similar tunneling service +AGENT_WEBHOOK_URL=http://localhost:3002 + +# Optional: Tempo RPC for on-chain operations +TEMPO_RPC_URL=https://rpc.moderato.tempo.xyz + +# Optional: Private key for on-chain transactions (NEVER commit this!) +# DAEMON_PRIVATE_KEY=0x... diff --git a/agents/airdrop-distributor/README.md b/agents/airdrop-distributor/README.md new file mode 100644 index 0000000..be09825 --- /dev/null +++ b/agents/airdrop-distributor/README.md @@ -0,0 +1,219 @@ +# Airdrop Distribution Agent + +Automates token airdrops using the PayPol MultisendVaultV2 contract on Tempo L1. + +## Features + +- ✅ Batch distribution to up to 100 recipients per transaction +- ✅ Address validation (checksummed, non-zero) +- ✅ Balance checking before execution +- ✅ Detailed per-recipient receipts +- ✅ Dry-run mode for cost estimation +- ✅ CSV and JSON input formats +- ✅ Multi-token support (AlphaUSD, pathUSD, etc.) +- ✅ Graceful error handling with detailed validation messages + +## Quick Start + +```bash +# Install dependencies +npm install + +# Configure environment +cp .env.example .env +# Edit .env with your DAEMON_PRIVATE_KEY and GITHUB_HANDLE + +# Start the agent +npm run dev + +# Test locally +curl -X POST http://localhost:3002/execute \ + -H "Content-Type: application/json" \ + -d @test-payload.json +``` + +## Usage + +### JSON Format + +```json +{ + "prompt": "Distribute 1000 AlphaUSD to community members", + "payload": { + "token": "AlphaUSD", + "recipients": [ + {"address": "0xabc...", "amount": "500"}, + {"address": "0xdef...", "amount": "300"}, + {"address": "0x123...", "amount": "200"} + ], + "dryRun": false + } +} +``` + +### CSV Format + +```json +{ + "prompt": "Airdrop from CSV", + "payload": { + "token": "AlphaUSD", + "csv": "address,amount\n0xabc...,500\n0xdef...,300\n0x123...,200", + "dryRun": false + } +} +``` + +## Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `token` | string | No | Token name or address (default: AlphaUSD) | +| `recipients` | array | Yes* | Array of {address, amount} objects | +| `csv` | string | Yes* | CSV string with header (address,amount) | +| `dryRun` | boolean | No | If true, validates and estimates without executing | + +*Either `recipients` or `csv` must be provided. + +## Supported Tokens + +- AlphaUSD (0x20c0000000000000000000000000000000000001) +- pathUSD +- BetaUSD +- ThetaUSD +- Custom ERC20 tokens (provide contract address) + +## Response Format + +### Success (Dry Run) + +```json +{ + "status": "success", + "result": { + "action": "airdrop_dry_run", + "token": "AlphaUSD", + "totalRecipients": 3, + "totalAmount": "1000", + "vaultBalance": "5000", + "recipients": [...], + "estimatedGas": "~500,000", + "message": "Dry run complete. Set dryRun: false to execute." + } +} +``` + +### Success (Executed) + +```json +{ + "status": "success", + "result": { + "action": "airdrop_executed", + "onChain": true, + "txHash": "0x...", + "explorerUrl": "https://explore.tempo.xyz/tx/0x...", + "batchId": "0x...", + "token": "AlphaUSD", + "totalRecipients": 3, + "totalAmount": "1000", + "gasUsed": 487234, + "blockNumber": 12345, + "recipients": [ + {"index": 0, "address": "0xabc...", "amount": "500", "status": "success"}, + {"index": 1, "address": "0xdef...", "amount": "300", "status": "success"}, + {"index": 2, "address": "0x123...", "amount": "200", "status": "success"} + ], + "message": "Successfully distributed 1000 AlphaUSD to 3 recipients." + } +} +``` + +### Error + +```json +{ + "status": "error", + "error": "Validation failed:\nRecipient 0: Invalid address 0xinvalid\nRecipient 2: Invalid amount -10" +} +``` + +## Validation Rules + +1. **Address Validation** + - Must be valid Ethereum address + - Must be checksummed + - Cannot be zero address (0x0000...0000) + +2. **Amount Validation** + - Must be positive number + - Must be valid decimal string + +3. **Batch Limits** + - Maximum 100 recipients per batch + - Total amount must not exceed vault balance + +4. **Balance Check** + - Vault must have sufficient token balance + - Check performed before execution + +## Error Handling + +The agent handles errors gracefully: + +- **Validation errors**: Returns detailed list of invalid recipients +- **Insufficient balance**: Shows required vs available amounts +- **Transaction failures**: Returns error message with context +- **Partial failures**: Individual transfer status in receipt + +## Contract Integration + +Uses PayPol MultisendVaultV2 at `0x6A467Cd4156093bB528e448C04366586a1052Fab`: + +```solidity +function executeMultiTokenBatch( + address token, + address[] calldata recipients, + uint256[] calldata amounts, + bytes32 batchId +) external; +``` + +## Network Info + +- **Chain**: Tempo Moderato (Chain ID: 42431) +- **RPC**: https://rpc.moderato.tempo.xyz +- **Explorer**: https://explore.tempo.xyz +- **MultisendVault**: 0x6A467Cd4156093bB528e448C04366586a1052Fab + +## Testing + +```bash +# Test with dry run +curl -X POST http://localhost:3002/execute \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Test airdrop", + "payload": { + "token": "AlphaUSD", + "recipients": [ + {"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", "amount": "10"} + ], + "dryRun": true + } + }' + +# Check manifest +curl http://localhost:3002/manifest + +# Health check +curl http://localhost:3002/health +``` + +## Author + +@dagangtj + +## Bounty + +This agent was built for PayPol issue #4. See [BOUNTY.md](../../BOUNTY.md) for details. diff --git a/agents/airdrop-distributor/package-lock.json b/agents/airdrop-distributor/package-lock.json new file mode 100644 index 0000000..30fed55 --- /dev/null +++ b/agents/airdrop-distributor/package-lock.json @@ -0,0 +1,269 @@ +{ + "name": "paypol-community-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paypol-community-agent", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.0", + "paypol-sdk": "file:../../packages/sdk" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0" + } + }, + "../../packages/sdk": { + "name": "paypol-sdk", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/paypol-sdk": { + "resolved": "../../packages/sdk", + "link": true + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/agents/airdrop-distributor/package.json b/agents/airdrop-distributor/package.json new file mode 100644 index 0000000..2291acd --- /dev/null +++ b/agents/airdrop-distributor/package.json @@ -0,0 +1,24 @@ +{ + "name": "airdrop-distributor", + "version": "1.0.0", + "description": "Automates token airdrops using PayPol MultisendVaultV2", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "npx ts-node src/index.ts", + "start": "node dist/index.js", + "register": "npx ts-node src/register.ts" + }, + "dependencies": { + "paypol-sdk": "workspace:^", + "dotenv": "^16.4.0", + "ethers": "^6.13.5", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/express": "^5.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0" + } +} diff --git a/agents/airdrop-distributor/src/index.ts b/agents/airdrop-distributor/src/index.ts new file mode 100644 index 0000000..528fda8 --- /dev/null +++ b/agents/airdrop-distributor/src/index.ts @@ -0,0 +1,307 @@ +/** + * Airdrop Distribution Agent + * Author: @dagangtj + * + * Automates token airdrops using PayPol MultisendVaultV2 contract. + * Supports batch distribution to up to 100 recipients per transaction. + */ + +import 'dotenv/config'; +import { ethers } from 'ethers'; +import { PayPolAgent, JobRequest, JobResult } from 'paypol-sdk'; + +const RPC_URL = process.env.TEMPO_RPC_URL ?? 'https://rpc.moderato.tempo.xyz'; +const MULTISEND_VAULT = '0x6A467Cd4156093bB528e448C04366586a1052Fab'; +const ALPHA_USD = '0x20c0000000000000000000000000000000000001'; + +const ERC20_ABI = [ + 'function transfer(address to, uint256 amount) returns (bool)', + 'function balanceOf(address account) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', + 'function decimals() view returns (uint8)', +]; + +const MULTISEND_ABI = [ + 'function executeMultiTokenBatch(address token, address[] calldata recipients, uint256[] calldata amounts, bytes32 batchId) external', + 'function getVaultBalance(address token) external view returns (uint256)', +]; + +// Token address mapping +const TOKEN_MAP: Record = { + 'AlphaUSD': ALPHA_USD, + 'alphausd': ALPHA_USD, + 'ALPHA': ALPHA_USD, +}; + +interface Recipient { + address: string; + amount: string; +} + +// ── Agent Configuration ────────────────────────────────── + +const agent = new PayPolAgent({ + id: 'airdrop-distributor', + name: 'Airdrop Distribution Agent', + description: 'Automates token airdrops using PayPol MultisendVaultV2. Validates addresses, checks balances, and executes batch transfers with detailed receipts.', + category: 'defi', + version: '1.0.0', + price: 10, + capabilities: ['airdrop', 'batch-transfer', 'token-distribution', 'address-validation', 'dry-run'], + author: 'dagangtj', +}); + +// ── Helper Functions ───────────────────────────────────── + +function validateAddress(address: string): boolean { + try { + const checksummed = ethers.getAddress(address); + return checksummed !== ethers.ZeroAddress; + } catch { + return false; + } +} + +function parseRecipients(payload: any): Recipient[] { + if (Array.isArray(payload.recipients)) { + return payload.recipients; + } + + // Support CSV format + if (typeof payload.csv === 'string') { + const lines = payload.csv.trim().split('\n').slice(1); // Skip header + return lines.map(line => { + const [address, amount] = line.split(',').map(s => s.trim()); + return { address, amount }; + }); + } + + return []; +} + +function resolveTokenAddress(tokenName: string): string { + const normalized = tokenName.trim(); + + // Check if it's already an address + if (normalized.startsWith('0x')) { + return normalized; + } + + // Look up in token map + return TOKEN_MAP[normalized] ?? ALPHA_USD; +} + +// ── Job Handler ────────────────────────────────────────── + +agent.onJob(async (job: JobRequest): Promise => { + const start = Date.now(); + console.log(`[airdrop-distributor] Job ${job.jobId}: ${job.prompt}`); + + try { + const provider = new ethers.JsonRpcProvider(RPC_URL); + const payload = job.payload || {}; + + // Parse parameters + const tokenName = payload.token ?? 'AlphaUSD'; + const tokenAddress = resolveTokenAddress(tokenName); + const recipients = parseRecipients(payload); + const dryRun = payload.dryRun === true; + + // Validate recipients + if (recipients.length === 0) { + throw new Error('No recipients provided. Use payload.recipients array or payload.csv string.'); + } + + if (recipients.length > 100) { + throw new Error(`Too many recipients (${recipients.length}). Maximum 100 per batch.`); + } + + const validationErrors: string[] = []; + const validRecipients: Recipient[] = []; + + for (let i = 0; i < recipients.length; i++) { + const r = recipients[i]; + + if (!validateAddress(r.address)) { + validationErrors.push(`Recipient ${i}: Invalid address ${r.address}`); + continue; + } + + if (ethers.getAddress(r.address) === ethers.ZeroAddress) { + validationErrors.push(`Recipient ${i}: Zero address not allowed`); + continue; + } + + const amount = parseFloat(r.amount); + if (isNaN(amount) || amount <= 0) { + validationErrors.push(`Recipient ${i}: Invalid amount ${r.amount}`); + continue; + } + + validRecipients.push({ + address: ethers.getAddress(r.address), + amount: r.amount, + }); + } + + if (validationErrors.length > 0) { + return { + jobId: job.jobId, + agentId: job.agentId, + status: 'error', + error: `Validation failed:\n${validationErrors.join('\n')}`, + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + } + + // Get token decimals and calculate total + const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider); + const decimals = await token.decimals(); + + let totalAmount = ethers.parseUnits('0', decimals); + const amounts: bigint[] = []; + + for (const r of validRecipients) { + const amountWei = ethers.parseUnits(r.amount, decimals); + amounts.push(amountWei); + totalAmount += amountWei; + } + + // Check vault balance + const multisend = new ethers.Contract(MULTISEND_VAULT, MULTISEND_ABI, provider); + const vaultBalance = await multisend.getVaultBalance(tokenAddress); + + if (vaultBalance < totalAmount) { + return { + jobId: job.jobId, + agentId: job.agentId, + status: 'error', + error: `Insufficient vault balance. Required: ${ethers.formatUnits(totalAmount, decimals)} ${tokenName}, Available: ${ethers.formatUnits(vaultBalance, decimals)} ${tokenName}`, + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + } + + // Dry run mode - return estimate without executing + if (dryRun) { + return { + jobId: job.jobId, + agentId: job.agentId, + status: 'success', + result: { + action: 'airdrop_dry_run', + token: tokenName, + tokenAddress, + totalRecipients: validRecipients.length, + totalAmount: ethers.formatUnits(totalAmount, decimals), + vaultBalance: ethers.formatUnits(vaultBalance, decimals), + recipients: validRecipients.map((r, i) => ({ + index: i, + address: r.address, + amount: r.amount, + })), + estimatedGas: '~500,000', + message: 'Dry run complete. Set dryRun: false to execute.', + }, + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + } + + // Execute batch transfer + if (!process.env.DAEMON_PRIVATE_KEY) { + throw new Error('DAEMON_PRIVATE_KEY not configured. Cannot execute on-chain transaction.'); + } + + const wallet = new ethers.Wallet(process.env.DAEMON_PRIVATE_KEY, provider); + const multisendWithSigner = multisend.connect(wallet); + + const batchId = ethers.keccak256( + ethers.toUtf8Bytes(`${job.jobId}-${Date.now()}`) + ); + + const addresses = validRecipients.map(r => r.address); + const nonce = await provider.getTransactionCount(wallet.address, 'pending'); + + const tx = await multisendWithSigner.executeMultiTokenBatch( + tokenAddress, + addresses, + amounts, + batchId, + { nonce, gasLimit: 5_000_000, type: 0 } + ); + + console.log(`[airdrop-distributor] Transaction sent: ${tx.hash}`); + const receipt = await tx.wait(1); + console.log(`[airdrop-distributor] Transaction confirmed in block ${receipt.blockNumber}`); + + // Parse events for per-recipient status + const transferEvents = receipt.logs + .filter((log: any) => { + try { + const parsed = multisend.interface.parseLog(log); + return parsed?.name === 'IndividualTransfer'; + } catch { + return false; + } + }) + .map((log: any) => { + const parsed = multisend.interface.parseLog(log); + return { + recipient: parsed?.args[1], + amount: parsed?.args[2], + index: Number(parsed?.args[3]), + }; + }); + + return { + jobId: job.jobId, + agentId: job.agentId, + status: 'success', + result: { + action: 'airdrop_executed', + onChain: true, + txHash: receipt.hash, + explorerUrl: `https://explore.tempo.xyz/tx/${receipt.hash}`, + batchId, + token: tokenName, + tokenAddress, + totalRecipients: validRecipients.length, + totalAmount: ethers.formatUnits(totalAmount, decimals), + gasUsed: Number(receipt.gasUsed), + blockNumber: receipt.blockNumber, + recipients: validRecipients.map((r, i) => ({ + index: i, + address: r.address, + amount: r.amount, + status: transferEvents.find(e => e.index === i) ? 'success' : 'unknown', + })), + message: `Successfully distributed ${ethers.formatUnits(totalAmount, decimals)} ${tokenName} to ${validRecipients.length} recipients.`, + }, + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + + } catch (err: any) { + console.error(`[airdrop-distributor] Error: ${err.message}`); + return { + jobId: job.jobId, + agentId: job.agentId, + status: 'error', + error: err.message ?? String(err), + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + } +}); + +// ── Start Server ───────────────────────────────────────── + +const PORT = Number(process.env.AGENT_PORT ?? 3002); +agent.listen(PORT, () => { + console.log(`[airdrop-distributor] Agent ready at http://localhost:${PORT}`); + console.log(` GET /manifest - agent metadata`); + console.log(` POST /execute - run airdrop job`); + console.log(` GET /health - health check`); +}); diff --git a/agents/airdrop-distributor/src/register.ts b/agents/airdrop-distributor/src/register.ts new file mode 100644 index 0000000..8fa0b3a --- /dev/null +++ b/agents/airdrop-distributor/src/register.ts @@ -0,0 +1,63 @@ +/** + * Self-Registration Script + * + * Run this after your agent is up and running to register + * it on the PayPol marketplace: + * + * npm run register + * + * Your agent must be accessible at AGENT_WEBHOOK_URL for + * the marketplace to call it with jobs. + */ + +import 'dotenv/config'; +import { registerAgent } from 'paypol-sdk'; + +async function main() { + const webhookUrl = process.env.AGENT_WEBHOOK_URL ?? 'http://localhost:3002'; + const ownerWallet = process.env.OWNER_WALLET; + const githubHandle = process.env.GITHUB_HANDLE; + const marketplaceUrl = process.env.PAYPOL_MARKETPLACE_URL ?? 'http://localhost:3000'; + + if (!ownerWallet) { + console.error('Error: OWNER_WALLET is required in .env'); + process.exit(1); + } + + console.log('Registering agent on PayPol marketplace...'); + console.log(` Webhook URL: ${webhookUrl}`); + console.log(` Owner Wallet: ${ownerWallet}`); + console.log(` GitHub: ${githubHandle ?? 'not set'}`); + console.log(` Marketplace: ${marketplaceUrl}`); + console.log(); + + try { + const result = await registerAgent( + { + // ── Update these to match your agent ── + id: 'my-community-agent', + name: 'My Community Agent', + description: 'Describe what your agent does.', + category: 'analytics', + version: '1.0.0', + price: 5, + capabilities: ['example-capability'], + webhookUrl, + ownerWallet, + githubHandle, + author: githubHandle ?? 'community', + }, + marketplaceUrl, + ); + + console.log('Registration successful!'); + console.log(` Agent ID: ${result.agentId}`); + console.log(` Marketplace ID: ${result.marketplaceId}`); + console.log(` Message: ${result.message}`); + } catch (err: any) { + console.error('Registration failed:', err.response?.data ?? err.message); + process.exit(1); + } +} + +main(); diff --git a/agents/airdrop-distributor/tsconfig.json b/agents/airdrop-distributor/tsconfig.json new file mode 100644 index 0000000..cca939d --- /dev/null +++ b/agents/airdrop-distributor/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}