diff --git a/backend/frontend/components/payment-requests/PaymentRequestExpirationBadge.tsx b/backend/frontend/components/payment-requests/PaymentRequestExpirationBadge.tsx new file mode 100644 index 00000000..86c3ceb3 --- /dev/null +++ b/backend/frontend/components/payment-requests/PaymentRequestExpirationBadge.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type RequestStatus = 'pending' | 'paid' | 'expired' | 'cancelled'; + +interface PaymentRequestExpirationBadgeProps { + status: RequestStatus; + expiresAt: string | Date; // ISO string or Date + expiredAt?: string | Date | null; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function msRemaining(expiresAt: Date): number { + return expiresAt.getTime() - Date.now(); +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return 'Expired'; + const totalSeconds = Math.floor(ms / 1000); + const d = Math.floor(totalSeconds / 86400); + const h = Math.floor((totalSeconds % 86400) / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (d > 0) return `${d}d ${h}h remaining`; + if (h > 0) return `${h}h ${m}m remaining`; + if (m > 0) return `${m}m ${s}s remaining`; + return `${s}s remaining`; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +/** + * Displays the expiration state of a payment request. + * + * - pending → live countdown ticker (turns red when < 5 min) + * - expired → "Expired" badge with timestamp + * - paid → "Paid" badge + * - cancelled→ "Cancelled" badge + */ +export function PaymentRequestExpirationBadge({ + status, + expiresAt, + expiredAt, +}: PaymentRequestExpirationBadgeProps) { + const deadline = new Date(expiresAt); + const [remaining, setRemaining] = useState(msRemaining(deadline)); + + // Live countdown — only active for pending requests. + useEffect(() => { + if (status !== 'pending') return; + const interval = setInterval(() => { + setRemaining(msRemaining(deadline)); + }, 1000); + return () => clearInterval(interval); + }, [status, deadline]); + + if (status === 'paid') { + return ( + + ✓ Paid + + ); + } + + if (status === 'cancelled') { + return ( + + Cancelled + + ); + } + + if (status === 'expired' || remaining <= 0) { + const expiredDisplay = expiredAt + ? new Date(expiredAt).toLocaleString() + : deadline.toLocaleString(); + return ( + + ⏰ Expired + + ); + } + + // Pending — show countdown, highlight urgency. + const critical = remaining < 5 * 60 * 1000; // < 5 min + const warning = remaining < 30 * 60 * 1000; // < 30 min + + const colorClass = critical + ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + : warning + ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; + + return ( + + {critical ? '⚠️' : '⏳'} {formatCountdown(remaining)} + + ); +} diff --git a/backend/frontend/components/payment-requests/PaymentRequestList.tsx b/backend/frontend/components/payment-requests/PaymentRequestList.tsx new file mode 100644 index 00000000..c9c6e133 --- /dev/null +++ b/backend/frontend/components/payment-requests/PaymentRequestList.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { PaymentRequestExpirationBadge, type RequestStatus } from './PaymentRequestExpirationBadge'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PaymentRequestItem { + id: string; + amount: string; + currency: string; + status: RequestStatus; + expiresAt: string; + expiredAt?: string | null; + paidAt?: string | null; + requesterAddress: string; + payerAddress?: string | null; + memo?: string | null; + createdAt: string; +} + +type FilterStatus = 'all' | RequestStatus; + +interface PaymentRequestListProps { + requests: PaymentRequestItem[]; + onRenew?: (id: string) => void; + onCancel?: (id: string) => void; + isLoading?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +/** + * Dashboard table for payment requests. + * + * Features: + * - Filter by status (all / pending / paid / expired / cancelled) + * - Live expiration countdown per row via PaymentRequestExpirationBadge + * - Renew action for expired/cancelled requests + * - Cancel action for pending requests + */ +export function PaymentRequestList({ + requests, + onRenew, + onCancel, + isLoading = false, +}: PaymentRequestListProps) { + const [filter, setFilter] = useState('all'); + + const filtered = useCallback( + () => + filter === 'all' + ? requests + : requests.filter((r) => r.status === filter), + [requests, filter], + )(); + + const filterButtons: { label: string; value: FilterStatus }[] = [ + { label: 'All', value: 'all' }, + { label: 'Pending', value: 'pending' }, + { label: 'Paid', value: 'paid' }, + { label: 'Expired', value: 'expired' }, + { label: 'Cancelled', value: 'cancelled' }, + ]; + + return ( +
+ {/* ── Filter bar ── */} +
+ {filterButtons.map(({ label, value }) => ( + + ))} +
+ + {/* ── Table ── */} + {isLoading ? ( +

Loading…

+ ) : filtered.length === 0 ? ( +

No payment requests found.

+ ) : ( +
+ + + + {['ID', 'Amount', 'Status / Expiry', 'From / To', 'Created', 'Actions'].map( + (h) => ( + + ), + )} + + + + {filtered.map((req) => ( + + {/* ID */} + + + {/* Amount */} + + + {/* Status / Expiry */} + + + {/* Addresses */} + + + {/* Created */} + + + {/* Actions */} + + + ))} + +
+ {h} +
+ {req.id.slice(0, 8)}… + + {req.amount} {req.currency} + + + +
+ From: {req.requesterAddress.slice(0, 8)}… +
+ {req.payerAddress && ( +
+ To: {req.payerAddress.slice(0, 8)}… +
+ )} +
+ {new Date(req.createdAt).toLocaleDateString()} + +
+ {(req.status === 'expired' || req.status === 'cancelled') && onRenew && ( + + )} + {req.status === 'pending' && onCancel && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/backend/prisma/migrations/20260629_add_payment_request_expiration/migration.sql b/backend/prisma/migrations/20260629_add_payment_request_expiration/migration.sql new file mode 100644 index 00000000..a253ee6c --- /dev/null +++ b/backend/prisma/migrations/20260629_add_payment_request_expiration/migration.sql @@ -0,0 +1,36 @@ +-- Migration: Add PaymentRequest model with expiration fields +-- Issue #460 — Payment Request Expiration with Smart Contract Enforcement + +CREATE TYPE "PaymentRequestStatus" AS ENUM ('pending', 'paid', 'expired', 'cancelled'); + +CREATE TABLE "payment_requests" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::text, + "tenant_id" TEXT NOT NULL, + "requester_id" TEXT NOT NULL, + "payer_address" TEXT, + "requester_address" TEXT NOT NULL, + "amount" DECIMAL(20,8) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'XLM', + "network" TEXT NOT NULL DEFAULT 'stellar', + "token_address" TEXT, + "status" "PaymentRequestStatus" NOT NULL DEFAULT 'pending', + "expires_at" TIMESTAMP(3) NOT NULL, + "expired_at" TIMESTAMP(3), + "paid_at" TIMESTAMP(3), + "contract_request_id" TEXT, + "memo" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "payment_requests_pkey" PRIMARY KEY ("id") +); + +-- Indexes for dashboard filtering and sweep cron +CREATE INDEX "payment_requests_tenant_status_idx" ON "payment_requests"("tenant_id", "status"); +CREATE INDEX "payment_requests_expires_at_idx" ON "payment_requests"("expires_at"); +CREATE INDEX "payment_requests_status_expires_idx" ON "payment_requests"("status", "expires_at"); +CREATE INDEX "payment_requests_requester_id_idx" ON "payment_requests"("requester_id"); +CREATE INDEX "payment_requests_payer_address_idx" ON "payment_requests"("payer_address"); +CREATE INDEX "payment_requests_created_at_idx" ON "payment_requests"("created_at"); diff --git a/backend/src/services/payments/expiration.ts b/backend/src/services/payments/expiration.ts new file mode 100644 index 00000000..dc81655d --- /dev/null +++ b/backend/src/services/payments/expiration.ts @@ -0,0 +1,474 @@ +/** + * Payment Request Expiration Service — Issue #460 + * + * Responsibilities: + * 1. Validate that a request has not expired before relaying to chain. + * 2. BullMQ cron: sweep pending requests that are past their deadline and + * transition them to `expired` in the database. + * 3. Send notifications to requester and payer on expiration. + * 4. Provide renewal helpers (calculate new rate + create renewal). + */ + +import { PrismaClient, Prisma } from '@prisma/client'; +import { Queue, Worker, type Job } from 'bullmq'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CreatePaymentRequestInput { + tenantId: string; + requesterId: string; + payerAddress?: string; + requesterAddress: string; + amount: string; // decimal string + currency: string; + network: 'stellar' | 'evm'; + tokenAddress?: string; + /** TTL in seconds. Defaults to 24 h. Min 60 s, Max 90 days. */ + ttlSeconds?: number; + memo?: string; + metadata?: Record; +} + +export interface PaymentRequestRecord { + id: string; + tenantId: string; + requesterId: string; + payerAddress: string | null; + requesterAddress: string; + amount: string; + currency: string; + network: string; + tokenAddress: string | null; + status: PaymentRequestStatus; + expiresAt: Date; + expiredAt: Date | null; + paidAt: Date | null; + createdAt: Date; + updatedAt: Date; + memo: string | null; + metadata: Record | null; + contractRequestId: string | null; +} + +export type PaymentRequestStatus = 'pending' | 'paid' | 'expired' | 'cancelled'; + +export interface RenewalResult { + newRequestId: string; + newAmount: string; + newExpiresAt: Date; + rateAdjusted: boolean; +} + +export interface ExpirationSweepResult { + expiredCount: number; + notified: number; + errors: string[]; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_TTL_SECS = 24 * 3600; // 24 hours +const MIN_TTL_SECS = 60; // 1 minute +const MAX_TTL_SECS = 90 * 24 * 3600; // 90 days +/** Grace period added server-side to match on-chain grace period (60 s). */ +const GRACE_PERIOD_MS = 60_000; +const SWEEP_QUEUE_NAME = 'payment-request-expiration-sweep'; +const SWEEP_CRON = '*/2 * * * *'; // every 2 minutes +const BATCH_SIZE = 100; + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class PaymentRequestExpirationService { + private prisma: PrismaClient; + private queue: Queue | null = null; + private worker: Worker | null = null; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + // ─── Request lifecycle ──────────────────────────────────────────────────── + + /** + * Create a new time-bound payment request in the database. + * Validates TTL bounds before persisting. + */ + async createRequest(input: CreatePaymentRequestInput): Promise { + const ttl = input.ttlSeconds ?? DEFAULT_TTL_SECS; + this.validateTtl(ttl); + + const expiresAt = new Date(Date.now() + ttl * 1000); + + const record = await (this.prisma as any).paymentRequest.create({ + data: { + tenantId: input.tenantId, + requesterId: input.requesterId, + payerAddress: input.payerAddress ?? null, + requesterAddress: input.requesterAddress, + amount: input.amount, + currency: input.currency, + network: input.network, + tokenAddress: input.tokenAddress ?? null, + status: 'pending' as PaymentRequestStatus, + expiresAt, + memo: input.memo ?? null, + metadata: input.metadata ?? Prisma.JsonNull, + }, + }); + + return record as PaymentRequestRecord; + } + + /** + * Guard: throws if the request is expired. Called before relaying to chain. + * Includes a backend grace period that matches the on-chain grace period. + */ + async assertNotExpired(requestId: string): Promise { + const req = await this.getRequest(requestId); + + if (req.status === 'expired') { + throw new PaymentRequestExpiredError(requestId, req.expiresAt); + } + if (req.status === 'cancelled') { + throw new PaymentRequestCancelledError(requestId); + } + if (req.status === 'paid') { + throw new PaymentRequestAlreadyPaidError(requestId); + } + + const deadline = new Date(req.expiresAt.getTime() + GRACE_PERIOD_MS); + if (new Date() > deadline) { + // Lazily expire it now. + await this.markExpired([requestId]); + throw new PaymentRequestExpiredError(requestId, req.expiresAt); + } + + return req; + } + + /** Fetch a single request, throws if not found. */ + async getRequest(requestId: string): Promise { + const req = await (this.prisma as any).paymentRequest.findUnique({ + where: { id: requestId }, + }); + if (!req) throw new PaymentRequestNotFoundError(requestId); + return req as PaymentRequestRecord; + } + + /** Mark a request as paid. Called after on-chain confirmation. */ + async markPaid(requestId: string, txHash?: string): Promise { + await (this.prisma as any).paymentRequest.update({ + where: { id: requestId }, + data: { + status: 'paid', + paidAt: new Date(), + metadata: txHash ? { txHash } : undefined, + }, + }); + } + + /** Cancel a request (requester only — auth handled in controller). */ + async cancelRequest(requestId: string): Promise { + const req = await this.getRequest(requestId); + if (req.status !== 'pending') { + throw new Error(`Cannot cancel request with status '${req.status}'`); + } + await (this.prisma as any).paymentRequest.update({ + where: { id: requestId }, + data: { status: 'cancelled' }, + }); + } + + /** + * Renew an expired or cancelled request. + * Optionally applies a rate adjustment (e.g. new FX quote). + */ + async renewRequest(params: { + requestId: string; + newAmount?: string; + ttlSeconds?: number; + rateMultiplier?: number; // e.g. 1.02 = 2 % higher rate + }): Promise { + const old = await this.getRequest(params.requestId); + + if (old.status !== 'expired' && old.status !== 'cancelled') { + throw new Error('Only expired or cancelled requests can be renewed'); + } + + const ttl = params.ttlSeconds ?? DEFAULT_TTL_SECS; + this.validateTtl(ttl); + + let newAmount = params.newAmount ?? old.amount; + let rateAdjusted = false; + + if (params.rateMultiplier && params.rateMultiplier !== 1) { + const oldAmt = parseFloat(old.amount); + newAmount = (oldAmt * params.rateMultiplier).toFixed(8); + rateAdjusted = true; + } + + const newExpiresAt = new Date(Date.now() + ttl * 1000); + + const newRecord = await (this.prisma as any).paymentRequest.create({ + data: { + tenantId: old.tenantId, + requesterId: old.requesterId, + payerAddress: old.payerAddress, + requesterAddress: old.requesterAddress, + amount: newAmount, + currency: old.currency, + network: old.network, + tokenAddress: old.tokenAddress, + status: 'pending', + expiresAt: newExpiresAt, + memo: old.memo, + metadata: { renewedFrom: old.id }, + }, + }); + + return { + newRequestId: newRecord.id, + newAmount, + newExpiresAt, + rateAdjusted, + }; + } + + // ─── Dashboard filtering ────────────────────────────────────────────────── + + /** + * List requests with optional status filter. + * Supports filtering for expired requests on the dashboard. + */ + async listRequests(params: { + tenantId: string; + status?: PaymentRequestStatus | 'all'; + page?: number; + pageSize?: number; + }): Promise<{ data: PaymentRequestRecord[]; total: number }> { + const page = params.page ?? 1; + const pageSize = params.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Record = { tenantId: params.tenantId }; + if (params.status && params.status !== 'all') { + where['status'] = params.status; + } + + const [data, total] = await Promise.all([ + (this.prisma as any).paymentRequest.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + }), + (this.prisma as any).paymentRequest.count({ where }), + ]); + + return { data: data as PaymentRequestRecord[], total }; + } + + // ─── BullMQ sweep cron ──────────────────────────────────────────────────── + + /** + * Start the BullMQ cron worker that sweeps expired requests every 2 minutes. + * Pass `redisUrl` or set REDIS_URL env var. + */ + startExpirationCron(redisUrl?: string): void { + const url = redisUrl ?? process.env['REDIS_URL']; + if (!url) { + console.warn('[expiration] REDIS_URL not set — expiration cron disabled'); + return; + } + + const connection = this.parseRedisUrl(url); + + this.queue = new Queue(SWEEP_QUEUE_NAME, { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 5_000 }, + removeOnComplete: { count: 10 }, + removeOnFail: { count: 50 }, + }, + }); + + // Register the repeating cron. + this.queue.add( + 'sweep', + {}, + { repeat: { pattern: SWEEP_CRON, tz: 'UTC' }, jobId: 'expiration-sweep' }, + ).catch(console.error); + + this.worker = new Worker( + SWEEP_QUEUE_NAME, + async (_job: Job) => { + const result = await this.sweepExpired(); + if (result.expiredCount > 0) { + console.log(`[expiration] Swept ${result.expiredCount} expired requests, notified ${result.notified}`); + } + if (result.errors.length > 0) { + console.error('[expiration] Sweep errors:', result.errors); + } + }, + { connection, concurrency: 1 }, + ); + + this.worker.on('failed', (_job, err) => { + console.error('[expiration] Sweep job failed:', err.message); + }); + + console.log('[expiration] Expiration cron started (every 2 minutes)'); + } + + async stopExpirationCron(): Promise { + await this.worker?.close(); + await this.queue?.close(); + } + + /** + * Sweep all pending requests whose `expiresAt` is in the past (+ grace). + * Updates status to `expired` and sends notifications. + */ + async sweepExpired(): Promise { + const cutoff = new Date(Date.now() - GRACE_PERIOD_MS); + const errors: string[] = []; + let expiredCount = 0; + let notified = 0; + + // Process in batches to avoid large DB transactions. + let cursor: string | undefined; + + for (;;) { + const batch: PaymentRequestRecord[] = await (this.prisma as any).paymentRequest.findMany({ + where: { + status: 'pending', + expiresAt: { lt: cutoff }, + }, + take: BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + orderBy: { createdAt: 'asc' }, + }); + + if (batch.length === 0) break; + + const ids = batch.map((r) => r.id); + try { + await this.markExpired(ids); + expiredCount += ids.length; + } catch (err) { + errors.push(`markExpired batch failed: ${(err as Error).message}`); + } + + // Send notifications for each expired request. + for (const req of batch) { + try { + await this.sendExpirationNotifications(req); + notified++; + } catch (err) { + errors.push(`notify ${req.id}: ${(err as Error).message}`); + } + } + + cursor = batch[batch.length - 1]?.id; + if (batch.length < BATCH_SIZE) break; + } + + return { expiredCount, notified, errors }; + } + + // ─── Notifications ──────────────────────────────────────────────────────── + + private async sendExpirationNotifications(req: PaymentRequestRecord): Promise { + // Emit to any registered notification channel. + // In production this would call notificationService.sendNotification(). + const payload = { + event: 'payment_request.expired', + requestId: req.id, + amount: req.amount, + currency: req.currency, + expiredAt: new Date().toISOString(), + requesterAddress: req.requesterAddress, + payerAddress: req.payerAddress, + }; + + // Requester notification. + console.log(`[expiration] notify requester ${req.requesterId} — request ${req.id} expired`, payload); + + // Payer notification (if payer is known). + if (req.payerAddress) { + console.log(`[expiration] notify payer ${req.payerAddress} — request ${req.id} expired`, payload); + } + } + + // ─── Internals ──────────────────────────────────────────────────────────── + + private async markExpired(ids: string[]): Promise { + await (this.prisma as any).paymentRequest.updateMany({ + where: { id: { in: ids }, status: 'pending' }, + data: { status: 'expired', expiredAt: new Date() }, + }); + } + + private validateTtl(ttl: number): void { + if (ttl < MIN_TTL_SECS || ttl > MAX_TTL_SECS) { + throw new Error(`TTL must be between ${MIN_TTL_SECS}s and ${MAX_TTL_SECS}s, got ${ttl}s`); + } + } + + private parseRedisUrl(url: string): { host: string; port: number; password?: string; tls?: object } { + try { + const parsed = new URL(url); + return { + host: parsed.hostname || 'localhost', + port: parseInt(parsed.port || '6379', 10), + password: parsed.password || undefined, + tls: parsed.protocol === 'rediss:' ? {} : undefined, + }; + } catch { + const [host, port] = url.split(':'); + return { host: host ?? 'localhost', port: parseInt(port ?? '6379', 10) }; + } + } +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +export class PaymentRequestExpiredError extends Error { + constructor(public readonly requestId: string, public readonly expiresAt: Date) { + super(`Payment request ${requestId} expired at ${expiresAt.toISOString()}`); + this.name = 'PaymentRequestExpiredError'; + } +} + +export class PaymentRequestNotFoundError extends Error { + constructor(public readonly requestId: string) { + super(`Payment request ${requestId} not found`); + this.name = 'PaymentRequestNotFoundError'; + } +} + +export class PaymentRequestAlreadyPaidError extends Error { + constructor(public readonly requestId: string) { + super(`Payment request ${requestId} has already been paid`); + this.name = 'PaymentRequestAlreadyPaidError'; + } +} + +export class PaymentRequestCancelledError extends Error { + constructor(public readonly requestId: string) { + super(`Payment request ${requestId} has been cancelled`); + this.name = 'PaymentRequestCancelledError'; + } +} + +// ─── Singleton ──────────────────────────────────────────────────────────────── + +let _instance: PaymentRequestExpirationService | null = null; + +export function getPaymentRequestExpirationService(prisma: PrismaClient): PaymentRequestExpirationService { + if (!_instance) { + _instance = new PaymentRequestExpirationService(prisma); + } + return _instance; +} diff --git a/contracts/evm/contracts/PaymentRequestExpiry.sol b/contracts/evm/contracts/PaymentRequestExpiry.sol new file mode 100644 index 00000000..f76a7d89 --- /dev/null +++ b/contracts/evm/contracts/PaymentRequestExpiry.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title AgenticPay Payment Request with Expiration — Issue #460 +/// @notice Creates time-bound payment requests enforced on-chain. +/// Any payment attempted after `expiresAt + gracePeriod` is reverted. +/// @dev Uses block.timestamp. Grace period mitigates minor miner manipulation. +contract PaymentRequestExpiry is Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + // ─── Types ──────────────────────────────────────────────────────────────── + + enum RequestStatus { Pending, Paid, Expired, Cancelled } + + struct PaymentRequest { + uint256 id; + address requester; + address payer; // address(0) = open (anyone may pay) + address token; // address(0) = native ETH + uint256 amount; + RequestStatus status; + uint256 createdAt; + uint256 expiresAt; + uint32 gracePeriod; // seconds of extra leniency + uint256 expiredAt; // 0 until expired + uint256 paidAt; // 0 until paid + string memo; + } + + // ─── State ──────────────────────────────────────────────────────────────── + + uint256 private _nextId; + mapping(uint256 => PaymentRequest) private _requests; + + /// Default grace period — absorbs ±15 s block timestamp variance. + uint32 public defaultGracePeriod = 60; + /// Maximum TTL for any request: 90 days. + uint256 public constant MAX_TTL = 90 days; + /// Minimum TTL: 60 seconds. + uint256 public constant MIN_TTL = 60 seconds; + + // ─── Events ─────────────────────────────────────────────────────────────── + + event RequestCreated( + uint256 indexed id, + address indexed requester, + address indexed payer, + address token, + uint256 amount, + uint256 expiresAt, + string memo + ); + event RequestPaid( + uint256 indexed id, + address indexed requester, + address indexed payer, + uint256 amount, + uint256 paidAt + ); + event RequestExpired(uint256 indexed id, address indexed requester, uint256 expiredAt); + event RequestCancelled(uint256 indexed id, address indexed requester); + event RequestRenewed(uint256 indexed oldId, uint256 indexed newId, uint256 newAmount, uint256 newExpiresAt); + event DefaultGracePeriodUpdated(uint32 newGracePeriod); + + // ─── Errors ─────────────────────────────────────────────────────────────── + + error RequestNotFound(uint256 id); + error RequestAlreadyPaid(uint256 id); + error RequestAlreadyExpired(uint256 id); + error RequestAlreadyCancelled(uint256 id); + error RequestNotExpiredYet(uint256 id); + error RequestIsExpired(uint256 id); + error UnauthorizedPayer(uint256 id, address caller); + error InvalidAmount(); + error InvalidTtl(uint256 ttl); + + // ─── Constructor ────────────────────────────────────────────────────────── + + constructor(address initialOwner) Ownable(initialOwner) {} + + // ─── Core functions ─────────────────────────────────────────────────────── + + /// @notice Create a time-bound payment request. + /// @param payer Specific payer address, or address(0) for open requests. + /// @param token ERC-20 token address, or address(0) for native ETH. + /// @param amount Payment amount in token units (or wei for ETH). + /// @param ttlSeconds Time-to-live in seconds from now (60 s – 90 days). + /// @param memo Optional human-readable note. + function createRequest( + address payer, + address token, + uint256 amount, + uint256 ttlSeconds, + string calldata memo + ) external returns (uint256 id) { + if (amount == 0) revert InvalidAmount(); + if (ttlSeconds < MIN_TTL || ttlSeconds > MAX_TTL) revert InvalidTtl(ttlSeconds); + + id = ++_nextId; + uint256 expiresAt = block.timestamp + ttlSeconds; + + _requests[id] = PaymentRequest({ + id: id, + requester: msg.sender, + payer: payer, + token: token, + amount: amount, + status: RequestStatus.Pending, + createdAt: block.timestamp, + expiresAt: expiresAt, + gracePeriod: defaultGracePeriod, + expiredAt: 0, + paidAt: 0, + memo: memo + }); + + emit RequestCreated(id, msg.sender, payer, token, amount, expiresAt, memo); + } + + /// @notice Pay a pending request. Enforces expiration on-chain. + /// @dev For ERC-20 tokens the caller must pre-approve this contract. + /// For ETH requests, msg.value must equal the request amount exactly. + function pay(uint256 id) external payable nonReentrant { + PaymentRequest storage req = _getActive(id); + + // ── Expiration check ────────────────────────────────────────────────── + if (block.timestamp > req.expiresAt + req.gracePeriod) { + // Lazily mark expired on first payment attempt after deadline. + req.status = RequestStatus.Expired; + req.expiredAt = block.timestamp; + emit RequestExpired(id, req.requester, block.timestamp); + revert RequestIsExpired(id); + } + + // ── Payer check ─────────────────────────────────────────────────────── + if (req.payer != address(0) && req.payer != msg.sender) { + revert UnauthorizedPayer(id, msg.sender); + } + + address _requester = req.requester; + uint256 _amount = req.amount; + address _token = req.token; + + req.status = RequestStatus.Paid; + req.paidAt = block.timestamp; + + // ── Transfer ────────────────────────────────────────────────────────── + if (_token == address(0)) { + // Native ETH + require(msg.value == _amount, "PaymentRequestExpiry: wrong ETH amount"); + (bool ok, ) = _requester.call{value: _amount}(""); + require(ok, "PaymentRequestExpiry: ETH transfer failed"); + } else { + require(msg.value == 0, "PaymentRequestExpiry: ETH sent for token request"); + IERC20(_token).safeTransferFrom(msg.sender, _requester, _amount); + } + + emit RequestPaid(id, _requester, msg.sender, _amount, block.timestamp); + } + + /// @notice Expire a request that is past its deadline + grace period. + /// Anyone may call this to sweep stale requests. + function expireRequest(uint256 id) external { + PaymentRequest storage req = _requests[id]; + if (req.id == 0) revert RequestNotFound(id); + if (req.status == RequestStatus.Paid) revert RequestAlreadyPaid(id); + if (req.status == RequestStatus.Cancelled) revert RequestAlreadyCancelled(id); + if (req.status == RequestStatus.Expired) revert RequestAlreadyExpired(id); + if (block.timestamp <= req.expiresAt + req.gracePeriod) revert RequestNotExpiredYet(id); + + req.status = RequestStatus.Expired; + req.expiredAt = block.timestamp; + emit RequestExpired(id, req.requester, block.timestamp); + } + + /// @notice Cancel a pending request. Only the requester may cancel. + function cancelRequest(uint256 id) external { + PaymentRequest storage req = _getActive(id); + require(msg.sender == req.requester, "PaymentRequestExpiry: not requester"); + req.status = RequestStatus.Cancelled; + emit RequestCancelled(id, req.requester); + } + + /// @notice Renew an expired or cancelled request with a new amount and TTL. + /// Creates a brand-new request linked by event to the original. + function renewRequest( + uint256 oldId, + uint256 newAmount, + uint256 newTtlSeconds + ) external returns (uint256 newId) { + PaymentRequest storage old = _requests[oldId]; + if (old.id == 0) revert RequestNotFound(oldId); + require( + old.status == RequestStatus.Expired || old.status == RequestStatus.Cancelled, + "PaymentRequestExpiry: original not expired/cancelled" + ); + require(msg.sender == old.requester, "PaymentRequestExpiry: not requester"); + if (newAmount == 0) revert InvalidAmount(); + if (newTtlSeconds < MIN_TTL || newTtlSeconds > MAX_TTL) revert InvalidTtl(newTtlSeconds); + + newId = ++_nextId; + uint256 newExpiresAt = block.timestamp + newTtlSeconds; + + _requests[newId] = PaymentRequest({ + id: newId, + requester: old.requester, + payer: old.payer, + token: old.token, + amount: newAmount, + status: RequestStatus.Pending, + createdAt: block.timestamp, + expiresAt: newExpiresAt, + gracePeriod: defaultGracePeriod, + expiredAt: 0, + paidAt: 0, + memo: old.memo + }); + + emit RequestRenewed(oldId, newId, newAmount, newExpiresAt); + emit RequestCreated(newId, old.requester, old.payer, old.token, newAmount, newExpiresAt, old.memo); + } + + // ─── Admin ──────────────────────────────────────────────────────────────── + + function setDefaultGracePeriod(uint32 gracePeriod) external onlyOwner { + defaultGracePeriod = gracePeriod; + emit DefaultGracePeriodUpdated(gracePeriod); + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + function getRequest(uint256 id) external view returns (PaymentRequest memory) { + if (_requests[id].id == 0) revert RequestNotFound(id); + return _requests[id]; + } + + function isExpired(uint256 id) external view returns (bool) { + PaymentRequest storage req = _requests[id]; + if (req.id == 0) return false; + if (req.status == RequestStatus.Expired) return true; + return block.timestamp > req.expiresAt + req.gracePeriod; + } + + function nextRequestId() external view returns (uint256) { + return _nextId + 1; + } + + // ─── Internals ──────────────────────────────────────────────────────────── + + function _getActive(uint256 id) internal view returns (PaymentRequest storage req) { + req = _requests[id]; + if (req.id == 0) revert RequestNotFound(id); + if (req.status == RequestStatus.Paid) revert RequestAlreadyPaid(id); + if (req.status == RequestStatus.Expired) revert RequestAlreadyExpired(id); + if (req.status == RequestStatus.Cancelled) revert RequestAlreadyCancelled(id); + } +} diff --git a/contracts/soroban/payment-request/Cargo.toml b/contracts/soroban/payment-request/Cargo.toml new file mode 100644 index 00000000..27b9aa16 --- /dev/null +++ b/contracts/soroban/payment-request/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agenticpay-payment-request" +version = "0.1.0" +edition = "2021" +description = "Soroban payment request contract with on-chain expiration enforcement — Issue #460" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.7.6" + +[dev-dependencies] +soroban-sdk = { version = "21.7.6", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/soroban/payment-request/src/errors.rs b/contracts/soroban/payment-request/src/errors.rs new file mode 100644 index 00000000..6e279001 --- /dev/null +++ b/contracts/soroban/payment-request/src/errors.rs @@ -0,0 +1,20 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum RequestError { + AlreadyInitialized = 1, + Unauthorized = 2, + NotFound = 3, + AlreadyPaid = 4, + AlreadyExpired = 5, + AlreadyCancelled = 6, + RequestIsExpired = 7, + NotExpiredYet = 8, + UnauthorizedPayer = 9, + InvalidAmount = 10, + InvalidTtl = 11, + CannotRenewActive = 12, + NotInitialized = 13, +} diff --git a/contracts/soroban/payment-request/src/events.rs b/contracts/soroban/payment-request/src/events.rs new file mode 100644 index 00000000..57ab4ccc --- /dev/null +++ b/contracts/soroban/payment-request/src/events.rs @@ -0,0 +1,45 @@ +use soroban_sdk::{symbol_short, Address, Env, String}; + +pub fn emit_created( + env: &Env, + id: u64, + requester: &Address, + payer: Option<&Address>, + token: &Address, + amount: i128, + expires_at: u64, + memo: &String, +) { + env.events().publish( + (symbol_short!("req_crtd"), requester.clone()), + (id, payer.cloned(), token.clone(), amount, expires_at, memo.clone()), + ); +} + +pub fn emit_paid(env: &Env, id: u64, requester: &Address, payer: &Address, amount: i128, paid_at: u64) { + env.events().publish( + (symbol_short!("req_paid"), requester.clone()), + (id, payer.clone(), amount, paid_at), + ); +} + +pub fn emit_expired(env: &Env, id: u64, requester: &Address, expired_at: u64) { + env.events().publish( + (symbol_short!("req_expd"), requester.clone()), + (id, expired_at), + ); +} + +pub fn emit_cancelled(env: &Env, id: u64, requester: &Address) { + env.events().publish( + (symbol_short!("req_cncl"), requester.clone()), + (id,), + ); +} + +pub fn emit_renewed(env: &Env, old_id: u64, new_id: u64, new_amount: i128, new_expires_at: u64) { + env.events().publish( + (symbol_short!("req_rnwd"),), + (old_id, new_id, new_amount, new_expires_at), + ); +} diff --git a/contracts/soroban/payment-request/src/lib.rs b/contracts/soroban/payment-request/src/lib.rs new file mode 100644 index 00000000..b96f9d8f --- /dev/null +++ b/contracts/soroban/payment-request/src/lib.rs @@ -0,0 +1,260 @@ +#![no_std] + +//! # Soroban Payment Request — Expiration Enforcement +//! +//! Time-bound payment requests enforced on-chain via Soroban ledger timestamps. +//! +//! Issue #460 — Payment Request Expiration with Smart Contract Enforcement + +mod errors; +mod events; +mod storage; +mod types; + +pub use types::*; + +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +use crate::errors::RequestError; +use crate::events::{emit_created, emit_paid, emit_expired, emit_cancelled, emit_renewed}; +use crate::storage::{ + bump_instance, get_next_id, inc_next_id, + get_request, set_request, + get_grace_period, set_grace_period, + get_admin, set_admin, require_admin, mark_initialized, is_initialized, +}; + +/// Minimum TTL: 60 seconds. +pub const MIN_TTL_SECS: u64 = 60; +/// Maximum TTL: 90 days. +pub const MAX_TTL_SECS: u64 = 90 * 24 * 3600; +/// Default grace period: 60 seconds (absorbs ledger close-time variance). +pub const DEFAULT_GRACE_PERIOD_SECS: u64 = 60; + +#[contract] +pub struct PaymentRequestContract; + +#[contractimpl] +impl PaymentRequestContract { + // ─── Init ───────────────────────────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), RequestError> { + if is_initialized(&env) { + return Err(RequestError::AlreadyInitialized); + } + admin.require_auth(); + set_admin(&env, &admin); + set_grace_period(&env, DEFAULT_GRACE_PERIOD_SECS); + mark_initialized(&env); + bump_instance(&env); + Ok(()) + } + + pub fn set_grace_period(env: Env, grace_secs: u64) -> Result<(), RequestError> { + require_admin(&env)?; + set_grace_period(&env, grace_secs); + bump_instance(&env); + Ok(()) + } + + // ─── Core ───────────────────────────────────────────────────────────────── + + /// Create a time-bound payment request. + /// + /// - `requester` — account creating the request (must auth) + /// - `payer` — optional specific payer; None = open to anyone + /// - `token` — SEP-41 token contract address + /// - `amount` — amount in token's smallest unit + /// - `ttl_secs` — time-to-live in seconds (60 – 7_776_000) + /// - `memo` — optional short description + pub fn create_request( + env: Env, + requester: Address, + payer: Option
, + token: Address, + amount: i128, + ttl_secs: u64, + memo: String, + ) -> Result { + requester.require_auth(); + if amount <= 0 { + return Err(RequestError::InvalidAmount); + } + if ttl_secs < MIN_TTL_SECS || ttl_secs > MAX_TTL_SECS { + return Err(RequestError::InvalidTtl); + } + + let id = get_next_id(&env); + inc_next_id(&env); + + let now = env.ledger().timestamp(); + let expires_at = now + ttl_secs; + + let req = PaymentRequest { + id, + requester: requester.clone(), + payer: payer.clone(), + token: token.clone(), + amount, + status: RequestStatus::Pending, + created_at: now, + expires_at, + grace_period: get_grace_period(&env), + expired_at: 0, + paid_at: 0, + memo: memo.clone(), + }; + + set_request(&env, id, &req); + emit_created(&env, id, &requester, payer.as_ref(), &token, amount, expires_at, &memo); + bump_instance(&env); + Ok(id) + } + + /// Pay a pending request. Enforces expiration via ledger timestamp. + /// The payer must have pre-approved this contract to spend `amount` of `token`. + pub fn pay(env: Env, payer: Address, id: u64) -> Result<(), RequestError> { + payer.require_auth(); + + let mut req = get_request(&env, id)?; + + // ── Expiration guard ────────────────────────────────────────────────── + let now = env.ledger().timestamp(); + if now > req.expires_at + req.grace_period { + req.status = RequestStatus::Expired; + req.expired_at = now; + set_request(&env, id, &req); + emit_expired(&env, id, &req.requester, now); + return Err(RequestError::RequestIsExpired); + } + + // ── Payer check ─────────────────────────────────────────────────────── + if let Some(ref expected) = req.payer { + if *expected != payer { + return Err(RequestError::UnauthorizedPayer); + } + } + + req.status = RequestStatus::Paid; + req.paid_at = now; + set_request(&env, id, &req); + + // ── Token transfer ──────────────────────────────────────────────────── + // Cross-contract call to the SEP-41 token contract. + let token_client = soroban_sdk::token::Client::new(&env, &req.token); + token_client.transfer(&payer, &req.requester, &req.amount); + + emit_paid(&env, id, &req.requester, &payer, req.amount, now); + bump_instance(&env); + Ok(()) + } + + /// Mark a request as expired (callable by anyone after deadline + grace). + pub fn expire_request(env: Env, id: u64) -> Result<(), RequestError> { + let mut req = get_request(&env, id)?; + let now = env.ledger().timestamp(); + + if now <= req.expires_at + req.grace_period { + return Err(RequestError::NotExpiredYet); + } + + req.status = RequestStatus::Expired; + req.expired_at = now; + set_request(&env, id, &req); + emit_expired(&env, id, &req.requester, now); + bump_instance(&env); + Ok(()) + } + + /// Cancel a pending request (requester only). + pub fn cancel_request(env: Env, requester: Address, id: u64) -> Result<(), RequestError> { + requester.require_auth(); + let mut req = get_request(&env, id)?; + + if req.requester != requester { + return Err(RequestError::Unauthorized); + } + + req.status = RequestStatus::Cancelled; + set_request(&env, id, &req); + emit_cancelled(&env, id, &requester); + bump_instance(&env); + Ok(()) + } + + /// Renew an expired or cancelled request with a new amount and TTL. + /// Returns the new request ID. + pub fn renew_request( + env: Env, + requester: Address, + old_id: u64, + new_amount: i128, + new_ttl_secs: u64, + ) -> Result { + requester.require_auth(); + + let old_req = get_request(&env, old_id)?; + if old_req.requester != requester { + return Err(RequestError::Unauthorized); + } + match old_req.status { + RequestStatus::Expired | RequestStatus::Cancelled => {} + _ => return Err(RequestError::CannotRenewActive), + } + if new_amount <= 0 { + return Err(RequestError::InvalidAmount); + } + if new_ttl_secs < MIN_TTL_SECS || new_ttl_secs > MAX_TTL_SECS { + return Err(RequestError::InvalidTtl); + } + + let new_id = get_next_id(&env); + inc_next_id(&env); + let now = env.ledger().timestamp(); + let new_expires_at = now + new_ttl_secs; + + let new_req = PaymentRequest { + id: new_id, + requester: old_req.requester.clone(), + payer: old_req.payer.clone(), + token: old_req.token.clone(), + amount: new_amount, + status: RequestStatus::Pending, + created_at: now, + expires_at: new_expires_at, + grace_period: get_grace_period(&env), + expired_at: 0, + paid_at: 0, + memo: old_req.memo.clone(), + }; + + set_request(&env, new_id, &new_req); + emit_renewed(&env, old_id, new_id, new_amount, new_expires_at); + bump_instance(&env); + Ok(new_id) + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + pub fn get_request(env: Env, id: u64) -> Result { + get_request(&env, id) + } + + pub fn is_expired(env: Env, id: u64) -> bool { + match get_request(&env, id) { + Err(_) => false, + Ok(req) => { + req.status == RequestStatus::Expired + || env.ledger().timestamp() > req.expires_at + req.grace_period + } + } + } + + pub fn admin(env: Env) -> Result { + get_admin(&env) + } + + pub fn grace_period(env: Env) -> u64 { + get_grace_period(&env) + } +} diff --git a/contracts/soroban/payment-request/src/storage.rs b/contracts/soroban/payment-request/src/storage.rs new file mode 100644 index 00000000..77a23e06 --- /dev/null +++ b/contracts/soroban/payment-request/src/storage.rs @@ -0,0 +1,61 @@ +use soroban_sdk::{symbol_short, Address, Env}; +use crate::errors::RequestError; +use crate::types::PaymentRequest; + +const BUMP_AMOUNT: u32 = 518_400; +const BUMP_THRESHOLD: u32 = 100_000; + +pub fn bump_instance(env: &Env) { + env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_AMOUNT); +} + +pub fn is_initialized(env: &Env) -> bool { + env.storage().instance().has(&symbol_short!("init")) +} +pub fn mark_initialized(env: &Env) { + env.storage().instance().set(&symbol_short!("init"), &true); +} + +pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&symbol_short!("admin"), admin); +} +pub fn get_admin(env: &Env) -> Result { + env.storage().instance() + .get::<_, Address>(&symbol_short!("admin")) + .ok_or(RequestError::NotInitialized) +} +pub fn require_admin(env: &Env) -> Result<(), RequestError> { + let admin = get_admin(env)?; + admin.require_auth(); + Ok(()) +} + +pub fn set_grace_period(env: &Env, secs: u64) { + env.storage().instance().set(&symbol_short!("grace"), &secs); +} +pub fn get_grace_period(env: &Env) -> u64 { + env.storage().instance() + .get::<_, u64>(&symbol_short!("grace")) + .unwrap_or(60) +} + +pub fn get_next_id(env: &Env) -> u64 { + env.storage().instance() + .get::<_, u64>(&symbol_short!("next_id")) + .unwrap_or(1) +} +pub fn inc_next_id(env: &Env) { + let id = get_next_id(env); + env.storage().instance().set(&symbol_short!("next_id"), &(id + 1)); +} + +/// Requests stored in persistent storage keyed by ID. +pub fn set_request(env: &Env, id: u64, req: &PaymentRequest) { + env.storage().persistent().set(&id, req); + env.storage().persistent().extend_ttl(&id, BUMP_THRESHOLD, BUMP_AMOUNT); +} +pub fn get_request(env: &Env, id: u64) -> Result { + env.storage().persistent() + .get::(&id) + .ok_or(RequestError::NotFound) +} diff --git a/contracts/soroban/payment-request/src/types.rs b/contracts/soroban/payment-request/src/types.rs new file mode 100644 index 00000000..1c759d70 --- /dev/null +++ b/contracts/soroban/payment-request/src/types.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{contracttype, Address, String}; + +#[contracttype] +#[derive(Clone, PartialEq)] +pub enum RequestStatus { + Pending, + Paid, + Expired, + Cancelled, +} + +#[contracttype] +#[derive(Clone)] +pub struct PaymentRequest { + pub id: u64, + pub requester: Address, + /// None = open to any payer. + pub payer: Option
, + /// SEP-41 token contract address. + pub token: Address, + pub amount: i128, + pub status: RequestStatus, + pub created_at: u64, + /// Unix timestamp after which the request is expired. + pub expires_at: u64, + /// Extra seconds of grace beyond expires_at. + pub grace_period: u64, + /// 0 until the request expires. + pub expired_at: u64, + /// 0 until the request is paid. + pub paid_at: u64, + pub memo: String, +}