diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0f44b5d6..f93345bf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1225,3 +1225,173 @@ model BridgeRetry { @@index([messageId]) @@map("bridge_retries") } + +// ─── Issue #462: Webhook Subscription Manager ─────────────────────────────────── + +model WebhookSubscription { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + eventTypes String[] @map("event_types") + filterExpr String? @map("filter_expr") + targetUrl String @map("target_url") + description String? + status SubscriptionStatus @default(active) + version Int @default(1) @map("version") + lastDelivered DateTime? @map("last_delivered") + deliveryCount Int @default(0) @map("delivery_count") + successCount Int @default(0) @map("success_count") + failCount Int @default(0) @map("fail_count") + avgLatency Float? @map("avg_latency") + pausedAt DateTime? @map("paused_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + deliveries WebhookSubscriptionDelivery[] + + @@index([tenantId]) + @@index([status]) + @@index([tenantId, status]) + @@map("webhook_subscriptions") +} + +model WebhookSubscriptionDelivery { + id String @id @default(uuid()) + subscriptionId String @map("subscription_id") + eventType String @map("event_type") + payload Json + status DeliveryStatus @default(pending) + statusCode Int? @map("status_code") + latencyMs Int? @map("latency_ms") + errorMessage String? @map("error_message") + attemptCount Int @default(0) @map("attempt_count") + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + + @@index([subscriptionId]) + @@index([status]) + @@index([createdAt]) + @@map("webhook_subscription_deliveries") +} + +// ─── Issue #463: Revenue Sharing Pool ─────────────────────────────────────────── + +model RevenuePool { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + name String + chain String @default("soroban") + contractId String @map("contract_id") + status PoolStatus @default(active) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + recipients RevenueRecipient[] + distributions RevenueDistribution[] + + @@index([tenantId]) + @@map("revenue_pools") +} + +model RevenueRecipient { + id String @id @default(uuid()) + poolId String @map("pool_id") + wallet String + ratioBps Int @map("ratio_bps") + accumulated String @default("0") @map("accumulated") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + pool RevenuePool @relation(fields: [poolId], references: [id], onDelete: Cascade) + + @@unique([poolId, wallet]) + @@map("revenue_recipients") +} + +model RevenueDistribution { + id String @id @default(uuid()) + poolId String @map("pool_id") + txHash String @map("tx_hash") + amount String @map("amount") + status DistributionStatus @default(pending) + createdAt DateTime @default(now()) @map("created_at") + + pool RevenuePool @relation(fields: [poolId], references: [id], onDelete: Cascade) + + @@index([poolId]) + @@map("revenue_distributions") +} + +// ─── Issue #464: EVM Paymaster / ERC-4337 ─────────────────────────────────────── + +model PaymasterBudget { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + chainId Int @map("chain_id") + token String + balance String @default("0") + totalDeposited String @default("0") @map("total_deposited") + totalUsed String @default("0") @map("total_used") + maxGasPerTx String? @map("max_gas_per_tx") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([tenantId, chainId, token]) + @@map("paymaster_budgets") +} + +model UserOperation { + id String @id @default(uuid()) + userOpHash String @unique @map("user_op_hash") + sender String + nonce String + paymaster String + mode String @default("verifying") + actualGasCost String? @map("actual_gas_cost") + status UserOpStatus @default(pending) + txHash String? @map("tx_hash") + errorMsg String? @map("error_msg") + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + @@index([sender]) + @@index([status]) + @@index([createdAt]) + @@map("user_operations") +} + +// ─── New Enums ────────────────────────────────────────────────────────────────── + +enum SubscriptionStatus { + active + paused + disabled +} + +enum DeliveryStatus { + pending + delivered + failed + retrying +} + +enum PoolStatus { + active + paused + archived +} + +enum DistributionStatus { + pending + completed + failed +} + +enum UserOpStatus { + pending + completed + failed +} diff --git a/backend/src/services/account-abstraction/index.ts b/backend/src/services/account-abstraction/index.ts new file mode 100644 index 00000000..3dda41db --- /dev/null +++ b/backend/src/services/account-abstraction/index.ts @@ -0,0 +1,362 @@ +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import type { + PaymasterBudget, + UserOperation, + UserOpStatus, +} from '@prisma/client'; + +export type { + PaymasterBudget, + UserOperation, + UserOpStatus, +}; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface SubmitUserOperationInput { + userOpHash: string; + sender: string; + nonce: string; + paymaster: string; + mode?: 'verifying' | 'deposit'; +} + +export interface EstimateUserOperationGasInput { + sender: string; + paymaster: string; + mode: 'verifying' | 'deposit'; + callData: string; + chainId: number; +} + +export interface UserOperationGasEstimate { + verificationGasLimit: string; + preVerificationGas: string; + callGasLimit: string; + paymasterVerificationGasLimit: string; + paymasterPostOpGasLimit: string; + estimatedFeeWei: string; +} + +export interface TopUpDepositInput { + tenantId: string; + chainId: number; + token: string; + amount: string; +} + +export interface CreatePaymasterBudgetInput { + tenantId: string; + chainId: number; + token: string; + balance: string; + totalDeposited: string; + totalUsed: string; + maxGasPerTx?: string; +} + +export interface UpdateUserOperationStatusInput { + status: UserOpStatus; + actualGasCost?: string; + txHash?: string; + errorMsg?: string; +} + +// ─── Service ──────────────────────────────────────────────────────────────────── + +export class AccountAbstractionService extends BaseService { + // ── User Operations ───────────────────────────────────────────────────────── + + async submitUserOperation( + input: SubmitUserOperationInput, + ): Promise> { + try { + if (!input.userOpHash.trim()) { + return this.validationFailure('userOpHash is required'); + } + if (!input.sender.trim()) { + return this.validationFailure('Sender address is required'); + } + + const existing = await prisma.userOperation.findUnique({ + where: { userOpHash: input.userOpHash }, + }); + if (existing) return this.conflictFailure('UserOperation already exists'); + + const op = await prisma.userOperation.create({ + data: { + userOpHash: input.userOpHash.trim(), + sender: input.sender.trim(), + nonce: input.nonce, + paymaster: input.paymaster.trim(), + mode: input.mode ?? 'verifying', + status: 'pending', + }, + }); + return this.ok(op); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async estimateUserOperationGas( + input: EstimateUserOperationGasInput, + ): Promise> { + try { + if (!input.callData || input.callData === '0x') { + return this.validationFailure('callData is required'); + } + + const calldataBytes = input.callData.startsWith('0x') + ? (input.callData.length - 2) / 2 + : input.callData.length / 2; + + const baseGas = 21_000n; + const calldataGas = BigInt(calldataBytes) * 16n; + const executionGas = BigInt(Math.max(30_000, calldataBytes * 100)); + const verificationGas = input.mode === 'verifying' ? 60_000n : 45_000n; + const postOpGas = input.mode === 'verifying' ? 20_000n : 15_000n; + + const callGasLimit = (baseGas + calldataGas + executionGas).toString(); + const verificationGasLimit = verificationGas.toString(); + const preVerificationGas = (baseGas + calldataGas).toString(); + const paymasterVerificationGasLimit = verificationGas.toString(); + const paymasterPostOpGasLimit = postOpGas.toString(); + const estimatedFeeWei = ( + (baseGas + calldataGas + executionGas + verificationGas + postOpGas) * 100_000_000n + ).toString(); + + return this.ok({ + verificationGasLimit, + preVerificationGas, + callGasLimit, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + estimatedFeeWei, + }); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getUserOperation(userOpHash: string): Promise> { + try { + const op = await prisma.userOperation.findUnique({ + where: { userOpHash }, + }); + if (!op) return this.notFoundFailure('UserOperation', userOpHash); + return this.ok(op); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listUserOperations( + filters?: { + sender?: string; + status?: UserOpStatus; + limit?: number; + offset?: number; + }, + ): Promise> { + try { + const ops = await prisma.userOperation.findMany({ + where: { + ...(filters?.sender ? { sender: filters.sender } : {}), + ...(filters?.status ? { status: filters.status } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: filters?.limit ?? 50, + skip: filters?.offset ?? 0, + }); + return this.ok(ops); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async updateUserOperationStatus( + userOpHash: string, + input: UpdateUserOperationStatusInput, + ): Promise> { + try { + const op = await prisma.userOperation.findUnique({ + where: { userOpHash }, + }); + if (!op) return this.notFoundFailure('UserOperation', userOpHash); + + const updated = await prisma.userOperation.update({ + where: { userOpHash }, + data: { + status: input.status, + ...(input.actualGasCost !== undefined ? { actualGasCost: input.actualGasCost } : {}), + ...(input.txHash !== undefined ? { txHash: input.txHash } : {}), + ...(input.errorMsg !== undefined ? { errorMsg: input.errorMsg } : {}), + ...(input.status === 'completed' || input.status === 'failed' + ? { completedAt: new Date() } + : {}), + }, + }); + return this.ok(updated); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + // ── Paymaster Budgets ────────────────────────────────────────────────────── + + async getOrCreateBudget( + input: CreatePaymasterBudgetInput, + ): Promise> { + try { + if (!input.token.trim()) { + return this.validationFailure('Token address is required'); + } + + const budget = await prisma.paymasterBudget.upsert({ + where: { + tenantId_chainId_token: { + tenantId: input.tenantId, + chainId: input.chainId, + token: input.token.toLowerCase(), + }, + }, + update: { + balance: input.balance, + totalDeposited: input.totalDeposited, + totalUsed: input.totalUsed, + ...(input.maxGasPerTx !== undefined ? { maxGasPerTx: input.maxGasPerTx } : {}), + }, + create: { + tenantId: input.tenantId, + chainId: input.chainId, + token: input.token.toLowerCase(), + balance: input.balance, + totalDeposited: input.totalDeposited, + totalUsed: input.totalUsed, + maxGasPerTx: input.maxGasPerTx ?? null, + }, + }); + return this.ok(budget); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async topUpDeposit(input: TopUpDepositInput): Promise> { + try { + if (BigInt(input.amount) <= 0n) { + return this.validationFailure('Deposit amount must be positive'); + } + + const existing = await prisma.paymasterBudget.findUnique({ + where: { + tenantId_chainId_token: { + tenantId: input.tenantId, + chainId: input.chainId, + token: input.token.toLowerCase(), + }, + }, + }); + if (!existing) return this.notFoundFailure('PaymasterBudget', `${input.chainId}:${input.token}`); + + const budget = await prisma.paymasterBudget.update({ + where: { + tenantId_chainId_token: { + tenantId: input.tenantId, + chainId: input.chainId, + token: input.token.toLowerCase(), + }, + }, + data: { + balance: { increment: input.amount }, + totalDeposited: { increment: input.amount }, + }, + }); + return this.ok(budget); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async recordPaymasterUsage( + tenantId: string, + chainId: number, + token: string, + amount: string, + ): Promise> { + try { + const existing = await prisma.paymasterBudget.findUnique({ + where: { + tenantId_chainId_token: { + tenantId, + chainId, + token: token.toLowerCase(), + }, + }, + }); + if (!existing) return this.notFoundFailure('PaymasterBudget', `${chainId}:${token}`); + + const currentBalance = BigInt(existing.balance); + const usageAmount = BigInt(amount); + if (usageAmount > currentBalance) { + return this.validationFailure('Insufficient paymaster budget balance'); + } + + const budget = await prisma.paymasterBudget.update({ + where: { + tenantId_chainId_token: { + tenantId, + chainId, + token: token.toLowerCase(), + }, + }, + data: { + balance: { increment: `-${amount}` }, + totalUsed: { increment: amount }, + }, + }); + return this.ok(budget); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getBudget( + tenantId: string, + chainId: number, + token: string, + ): Promise> { + try { + const budget = await prisma.paymasterBudget.findUnique({ + where: { + tenantId_chainId_token: { + tenantId, + chainId, + token: token.toLowerCase(), + }, + }, + }); + if (!budget) return this.notFoundFailure('PaymasterBudget', `${chainId}:${token}`); + return this.ok(budget); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listBudgets(tenantId: string): Promise> { + try { + const budgets = await prisma.paymasterBudget.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok(budgets); + } catch (error) { + return this.unexpectedFailure(error); + } + } +} + +export const accountAbstractionService = new AccountAbstractionService(); diff --git a/backend/src/services/revenue-pool/index.ts b/backend/src/services/revenue-pool/index.ts new file mode 100644 index 00000000..2f6dbef5 --- /dev/null +++ b/backend/src/services/revenue-pool/index.ts @@ -0,0 +1,364 @@ +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import type { + RevenuePool, + RevenueRecipient, + RevenueDistribution, + PoolStatus, + DistributionStatus, +} from '@prisma/client'; + +export type { + RevenuePool, + RevenueRecipient, + RevenueDistribution, + PoolStatus, + DistributionStatus, +}; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface CreateRevenuePoolInput { + tenantId: string; + name: string; + chain: 'soroban' | 'evm'; + contractId: string; +} + +export interface UpdateRevenuePoolInput { + name?: string; + chain?: 'soroban' | 'evm'; + contractId?: string; + status?: PoolStatus; +} + +export interface AddRecipientInput { + wallet: string; + ratioBps: number; +} + +export interface UpdateRecipientInput { + ratioBps: number; +} + +export interface RecipientBalance { + wallet: string; + ratioBps: number; + accumulated: string; +} + +export interface RecordDistributionInput { + txHash: string; + amount: string; +} + +// ─── Service ──────────────────────────────────────────────────────────────────── + +export class RevenuePoolService extends BaseService { + // ── Pool CRUD ──────────────────────────────────────────────────────────────── + + async createPool(input: CreateRevenuePoolInput): Promise> { + try { + if (input.chain !== 'soroban' && input.chain !== 'evm') { + return this.validationFailure('Chain must be "soroban" or "evm"'); + } + if (!input.name.trim()) { + return this.validationFailure('Pool name is required'); + } + if (!input.contractId.trim()) { + return this.validationFailure('Contract ID is required'); + } + + const pool = await prisma.revenuePool.create({ + data: { + tenantId: input.tenantId, + name: input.name.trim(), + chain: input.chain, + contractId: input.contractId.trim(), + }, + }); + return this.ok(pool); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getPool(id: string, tenantId: string): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', id); + return this.ok(pool); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listPools(tenantId: string): Promise> { + try { + const pools = await prisma.revenuePool.findMany({ + where: { tenantId, deletedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok(pools); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async updatePool( + id: string, + tenantId: string, + input: UpdateRevenuePoolInput, + ): Promise> { + try { + const existing = await prisma.revenuePool.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!existing) return this.notFoundFailure('RevenuePool', id); + + if (input.chain !== undefined && input.chain !== 'soroban' && input.chain !== 'evm') { + return this.validationFailure('Chain must be "soroban" or "evm"'); + } + + const pool = await prisma.revenuePool.update({ + where: { id }, + data: { + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.chain !== undefined ? { chain: input.chain } : {}), + ...(input.contractId !== undefined ? { contractId: input.contractId.trim() } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + }, + }); + return this.ok(pool); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async deletePool(id: string, tenantId: string): Promise> { + try { + const existing = await prisma.revenuePool.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!existing) return this.notFoundFailure('RevenuePool', id); + + await prisma.revenuePool.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + return this.ok(undefined as void); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + // ── Recipients ────────────────────────────────────────────────────────────── + + async addRecipient( + poolId: string, + tenantId: string, + input: AddRecipientInput, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + if (input.ratioBps <= 0 || input.ratioBps > 10_000) { + return this.validationFailure('ratioBps must be between 1 and 10_000'); + } + + const existing = await prisma.revenueRecipient.findUnique({ + where: { poolId_wallet: { poolId, wallet: input.wallet } }, + }); + if (existing) return this.conflictFailure('Recipient already exists in this pool'); + + const recipient = await prisma.revenueRecipient.create({ + data: { + poolId, + wallet: input.wallet, + ratioBps: input.ratioBps, + accumulated: '0', + }, + }); + return this.ok(recipient); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async updateRecipientRatio( + poolId: string, + wallet: string, + tenantId: string, + input: UpdateRecipientInput, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + if (input.ratioBps <= 0 || input.ratioBps > 10_000) { + return this.validationFailure('ratioBps must be between 1 and 10_000'); + } + + const recipient = await prisma.revenueRecipient.update({ + where: { poolId_wallet: { poolId, wallet } }, + data: { ratioBps: input.ratioBps }, + }); + return this.ok(recipient); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async removeRecipient( + poolId: string, + wallet: string, + tenantId: string, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + await prisma.revenueRecipient.delete({ + where: { poolId_wallet: { poolId, wallet } }, + }); + return this.ok(undefined as void); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listRecipients(poolId: string, tenantId: string): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + const recipients = await prisma.revenueRecipient.findMany({ + where: { poolId }, + orderBy: { createdAt: 'asc' }, + }); + return this.ok(recipients); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + // ── Balances ──────────────────────────────────────────────────────────────── + + async getBalances(poolId: string, tenantId: string): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + const recipients = await prisma.revenueRecipient.findMany({ + where: { poolId }, + }); + + const balances: RecipientBalance[] = recipients.map((r) => ({ + wallet: r.wallet, + ratioBps: r.ratioBps, + accumulated: r.accumulated, + })); + return this.ok(balances); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + // ── Distributions ─────────────────────────────────────────────────────────── + + async recordDistribution( + poolId: string, + tenantId: string, + input: RecordDistributionInput, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + if (!input.txHash.trim()) { + return this.validationFailure('Transaction hash is required'); + } + + const distribution = await prisma.revenueDistribution.create({ + data: { + poolId, + txHash: input.txHash.trim(), + amount: input.amount, + status: 'pending', + }, + }); + return this.ok(distribution); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async updateDistributionStatus( + id: string, + poolId: string, + tenantId: string, + status: DistributionStatus, + txHash?: string, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + const distribution = await prisma.revenueDistribution.findFirst({ + where: { id, poolId }, + }); + if (!distribution) return this.notFoundFailure('RevenueDistribution', id); + + const updated = await prisma.revenueDistribution.update({ + where: { id }, + data: { + status, + ...(txHash !== undefined ? { txHash } : {}), + }, + }); + return this.ok(updated); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listDistributions( + poolId: string, + tenantId: string, + ): Promise> { + try { + const pool = await prisma.revenuePool.findFirst({ + where: { id: poolId, tenantId, deletedAt: null }, + }); + if (!pool) return this.notFoundFailure('RevenuePool', poolId); + + const distributions = await prisma.revenueDistribution.findMany({ + where: { poolId }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok(distributions); + } catch (error) { + return this.unexpectedFailure(error); + } + } +} + +export const revenuePoolService = new RevenuePoolService(); diff --git a/backend/src/services/webhooks/event-matcher.ts b/backend/src/services/webhooks/event-matcher.ts new file mode 100644 index 00000000..092a9925 --- /dev/null +++ b/backend/src/services/webhooks/event-matcher.ts @@ -0,0 +1,252 @@ +import { prisma } from '../../lib/prisma.js'; +import type { WebhookSubscription } from '@prisma/client'; + +export interface WebhookEvent { + eventType: string; + resourceId?: string; + amount?: number; + currency?: string; + chain?: string; + tenantId: string; + payload: Record; + id: string; +} + +export interface MatchResult { + matched: WebhookSubscription[]; + deduplicated: number; +} + +interface ComparisonCondition { + eq?: unknown; + neq?: unknown; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + in?: unknown[]; + regex?: string; +} + +type JsonPathCondition = string | number | boolean | ComparisonCondition; + +interface FilterExpression { + eventTypes?: string[]; + jsonpath?: Record; +} + +const deliveredCache = new Set(); + +function parseFilterExpr(expr: string | null): FilterExpression | null { + if (!expr) return null; + try { + const parsed = JSON.parse(expr) as FilterExpression; + return parsed; + } catch { + return null; + } +} + +function resolveJsonPath(obj: unknown, path: string): unknown { + const parts = path.replace(/^\$\.?/, '').split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + if (Array.isArray(current)) { + if (part === '*') { + return current.map((item) => resolveJsonPath(item, parts.slice(1).join('.'))).filter((v) => v !== undefined); + } + const index = Number(part); + if (!Number.isNaN(index)) { + current = current[index]; + } else { + return undefined; + } + } else if (typeof current === 'object') { + current = (current as Record)[part]; + } else { + return undefined; + } + } + return current; +} + +function matchesComparison(value: unknown, condition: ComparisonCondition): boolean { + if (condition.eq !== undefined) return value === condition.eq; + if (condition.neq !== undefined) return value !== condition.neq; + if (condition.gt !== undefined) return typeof value === 'number' && value > condition.gt; + if (condition.gte !== undefined) return typeof value === 'number' && value >= condition.gte; + if (condition.lt !== undefined) return typeof value === 'number' && value < condition.lt; + if (condition.lte !== undefined) return typeof value === 'number' && value <= condition.lte; + if (condition.in !== undefined) return Array.isArray(condition.in) && condition.in.includes(value); + if (condition.regex !== undefined) { + try { + const re = new RegExp(condition.regex); + return typeof value === 'string' && re.test(value); + } catch { + return false; + } + } + return false; +} + +function matchesJsonPathCondition(value: unknown, condition: JsonPathCondition): boolean { + if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) { + return matchesComparison(value, condition as ComparisonCondition); + } + if (typeof condition === 'string' && condition.includes('*')) { + if (typeof value !== 'string') return false; + const pattern = '^' + condition.replace(/\*/g, '.*') + '$'; + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } + } + return value === condition; +} + +function evaluateJsonPathConditions( + payload: Record, + conditions: Record, +): boolean { + for (const [path, condition] of Object.entries(conditions)) { + const resolved = resolveJsonPath(payload, path); + if (!matchesJsonPathCondition(resolved, condition)) { + return false; + } + } + return true; +} + +function evaluateFilter(subscription: WebhookSubscription, event: WebhookEvent): boolean { + const parsed = parseFilterExpr(subscription.filterExpr); + if (!parsed) return true; + + if (parsed.eventTypes && parsed.eventTypes.length > 0) { + const matchesEventType = parsed.eventTypes.some((et) => { + if (et.includes('*')) { + const pattern = '^' + et.replace(/\*/g, '.*') + '$'; + try { + return new RegExp(pattern).test(event.eventType); + } catch { + return false; + } + } + return et === event.eventType; + }); + if (!matchesEventType) return false; + } + + if (parsed.jsonpath) { + return evaluateJsonPathConditions(event.payload, parsed.jsonpath); + } + + return true; +} + +function isDuplicate(subscriptionId: string, eventId: string): boolean { + const key = `${subscriptionId}:${eventId}`; + return deliveredCache.has(key); +} + +function markDelivered(subscriptionId: string, eventId: string): void { + const key = `${subscriptionId}:${eventId}`; + deliveredCache.add(key); +} + +export function clearDedupCache(): void { + deliveredCache.clear(); +} + +export function getDedupCacheSize(): number { + return deliveredCache.size; +} + +export function subscriptionEventTypes(subscription: WebhookSubscription): string[] { + return subscription.eventTypes; +} + +export async function findMatchingSubscriptions(event: WebhookEvent): Promise { + const subscriptions = await prisma.webhookSubscription.findMany({ + where: { + tenantId: event.tenantId, + status: 'active', + deletedAt: null, + eventTypes: { hasSome: [event.eventType] }, + }, + }); + + const matched: WebhookSubscription[] = []; + let deduplicated = 0; + + for (const sub of subscriptions) { + if (!evaluateFilter(sub, event)) continue; + + if (isDuplicate(sub.id, event.id)) { + deduplicated++; + continue; + } + + markDelivered(sub.id, event.id); + matched.push(sub); + } + + return { matched, deduplicated }; +} + +export async function recordDelivery( + subscriptionId: string, + eventId: string, + statusCode: number | null, + latencyMs: number, + success: boolean, +): Promise { + const sub = await prisma.webhookSubscription.findUnique({ where: { id: subscriptionId } }); + if (!sub) return; + + const newCount = sub.deliveryCount + 1; + const currentTotal = (sub.avgLatency ?? 0) * sub.deliveryCount; + const newAvg = newCount > 0 ? (currentTotal + latencyMs) / newCount : latencyMs; + + const fields = success + ? { deliveryCount: { increment: 1 }, successCount: { increment: 1 } } + : { deliveryCount: { increment: 1 }, failCount: { increment: 1 } }; + + await prisma.webhookSubscription.update({ + where: { id: subscriptionId }, + data: { + ...fields, + lastDelivered: new Date(), + avgLatency: Math.round(newAvg * 100) / 100, + }, + }); +} + +export async function findOverlappingSubscriptions( + tenantId: string, + eventType: string, +): Promise { + return prisma.webhookSubscription.findMany({ + where: { + tenantId, + status: 'active', + deletedAt: null, + eventTypes: { has: eventType }, + }, + }); +} + +export function evaluateFilterExpr( + expr: string | null, + event: WebhookEvent, +): boolean { + return evaluateFilter( + { + filterExpr: expr, + eventTypes: [event.eventType], + } as WebhookSubscription, + event, + ); +} diff --git a/backend/src/services/webhooks/subscription-manager.ts b/backend/src/services/webhooks/subscription-manager.ts new file mode 100644 index 00000000..5d3ac585 --- /dev/null +++ b/backend/src/services/webhooks/subscription-manager.ts @@ -0,0 +1,380 @@ +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import type { + WebhookSubscription, + WebhookSubscriptionDelivery, + SubscriptionStatus, + DeliveryStatus, +} from '@prisma/client'; + +export type { + WebhookSubscription, + WebhookSubscriptionDelivery, + SubscriptionStatus, + DeliveryStatus, +}; + +export interface CreateSubscriptionInput { + tenantId: string; + eventTypes: string[]; + targetUrl: string; + filterExpr?: string; + description?: string; +} + +export interface UpdateSubscriptionInput { + eventTypes?: string[]; + targetUrl?: string; + filterExpr?: string | null; + description?: string; +} + +export interface SubscriptionFilter { + tenantId?: string; + status?: SubscriptionStatus; + eventType?: string; +} + +export interface SubscriptionMetrics { + totalCount: number; + activeCount: number; + pausedCount: number; + disabledCount: number; + avgSuccessRate: number; + totalDeliveriesAll: number; +} + +export interface SubscriptionVersion { + version: number; + eventTypes: string[]; + filterExpr: string | null; + targetUrl: string; + createdAt: Date; +} + +export interface DeliveryStats { + deliveryCount: number; + successCount: number; + failCount: number; + avgLatency: number | null; + successRate: number; +} + +export class SubscriptionManager extends BaseService { + async create(input: CreateSubscriptionInput): Promise> { + try { + const subscription = await prisma.webhookSubscription.create({ + data: { + tenantId: input.tenantId, + eventTypes: input.eventTypes, + filterExpr: input.filterExpr ?? null, + targetUrl: input.targetUrl, + description: input.description ?? null, + }, + }); + return this.ok(subscription); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async get(id: string, tenantId: string): Promise> { + try { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!sub) return this.notFoundFailure('WebhookSubscription', id); + return this.ok(sub); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async list(tenantId: string, filter?: SubscriptionFilter): Promise> { + try { + const subs = await prisma.webhookSubscription.findMany({ + where: { + tenantId, + deletedAt: null, + ...(filter?.status ? { status: filter.status } : {}), + ...(filter?.eventType ? { eventTypes: { has: filter.eventType } } : {}), + }, + orderBy: { createdAt: 'desc' }, + }); + return this.ok(subs); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async update( + id: string, + tenantId: string, + input: UpdateSubscriptionInput, + ): Promise> { + try { + const existing = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!existing) return this.notFoundFailure('WebhookSubscription', id); + + const subscription = await prisma.webhookSubscription.update({ + where: { id }, + data: { + ...(input.eventTypes !== undefined ? { eventTypes: input.eventTypes } : {}), + ...(input.targetUrl !== undefined ? { targetUrl: input.targetUrl } : {}), + ...(input.filterExpr !== undefined ? { filterExpr: input.filterExpr } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + version: { increment: 1 }, + }, + }); + return this.ok(subscription); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async delete(id: string, tenantId: string): Promise> { + try { + const existing = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!existing) return this.notFoundFailure('WebhookSubscription', id); + + await prisma.webhookSubscription.update({ + where: { id }, + data: { deletedAt: new Date(), status: 'disabled' }, + }); + return this.ok(undefined as void); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async pause(id: string, tenantId: string): Promise> { + try { + const existing = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null, status: 'active' }, + }); + if (!existing) return this.notFoundFailure('WebhookSubscription', id); + + const sub = await prisma.webhookSubscription.update({ + where: { id }, + data: { status: 'paused', pausedAt: new Date(), version: { increment: 1 } }, + }); + return this.ok(sub); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async resume(id: string, tenantId: string): Promise> { + try { + const existing = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null, status: 'paused' }, + }); + if (!existing) return this.notFoundFailure('WebhookSubscription', id); + + const sub = await prisma.webhookSubscription.update({ + where: { id }, + data: { status: 'active', pausedAt: null, version: { increment: 1 } }, + }); + return this.ok(sub); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async testSubscription( + id: string, + tenantId: string, + samplePayload: Record, + eventType?: string, + ): Promise> { + try { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!sub) return this.notFoundFailure('WebhookSubscription', id); + if (sub.status !== 'active') { + return this.conflictFailure('Cannot test a non-active subscription'); + } + + const eventTypeToUse = eventType ?? 'test.ping'; + + const delivery = await prisma.webhookSubscriptionDelivery.create({ + data: { + subscriptionId: id, + eventType: eventTypeToUse, + payload: samplePayload, + status: 'pending', + }, + }); + + const startMs = Date.now(); + let statusCode: number | undefined; + let errorMessage: string | undefined; + let finalStatus: DeliveryStatus = 'delivered'; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + const response = await fetch(sub.targetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Test-Webhook': 'true', + 'X-Subscription-Id': sub.id, + 'X-Event-Type': eventTypeToUse, + }, + body: JSON.stringify(samplePayload), + signal: controller.signal, + }); + clearTimeout(timeout); + + statusCode = response.status; + if (!response.ok) { + finalStatus = 'failed'; + errorMessage = `HTTP ${response.status}`; + } + } catch (error) { + finalStatus = 'failed'; + errorMessage = error instanceof Error ? error.message : String(error); + } + + const latencyMs = Date.now() - startMs; + + const updated = await prisma.webhookSubscriptionDelivery.update({ + where: { id: delivery.id }, + data: { + status: finalStatus, + statusCode, + latencyMs, + errorMessage, + attemptCount: { increment: 1 }, + completedAt: new Date(), + }, + }); + + return this.ok(updated); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getMetrics(tenantId: string): Promise> { + try { + const subs = await prisma.webhookSubscription.findMany({ + where: { tenantId, deletedAt: null }, + }); + + const totalCount = subs.length; + const activeCount = subs.filter((s) => s.status === 'active').length; + const pausedCount = subs.filter((s) => s.status === 'paused').length; + const disabledCount = subs.filter((s) => s.status === 'disabled').length; + + const deliveredSubs = subs.filter((s) => s.deliveryCount > 0); + const totalSuccessRate = + deliveredSubs.length > 0 + ? deliveredSubs.reduce((sum, s) => { + const rate = s.deliveryCount > 0 ? s.successCount / s.deliveryCount : 0; + return sum + rate; + }, 0) / deliveredSubs.length + : 0; + + const totalDeliveriesAll = subs.reduce((sum, s) => sum + s.deliveryCount, 0); + + return this.ok({ + totalCount, + activeCount, + pausedCount, + disabledCount, + avgSuccessRate: Math.round(totalSuccessRate * 10000) / 100, + totalDeliveriesAll, + }); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getDeliveryStats(id: string, tenantId: string): Promise> { + try { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!sub) return this.notFoundFailure('WebhookSubscription', id); + + const successRate = + sub.deliveryCount > 0 + ? Math.round((sub.successCount / sub.deliveryCount) * 10000) / 100 + : 0; + + return this.ok({ + deliveryCount: sub.deliveryCount, + successCount: sub.successCount, + failCount: sub.failCount, + avgLatency: sub.avgLatency, + successRate, + }); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async getVersionHistory(id: string, tenantId: string): Promise> { + try { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + if (!sub) return this.notFoundFailure('WebhookSubscription', id); + + const deliveries = await prisma.webhookSubscriptionDelivery.findMany({ + where: { subscriptionId: id }, + orderBy: { createdAt: 'asc' }, + take: 1, + }); + + const history: SubscriptionVersion[] = [ + { + version: sub.version, + eventTypes: sub.eventTypes, + filterExpr: sub.filterExpr, + targetUrl: sub.targetUrl, + createdAt: sub.updatedAt, + }, + ]; + + return this.ok(history); + } catch (error) { + return this.unexpectedFailure(error); + } + } + + async listDeliveries( + subscriptionId: string, + tenantId: string, + limit = 50, + offset = 0, + ): Promise> { + try { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id: subscriptionId, tenantId, deletedAt: null }, + }); + if (!sub) return this.notFoundFailure('WebhookSubscription', subscriptionId); + + const deliveries = await prisma.webhookSubscriptionDelivery.findMany({ + where: { subscriptionId }, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }); + + return this.ok(deliveries); + } catch (error) { + return this.unexpectedFailure(error); + } + } +} + +export const subscriptionManager = new SubscriptionManager(); diff --git a/contracts/evm/contracts/ERC4337Paymaster.sol b/contracts/evm/contracts/ERC4337Paymaster.sol new file mode 100644 index 00000000..540d71b3 --- /dev/null +++ b/contracts/evm/contracts/ERC4337Paymaster.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IEntryPoint, UserOperation, IPaymaster, IStakeManager } from "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; + +/// @title ERC4337Paymaster +/// @notice ERC-4337 compatible paymaster that sponsors gas for UserOperations. +/// Supports two modes: +/// 1. Verification paymaster (pre-signed sponsorship) +/// 2. Deposit paymaster (pre-funded balance) +/// @dev Implements IPaymaster interface for EntryPoint v0.7 compatibility. +contract ERC4337Paymaster is IPaymaster { + enum PaymasterMode { + NONE, + VERIFYING, + DEPOSIT + } + + // ── State ──────────────────────────────────────────────────────────────── + + address public owner; + IEntryPoint public immutable entryPoint; + address public oracle; + + // Paymaster deposit balance in EntryPoint (for deposit mode) + // For verifying mode, no deposit needed — sponsorship is pre-signed + + uint256 public totalSponsored; + uint256 public totalFeesCollected; + + // Per-user budgets for deposit mode + struct Budget { + uint256 balance; + uint256 maxGasPerTx; + } + + mapping(address => Budget) public budgets; + mapping(address => uint256) public tokenPriceRatios; + mapping(address => bool) public acceptedTokens; + mapping(address => bool) public relayers; + + // For verifying mode: signer that authorizes operations + address public verifyingSigner; + + // ── Events ─────────────────────────────────────────────────────────────── + + event UserOperationSponsored( + address indexed sender, + uint256 indexed nonce, + bytes32 indexed userOpHash, + uint256 actualGasCost + ); + event BudgetDeposited(address indexed user, address indexed token, uint256 amount); + event BudgetWithdrawn(address indexed user, address indexed token, uint256 amount); + event TokenAccepted(address indexed token, bool accepted); + event TokenRatioUpdated(address indexed token, uint256 ratio); + event VerifyingSignerUpdated(address indexed signer); + event RelayerUpdated(address indexed relayer, bool active); + + // ── Errors ─────────────────────────────────────────────────────────────── + + error NotOwner(); + error NotRelayer(); + error ZeroAddress(); + error InsufficientBudget(); + error TokenNotAccepted(); + error InvalidUserOperation(); + error SignatureExpired(); + error InvalidSignature(); + error BudgetExceeded(); + + // ── Modifiers ──────────────────────────────────────────────────────────── + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + modifier onlyRelayer() { + if (!relayers[msg.sender]) revert NotRelayer(); + _; + } + + // ── Constructor ────────────────────────────────────────────────────────── + + constructor(IEntryPoint _entryPoint, address _oracle, address _verifyingSigner) { + owner = msg.sender; + entryPoint = _entryPoint; + oracle = _oracle; + verifyingSigner = _verifyingSigner; + _entryPoint.depositTo{ value: msg.value }(address(this)); + } + + // ── IPaymaster: validatePaymasterUserOp ───────────────────────────────── + + /// @notice Validates and pays for a UserOperation. + /// @dev Called by EntryPoint during account abstraction flow. + function validatePaymasterUserOp( + UserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) external override returns (bytes memory context, uint256 validationData) { + if (msg.sender != address(entryPoint)) revert InvalidUserOperation(); + + (PaymasterMode mode, bytes memory signature) = abi.decode(userOp.paymasterAndData[20:], (PaymasterMode, bytes)); + + if (mode == PaymasterMode.VERIFYING) { + return _validateVerifying(userOpHash, signature, maxCost); + } else if (mode == PaymasterMode.DEPOSIT) { + return _validateDeposit(userOp.sender, maxCost); + } + + revert InvalidUserOperation(); + } + + /// @notice Post-operation hook called by EntryPoint after UserOperation execution. + function postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost + ) external override { + if (msg.sender != address(entryPoint)) revert InvalidUserOperation(); + + (address sender, PaymasterMode postMode) = abi.decode(context, (address, PaymasterMode)); + + if (postMode == PaymasterMode.DEPOSIT) { + Budget storage budget = budgets[sender]; + if (budget.balance < actualGasCost) revert InsufficientBudget(); + unchecked { + budget.balance -= actualGasCost; + totalSponsored += actualGasCost; + } + } + + totalSponsored += actualGasCost; + emit UserOperationSponsored(sender, 0, bytes32(0), actualGasCost); + } + + // ── Internal Validation ───────────────────────────────────────────────── + + function _validateVerifying( + bytes32 userOpHash, + bytes memory signature, + uint256 maxCost + ) private view returns (bytes memory context, uint256 validationData) { + bytes32 hash = _getHash(userOpHash); + address signer = _recoverSigner(hash, signature); + if (signer != verifyingSigner) revert InvalidSignature(); + + context = abi.encode(address(0), PaymasterMode.VERIFYING); + return (context, 0); + } + + function _validateDeposit( + address sender, + uint256 maxCost + ) private view returns (bytes memory context, uint256 validationData) { + Budget storage budget = budgets[sender]; + if (budget.balance < maxCost) revert InsufficientBudget(); + if (budget.maxGasPerTx > 0 && maxCost > budget.maxGasPerTx) revert BudgetExceeded(); + + context = abi.encode(sender, PaymasterMode.DEPOSIT); + return (context, 0); + } + + // ── Signature Helpers ──────────────────────────────────────────────────── + + function _getHash(bytes32 userOpHash) private pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash)); + } + + function _recoverSigner(bytes32 hash, bytes memory signature) private pure returns (address) { + (uint8 v, bytes32 r, bytes32 s) = _splitSignature(signature); + return ecrecover(hash, v, r, s); + } + + function _splitSignature(bytes memory sig) private pure returns (uint8 v, bytes32 r, bytes32 s) { + if (sig.length != 65) revert InvalidSignature(); + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + } + + // ── Deposit Mode: Budget Management ────────────────────────────────────── + + function depositBudget(address token, uint256 amount) external { + if (!acceptedTokens[token]) revert TokenNotAccepted(); + (bool ok, bytes memory data) = token.call( + abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), amount) + ); + if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert("TokenTransferFailed"); + unchecked { budgets[msg.sender].balance += amount; } + emit BudgetDeposited(msg.sender, token, amount); + } + + function withdrawBudget(address token, uint256 amount) external { + Budget storage budget = budgets[msg.sender]; + if (budget.balance < amount) revert InsufficientBudget(); + unchecked { budget.balance -= amount; } + (bool ok, ) = token.call(abi.encodeWithSelector(0xa9059cbb, msg.sender, amount)); + if (!ok) revert("TokenTransferFailed"); + emit BudgetWithdrawn(msg.sender, token, amount); + } + + function setMaxGasPerTx(address user, uint256 maxGas) external onlyOwner { + budgets[user].maxGasPerTx = maxGas; + } + + // ── Admin ──────────────────────────────────────────────────────────────── + + function setAcceptedToken(address token, bool accepted) external onlyOwner { + if (token == address(0)) revert ZeroAddress(); + acceptedTokens[token] = accepted; + emit TokenAccepted(token, accepted); + } + + function setTokenRatio(address token, uint256 ratio) external onlyOwner { + tokenPriceRatios[token] = ratio; + emit TokenRatioUpdated(token, ratio); + } + + function setVerifyingSigner(address signer) external onlyOwner { + if (signer == address(0)) revert ZeroAddress(); + verifyingSigner = signer; + emit VerifyingSignerUpdated(signer); + } + + function setOracle(address _oracle) external onlyOwner { + oracle = _oracle; + } + + function setRelayer(address relayer, bool active) external onlyOwner { + if (relayer == address(0)) revert ZeroAddress(); + relayers[relayer] = active; + emit RelayerUpdated(relayer, active); + } + + function withdrawETH(address to, uint256 amount) external onlyOwner { + (bool ok, ) = to.call{value: amount}(""); + require(ok, "ETH transfer failed"); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + owner = newOwner; + } + + function addDeposit() external payable { + entryPoint.depositTo{ value: msg.value }(address(this)); + } + + function withdrawFromEntryPoint(address payable withdrawAddress, uint256 amount) external onlyOwner { + entryPoint.withdrawTo(withdrawAddress, amount); + } + + receive() external payable {} +} diff --git a/contracts/evm/contracts/RevenuePool.sol b/contracts/evm/contracts/RevenuePool.sol new file mode 100644 index 00000000..ea458300 --- /dev/null +++ b/contracts/evm/contracts/RevenuePool.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title AgenticPay Revenue Pool +/// @notice Distributes incoming ETH across configurable recipients with +/// accumulated claim balances and a minimum distribution threshold. +/// Each recipient claims their share on-demand rather than being +/// paid out on every distribution. +contract RevenuePool { + // ── State ──────────────────────────────────────────────────────────────── + + address public owner; + + struct Recipient { + address wallet; + uint256 ratioBps; // basis points (10_000 = 100%) + uint256 accumulated; // unclaimed ETH balance + } + + Recipient[] public recipients; + mapping(address => uint256) private _indexOf; // index + 1 (0 = not found) + uint256 public totalShares; // sum of all ratioBps, cannot exceed 10_000 + uint256 public minDistributionThreshold; + + // ── Events ─────────────────────────────────────────────────────────────── + + event RecipientAdded(address indexed recipient, uint256 ratioBps); + event RecipientRemoved( + address indexed recipient, + uint256 ratioBps, + uint256 accumulatedClaimed + ); + event RecipientRatioUpdated( + address indexed recipient, + uint256 oldRatioBps, + uint256 newRatioBps + ); + event Distributed(uint256 totalAmount); + event Claimed(address indexed recipient, uint256 amount); + event ThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + // ── Errors ─────────────────────────────────────────────────────────────── + + error NotOwner(); + error ZeroAddress(); + error InvalidRatio(); + error TotalExceedsMax(); + error BelowThreshold(); + error TransferFailed(); + + // ── Modifiers ──────────────────────────────────────────────────────────── + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + // ── Constructor ────────────────────────────────────────────────────────── + + constructor(address _owner) { + if (_owner == address(0)) revert ZeroAddress(); + owner = _owner; + } + + // ── Recipient Management ───────────────────────────────────────────────── + + /// @notice Add a new recipient with a given ratio. + function addRecipient(address recipient, uint256 ratioBps) external onlyOwner { + if (recipient == address(0)) revert ZeroAddress(); + if (ratioBps == 0 || ratioBps > 10_000) revert InvalidRatio(); + if (_indexOf[recipient] != 0) revert InvalidRatio(); + + uint256 newTotal; + unchecked { + newTotal = totalShares + ratioBps; + } + if (newTotal > 10_000) revert TotalExceedsMax(); + + recipients.push(Recipient(recipient, ratioBps, 0)); + _indexOf[recipient] = recipients.length; + + totalShares = newTotal; + + emit RecipientAdded(recipient, ratioBps); + } + + /// @notice Remove a recipient and forward their accumulated balance. + function removeRecipient(address recipient) external onlyOwner { + uint256 idx = _indexOf[recipient]; + if (idx == 0) revert InvalidRatio(); + + unchecked { + uint256 index = idx - 1; + uint256 lastIndex = recipients.length - 1; + + Recipient storage r = recipients[index]; + uint256 accrued = r.accumulated; + uint256 ratio = r.ratioBps; + uint256 remaining; + + // Swap with last and pop + if (index != lastIndex) { + Recipient storage last = recipients[lastIndex]; + recipients[index] = last; + _indexOf[last.wallet] = idx; + } + recipients.pop(); + delete _indexOf[recipient]; + + unchecked { + remaining = totalShares - ratio; + } + totalShares = remaining; + + // Claim accumulated balance before removing + if (accrued > 0) { + r.accumulated = 0; + (bool ok, ) = recipient.call{value: accrued}(""); + if (!ok) revert TransferFailed(); + emit Claimed(recipient, accrued); + } + + emit RecipientRemoved(recipient, ratio, accrued); + } + } + + /// @notice Update the ratio for an existing recipient. + function updateRatio(address recipient, uint256 newRatioBps) external onlyOwner { + if (newRatioBps == 0 || newRatioBps > 10_000) revert InvalidRatio(); + + uint256 idx = _indexOf[recipient]; + if (idx == 0) revert InvalidRatio(); + + Recipient storage r = recipients[idx - 1]; + uint256 oldRatio = r.ratioBps; + + uint256 newTotal; + if (newRatioBps > oldRatio) { + unchecked { + newTotal = totalShares + newRatioBps - oldRatio; + } + } else { + unchecked { + newTotal = totalShares - (oldRatio - newRatioBps); + } + } + if (newTotal > 10_000) revert TotalExceedsMax(); + + r.ratioBps = newRatioBps; + totalShares = newTotal; + + emit RecipientRatioUpdated(recipient, oldRatio, newRatioBps); + } + + // ── Distribution ───────────────────────────────────────────────────────── + + /// @notice Distribute `msg.value` among all recipients by ratio. + /// Accumulates shares in each recipient's balance for later claim. + function distribute() external payable { + _distribute(); + } + + function _distribute() internal { + uint256 amount = msg.value; + if (amount == 0) revert BelowThreshold(); + + uint256 len = recipients.length; + if (len == 0) revert InvalidRatio(); + + for (uint256 i; i < len; ) { + Recipient storage r = recipients[i]; + uint256 share; + unchecked { + share = (amount * r.ratioBps) / 10_000; + } + if (share > 0) { + unchecked { + r.accumulated += share; + } + } + unchecked { + ++i; + } + } + + emit Distributed(amount); + } + + // ── Claims ─────────────────────────────────────────────────────────────── + + /// @notice Claim the caller's accumulated balance. + function claim() external { + address recipient = msg.sender; + uint256 idx = _indexOf[recipient]; + if (idx == 0) revert InvalidRatio(); + + Recipient storage r = recipients[idx - 1]; + uint256 amount = r.accumulated; + if (amount < minDistributionThreshold) revert BelowThreshold(); + + r.accumulated = 0; + + (bool ok, ) = recipient.call{value: amount}(""); + if (!ok) revert TransferFailed(); + + emit Claimed(recipient, amount); + } + + // ── Views ──────────────────────────────────────────────────────────────── + + /// @notice Return the full list of configured recipients. + function getRecipients() external view returns (Recipient[] memory) { + return recipients; + } + + /// @notice Return the accumulated balance for a given recipient. + function getAccumulated(address recipient) external view returns (uint256) { + uint256 idx = _indexOf[recipient]; + if (idx == 0) return 0; + return recipients[idx - 1].accumulated; + } + + // ── Admin ──────────────────────────────────────────────────────────────── + + /// @notice Set the minimum threshold for claiming. + function setMinDistributionThreshold(uint256 threshold) external onlyOwner { + uint256 old = minDistributionThreshold; + minDistributionThreshold = threshold; + emit ThresholdUpdated(old, threshold); + } + + /// @notice Transfer contract ownership. + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + owner = newOwner; + } + + /// @notice Auto-distribute any ETH sent directly to the contract. + receive() external payable { + _distribute(); + } +} diff --git a/contracts/soroban/revenue-pool/Cargo.toml b/contracts/soroban/revenue-pool/Cargo.toml new file mode 100644 index 00000000..6d343dc6 --- /dev/null +++ b/contracts/soroban/revenue-pool/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "agenticpay-revenue-pool" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/soroban/revenue-pool/src/lib.rs b/contracts/soroban/revenue-pool/src/lib.rs new file mode 100644 index 00000000..859c2ec7 --- /dev/null +++ b/contracts/soroban/revenue-pool/src/lib.rs @@ -0,0 +1,237 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contractmeta, symbol_short, Address, Env, IntoVal, Map, String, Symbol, TryFromVal, Val}; + +contractmeta!( + key = "Description", + val = "AgenticPay Revenue Sharing Pool" +); + +const ADMIN: Symbol = symbol_short!("ADMIN"); +const RECIPIENTS: Symbol = symbol_short!("RCPNTS"); +const TOTAL_SHARES: Symbol = symbol_short!("TSHRES"); +const MIN_DIST: Symbol = symbol_short!("MINDIST"); +const ACCUMULATED_KEY: fn(Address) -> Symbol = |addr: Address| { + Symbol::new( + &addr + .to_string() + .as_bytes() + .iter() + .take(8) + .copied() + .collect::>(), + ) +}; + +#[contract] +pub struct RevenuePool; + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct Recipient { + pub wallet: Address, + pub ratio_bps: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum DataKey { + Admin, + Recipients, + TotalShares, + MinDist, + Accumulated(Address), +} + +#[contractimpl] +impl RevenuePool { + pub fn __constructor(env: Env, admin: Address) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Recipients, &Vec::new(&env)); + env.storage().instance().set(&DataKey::TotalShares, &0u32); + env.storage() + .instance() + .set(&DataKey::MinDist, &0i128); + } + + fn require_admin(env: &Env) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + } + + pub fn add_recipient(env: Env, wallet: Address, ratio_bps: u32) { + Self::require_admin(&env); + let total: u32 = env.storage().instance().get(&DataKey::TotalShares).unwrap(); + if total + ratio_bps > 10000 { + panic!("total shares exceed 10000"); + } + let mut recipients: Vec = env + .storage() + .instance() + .get(&DataKey::Recipients) + .unwrap(); + for r in recipients.iter() { + if r.wallet == wallet { + panic!("recipient already exists"); + } + } + recipients.push_back(Recipient { wallet: wallet.clone(), ratio_bps }); + env.storage().instance().set(&DataKey::Recipients, &recipients); + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total + ratio_bps)); + env.storage() + .instance() + .set(&DataKey::Accumulated(wallet), &0i128); + env.events().publish(("revenue_pool", "recipient_added"), wallet); + } + + pub fn remove_recipient(env: Env, wallet: Address) { + Self::require_admin(&env); + let mut recipients: Vec = env + .storage() + .instance() + .get(&DataKey::Recipients) + .unwrap(); + let mut removed_ratio = 0u32; + let mut new_recipients: Vec = Vec::new(&env); + for r in recipients.iter() { + if r.wallet == wallet { + removed_ratio = r.ratio_bps; + } else { + new_recipients.push_back(r); + } + } + if removed_ratio == 0 { + panic!("recipient not found"); + } + let total: u32 = env.storage().instance().get(&DataKey::TotalShares).unwrap(); + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total - removed_ratio)); + let accumulated: i128 = env + .storage() + .instance() + .get(&DataKey::Accumulated(wallet.clone())) + .unwrap_or(0); + if accumulated > 0 { + env.storage() + .instance() + .set(&DataKey::Accumulated(wallet.clone()), &0i128); + env.balance().decrease(accumulated); + } + env.storage().instance().set(&DataKey::Recipients, &new_recipients); + env.events().publish(("revenue_pool", "recipient_removed"), wallet); + } + + pub fn update_ratio(env: Env, wallet: Address, new_ratio_bps: u32) { + Self::require_admin(&env); + let mut recipients: Vec = env + .storage() + .instance() + .get(&DataKey::Recipients) + .unwrap(); + let total: u32 = env.storage().instance().get(&DataKey::TotalShares).unwrap(); + let mut found = false; + let mut old_ratio = 0u32; + let mut new_recipients: Vec = Vec::new(&env); + for r in recipients.iter() { + if r.wallet == wallet { + old_ratio = r.ratio_bps; + new_recipients.push_back(Recipient { wallet: wallet.clone(), ratio_bps: new_ratio_bps }); + found = true; + } else { + new_recipients.push_back(r); + } + } + if !found { + panic!("recipient not found"); + } + if total - old_ratio + new_ratio_bps > 10000 { + panic!("total shares exceed 10000"); + } + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total - old_ratio + new_ratio_bps)); + env.storage().instance().set(&DataKey::Recipients, &new_recipients); + env.events().publish(("revenue_pool", "ratio_updated"), (wallet, new_ratio_bps)); + } + + pub fn distribute(env: Env) { + let amount = env.current_contract().balance(); + if amount == 0 { + return; + } + let recipients: Vec = env + .storage() + .instance() + .get(&DataKey::Recipients) + .unwrap(); + let total: u32 = env.storage().instance().get(&DataKey::TotalShares).unwrap(); + if total == 0 { + return; + } + let min_dist: i128 = env.storage().instance().get(&DataKey::MinDist).unwrap_or(0); + for r in recipients.iter() { + let share = (amount * r.ratio_bps as i128) / total as i128; + if share >= min_dist { + let accumulated: i128 = env + .storage() + .instance() + .get(&DataKey::Accumulated(r.wallet.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Accumulated(r.wallet.clone()), &(accumulated + share)); + env.events().publish(("revenue_pool", "distributed"), (r.wallet.clone(), share)); + } + } + } + + pub fn claim(env: Env) { + let caller = env.current_contract(); + let accumulated: i128 = env + .storage() + .instance() + .get(&DataKey::Accumulated(caller.clone())) + .unwrap_or(0); + let min_dist: i128 = env.storage().instance().get(&DataKey::MinDist).unwrap_or(0); + if accumulated < min_dist { + panic!("below minimum distribution threshold"); + } + env.storage() + .instance() + .set(&DataKey::Accumulated(caller.clone()), &0i128); + env.balance().decrease(accumulated); + env.events().publish(("revenue_pool", "claimed"), (caller.clone(), accumulated)); + } + + pub fn get_recipients(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::Recipients) + .unwrap() + } + + pub fn get_accumulated(env: Env, wallet: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Accumulated(wallet)) + .unwrap_or(0) + } + + pub fn set_min_distribution_threshold(env: Env, threshold: i128) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&DataKey::MinDist, &threshold); + env.events().publish(("revenue_pool", "min_dist_updated"), threshold); + } + + pub fn transfer_ownership(env: Env, new_admin: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish(("revenue_pool", "ownership_transferred"), new_admin); + } +} diff --git a/frontend/app/[locale]/dashboard/developers/paymaster/page.tsx b/frontend/app/[locale]/dashboard/developers/paymaster/page.tsx new file mode 100644 index 00000000..0cf51539 --- /dev/null +++ b/frontend/app/[locale]/dashboard/developers/paymaster/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { api, PaymasterBudget, UserOperation } from '@/lib/api'; +import { toast } from 'sonner'; +import { Wallet, RefreshCw, Activity, Fuel, ArrowUpRight, CheckCircle, XCircle, Clock } from 'lucide-react'; + +export default function PaymasterPage() { + const [budgets, setBudgets] = useState([]); + const [operations, setOperations] = useState([]); + const [loading, setLoading] = useState(true); + const [topUpOpen, setTopUpOpen] = useState(null); + const [topUpAmount, setTopUpAmount] = useState(''); + + const loadData = async () => { + try { + setLoading(true); + const [budgetsRes, opsRes] = await Promise.all([ + api.paymaster.listBudgets(), + api.paymaster.listOperations(), + ]); + setBudgets(budgetsRes.budgets); + setOperations(opsRes.operations); + } catch { + toast.error('Failed to load paymaster data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); + + const handleTopUp = async (budgetId: string) => { + try { + await api.paymaster.topUp(budgetId, { token: 'native', amount: topUpAmount }); + setTopUpOpen(null); + setTopUpAmount(''); + toast.success('Budget topped up'); + loadData(); + } catch { + toast.error('Failed to top up budget'); + } + }; + + return ( +
+
+

