|
| 1 | +import { |
| 2 | + Connection, |
| 3 | + PublicKey, |
| 4 | + Transaction, |
| 5 | + SystemProgram, |
| 6 | + Keypair, |
| 7 | + sendAndConfirmTransaction, |
| 8 | + LAMPORTS_PER_SOL, |
| 9 | +} from '@solana/web3.js'; |
| 10 | +import { BN } from '@coral-xyz/anchor'; |
| 11 | +import { BlindfoldClient } from './client'; |
| 12 | +import { |
| 13 | + SwarmConfig, |
| 14 | + SwarmMember, |
| 15 | + SwarmDistribution, |
| 16 | + SwarmPayoutResult, |
| 17 | + CardTier, |
| 18 | + CARD_TIERS, |
| 19 | + NATIVE_SOL_MINT, |
| 20 | +} from './types'; |
| 21 | + |
| 22 | +export interface SwarmPayrollConfig { |
| 23 | + connection: Connection; |
| 24 | + blindfoldBaseUrl?: string; |
| 25 | +} |
| 26 | + |
| 27 | +// Manages swarm registration and payroll distribution |
| 28 | +export class SwarmPayroll { |
| 29 | + private connection: Connection; |
| 30 | + private blindfold: BlindfoldClient; |
| 31 | + private swarms: Map<string, SwarmConfig> = new Map(); |
| 32 | + |
| 33 | + constructor(config: SwarmPayrollConfig) { |
| 34 | + this.connection = config.connection; |
| 35 | + this.blindfold = new BlindfoldClient({ baseUrl: config.blindfoldBaseUrl }); |
| 36 | + } |
| 37 | + |
| 38 | + // Register a new swarm with members and weights |
| 39 | + registerSwarm( |
| 40 | + swarmId: string, |
| 41 | + name: string, |
| 42 | + members: Array<{ agentPk: PublicKey | string; email: string; weight: number; tier?: CardTier }> |
| 43 | + ): SwarmConfig { |
| 44 | + const totalWeight = members.reduce((sum, m) => sum + m.weight, 0); |
| 45 | + if (Math.abs(totalWeight - 100) > 0.01) { |
| 46 | + throw new Error(`Weights must sum to 100, got ${totalWeight}`); |
| 47 | + } |
| 48 | + |
| 49 | + const swarmMembers: SwarmMember[] = members.map((m) => ({ |
| 50 | + agentPk: typeof m.agentPk === 'string' ? new PublicKey(m.agentPk) : m.agentPk, |
| 51 | + email: m.email, |
| 52 | + weight: m.weight, |
| 53 | + tier: m.tier, |
| 54 | + })); |
| 55 | + |
| 56 | + const config: SwarmConfig = { |
| 57 | + swarmId, |
| 58 | + name, |
| 59 | + members: swarmMembers, |
| 60 | + createdAt: Date.now(), |
| 61 | + updatedAt: Date.now(), |
| 62 | + }; |
| 63 | + |
| 64 | + this.swarms.set(swarmId, config); |
| 65 | + return config; |
| 66 | + } |
| 67 | + |
| 68 | + // Get swarm config |
| 69 | + getSwarm(swarmId: string): SwarmConfig | undefined { |
| 70 | + return this.swarms.get(swarmId); |
| 71 | + } |
| 72 | + |
| 73 | + // Update member weights |
| 74 | + updateWeights( |
| 75 | + swarmId: string, |
| 76 | + newWeights: Array<{ agentPk: string; weight: number }> |
| 77 | + ): SwarmConfig { |
| 78 | + const swarm = this.swarms.get(swarmId); |
| 79 | + if (!swarm) { |
| 80 | + throw new Error(`Swarm ${swarmId} not found`); |
| 81 | + } |
| 82 | + |
| 83 | + const totalWeight = newWeights.reduce((sum, w) => sum + w.weight, 0); |
| 84 | + if (Math.abs(totalWeight - 100) > 0.01) { |
| 85 | + throw new Error(`Weights must sum to 100, got ${totalWeight}`); |
| 86 | + } |
| 87 | + |
| 88 | + for (const update of newWeights) { |
| 89 | + const member = swarm.members.find((m) => m.agentPk.toBase58() === update.agentPk); |
| 90 | + if (member) { |
| 91 | + member.weight = update.weight; |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + swarm.updatedAt = Date.now(); |
| 96 | + return swarm; |
| 97 | + } |
| 98 | + |
| 99 | + // Calculate distribution for a given amount |
| 100 | + calculateDistribution(swarmId: string, totalAmount: bigint): SwarmDistribution[] { |
| 101 | + const swarm = this.swarms.get(swarmId); |
| 102 | + if (!swarm) { |
| 103 | + throw new Error(`Swarm ${swarmId} not found`); |
| 104 | + } |
| 105 | + |
| 106 | + return swarm.members.map((member) => ({ |
| 107 | + member, |
| 108 | + amount: (totalAmount * BigInt(Math.floor(member.weight * 100))) / 10000n, |
| 109 | + percentage: member.weight, |
| 110 | + })); |
| 111 | + } |
| 112 | + |
| 113 | + // Distribute funds to all swarm members' Blindfold cards |
| 114 | + async distribute( |
| 115 | + swarmId: string, |
| 116 | + totalAmount: BN, |
| 117 | + payer: Keypair, |
| 118 | + tokenMint: PublicKey = NATIVE_SOL_MINT |
| 119 | + ): Promise<SwarmPayoutResult> { |
| 120 | + const swarm = this.swarms.get(swarmId); |
| 121 | + if (!swarm) { |
| 122 | + throw new Error(`Swarm ${swarmId} not found`); |
| 123 | + } |
| 124 | + |
| 125 | + const distributions = this.calculateDistribution(swarmId, BigInt(totalAmount.toString())); |
| 126 | + const results: SwarmPayoutResult['distributions'] = []; |
| 127 | + |
| 128 | + // Process each member's payment |
| 129 | + for (const dist of distributions) { |
| 130 | + if (dist.amount === 0n) continue; |
| 131 | + |
| 132 | + const amountSol = Number(dist.amount) / LAMPORTS_PER_SOL; |
| 133 | + const tier = dist.member.tier || this.getTierForAmount(amountSol); |
| 134 | + |
| 135 | + // Create payment on Blindfold |
| 136 | + const payment = await this.blindfold.createPayment({ |
| 137 | + amount: amountSol, |
| 138 | + currency: 'SOL', |
| 139 | + recipientEmail: dist.member.email, |
| 140 | + useZkProof: true, |
| 141 | + agentPk: dist.member.agentPk.toBase58(), |
| 142 | + requestedTier: tier, |
| 143 | + }); |
| 144 | + |
| 145 | + // Create holding wallet |
| 146 | + const holding = await this.blindfold.createHoldingWallet( |
| 147 | + payment.paymentId, |
| 148 | + dist.amount.toString(), |
| 149 | + tokenMint.toBase58() |
| 150 | + ); |
| 151 | + |
| 152 | + // Transfer to holding wallet |
| 153 | + const holdingWalletPk = new PublicKey(holding.holdingWalletAddress); |
| 154 | + const transferSig = await this.transferSOL( |
| 155 | + payer, |
| 156 | + holdingWalletPk, |
| 157 | + new BN(dist.amount.toString()) |
| 158 | + ); |
| 159 | + |
| 160 | + results.push({ |
| 161 | + agentPk: dist.member.agentPk.toBase58(), |
| 162 | + email: dist.member.email, |
| 163 | + amount: dist.amount, |
| 164 | + paymentId: payment.paymentId, |
| 165 | + holdingWallet: holding.holdingWalletAddress, |
| 166 | + transferSignature: transferSig, |
| 167 | + tier, |
| 168 | + }); |
| 169 | + } |
| 170 | + |
| 171 | + return { |
| 172 | + swarmId, |
| 173 | + totalAmount: BigInt(totalAmount.toString()), |
| 174 | + distributions: results, |
| 175 | + timestamp: Date.now(), |
| 176 | + }; |
| 177 | + } |
| 178 | + |
| 179 | + // Batch distribute - when Blindfold supports batch endpoint |
| 180 | + async distributeBatch( |
| 181 | + swarmId: string, |
| 182 | + totalAmount: BN, |
| 183 | + payer: Keypair, |
| 184 | + _tokenMint: PublicKey = NATIVE_SOL_MINT |
| 185 | + ): Promise<SwarmPayoutResult> { |
| 186 | + const swarm = this.swarms.get(swarmId); |
| 187 | + if (!swarm) { |
| 188 | + throw new Error(`Swarm ${swarmId} not found`); |
| 189 | + } |
| 190 | + |
| 191 | + const distributions = this.calculateDistribution(swarmId, BigInt(totalAmount.toString())); |
| 192 | + |
| 193 | + // Prepare batch request (for when Blindfold implements batch endpoint) |
| 194 | + const _batchPayments = distributions |
| 195 | + .filter((d) => d.amount > 0n) |
| 196 | + .map((dist) => ({ |
| 197 | + amount: Number(dist.amount) / LAMPORTS_PER_SOL, |
| 198 | + currency: 'SOL' as const, |
| 199 | + recipientEmail: dist.member.email, |
| 200 | + agentPk: dist.member.agentPk.toBase58(), |
| 201 | + requestedTier: dist.member.tier || this.getTierForAmount(Number(dist.amount) / LAMPORTS_PER_SOL), |
| 202 | + })); |
| 203 | + |
| 204 | + // For now, fall back to sequential distribution |
| 205 | + // TODO: Replace with batch API call when available |
| 206 | + // const batchResponse = await this.blindfold.createBatchPayment({ payments: batchPayments, swarmId }); |
| 207 | + return this.distribute(swarmId, totalAmount, payer); |
| 208 | + } |
| 209 | + |
| 210 | + // Get appropriate tier based on amount |
| 211 | + private getTierForAmount(amountUsd: number): CardTier { |
| 212 | + for (let i = CARD_TIERS.length - 1; i >= 0; i--) { |
| 213 | + if (amountUsd <= CARD_TIERS[i].limit) { |
| 214 | + return CARD_TIERS[i].tier; |
| 215 | + } |
| 216 | + } |
| 217 | + return 'elite'; |
| 218 | + } |
| 219 | + |
| 220 | + // Transfer SOL to holding wallet |
| 221 | + private async transferSOL( |
| 222 | + payer: Keypair, |
| 223 | + destination: PublicKey, |
| 224 | + amount: BN |
| 225 | + ): Promise<string> { |
| 226 | + const tx = new Transaction().add( |
| 227 | + SystemProgram.transfer({ |
| 228 | + fromPubkey: payer.publicKey, |
| 229 | + toPubkey: destination, |
| 230 | + lamports: amount.toNumber(), |
| 231 | + }) |
| 232 | + ); |
| 233 | + return sendAndConfirmTransaction(this.connection, tx, [payer]); |
| 234 | + } |
| 235 | + |
| 236 | + // Add a member to existing swarm |
| 237 | + addMember( |
| 238 | + swarmId: string, |
| 239 | + member: { agentPk: PublicKey | string; email: string; weight: number; tier?: CardTier } |
| 240 | + ): SwarmConfig { |
| 241 | + const swarm = this.swarms.get(swarmId); |
| 242 | + if (!swarm) { |
| 243 | + throw new Error(`Swarm ${swarmId} not found`); |
| 244 | + } |
| 245 | + |
| 246 | + swarm.members.push({ |
| 247 | + agentPk: typeof member.agentPk === 'string' ? new PublicKey(member.agentPk) : member.agentPk, |
| 248 | + email: member.email, |
| 249 | + weight: member.weight, |
| 250 | + tier: member.tier, |
| 251 | + }); |
| 252 | + |
| 253 | + swarm.updatedAt = Date.now(); |
| 254 | + return swarm; |
| 255 | + } |
| 256 | + |
| 257 | + // Remove a member from swarm |
| 258 | + removeMember(swarmId: string, agentPk: string): SwarmConfig { |
| 259 | + const swarm = this.swarms.get(swarmId); |
| 260 | + if (!swarm) { |
| 261 | + throw new Error(`Swarm ${swarmId} not found`); |
| 262 | + } |
| 263 | + |
| 264 | + swarm.members = swarm.members.filter((m) => m.agentPk.toBase58() !== agentPk); |
| 265 | + swarm.updatedAt = Date.now(); |
| 266 | + return swarm; |
| 267 | + } |
| 268 | + |
| 269 | + // Validate swarm weights sum to 100 |
| 270 | + validateWeights(swarmId: string): boolean { |
| 271 | + const swarm = this.swarms.get(swarmId); |
| 272 | + if (!swarm) return false; |
| 273 | + |
| 274 | + const total = swarm.members.reduce((sum, m) => sum + m.weight, 0); |
| 275 | + return Math.abs(total - 100) < 0.01; |
| 276 | + } |
| 277 | + |
| 278 | + // Export swarm config for persistence |
| 279 | + exportSwarm(swarmId: string): string { |
| 280 | + const swarm = this.swarms.get(swarmId); |
| 281 | + if (!swarm) { |
| 282 | + throw new Error(`Swarm ${swarmId} not found`); |
| 283 | + } |
| 284 | + |
| 285 | + return JSON.stringify({ |
| 286 | + ...swarm, |
| 287 | + members: swarm.members.map((m) => ({ |
| 288 | + ...m, |
| 289 | + agentPk: m.agentPk.toBase58(), |
| 290 | + })), |
| 291 | + }); |
| 292 | + } |
| 293 | + |
| 294 | + // Import swarm config |
| 295 | + importSwarm(configJson: string): SwarmConfig { |
| 296 | + const parsed = JSON.parse(configJson); |
| 297 | + const config: SwarmConfig = { |
| 298 | + ...parsed, |
| 299 | + members: parsed.members.map((m: { agentPk: string; email: string; weight: number; tier?: CardTier }) => ({ |
| 300 | + ...m, |
| 301 | + agentPk: new PublicKey(m.agentPk), |
| 302 | + })), |
| 303 | + }; |
| 304 | + |
| 305 | + this.swarms.set(config.swarmId, config); |
| 306 | + return config; |
| 307 | + } |
| 308 | +} |
| 309 | + |
| 310 | +// Helper to create equal-weight swarm |
| 311 | +export function createEqualWeightSwarm( |
| 312 | + swarmId: string, |
| 313 | + name: string, |
| 314 | + members: Array<{ agentPk: PublicKey | string; email: string; tier?: CardTier }> |
| 315 | +): Array<{ agentPk: PublicKey | string; email: string; weight: number; tier?: CardTier }> { |
| 316 | + const weight = 100 / members.length; |
| 317 | + return members.map((m) => ({ ...m, weight })); |
| 318 | +} |
| 319 | + |
| 320 | +// Helper to create performance-weighted distribution |
| 321 | +export function createPerformanceWeights( |
| 322 | + contributions: Array<{ agentPk: string; score: number }> |
| 323 | +): Array<{ agentPk: string; weight: number }> { |
| 324 | + const totalScore = contributions.reduce((sum, c) => sum + c.score, 0); |
| 325 | + if (totalScore === 0) { |
| 326 | + return contributions.map((c) => ({ agentPk: c.agentPk, weight: 100 / contributions.length })); |
| 327 | + } |
| 328 | + |
| 329 | + return contributions.map((c) => ({ |
| 330 | + agentPk: c.agentPk, |
| 331 | + weight: (c.score / totalScore) * 100, |
| 332 | + })); |
| 333 | +} |
0 commit comments