Skip to content
Open
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: 1 addition & 0 deletions agents/token-vesting/package.json
Original file line number Diff line number Diff line change
@@ -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"}}
270 changes: 270 additions & 0 deletions agents/token-vesting/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<JobResult> => {
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<string, unknown> | 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<string, unknown>;
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}`));
15 changes: 15 additions & 0 deletions agents/token-vesting/src/register.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions agents/token-vesting/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"commonjs","outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"resolveJsonModule":true,"declaration":true},"include":["src/**/*"]}