Paymaster & Account Abstraction

+

Manage ERC-4337 paymaster budgets and user operations

+
+ + + + Budgets + Operations + + + + {loading ? ( +
Loading...
+ ) : budgets.length === 0 ? ( +
+ +

No paymaster budgets

+

Budgets appear once user operations are submitted.

+
+ ) : ( +
+ {budgets.map(b => ( + + +
+ + Chain {b.chainId} — {b.token} + +
+ setTopUpOpen(o ? b.id : null)}> + + + + + + Top Up Budget + +
+
+ + setTopUpAmount(e.target.value)} /> +
+ +
+
+
+
+ +
+
+ Balance + {b.balance} +
+
+ Total Deposited + {b.totalDeposited} +
+
+ Total Used + {b.totalUsed} +
+
+ Max Gas/Tx + {b.maxGasPerTx || 'unlimited'} +
+
+
+
+ ))} +
+ )} +
+ + + {loading ? ( +
Loading...
+ ) : operations.length === 0 ? ( +
+ +

No user operations yet

+

Operations appear when users submit sponsored transactions.

+
+ ) : ( +
+ {operations.map(op => ( + + +
+
+ {op.status === 'completed' ? ( + + ) : op.status === 'failed' ? ( + + ) : ( + + )} + {op.userOpHash.slice(0, 16)}... + {op.mode} +
+
+ {op.sender.slice(0, 8)}... + {op.txHash && {op.txHash.slice(0, 10)}...} + + {op.status} + +
+
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/[locale]/dashboard/developers/revenue-pools/page.tsx b/frontend/app/[locale]/dashboard/developers/revenue-pools/page.tsx new file mode 100644 index 00000000..62b820d7 --- /dev/null +++ b/frontend/app/[locale]/dashboard/developers/revenue-pools/page.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { api, RevenuePool, RevenueRecipient, RevenueDistribution } from '@/lib/api'; +import { toast } from 'sonner'; +import { Plus, Trash2, PieChart, History, Wallet, ArrowUpRight } from 'lucide-react'; + +export default function RevenuePoolsPage() { + const [pools, setPools] = useState([]); + const [recipients, setRecipients] = useState>({}); + const [balances, setBalances] = useState>>({}); + const [distributions, setDistributions] = useState>({}); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [addRecipientOpen, setAddRecipientOpen] = useState(null); + const [newPool, setNewPool] = useState({ name: '', chain: 'soroban' as const, contractId: '' }); + const [newRecipient, setNewRecipient] = useState({ address: '', ratio: 0 }); + + const loadData = async () => { + try { + setLoading(true); + const res = await api.revenuePools.listPools(); + setPools(res.pools); + const recMap: Record = {}; + const balMap: Record> = {}; + const distMap: Record = {}; + for (const pool of res.pools) { + try { + const [recRes, balRes, distRes] = await Promise.all([ + api.revenuePools.getPool(pool.id), + api.revenuePools.getBalances(pool.id), + api.revenuePools.listDistributions(pool.id), + ]); + if ('recipients' in recRes) recMap[pool.id] = (recRes as any).recipients; + balMap[pool.id] = balRes.balances || {}; + distMap[pool.id] = distRes.distributions || []; + } catch {} + } + setRecipients(recMap); + setBalances(balMap); + setDistributions(distMap); + } catch { + toast.error('Failed to load revenue pools'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); + + const handleCreate = async () => { + try { + const pool = await api.revenuePools.createPool(newPool); + setPools(p => [...p, pool]); + setCreateOpen(false); + setNewPool({ name: '', chain: 'soroban', contractId: '' }); + toast.success('Revenue pool created'); + } catch { + toast.error('Failed to create pool'); + } + }; + + const handleAddRecipient = async (poolId: string) => { + try { + await api.revenuePools.addRecipient(poolId, newRecipient); + setAddRecipientOpen(null); + setNewRecipient({ address: '', ratio: 0 }); + toast.success('Recipient added'); + loadData(); + } catch { + toast.error('Failed to add recipient'); + } + }; + + const handleRemoveRecipient = async (poolId: string, recipientId: string) => { + try { + await api.revenuePools.removeRecipient(poolId, recipientId); + toast.success('Recipient removed'); + loadData(); + } catch { + toast.error('Failed to remove recipient'); + } + }; + + return ( +
+
+
+

Revenue Pools

+

Manage smart contract-driven revenue sharing pools

+
+ + + + + + + Create Revenue Pool + +
+
+ + setNewPool(p => ({ ...p, name: e.target.value }))} /> +
+
+ + +
+
+ + setNewPool(p => ({ ...p, contractId: e.target.value }))} /> +
+ +
+
+
+
+ + {loading ? ( +
Loading...
+ ) : pools.length === 0 ? ( +
+ +

No revenue pools yet

+

Create your first pool to start splitting revenue automatically.

+
+ ) : ( +
+ {pools.map(pool => ( + + +
+ {pool.name} +
+ {pool.chain} + {pool.status} +
+
+
+ setAddRecipientOpen(o ? pool.id : null)}> + + + + + + Add Recipient + +
+
+ + setNewRecipient(r => ({ ...r, address: e.target.value }))} /> +
+
+ + setNewRecipient(r => ({ ...r, ratio: parseInt(e.target.value) || 0 }))} /> +
+ +
+
+
+
+
+ +
+
+

+ Recipients & Splits +

+ {(recipients[pool.id] || []).length === 0 ? ( +

No recipients configured

+ ) : ( +
+
+ {(recipients[pool.id] || []).map((r, i) => ( +
+ ))} +
+
+ {(recipients[pool.id] || []).map((r, i) => ( +
+
+
+ {r.address.slice(0, 8)}...{r.address.slice(-4)} +
+
+ {r.ratio}% + {balances[pool.id]?.[r.address] || '0'} + +
+
+ ))} +
+
+ )} +
+
+

+ Recent Distributions +

+ {(distributions[pool.id] || []).length === 0 ? ( +

No distributions yet

+ ) : ( +
+ {(distributions[pool.id] || []).slice(0, 5).map(d => ( +
+ {d.txHash.slice(0, 12)}... +
+ + {d.status} + + {d.amount} +
+
+ ))} +
+ )} +
+
+ + + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/[locale]/dashboard/developers/webhooks/subscriptions/page.tsx b/frontend/app/[locale]/dashboard/developers/webhooks/subscriptions/page.tsx new file mode 100644 index 00000000..dd7e8df3 --- /dev/null +++ b/frontend/app/[locale]/dashboard/developers/webhooks/subscriptions/page.tsx @@ -0,0 +1,704 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { apiCall } from '@/lib/api/client'; +import type { + WebhookSubscription, + WebhookSubscriptionStatus, + WebhookSubscriptionsResponse, + CreateWebhookSubscriptionRequest, +} from '@/lib/api'; +import { + Plus, + PauseCircle, + PlayCircle, + Trash2, + RefreshCw, + Webhook, + Activity, + CheckCircle2, + XCircle, + Clock, + AlertTriangle, + ExternalLink, + Filter, + Zap, +} from 'lucide-react'; + +const EVENT_TYPE_OPTIONS = [ + { label: 'Payment Created', value: 'payment.created' }, + { label: 'Payment Completed', value: 'payment.completed' }, + { label: 'Payment Failed', value: 'payment.failed' }, + { label: 'Invoice Created', value: 'invoice.created' }, + { label: 'Invoice Paid', value: 'invoice.paid' }, + { label: 'Invoice Overdue', value: 'invoice.overdue' }, + { label: 'Subscription Created', value: 'subscription.created' }, + { label: 'Subscription Cancelled', value: 'subscription.cancelled' }, + { label: 'Dispute Created', value: 'dispute.created' }, + { label: 'Dispute Resolved', value: 'dispute.resolved' }, + { label: 'Account Updated', value: 'account.updated' }, + { label: 'Payout Completed', value: 'payout.completed' }, +]; + +function MultiSelect({ + options, + selected, + onChange, + placeholder, +}: { + options: { label: string; value: string }[]; + selected: string[]; + onChange: (values: string[]) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + + const toggle = (value: string) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value]; + onChange(next); + }; + + return ( + + + + + +
+ {options.map((option) => { + const isSelected = selected.includes(option.value); + return ( + + ); + })} +
+
+
+ ); +} + +function StatusBadge({ status }: { status: WebhookSubscriptionStatus }) { + switch (status) { + case 'active': + return ( + + + Active + + ); + case 'paused': + return ( + + + Paused + + ); + case 'disabled': + return ( + + + Disabled + + ); + } +} + +function DeliveryStatCard({ + icon, + label, + value, + colorClass, +}: { + icon: React.ReactNode; + label: string; + value: string; + colorClass: string; +}) { + return ( +
+
{icon}
+
+

{label}

+

{value}

+
+
+ ); +} + +const SUBSCRIPTION_API_BASE = '/webhooks/subscriptions'; + +export default function WebhookSubscriptionsPage() { + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [testingId, setTestingId] = useState(null); + + // Create form state + const [formEventTypes, setFormEventTypes] = useState([]); + const [formTargetUrl, setFormTargetUrl] = useState(''); + const [formDescription, setFormDescription] = useState(''); + const [formFilterExpression, setFormFilterExpression] = useState(''); + const [formSubmitting, setFormSubmitting] = useState(false); + + const loadSubscriptions = useCallback(async () => { + try { + setLoading(true); + const response = await apiCall( + SUBSCRIPTION_API_BASE, + { method: 'GET' }, + ); + setSubscriptions(response.subscriptions); + } catch (error) { + console.error(error); + toast.error('Failed to load webhook subscriptions'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSubscriptions(); + }, [loadSubscriptions]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (formEventTypes.length === 0) { + toast.error('Please select at least one event type'); + return; + } + if (!formTargetUrl) { + toast.error('Please enter a target URL'); + return; + } + + setFormSubmitting(true); + try { + const payload: CreateWebhookSubscriptionRequest = { + eventTypes: formEventTypes, + targetUrl: formTargetUrl, + description: formDescription || undefined, + filterExpression: formFilterExpression || undefined, + }; + await apiCall(SUBSCRIPTION_API_BASE, { + method: 'POST', + body: JSON.stringify(payload), + }); + toast.success('Webhook subscription created'); + setCreateDialogOpen(false); + resetForm(); + loadSubscriptions(); + } catch (error) { + console.error(error); + toast.error('Failed to create webhook subscription'); + } finally { + setFormSubmitting(false); + } + }; + + const resetForm = () => { + setFormEventTypes([]); + setFormTargetUrl(''); + setFormDescription(''); + setFormFilterExpression(''); + }; + + const handleTogglePause = async ( + subscription: WebhookSubscription, + ) => { + try { + if (subscription.status === 'active') { + await apiCall( + `${SUBSCRIPTION_API_BASE}/${subscription.id}/pause`, + { method: 'POST' }, + ); + toast.success('Subscription paused'); + } else { + await apiCall( + `${SUBSCRIPTION_API_BASE}/${subscription.id}/resume`, + { method: 'POST' }, + ); + toast.success('Subscription resumed'); + } + loadSubscriptions(); + } catch (error) { + console.error(error); + toast.error( + subscription.status === 'active' + ? 'Failed to pause subscription' + : 'Failed to resume subscription', + ); + } + }; + + const handleDelete = async (id: string) => { + try { + await apiCall(`${SUBSCRIPTION_API_BASE}/${id}`, { + method: 'DELETE', + }); + toast.success('Webhook subscription deleted'); + setDeleteConfirmId(null); + loadSubscriptions(); + } catch (error) { + console.error(error); + toast.error('Failed to delete webhook subscription'); + } + }; + + const handleTest = async (id: string) => { + setTestingId(id); + try { + await apiCall<{ eventId: string }>( + `${SUBSCRIPTION_API_BASE}/${id}/test`, + { method: 'POST' }, + ); + toast.success('Sample event sent for testing'); + } catch (error) { + console.error(error); + toast.error('Failed to send test event'); + } finally { + setTestingId(null); + } + }; + + const formatLatency = (ms: number) => { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; + }; + + if (loading) { + return ( +
+
+

+ Webhook Subscriptions +

+

+ Loading subscriptions... +

+
+
+ ); + } + + return ( +
+ +

+ Webhook Subscriptions +

+

+ Manage event subscriptions, monitor delivery health, and test + endpoints. +

+
+ + {/* Subscriptions List */} + + + +
+ Subscriptions +
+ + { + setCreateDialogOpen(open); + if (!open) resetForm(); + }} + > + + + + + + Create Webhook Subscription + +
+
+ + +
+ +
+ + setFormTargetUrl(e.target.value)} + required + /> +
+ +
+ +