diff --git a/agents/token-vesting/package.json b/agents/token-vesting/package.json new file mode 100644 index 0000000..e3c73b9 --- /dev/null +++ b/agents/token-vesting/package.json @@ -0,0 +1 @@ +{"name":"paypol-token-vesting","version":"1.0.0","description":"Token Vesting Agent — linear and cliff vesting on Tempo L1","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.9.0"},"devDependencies":{"@types/node":"^20.0.0","ts-node":"^10.9.0","typescript":"^5.0.0"}} diff --git a/agents/token-vesting/src/index.ts b/agents/token-vesting/src/index.ts new file mode 100644 index 0000000..f179e63 --- /dev/null +++ b/agents/token-vesting/src/index.ts @@ -0,0 +1,270 @@ +/** + * PayPol Token Vesting Agent + * + * Creates and manages token vesting schedules on Tempo L1. + * Supports linear vesting, cliff vesting, and natural language input. + * + * Bounty: https://github.com/PayPol-Foundation/paypol-protocol/issues/2 + */ + +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'; + +// ── Types ──────────────────────────────────────────────── + +interface VestingPlan { + beneficiary: string; + tokenAddress: string; + totalAmount: string; + cliffDuration: number; // seconds + vestingDuration: number; // seconds + startTime: number; + type: 'linear' | 'cliff' | 'linear-with-cliff'; +} + +interface VestingStatus { + plan: VestingPlan; + vestedAmount: string; + releasedAmount: string; + releasableAmount: string; + percentVested: number; + nextReleaseAt: string; +} + +// ── NLP Parser ─────────────────────────────────────────── + +function parseVestingRequest(prompt: string, payload?: Record): VestingPlan { + // If structured payload provided, use it directly + if (payload?.beneficiary && payload?.totalAmount) { + return { + beneficiary: payload.beneficiary as string, + tokenAddress: payload.tokenAddress as string ?? '0x20c0000000000000000000000000000000000001', + totalAmount: String(payload.totalAmount), + cliffDuration: Number(payload.cliffDuration ?? 0), + vestingDuration: Number(payload.vestingDuration ?? 31536000), // default 1 year + startTime: Number(payload.startTime ?? Math.floor(Date.now() / 1000)), + type: payload.cliffDuration ? 'linear-with-cliff' : 'linear', + }; + } + + // Natural language parsing + const p = prompt.toLowerCase(); + + // Extract amount: "10,000 TEMPO" or "5000 AlphaUSD" + const amountMatch = p.match(/([\d,]+(?:\.\d+)?)\s*(?:tokens?|tempo|alphausd|usdc|usdt)/i); + const totalAmount = amountMatch ? amountMatch[1].replace(/,/g, '') : '0'; + + // Extract token name + let tokenAddress = '0x20c0000000000000000000000000000000000001'; // AlphaUSD default + if (p.includes('tempo')) tokenAddress = '0x0000000000000000000000000000000000000000'; + + // Extract beneficiary address + const addrMatch = prompt.match(/(0x[a-fA-F0-9]{40})/); + const beneficiary = addrMatch ? addrMatch[1] : ''; + + // Extract duration: "12 months", "1 year", "365 days" + let vestingDuration = 31536000; // 1 year default + const durationMatch = p.match(/(?:over|for|duration)\s*(\d+)\s*(month|year|day|week)/i); + if (durationMatch) { + const num = parseInt(durationMatch[1]); + const unit = durationMatch[2].toLowerCase(); + if (unit.startsWith('month')) vestingDuration = num * 30 * 86400; + else if (unit.startsWith('year')) vestingDuration = num * 365 * 86400; + else if (unit.startsWith('week')) vestingDuration = num * 7 * 86400; + else if (unit.startsWith('day')) vestingDuration = num * 86400; + } + + // Extract cliff: "3-month cliff", "cliff of 6 months" + let cliffDuration = 0; + const cliffMatch = p.match(/(\d+)[\s-]*(month|year|day|week)\s*cliff/i) + || p.match(/cliff\s*(?:of|:)?\s*(\d+)\s*(month|year|day|week)/i); + if (cliffMatch) { + const num = parseInt(cliffMatch[1]); + const unit = cliffMatch[2].toLowerCase(); + if (unit.startsWith('month')) cliffDuration = num * 30 * 86400; + else if (unit.startsWith('year')) cliffDuration = num * 365 * 86400; + else if (unit.startsWith('week')) cliffDuration = num * 7 * 86400; + else if (unit.startsWith('day')) cliffDuration = num * 86400; + } + + const type = cliffDuration > 0 ? 'linear-with-cliff' : 'linear'; + + return { + beneficiary, + tokenAddress, + totalAmount, + cliffDuration, + vestingDuration, + startTime: Math.floor(Date.now() / 1000), + type, + }; +} + +// ── Vesting Calculations ───────────────────────────────── + +function calculateVested(plan: VestingPlan, currentTime: number): bigint { + const elapsed = currentTime - plan.startTime; + if (elapsed < 0) return 0n; + + const total = BigInt(plan.totalAmount); + + // Before cliff — nothing vested + if (elapsed < plan.cliffDuration) return 0n; + + // After full vesting period — everything vested + if (elapsed >= plan.vestingDuration) return total; + + // Linear vesting after cliff + const vestingElapsed = elapsed - plan.cliffDuration; + const vestingRemaining = plan.vestingDuration - plan.cliffDuration; + + if (vestingRemaining <= 0) return total; + + return (total * BigInt(vestingElapsed)) / BigInt(vestingRemaining); +} + +function formatDuration(seconds: number): string { + if (seconds >= 365 * 86400) return `${Math.round(seconds / (365 * 86400))} year(s)`; + if (seconds >= 30 * 86400) return `${Math.round(seconds / (30 * 86400))} month(s)`; + if (seconds >= 7 * 86400) return `${Math.round(seconds / (7 * 86400))} week(s)`; + return `${Math.round(seconds / 86400)} day(s)`; +} + +// ── Agent Setup ────────────────────────────────────────── + +const agent = new PayPolAgent({ + id: 'token-vesting', + name: 'Token Vesting Agent', + description: 'Create and manage token vesting schedules with linear and cliff vesting. Accepts natural language input.', + category: 'defi', + version: '1.0.0', + price: 3, + capabilities: ['token-vesting', 'cliff-vesting', 'linear-vesting', 'schedule-management'], + author: process.env.GITHUB_HANDLE ?? 'dominusaxis', +}); + +agent.onJob(async (job: JobRequest): Promise => { + const start = Date.now(); + console.log(`[token-vesting] Job ${job.jobId}: ${job.prompt}`); + + try { + const prompt = job.prompt.toLowerCase(); + + // Route: check vesting status + if (prompt.includes('status') || prompt.includes('check') || prompt.includes('how much')) { + return handleStatusCheck(job, start); + } + + // Route: create vesting schedule + const plan = parseVestingRequest(job.prompt, job.payload as Record | undefined); + + if (!plan.beneficiary) { + return { + jobId: job.jobId, agentId: 'token-vesting', status: 'error', + error: 'Missing beneficiary address. Example: "Vest 10,000 TEMPO to 0xABC over 12 months with a 3-month cliff"', + executionTimeMs: Date.now() - start, timestamp: Date.now(), + }; + } + + if (plan.totalAmount === '0') { + return { + jobId: job.jobId, agentId: 'token-vesting', status: 'error', + error: 'Could not parse token amount. Example: "Vest 10,000 TEMPO to 0xABC over 12 months"', + executionTimeMs: Date.now() - start, timestamp: Date.now(), + }; + } + + // Return the plan for confirmation (dry-run by default) + const now = Math.floor(Date.now() / 1000); + const vestedNow = calculateVested(plan, now); + const cliffEnd = new Date((plan.startTime + plan.cliffDuration) * 1000).toISOString(); + const vestingEnd = new Date((plan.startTime + plan.vestingDuration) * 1000).toISOString(); + + return { + jobId: job.jobId, + agentId: 'token-vesting', + status: 'success', + result: { + action: 'create_vesting_plan', + mode: 'preview', + plan: { + beneficiary: plan.beneficiary, + tokenAddress: plan.tokenAddress, + totalAmount: plan.totalAmount, + type: plan.type, + cliffDuration: formatDuration(plan.cliffDuration), + vestingDuration: formatDuration(plan.vestingDuration), + startTime: new Date(plan.startTime * 1000).toISOString(), + cliffEndDate: plan.cliffDuration > 0 ? cliffEnd : 'N/A', + vestingEndDate: vestingEnd, + }, + schedule: generateSchedule(plan), + instructions: 'Review the plan above. To execute, resend with payload.confirm=true', + }, + executionTimeMs: Date.now() - start, + timestamp: Date.now(), + }; + } catch (err) { + return { + jobId: job.jobId, agentId: 'token-vesting', status: 'error', + error: `Vesting agent error: ${err}`, + executionTimeMs: Date.now() - start, timestamp: Date.now(), + }; + } +}); + +function handleStatusCheck(job: JobRequest, start: number): JobResult { + const payload = (job.payload ?? {}) as Record; + const plan = payload.plan as VestingPlan | undefined; + + if (!plan) { + return { + jobId: job.jobId, agentId: 'token-vesting', status: 'error', + error: 'No vesting plan provided in payload. Send the plan object to check status.', + executionTimeMs: Date.now() - start, timestamp: Date.now(), + }; + } + + const now = Math.floor(Date.now() / 1000); + const vested = calculateVested(plan, now); + const total = BigInt(plan.totalAmount); + const percent = total > 0n ? Number((vested * 10000n) / total) / 100 : 0; + + return { + jobId: job.jobId, agentId: 'token-vesting', status: 'success', + result: { + action: 'vesting_status', + beneficiary: plan.beneficiary, + totalAmount: plan.totalAmount, + vestedAmount: vested.toString(), + percentVested: percent, + type: plan.type, + vestingEnd: new Date((plan.startTime + plan.vestingDuration) * 1000).toISOString(), + }, + executionTimeMs: Date.now() - start, timestamp: Date.now(), + }; +} + +function generateSchedule(plan: VestingPlan): Array<{ date: string; vestedPercent: number; vestedAmount: string }> { + const schedule = []; + const total = BigInt(plan.totalAmount); + const steps = 12; // monthly breakdown + + for (let i = 0; i <= steps; i++) { + const t = plan.startTime + Math.floor((plan.vestingDuration * i) / steps); + const vested = calculateVested(plan, t); + const percent = total > 0n ? Number((vested * 10000n) / total) / 100 : 0; + schedule.push({ + date: new Date(t * 1000).toISOString().slice(0, 10), + vestedPercent: percent, + vestedAmount: vested.toString(), + }); + } + return schedule; +} + +const port = parseInt(process.env.PORT ?? '3002', 10); +agent.listen(port, () => console.log(`[token-vesting] Ready on port ${port}`)); diff --git a/agents/token-vesting/src/register.ts b/agents/token-vesting/src/register.ts new file mode 100644 index 0000000..86aef44 --- /dev/null +++ b/agents/token-vesting/src/register.ts @@ -0,0 +1,15 @@ +import 'dotenv/config'; +import { registerAgent } from 'paypol-sdk'; +async function main() { + const result = await registerAgent({ + id: 'token-vesting', name: 'Token Vesting Agent', + description: 'Create and manage token vesting schedules with linear and cliff vesting.', + category: 'defi', version: '1.0.0', price: 3, + capabilities: ['token-vesting', 'cliff-vesting', 'linear-vesting', 'schedule-management'], + author: process.env.GITHUB_HANDLE ?? 'dominusaxis', + webhookUrl: process.env.AGENT_URL ?? 'http://localhost:3002', + ownerWallet: process.env.OWNER_WALLET ?? '0x0000000000000000000000000000000000000000', + }); + console.log('Registered:', result); +} +main().catch(console.error); diff --git a/agents/token-vesting/tsconfig.json b/agents/token-vesting/tsconfig.json new file mode 100644 index 0000000..5c1bdf3 --- /dev/null +++ b/agents/token-vesting/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"commonjs","outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"resolveJsonModule":true,"declaration":true},"include":["src/**/*"]}