diff --git a/.gitignore b/.gitignore index daaac7bf..fbbbe0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Environment .env* +!.env.example # OS .DS_Store @@ -18,22 +19,46 @@ Thumbs.db dist/ build/ out/ - -# Next.js .next/ .vercel # Debug *.log npm-debug.log* +lighthouse/ # Keys *.pem +# AI .claude +.kilocode/ + +# Reports .lhci_reports/ bundle-reports/ trend-data.json + +# Generated code packages/*/dist/ frontend/src/generated/ -packages/contracts/src/generated/ \ No newline at end of file +packages/contracts/src/generated/ + +# Test snapshots and artifacts +**/__snapshots__/ +**/*.snap +coverage/ + +# Rust +contracts/target/ +contracts/*.wasm + +# Docker +docker-compose.override.yml + +# Turbo +.turbo/ + +# Misc +test-write.txt +AGENTS.md diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f1c6e658..e5a76dba 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,5 +1,12 @@ -// Prisma schema — Issue #207 -// Full type-safe schema with soft deletes, indexes, and relations. +// Prisma schema — Issue #598 +// Full type-safe schema with soft deletes, indexes, relations, and base model patterns. +// +// Base model pattern (applied to all auditable entities): +// id String @id @default(uuid()) +// tenantId String @map("tenant_id") +// createdAt DateTime @default(now()) @map("created_at") +// updatedAt DateTime @updatedAt @map("updated_at") +// deletedAt DateTime? @map("deleted_at") generator client { provider = "prisma-client-js" diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index db4b7efc..6d74892e 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import { ERROR_CODE_REGISTRY, resolveErrorCode } from '@agenticpay/error-codes'; +import { PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError } from '../types/errors'; type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise; @@ -7,16 +8,20 @@ export class AppError extends Error { statusCode: number; code: string; details?: unknown; + metadata?: Record; - constructor(statusCode: number, message: string, code = 'INTERNAL_SERVER_ERROR', details?: unknown) { + constructor(statusCode: number, message: string, code = 'INTERNAL_SERVER_ERROR', details?: unknown, metadata?: Record) { super(message); this.name = 'AppError'; this.statusCode = statusCode; this.code = code; this.details = details; + this.metadata = metadata; } } +export { PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError }; + export function asyncHandler(handler: AsyncRouteHandler): RequestHandler { return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); @@ -24,7 +29,7 @@ export function asyncHandler(handler: AsyncRouteHandler): RequestHandler { } export function notFoundHandler(req: Request, _res: Response, next: NextFunction) { - next(new AppError(404, `Route not found: ${req.method} ${req.originalUrl}`, 'NOT_FOUND')); + next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`)); } export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) { @@ -42,7 +47,16 @@ export function errorHandler(err: unknown, req: Request, res: Response, _next: N : 'Unexpected error'; const logMethod = registered.httpStatus >= 500 ? console.error : console.warn; - logMethod(`[${code}] ${message}`, err); + const logContext = { + code, + message, + statusCode: registered.httpStatus || statusCode, + ...(isAppError && err.metadata ? { metadata: err.metadata } : {}), + ...(req.requestId ? { requestId: req.requestId } : {}), + ...(req.user?.id ? { userId: req.user.id } : {}), + ...(!isProduction && !isAppError && err instanceof Error && err.stack ? { stack: err.stack } : {}), + }; + logMethod(`[${code}] ${message}`, logContext); if (registered.deprecated && registered.sunsetAt) { res.setHeader('Sunset', registered.sunsetAt); diff --git a/backend/src/types/errors.ts b/backend/src/types/errors.ts new file mode 100644 index 00000000..89e2a9f2 --- /dev/null +++ b/backend/src/types/errors.ts @@ -0,0 +1,71 @@ +export class PaymentError extends Error { + constructor( + message: string, + public code: string = 'PAYMENT_ERROR', + public statusCode: number = 400, + public metadata?: Record, + ) { + super(message); + this.name = 'PaymentError'; + } +} + +export class AuthError extends Error { + constructor( + message: string, + public code: string = 'AUTH_ERROR', + public statusCode: number = 401, + public metadata?: Record, + ) { + super(message); + this.name = 'AuthError'; + } +} + +export class ProjectError extends Error { + constructor( + message: string, + public code: string = 'PROJECT_ERROR', + public statusCode: number = 400, + public metadata?: Record, + ) { + super(message); + this.name = 'ProjectError'; + } +} + +export class DisputeError extends Error { + constructor( + message: string, + public code: string = 'DISPUTE_ERROR', + public statusCode: number = 400, + public metadata?: Record, + ) { + super(message); + this.name = 'DisputeError'; + } +} + +export class NotFoundError extends Error { + constructor( + message: string, + public code: string = 'NOT_FOUND', + public statusCode: number = 404, + public metadata?: Record, + ) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ValidationError extends Error { + constructor( + message: string, + public code: string = 'VALIDATION_ERROR', + public statusCode: number = 422, + public metadata?: Record, + ) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/contracts/src/common.rs b/contracts/src/common.rs new file mode 100644 index 00000000..07502cd2 --- /dev/null +++ b/contracts/src/common.rs @@ -0,0 +1,343 @@ +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ProjectStatus { + Created, + Funded, + InProgress, + WorkSubmitted, + Verified, + Completed, + Disputed, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct Project { + pub id: u64, + pub client: Address, + pub freelancer: Address, + pub amount: i128, + pub deposited: i128, + pub status: ProjectStatus, + pub github_repo: String, + pub description: String, + pub created_at: u64, + pub deadline: u64, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct Receipt { + pub id: u64, + pub project_id: u64, + pub amount: i128, + pub currency: String, + pub sender: Address, + pub recipient: Address, + pub timestamp: u64, +} + +#[contracttype] +pub enum DataKey { + Project(u64), + ProjectCount, + Receipt(u64), + ReceiptCount, + Admin, + Metadata(String), + ReentrancyLock, + Paused, + MultisigWalletCount, + MultisigWallet(u64), + MultisigProposalCount, + MultisigProposal(u64), + HtlcCount, + HtlcLock(u64), + BridgeConfig, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MultisigProposalStatus { + Pending, + Executed, + Rejected, + Expired, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MultisigWallet { + pub id: u64, + pub signers: Vec
, + pub threshold: u32, + pub timeout_ledgers: u64, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MultisigProposal { + pub id: u64, + pub wallet_id: u64, + pub amount: i128, + pub recipient: Address, + pub description: String, + pub status: MultisigProposalStatus, + pub approvals: Vec
, + pub rejections: Vec
, + pub created_at: u64, + pub expires_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum HtlcStatus { + Pending, + Claimed, + Refunded, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct HtlcLock { + pub id: u64, + pub sender: Address, + pub recipient: Address, + pub amount: i128, + pub hashlock: BytesN<32>, + pub timelock: u64, + pub dispute_window: u64, + pub status: HtlcStatus, + pub target_chain: String, + pub target_lock_id: String, + pub created_at: u64, + pub claimed_at: u64, + pub refunded_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct BridgeConfigData { + pub fee_bps: u32, + pub fee_collector: Address, + pub paused: bool, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct HtlcLockInput { + pub recipient: Address, + pub amount: i128, + pub hashlock: BytesN<32>, + pub timelock: u64, + pub dispute_window: u64, + pub target_chain: String, + pub target_lock_id: String, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct ProjectInput { + pub freelancer: Address, + pub amount: i128, + pub description: String, + pub github_repo: String, +} + +pub fn _acquire_lock(env: &Env) { + let locked: bool = env + .storage() + .instance() + .get(&DataKey::ReentrancyLock) + .unwrap_or(false); + assert!(!locked, "reentrant call"); + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &true); +} + +pub fn _release_lock(env: &Env) { + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &false); +} + +pub fn _require_not_paused(env: &Env) { + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + assert!(!paused, "contract paused"); +} + +pub fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized") +} + +pub fn initialize(env: &Env, admin: Address) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::ProjectCount, &0u64); + env.storage().instance().set(&DataKey::ReceiptCount, &0u64); + env.storage().instance().set(&DataKey::ReentrancyLock, &false); + env.storage().instance().set(&DataKey::Paused, &false); +} + +pub fn pause(env: &Env, admin: Address) { + admin.require_auth(); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can pause"); + _acquire_lock(env); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish( + (symbol_short!("circuit"), symbol_short!("paused")), + true, + ); + _release_lock(env); +} + +pub fn unpause(env: &Env, admin: Address) { + admin.require_auth(); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can unpause"); + _acquire_lock(env); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish( + (symbol_short!("circuit"), symbol_short!("paused")), + false, + ); + _release_lock(env); +} + +pub fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) +} + +pub fn set_metadata(env: &Env, admin: Address, key: String, value: String) { + _require_not_paused(env); + admin.require_auth(); + _acquire_lock(env); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can set metadata"); + + env.storage() + .persistent() + .set(&DataKey::Metadata(key.clone()), &value); + + env.events().publish( + (symbol_short!("meta"), symbol_short!("set")), + (key, value), + ); + + _release_lock(env); +} + +pub fn get_metadata(env: &Env, key: String) -> Option { + env.storage().persistent().get(&DataKey::Metadata(key)) +} + +pub fn remove_metadata(env: &Env, admin: Address, key: String) { + _require_not_paused(env); + admin.require_auth(); + _acquire_lock(env); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can remove metadata"); + + env.storage().persistent().remove(&DataKey::Metadata(key.clone())); + + env.events().publish( + (symbol_short!("meta"), symbol_short!("del")), + key, + ); + + _release_lock(env); +} + +pub fn upgrade(env: &Env, admin: Address, new_wasm_hash: BytesN<32>) { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + assert!(admin == stored_admin, "Only admin can upgrade"); + + env.deployer().update_current_contract_wasm(new_wasm_hash); +} + +pub fn version(_env: &Env) -> u32 { + 1 +} + +pub fn init_bridge_config(env: &Env, admin: Address, fee_bps: u32, fee_collector: Address) { + admin.require_auth(); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can init bridge config"); + assert!(fee_bps <= 1000, "Fee bps cannot exceed 1000 (10%)"); + + let config = BridgeConfigData { + fee_bps, + fee_collector, + paused: false, + }; + env.storage() + .instance() + .set(&DataKey::BridgeConfig, &config); + + env.events().publish( + (symbol_short!("bridge"), symbol_short!("config")), + (fee_bps,), + ); +} + +pub fn update_bridge_config( + env: &Env, + admin: Address, + fee_bps: Option, + fee_collector: Option
, + paused: Option, +) { + admin.require_auth(); + let stored_admin = get_admin(env); + assert!(admin == stored_admin, "Only admin can update bridge config"); + + let mut config: BridgeConfigData = env + .storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized"); + + if let Some(fee) = fee_bps { + assert!(fee <= 1000, "Fee bps cannot exceed 1000 (10%)"); + config.fee_bps = fee; + } + if let Some(collector) = fee_collector { + config.fee_collector = collector; + } + if let Some(p) = paused { + config.paused = p; + } + + env.storage() + .instance() + .set(&DataKey::BridgeConfig, &config); +} + +pub fn get_bridge_config(env: &Env) -> BridgeConfigData { + env.storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized") +} diff --git a/contracts/src/dispute.rs b/contracts/src/dispute.rs new file mode 100644 index 00000000..09d648c8 --- /dev/null +++ b/contracts/src/dispute.rs @@ -0,0 +1,71 @@ +use soroban_sdk::{Address, Env}; +use crate::common::{self, *}; + +pub fn raise_dispute(env: &Env, project_id: u64, caller: Address) { + common::_require_not_paused(env); + caller.require_auth(); + common::_acquire_lock(env); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + caller == project.client || caller == project.freelancer, + "Only client or freelancer can dispute" + ); + + project.status = ProjectStatus::Disputed; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("disputed")), + (project_id, caller), + ); + + common::_release_lock(env); +} + +pub fn resolve_dispute(env: &Env, project_id: u64, admin: Address, release_to_freelancer: bool) { + common::_require_not_paused(env); + admin.require_auth(); + common::_acquire_lock(env); + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + assert!(admin == stored_admin, "Only admin can resolve disputes"); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + project.status == ProjectStatus::Disputed, + "Project must be disputed" + ); + + let _refund_amount = project.deposited; + project.deposited = 0; + + if release_to_freelancer { + project.status = ProjectStatus::Completed; + } else { + project.status = ProjectStatus::Cancelled; + } + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + common::_release_lock(env); +} diff --git a/contracts/src/escrow.rs b/contracts/src/escrow.rs new file mode 100644 index 00000000..14651955 --- /dev/null +++ b/contracts/src/escrow.rs @@ -0,0 +1,317 @@ +use soroban_sdk::{Address, Env, String, Vec}; +use crate::common::{self, *}; + +pub fn create_project( + env: &Env, + client: Address, + freelancer: Address, + amount: i128, + description: String, + github_repo: String, + deadline: u64, +) -> u64 { + common::_require_not_paused(env); + client.require_auth(); + common::_acquire_lock(env); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProjectCount) + .unwrap_or(0); + count += 1; + + let project = Project { + id: count, + client: client.clone(), + freelancer: freelancer.clone(), + amount, + deposited: 0, + status: ProjectStatus::Created, + github_repo, + description, + created_at: env.ledger().timestamp(), + deadline, + }; + + env.storage() + .persistent() + .set(&DataKey::Project(count), &project); + env.storage().instance().set(&DataKey::ProjectCount, &count); + + env.events().publish( + (symbol_short!("project"), symbol_short!("created")), + (count, client, freelancer, amount), + ); + + common::_release_lock(env); + count +} + +pub fn batch_create_projects( + env: &Env, + client: Address, + projects: Vec, +) -> Vec { + common::_require_not_paused(env); + client.require_auth(); + common::_acquire_lock(env); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProjectCount) + .unwrap_or(0); + + let timestamp = env.ledger().timestamp(); + let mut ids = Vec::new(env); + + for i in 0..projects.len() { + let input = projects.get(i).expect("Invalid project input"); + count += 1; + + let project = Project { + id: count, + client: client.clone(), + freelancer: input.freelancer.clone(), + amount: input.amount, + deposited: 0, + status: ProjectStatus::Created, + github_repo: input.github_repo.clone(), + description: input.description.clone(), + created_at: timestamp, + deadline: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::Project(count), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("created")), + (count, client.clone(), input.freelancer, input.amount), + ); + + ids.push_back(count); + } + + env.storage().instance().set(&DataKey::ProjectCount, &count); + + common::_release_lock(env); + ids +} + +pub fn fund_project(env: &Env, project_id: u64, client: Address, amount: i128) { + common::_require_not_paused(env); + client.require_auth(); + common::_acquire_lock(env); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!(project.client == client, "Only client can fund"); + assert!( + project.status == ProjectStatus::Created, + "Project must be in Created status" + ); + assert!(amount > 0, "Amount must be positive"); + + project.deposited += amount; + if project.deposited >= project.amount { + project.status = ProjectStatus::Funded; + } + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("funded")), + (project_id, amount), + ); + + common::_release_lock(env); +} + +pub fn submit_work(env: &Env, project_id: u64, freelancer: Address, github_repo: String) { + common::_require_not_paused(env); + freelancer.require_auth(); + common::_acquire_lock(env); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + project.freelancer == freelancer, + "Only assigned freelancer can submit" + ); + assert!( + project.status == ProjectStatus::Funded || project.status == ProjectStatus::InProgress, + "Project must be funded or in progress" + ); + + project.github_repo = github_repo.clone(); + project.status = ProjectStatus::WorkSubmitted; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("work_sub")), + (project_id, github_repo), + ); + + common::_release_lock(env); +} + +pub fn approve_work(env: &Env, project_id: u64, client: Address) { + common::_require_not_paused(env); + client.require_auth(); + common::_acquire_lock(env); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!(project.client == client, "Only client can approve"); + assert!( + project.status == ProjectStatus::WorkSubmitted + || project.status == ProjectStatus::Verified, + "Work must be submitted or verified" + ); + + let amount_released = project.deposited; + let freelancer = project.freelancer.clone(); + let project_client = project.client.clone(); + project.status = ProjectStatus::Completed; + project.deposited = 0; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("payment")), + (project_id, amount_released), + ); + + record_receipt(env, project_id, amount_released, String::from_str(env, "XLM"), project_client, freelancer); + + common::_release_lock(env); +} + +pub fn record_receipt( + env: &Env, + project_id: u64, + amount: i128, + currency: String, + sender: Address, + recipient: Address, +) -> u64 { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ReceiptCount) + .unwrap_or(0); + count += 1; + + let receipt = Receipt { + id: count, + project_id, + amount, + currency: currency.clone(), + sender: sender.clone(), + recipient: recipient.clone(), + timestamp: env.ledger().timestamp(), + }; + + env.storage().persistent().set(&DataKey::Receipt(count), &receipt); + env.storage().instance().set(&DataKey::ReceiptCount, &count); + env.events().publish( + (symbol_short!("receipt"), symbol_short!("issued")), + (count, project_id, amount, currency, sender, recipient), + ); + + count +} + +pub fn check_deadline(env: &Env, project_id: u64) -> bool { + common::_acquire_lock(env); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + if project.deadline == 0 { + common::_release_lock(env); + return false; + } + if project.status == ProjectStatus::Completed + || project.status == ProjectStatus::Cancelled + || project.status == ProjectStatus::Disputed + { + common::_release_lock(env); + return false; + } + + let now = env.ledger().timestamp(); + if now < project.deadline { + common::_release_lock(env); + return false; + } + + let refund_amount = project.deposited; + project.deposited = 0; + project.status = ProjectStatus::Cancelled; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("expired")), + (project_id, refund_amount), + ); + + common::_release_lock(env); + true +} + +pub fn get_project(env: &Env, project_id: u64) -> Project { + env.storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found") +} + +pub fn get_project_count(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ProjectCount) + .unwrap_or(0) +} + +pub fn get_receipt(env: &Env, receipt_id: u64) -> Receipt { + env.storage() + .persistent() + .get(&DataKey::Receipt(receipt_id)) + .expect("Receipt not found") +} + +pub fn get_receipt_count(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ReceiptCount) + .unwrap_or(0) +} diff --git a/contracts/src/htlc.rs b/contracts/src/htlc.rs new file mode 100644 index 00000000..6716745d --- /dev/null +++ b/contracts/src/htlc.rs @@ -0,0 +1,127 @@ +use soroban_sdk::{Address, BytesN, Env}; +use crate::common::{self, *}; + +pub fn create_lock( + env: &Env, + sender: Address, + input: HtlcLockInput, +) -> u64 { + common::_require_not_paused(env); + sender.require_auth(); + common::_acquire_lock(env); + + let config: BridgeConfigData = env + .storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized"); + assert!(!config.paused, "Bridge is paused"); + assert!(input.amount > 0, "Amount must be positive"); + assert!(input.timelock > env.ledger().timestamp(), "Timelock must be in the future"); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::HtlcCount) + .unwrap_or(0); + count += 1; + + let lock = HtlcLock { + id: count, + sender: sender.clone(), + recipient: input.recipient.clone(), + amount: input.amount, + hashlock: input.hashlock, + timelock: input.timelock, + dispute_window: input.dispute_window, + status: HtlcStatus::Pending, + target_chain: input.target_chain.clone(), + target_lock_id: input.target_lock_id.clone(), + created_at: env.ledger().timestamp(), + claimed_at: 0, + refunded_at: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(count), &lock); + env.storage().instance().set(&DataKey::HtlcCount, &count); + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("locked")), + (count, sender, input.recipient, input.amount, input.target_chain), + ); + + common::_release_lock(env); + count +} + +pub fn claim_lock(env: &Env, lock_id: u64, secret: BytesN<32>) { + common::_require_not_paused(env); + common::_acquire_lock(env); + + let mut lock: HtlcLock = env + .storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found"); + + assert!(lock.status == HtlcStatus::Pending, "Lock is not pending"); + assert!( + env.ledger().timestamp() < lock.timelock, + "Timelock has expired" + ); + + let computed_hash = env.crypto().sha256(&secret); + assert!(computed_hash == lock.hashlock, "Invalid secret"); + + lock.status = HtlcStatus::Claimed; + lock.claimed_at = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(lock_id), &lock); + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("claimed")), + (lock_id, lock.sender, lock.recipient, lock.amount), + ); + + common::_release_lock(env); +} + +pub fn refund_lock(env: &Env, lock_id: u64) { + common::_require_not_paused(env); + common::_acquire_lock(env); + + let mut lock: HtlcLock = env + .storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found"); + + assert!(lock.status == HtlcStatus::Pending, "Lock is not pending"); + let now = env.ledger().timestamp(); + assert!(now >= lock.timelock, "Timelock has not expired yet"); + + lock.status = HtlcStatus::Refunded; + lock.refunded_at = now; + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(lock_id), &lock); + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("refunded")), + (lock_id, lock.sender, lock.amount), + ); + + common::_release_lock(env); +} + +pub fn get_lock(env: &Env, lock_id: u64) -> HtlcLock { + env.storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found") +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 3b353290..bde1fa4f 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -5,6 +5,17 @@ extern crate std; use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec}; +pub mod common; +pub mod escrow; +pub mod dispute; +pub mod multisig; +pub mod htlc; + +pub use common::{ + BridgeConfigData, DataKey, HtlcLock, HtlcLockInput, HtlcStatus, MultisigProposal, MultisigProposalStatus, + MultisigWallet, Project, ProjectInput, ProjectStatus, Receipt, +}; + // --------------------------------------------------------------------------- // Multi-signature wallet types // --------------------------------------------------------------------------- diff --git a/contracts/src/multisig.rs b/contracts/src/multisig.rs new file mode 100644 index 00000000..fe73dc8b --- /dev/null +++ b/contracts/src/multisig.rs @@ -0,0 +1,437 @@ +use soroban_sdk::{Address, Env, String, Vec}; +use crate::common::{self, *}; + +pub fn create_wallet( + env: &Env, + creator: Address, + signers: Vec
, + threshold: u32, + timeout_ledgers: u64, +) -> u64 { + common::_require_not_paused(env); + creator.require_auth(); + common::_acquire_lock(env); + + assert!(signers.len() >= 2, "At least 2 signers required"); + assert!(threshold >= 1, "Threshold must be at least 1"); + assert!( + threshold as usize <= signers.len(), + "Threshold cannot exceed number of signers" + ); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::MultisigWalletCount) + .unwrap_or(0); + count += 1; + + let wallet = MultisigWallet { + id: count, + signers, + threshold, + timeout_ledgers, + active: true, + }; + + env.storage() + .persistent() + .set(&DataKey::MultisigWallet(count), &wallet); + env.storage() + .instance() + .set(&DataKey::MultisigWalletCount, &count); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("created")), + (count, threshold), + ); + + common::_release_lock(env); + count +} + +pub fn get_wallet(env: &Env, wallet_id: u64) -> MultisigWallet { + env.storage() + .persistent() + .get(&DataKey::MultisigWallet(wallet_id)) + .expect("Multisig wallet not found") +} + +pub fn add_signer( + env: &Env, + authorizer: Address, + wallet_id: u64, + new_signer: Address, +) { + common::_require_not_paused(env); + authorizer.require_auth(); + common::_acquire_lock(env); + + let mut wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(wallet_id)) + .expect("Multisig wallet not found"); + + assert!(wallet.active, "Wallet is inactive"); + + let mut is_signer = false; + for i in 0..wallet.signers.len() { + if wallet.signers.get(i).unwrap() == authorizer { + is_signer = true; + break; + } + } + assert!(is_signer, "Authorizer is not a signer"); + + wallet.signers.push_back(new_signer.clone()); + env.storage() + .persistent() + .set(&DataKey::MultisigWallet(wallet_id), &wallet); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("sgn_add")), + (wallet_id, new_signer), + ); + + common::_release_lock(env); +} + +pub fn remove_signer( + env: &Env, + authorizer: Address, + wallet_id: u64, + signer_to_remove: Address, +) { + common::_require_not_paused(env); + authorizer.require_auth(); + common::_acquire_lock(env); + + let mut wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(wallet_id)) + .expect("Multisig wallet not found"); + + assert!(wallet.active, "Wallet is inactive"); + + let mut is_authorizer_signer = false; + let mut remove_idx: Option = None; + for i in 0..wallet.signers.len() { + let s = wallet.signers.get(i).unwrap(); + if s == authorizer { + is_authorizer_signer = true; + } + if s == signer_to_remove { + remove_idx = Some(i); + } + } + assert!(is_authorizer_signer, "Authorizer is not a signer"); + assert!(remove_idx.is_some(), "Signer to remove not found"); + + let new_len = wallet.signers.len() - 1; + assert!(new_len >= 2, "Cannot reduce below 2 signers"); + assert!( + wallet.threshold as usize <= new_len, + "Removal would make threshold unreachable" + ); + + let mut new_signers = Vec::new(env); + for i in 0..wallet.signers.len() { + if Some(i) != remove_idx { + new_signers.push_back(wallet.signers.get(i).unwrap()); + } + } + wallet.signers = new_signers; + + env.storage() + .persistent() + .set(&DataKey::MultisigWallet(wallet_id), &wallet); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("sgn_rem")), + (wallet_id, signer_to_remove), + ); + + common::_release_lock(env); +} + +pub fn create_proposal( + env: &Env, + proposer: Address, + wallet_id: u64, + amount: i128, + recipient: Address, + description: String, +) -> u64 { + common::_require_not_paused(env); + proposer.require_auth(); + common::_acquire_lock(env); + + let wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(wallet_id)) + .expect("Multisig wallet not found"); + assert!(wallet.active, "Wallet is inactive"); + assert!(amount > 0, "Amount must be positive"); + + let mut is_signer = false; + for i in 0..wallet.signers.len() { + if wallet.signers.get(i).unwrap() == proposer { + is_signer = true; + break; + } + } + assert!(is_signer, "Proposer is not a signer of this wallet"); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::MultisigProposalCount) + .unwrap_or(0); + count += 1; + + let now = env.ledger().timestamp(); + let expires_at = if wallet.timeout_ledgers > 0 { + now + wallet.timeout_ledgers + } else { + 0 + }; + + let mut initial_approvals = Vec::new(env); + initial_approvals.push_back(proposer.clone()); + + let proposal = MultisigProposal { + id: count, + wallet_id, + amount, + recipient: recipient.clone(), + description, + status: MultisigProposalStatus::Pending, + approvals: initial_approvals, + rejections: Vec::new(env), + created_at: now, + expires_at, + }; + + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(count), &proposal); + env.storage() + .instance() + .set(&DataKey::MultisigProposalCount, &count); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("prop")), + (count, wallet_id, amount, recipient), + ); + + common::_release_lock(env); + count +} + +pub fn approve_proposal(env: &Env, signer: Address, proposal_id: u64) { + common::_require_not_paused(env); + signer.require_auth(); + common::_acquire_lock(env); + + let mut proposal: MultisigProposal = env + .storage() + .persistent() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + assert!( + proposal.status == MultisigProposalStatus::Pending, + "Proposal is not pending" + ); + + let wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(proposal.wallet_id)) + .expect("Wallet not found"); + + if proposal.expires_at > 0 && env.ledger().timestamp() >= proposal.expires_at { + proposal.status = MultisigProposalStatus::Expired; + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + common::_release_lock(env); + panic!("Proposal has expired"); + } + + let mut is_signer = false; + for i in 0..wallet.signers.len() { + if wallet.signers.get(i).unwrap() == signer { + is_signer = true; + break; + } + } + assert!(is_signer, "Not a signer of this wallet"); + + for i in 0..proposal.approvals.len() { + if proposal.approvals.get(i).unwrap() == signer { + common::_release_lock(env); + return; + } + } + + proposal.approvals.push_back(signer.clone()); + + if proposal.approvals.len() >= wallet.threshold { + proposal.status = MultisigProposalStatus::Executed; + env.events().publish( + (symbol_short!("msig"), symbol_short!("exec")), + (proposal_id, proposal.wallet_id, proposal.amount, proposal.recipient.clone()), + ); + } + + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + + common::_release_lock(env); +} + +pub fn reject_proposal(env: &Env, signer: Address, proposal_id: u64) { + common::_require_not_paused(env); + signer.require_auth(); + common::_acquire_lock(env); + + let mut proposal: MultisigProposal = env + .storage() + .persistent() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + assert!( + proposal.status == MultisigProposalStatus::Pending, + "Proposal is not pending" + ); + + let wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(proposal.wallet_id)) + .expect("Wallet not found"); + + let mut is_signer = false; + for i in 0..wallet.signers.len() { + if wallet.signers.get(i).unwrap() == signer { + is_signer = true; + break; + } + } + assert!(is_signer, "Not a signer of this wallet"); + + for i in 0..proposal.rejections.len() { + if proposal.rejections.get(i).unwrap() == signer { + common::_release_lock(env); + return; + } + } + + proposal.rejections.push_back(signer); + + let blocking = wallet.signers.len() - wallet.threshold + 1; + if proposal.rejections.len() >= blocking { + proposal.status = MultisigProposalStatus::Rejected; + env.events().publish( + (symbol_short!("msig"), symbol_short!("reject")), + (proposal_id, proposal.wallet_id), + ); + } + + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + + common::_release_lock(env); +} + +pub fn cancel_proposal(env: &Env, signer: Address, proposal_id: u64) { + common::_require_not_paused(env); + signer.require_auth(); + common::_acquire_lock(env); + + let mut proposal: MultisigProposal = env + .storage() + .persistent() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + assert!( + proposal.status == MultisigProposalStatus::Pending, + "Only pending proposals can be cancelled" + ); + + let wallet: MultisigWallet = env + .storage() + .persistent() + .get(&DataKey::MultisigWallet(proposal.wallet_id)) + .expect("Wallet not found"); + + let mut is_signer = false; + for i in 0..wallet.signers.len() { + if wallet.signers.get(i).unwrap() == signer { + is_signer = true; + break; + } + } + assert!(is_signer, "Not a signer of this wallet"); + + proposal.status = MultisigProposalStatus::Cancelled; + + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("cancel")), + (proposal_id, proposal.wallet_id), + ); + + common::_release_lock(env); +} + +pub fn get_proposal(env: &Env, proposal_id: u64) -> MultisigProposal { + env.storage() + .persistent() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found") +} + +pub fn check_expiry(env: &Env, proposal_id: u64) -> bool { + common::_acquire_lock(env); + + let mut proposal: MultisigProposal = env + .storage() + .persistent() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + if proposal.status != MultisigProposalStatus::Pending || proposal.expires_at == 0 { + common::_release_lock(env); + return false; + } + + if env.ledger().timestamp() < proposal.expires_at { + common::_release_lock(env); + return false; + } + + proposal.status = MultisigProposalStatus::Expired; + env.storage() + .persistent() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + + env.events().publish( + (symbol_short!("msig"), symbol_short!("expired")), + (proposal_id, proposal.wallet_id), + ); + + common::_release_lock(env); + true +} diff --git a/frontend/lib/errors.ts b/frontend/lib/errors.ts new file mode 100644 index 00000000..251590e6 --- /dev/null +++ b/frontend/lib/errors.ts @@ -0,0 +1,70 @@ +export interface AppError { + code: string; + message: string; + statusCode: number; + details?: Record; + timestamp?: string; + requestId?: string; + userId?: string; +} + +export type ErrorDomain = 'payment' | 'auth' | 'project' | 'dispute' | 'validation' | 'not_found' | 'internal'; + +export interface DomainErrorConfig { + domain: ErrorDomain; + defaultCode: string; + defaultStatus: number; +} + +export const DOMAIN_ERROR_CONFIG: Record = { + payment: { domain: 'payment', defaultCode: 'PAYMENT_ERROR', defaultStatus: 400 }, + auth: { domain: 'auth', defaultCode: 'AUTH_ERROR', defaultStatus: 401 }, + project: { domain: 'project', defaultCode: 'PROJECT_ERROR', defaultStatus: 400 }, + dispute: { domain: 'dispute', defaultCode: 'DISPUTE_ERROR', defaultStatus: 400 }, + validation: { domain: 'validation', defaultCode: 'VALIDATION_ERROR', defaultStatus: 422 }, + not_found: { domain: 'not_found', defaultCode: 'NOT_FOUND', defaultStatus: 404 }, + internal: { domain: 'internal', defaultCode: 'INTERNAL_SERVER_ERROR', defaultStatus: 500 }, +}; + +export function createError( + domain: ErrorDomain, + message: string, + statusCode?: number, + details?: Record, +): AppError { + const config = DOMAIN_ERROR_CONFIG[domain]; + return { + code: config.defaultCode, + message, + statusCode: statusCode ?? config.defaultStatus, + details, + timestamp: new Date().toISOString(), + }; +} + +export function isAppError(error: unknown): error is AppError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + 'statusCode' in error + ); +} + +export function getErrorMessage(error: unknown): string { + if (isAppError(error)) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return 'An unexpected error occurred'; +} + +export function getErrorCode(error: unknown): string { + if (isAppError(error)) { + return error.code; + } + return 'UNKNOWN_ERROR'; +} diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx new file mode 100644 index 00000000..675bef53 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Component, ReactNode } from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error) => ReactNode; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: { componentStack: string }) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback(this.state.error!); + } + return ( +
+

Something went wrong

+
+ Error details +
{this.state.error?.message}
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/store/slices.ts b/frontend/store/slices.ts new file mode 100644 index 00000000..19f123b6 --- /dev/null +++ b/frontend/store/slices.ts @@ -0,0 +1,100 @@ +import { create } from 'zustand'; + +export interface AuthSlice { + address: string | null; + email?: string; + name?: string; + profileImage?: string; + timezone?: string; + loginType: 'social' | 'wallet' | null; + isAuthenticated: boolean; + setAuth: (data: Partial) => void; + setTimezone: (timezone: string) => void; + logout: () => void; +} + +export const createAuthSlice = (): AuthSlice => ({ + address: null, + email: undefined, + name: undefined, + profileImage: undefined, + timezone: undefined, + loginType: null, + isAuthenticated: false, + + setAuth: (data) => + set({ + ...data, + isAuthenticated: true, + }), + + setTimezone: (timezone) => + set({ + timezone, + }), + + logout: () => + set({ + address: null, + email: undefined, + name: undefined, + profileImage: undefined, + timezone: undefined, + loginType: null, + isAuthenticated: false, + }), +}); + +export type PaymentSliceState = { + paymentType: 'simple' | 'escrow' | 'subscription' | 'batch' | null; + formData: Record; + isProcessing: boolean; + errorMessage: string | null; +}; + +export type PaymentSliceActions = { + setPaymentType: (type: 'simple' | 'escrow' | 'subscription' | 'batch') => void; + updateFormData: (data: Record) => void; + setProcessing: (isProcessing: boolean) => void; + setErrorMessage: (message: string | null) => void; + resetPayment: () => void; +}; + +export type PaymentSlice = PaymentSliceState & PaymentSliceActions; + +export const createPaymentSlice = (): PaymentSlice => ({ + paymentType: null, + formData: {}, + isProcessing: false, + errorMessage: null, + + setPaymentType: (type) => set({ paymentType: type }), + updateFormData: (data) => set((state) => ({ formData: { ...state.formData, ...data } })), + setProcessing: (isProcessing) => set({ isProcessing }), + setErrorMessage: (message) => set({ errorMessage: message }), + resetPayment: () => set({ paymentType: null, formData: {}, isProcessing: false, errorMessage: null }), +}); + +export type ProjectSliceState = { + projects: Array<{ id: string; status: string }>; + currentProjectId: string | null; + isLoading: boolean; +}; + +export type ProjectSliceActions = { + setProjects: (projects: Array<{ id: string; status: string }>) => void; + setCurrentProject: (id: string) => void; + setLoading: (isLoading: boolean) => void; +}; + +export type ProjectSlice = ProjectSliceState & ProjectSliceActions; + +export const createProjectSlice = (): ProjectSlice => ({ + projects: [], + currentProjectId: null, + isLoading: false, + + setProjects: (projects) => set({ projects }), + setCurrentProject: (id) => set({ currentProjectId: id }), + setLoading: (isLoading) => set({ isLoading }), +}); diff --git a/frontend/store/useStore.ts b/frontend/store/useStore.ts new file mode 100644 index 00000000..e011581f --- /dev/null +++ b/frontend/store/useStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { createAuthSlice, createPaymentSlice, createProjectSlice } from './slices'; + +type AuthSlice = ReturnType; +type PaymentSlice = ReturnType; +type ProjectSlice = ReturnType; + +interface AppStore extends AuthSlice, PaymentSlice, ProjectSlice {} + +export const useStore = create()( + persist( + (...args) => ({ + ...createAuthSlice(), + ...createPaymentSlice(), + ...createProjectSlice(), + }), + { + name: 'agenticpay-store', + partialize: (state) => ({ + address: state.address, + email: state.email, + name: state.name, + loginType: state.loginType, + isAuthenticated: state.isAuthenticated, + paymentType: state.paymentType, + formData: state.formData, + }), + } + ) +); diff --git a/packages/types/src/domain/primitives.ts b/packages/types/src/domain/primitives.ts index ee7609fb..b4f991a1 100644 --- a/packages/types/src/domain/primitives.ts +++ b/packages/types/src/domain/primitives.ts @@ -13,4 +13,15 @@ export interface AppError { message: string; statusCode: number; details?: Record; + timestamp?: string; + requestId?: string; + userId?: string; +} + +export type ErrorDomain = 'payment' | 'auth' | 'project' | 'dispute' | 'validation' | 'not_found' | 'internal'; + +export interface DomainErrorConfig { + domain: ErrorDomain; + defaultCode: string; + defaultStatus: number; }