From 56089489c26d638f7709fdd9a4092be78269fb3b Mon Sep 17 00:00:00 2001 From: kingsrichie79-cmyk Date: Tue, 20 Oct 2026 22:50:18 +0100 Subject: [PATCH] feat(escrow): add escrow condition evaluation, state management, release triggers, and dispute hold --- .../escrow/EscrowAnalyticsDashboard.tsx | 204 ++++++++ .../EscrowAnalyticsDashboard.test.tsx | 83 +++ src/lib/escrow/__tests__/analytics.test.ts | 130 +++++ .../escrow/__tests__/conditionEngine.test.ts | 318 ++++++++++++ src/lib/escrow/__tests__/disputeHold.test.ts | 114 +++++ .../escrow/__tests__/escrowService.test.ts | 163 ++++++ .../escrow/__tests__/releaseTrigger.test.ts | 145 ++++++ src/lib/escrow/__tests__/setup.ts | 26 + src/lib/escrow/__tests__/stateMachine.test.ts | 86 ++++ src/lib/escrow/analytics.ts | 292 +++++++++++ src/lib/escrow/conditionEngine.ts | 279 ++++++++++ src/lib/escrow/disputeHold.ts | 148 ++++++ src/lib/escrow/escrowService.ts | 482 ++++++++++++++++++ src/lib/escrow/index.ts | 7 + src/lib/escrow/releaseTrigger.ts | 294 +++++++++++ src/lib/escrow/stateMachine.ts | 139 +++++ src/types/escrow.ts | 311 +++++++++++ 17 files changed, 3221 insertions(+) create mode 100644 src/components/escrow/EscrowAnalyticsDashboard.tsx create mode 100644 src/components/escrow/__tests__/EscrowAnalyticsDashboard.test.tsx create mode 100644 src/lib/escrow/__tests__/analytics.test.ts create mode 100644 src/lib/escrow/__tests__/conditionEngine.test.ts create mode 100644 src/lib/escrow/__tests__/disputeHold.test.ts create mode 100644 src/lib/escrow/__tests__/escrowService.test.ts create mode 100644 src/lib/escrow/__tests__/releaseTrigger.test.ts create mode 100644 src/lib/escrow/__tests__/setup.ts create mode 100644 src/lib/escrow/__tests__/stateMachine.test.ts create mode 100644 src/lib/escrow/analytics.ts create mode 100644 src/lib/escrow/conditionEngine.ts create mode 100644 src/lib/escrow/disputeHold.ts create mode 100644 src/lib/escrow/escrowService.ts create mode 100644 src/lib/escrow/index.ts create mode 100644 src/lib/escrow/releaseTrigger.ts create mode 100644 src/lib/escrow/stateMachine.ts create mode 100644 src/types/escrow.ts diff --git a/src/components/escrow/EscrowAnalyticsDashboard.tsx b/src/components/escrow/EscrowAnalyticsDashboard.tsx new file mode 100644 index 00000000..928e77b6 --- /dev/null +++ b/src/components/escrow/EscrowAnalyticsDashboard.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { formatAmount } from "@stellar-split/sdk"; +import type { EscrowAnalyticsReport, EscrowContract, EscrowStatus } from "@/types/escrow"; +import { computeEscrowAnalytics, generateEscrowMarkdownReport } from "@/lib/escrow/analytics"; + +interface Props { + escrows: EscrowContract[]; + title?: string; +} + +export default function EscrowAnalyticsDashboard({ + escrows, + title = "Escrow Analytics & Reporting", +}: Props) { + const [selectedStatus, setSelectedStatus] = useState("ALL"); + const [copied, setCopied] = useState(false); + + const report: EscrowAnalyticsReport = useMemo(() => { + return computeEscrowAnalytics( + selectedStatus === "ALL" + ? escrows + : escrows.filter((e) => e.status === selectedStatus) + ); + }, [escrows, selectedStatus]); + + const handleCopyReport = () => { + const md = generateEscrowMarkdownReport(report); + if (navigator.clipboard) { + navigator.clipboard.writeText(md); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const { summary, byStatus, byConditionType, byToken } = report; + + return ( +
+
+
+

+ + {title} +

+

+ Real-time escrow contracts, condition evaluations, dispute holds, and value metrics +

+
+ +
+ + + +
+
+ + {/* Metric Cards Grid */} +
+
+

Total Value Locked (TVL)

+

+ {formatAmount(summary.totalValueLocked)} +

+

{summary.activeEscrows} active escrows

+
+ +
+

Released Funds

+

+ {formatAmount(summary.totalValueReleased)} +

+

{summary.releasedEscrows} completed payouts

+
+ +
+

Dispute Rate

+

+ {summary.disputeRatePercentage}% +

+

+ {summary.disputedEscrows} disputed / {summary.disputeResolutionRatePercentage}% resolved +

+
+ +
+

Total Escrows

+

+ {summary.totalEscrows} +

+

+ {summary.completedEscrows} finalized ({summary.refundedEscrows} refunded) +

+
+
+ + {/* Status Breakdown & Condition Pass Rates */} +
+ {/* Status Distribution */} +
+

Status Distribution

+
+ {(Object.keys(byStatus) as EscrowStatus[]).map((status) => { + const count = byStatus[status]; + const pct = summary.totalEscrows > 0 ? (count / summary.totalEscrows) * 100 : 0; + return ( +
+ {status} +
+
+
+ + {count} + +
+ ); + })} +
+
+ + {/* Condition Pass Rates */} +
+

Condition Evaluation Pass Rates

+
+ {Object.entries(byConditionType).map(([type, stat]) => { + return ( +
+
+ {type} + + {stat.met}/{stat.total} ({stat.passRatePercentage}%) + +
+
+
+
+
+ ); + })} +
+
+
+ + {/* Token Distribution */} + {Object.keys(byToken).length > 0 && ( +
+

Volume by Token Asset

+
+ {Object.entries(byToken).map(([token, val]) => ( +
+

{token}

+

+ Volume: {formatAmount(val.totalVolume)} +

+

+ TVL: {formatAmount(val.activeTvl)} +

+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/escrow/__tests__/EscrowAnalyticsDashboard.test.tsx b/src/components/escrow/__tests__/EscrowAnalyticsDashboard.test.tsx new file mode 100644 index 00000000..00f57b04 --- /dev/null +++ b/src/components/escrow/__tests__/EscrowAnalyticsDashboard.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import EscrowAnalyticsDashboard from "../EscrowAnalyticsDashboard"; +import type { EscrowContract } from "@/types/escrow"; + +const mockEscrows: EscrowContract[] = [ + { + id: "e1", + depositor: "alice", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 1000n }], + token: "USDC", + targetAmount: 1000n, + fundedAmount: 1000n, + status: "Released", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [ + { id: "c1", type: "TIME_LOCK", title: "Time Lock", releaseTimestamp: 100, met: true }, + ], + disputeHold: false, + history: [], + createdAt: 1000, + updatedAt: 1060, + }, + { + id: "e2", + depositor: "alice", + beneficiary: "carol", + recipients: [{ address: "carol", amount: 2000n }], + token: "USDC", + targetAmount: 2000n, + fundedAmount: 2000n, + status: "Disputed", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [ + { id: "c2", type: "MILESTONE", title: "Milestone", milestoneId: "m1", completed: false, met: false }, + ], + disputeHold: true, + dispute: { + id: "d1", + initiator: "alice", + reason: "Delayed delivery", + status: "Open", + createdAt: 1010, + }, + history: [], + createdAt: 1000, + updatedAt: 1010, + }, +]; + +describe("EscrowAnalyticsDashboard Component", () => { + it("renders metric cards, TVL, and status distribution", () => { + render(); + + expect(screen.getByTestId("escrow-analytics-dashboard")).toBeInTheDocument(); + expect(screen.getByText("Escrow Analytics & Reporting")).toBeInTheDocument(); + expect(screen.getByText("Total Value Locked (TVL)")).toBeInTheDocument(); + expect(screen.getByText("Released Funds")).toBeInTheDocument(); + expect(screen.getByText("Dispute Rate")).toBeInTheDocument(); + expect(screen.getByText("Status Distribution")).toBeInTheDocument(); + expect(screen.getByText("Condition Evaluation Pass Rates")).toBeInTheDocument(); + }); + + it("filters by status using dropdown", () => { + render(); + + const select = screen.getByLabelText("Filter escrows by status"); + fireEvent.change(select, { target: { value: "Released" } }); + + expect(select).toHaveValue("Released"); + }); + + it("handles export report button click", () => { + render(); + + const exportBtn = screen.getByText("Export Report"); + expect(exportBtn).toBeInTheDocument(); + fireEvent.click(exportBtn); + }); +}); diff --git a/src/lib/escrow/__tests__/analytics.test.ts b/src/lib/escrow/__tests__/analytics.test.ts new file mode 100644 index 00000000..ceddc4d1 --- /dev/null +++ b/src/lib/escrow/__tests__/analytics.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + computeEscrowAnalytics, + filterEscrows, + generateEscrowMarkdownReport, +} from "../analytics"; +import type { EscrowContract } from "@/types/escrow"; + +function createMockEscrows(): EscrowContract[] { + return [ + { + id: "e1", + depositor: "alice", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 1000n }], + token: "USDC", + targetAmount: 1000n, + fundedAmount: 1000n, + status: "Released", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [ + { id: "c1", type: "TIME_LOCK", title: "Time", releaseTimestamp: 100, met: true }, + ], + disputeHold: false, + history: [], + createdAt: 1000, + updatedAt: 1060, + releasedAt: 1060, + }, + { + id: "e2", + depositor: "alice", + beneficiary: "charlie", + recipients: [{ address: "charlie", amount: 2000n }], + token: "USDC", + targetAmount: 2000n, + fundedAmount: 2000n, + status: "Disputed", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [ + { id: "c2", type: "MILESTONE", title: "Milestone", milestoneId: "m1", completed: false, met: false }, + ], + disputeHold: true, + dispute: { + id: "d1", + initiator: "alice", + reason: "Delayed", + status: "Open", + createdAt: 1010, + }, + history: [], + createdAt: 1000, + updatedAt: 1010, + }, + { + id: "e3", + depositor: "dave", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 500n }], + token: "XLM", + targetAmount: 500n, + fundedAmount: 500n, + status: "Funded", + releaseTriggerType: "MANUAL", + conditionLogic: "ALL", + conditions: [], + disputeHold: false, + history: [], + createdAt: 1050, + updatedAt: 1050, + }, + ]; +} + +describe("Escrow Analytics & Reporting", () => { + it("computes comprehensive summary metrics", () => { + const escrows = createMockEscrows(); + const report = computeEscrowAnalytics(escrows); + + expect(report.summary.totalEscrows).toBe(3); + expect(report.summary.releasedEscrows).toBe(1); + expect(report.summary.disputedEscrows).toBe(1); + expect(report.summary.activeEscrows).toBe(2); // Funded + Disputed + + // Total Value Locked: active escrows (Disputed 2000 + Funded 500) = 2500 + expect(report.summary.totalValueLocked).toBe(2500n); + expect(report.summary.totalValueReleased).toBe(1000n); + expect(report.summary.totalValueDisputed).toBe(2000n); + + expect(report.byStatus.Released).toBe(1); + expect(report.byStatus.Disputed).toBe(1); + expect(report.byStatus.Funded).toBe(1); + + expect(report.byConditionType.TIME_LOCK.total).toBe(1); + expect(report.byConditionType.TIME_LOCK.met).toBe(1); + expect(report.byConditionType.TIME_LOCK.passRatePercentage).toBe(100); + + expect(report.byConditionType.MILESTONE.total).toBe(1); + expect(report.byConditionType.MILESTONE.met).toBe(0); + expect(report.byConditionType.MILESTONE.passRatePercentage).toBe(0); + + expect(report.byToken.USDC.count).toBe(2); + expect(report.byToken.XLM.count).toBe(1); + }); + + it("filters escrows accurately", () => { + const escrows = createMockEscrows(); + const xlmFiltered = filterEscrows(escrows, { token: "XLM" }); + expect(xlmFiltered.length).toBe(1); + expect(xlmFiltered[0].id).toBe("e3"); + + const statusFiltered = filterEscrows(escrows, { status: "Released" }); + expect(statusFiltered.length).toBe(1); + expect(statusFiltered[0].id).toBe("e1"); + }); + + it("generates formatted Markdown report", () => { + const escrows = createMockEscrows(); + const report = computeEscrowAnalytics(escrows); + const md = generateEscrowMarkdownReport(report); + + expect(md).toContain("# Escrow Analytics & Performance Report"); + expect(md).toContain("Total Value Locked (TVL)"); + expect(md).toContain("Dispute Rate"); + expect(md).toContain("Condition Evaluation Pass Rates"); + expect(md).toContain("Volume by Token"); + }); +}); diff --git a/src/lib/escrow/__tests__/conditionEngine.test.ts b/src/lib/escrow/__tests__/conditionEngine.test.ts new file mode 100644 index 00000000..9771c950 --- /dev/null +++ b/src/lib/escrow/__tests__/conditionEngine.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateApproval, + evaluateConditions, + evaluateCustom, + evaluateMilestone, + evaluateOracle, + evaluateSingleCondition, + evaluateThreshold, + evaluateTimeLock, +} from "../conditionEngine"; +import type { + ApprovalCondition, + CustomCondition, + MilestoneCondition, + OracleCondition, + ThresholdCondition, + TimeLockCondition, +} from "@/types/escrow"; + +describe("Condition Evaluation Engine", () => { + describe("TimeLockCondition", () => { + it("returns met=true when currentTimestamp >= releaseTimestamp", () => { + const cond: TimeLockCondition = { + id: "c_time_1", + type: "TIME_LOCK", + title: "7-day lock", + releaseTimestamp: 1000, + met: false, + }; + const res = evaluateTimeLock(cond, 1000); + expect(res.met).toBe(true); + expect(res.reason).toBeUndefined(); + + const futureRes = evaluateTimeLock(cond, 1050); + expect(futureRes.met).toBe(true); + }); + + it("returns met=false with remaining duration when timestamp is before unlock", () => { + const cond: TimeLockCondition = { + id: "c_time_2", + type: "TIME_LOCK", + title: "Lock", + releaseTimestamp: 2000, + met: false, + }; + const res = evaluateTimeLock(cond, 1500); + expect(res.met).toBe(false); + expect(res.reason).toContain("500s remaining"); + }); + }); + + describe("MilestoneCondition", () => { + it("evaluates milestone status and respects context overrides", () => { + const cond: MilestoneCondition = { + id: "c_mile_1", + type: "MILESTONE", + title: "Phase 1 Delivery", + milestoneId: "m_1", + completed: false, + met: false, + }; + + expect(evaluateMilestone(cond).met).toBe(false); + + const withContext = evaluateMilestone(cond, { + milestoneCompletions: { m_1: true }, + }); + expect(withContext.met).toBe(true); + + const completedCond: MilestoneCondition = { + ...cond, + completed: true, + }; + expect(evaluateMilestone(completedCond).met).toBe(true); + }); + }); + + describe("ApprovalCondition (Multi-Sig)", () => { + it("verifies required signatures against minimum count", () => { + const cond: ApprovalCondition = { + id: "c_app_1", + type: "APPROVAL", + title: "2-of-3 Multisig", + requiredSigners: ["alice", "bob", "carol"], + minSignatures: 2, + currentSignatures: ["alice"], + met: false, + }; + + // Only 1 of 2 signatures + expect(evaluateApproval(cond).met).toBe(false); + + // 2 signatures collected + const cond2: ApprovalCondition = { + ...cond, + currentSignatures: ["alice", "bob"], + }; + expect(evaluateApproval(cond2).met).toBe(true); + + // Context signature inclusion and deduplication + const resContext = evaluateApproval(cond, { + approvals: { c_app_1: ["bob", "alice"] }, + }); + expect(resContext.met).toBe(true); + + // Signatures not in requiredSigners are ignored + const condWithUnauthorized: ApprovalCondition = { + ...cond, + currentSignatures: ["alice", "mallory"], + }; + expect(evaluateApproval(condWithUnauthorized).met).toBe(false); + }); + }); + + describe("OracleCondition", () => { + it("evaluates operators eq, gte, lte, neq against metric feed", () => { + const condEq: OracleCondition = { + id: "c_ora_1", + type: "ORACLE", + title: "Delivered status", + oracleAddress: "GDELIVERY_ORACLE", + metricKey: "packageStatus", + expectedValue: "DELIVERED", + operator: "eq", + met: false, + }; + + expect( + evaluateOracle(condEq, { + oracleData: { GDELIVERY_ORACLE: { packageStatus: "DELIVERED" } }, + }).met + ).toBe(true); + + expect( + evaluateOracle(condEq, { + oracleData: { GDELIVERY_ORACLE: { packageStatus: "IN_TRANSIT" } }, + }).met + ).toBe(false); + + const condGte: OracleCondition = { + id: "c_ora_2", + type: "ORACLE", + title: "Min Price", + oracleAddress: "GPRICE_ORACLE", + metricKey: "xlmPriceUsd", + expectedValue: 0.15, + operator: "gte", + met: false, + }; + + expect( + evaluateOracle(condGte, { + oracleData: { GPRICE_ORACLE: { xlmPriceUsd: 0.2 } }, + }).met + ).toBe(true); + + expect( + evaluateOracle(condGte, { + oracleData: { GPRICE_ORACLE: { xlmPriceUsd: 0.1 } }, + }).met + ).toBe(false); + }); + + it("returns met=false when oracle data is missing", () => { + const cond: OracleCondition = { + id: "c_ora_3", + type: "ORACLE", + title: "Oracle Check", + oracleAddress: "GUNKNOWN_ORACLE", + metricKey: "temp", + expectedValue: 25, + operator: "eq", + met: false, + }; + const res = evaluateOracle(cond); + expect(res.met).toBe(false); + expect(res.reason).toContain("Oracle data unavailable"); + }); + }); + + describe("ThresholdCondition", () => { + it("evaluates target amount against funded amount", () => { + const cond: ThresholdCondition = { + id: "c_thresh_1", + type: "THRESHOLD", + title: "Full funding required", + targetAmount: 50000000n, + currentAmount: 30000000n, + met: false, + }; + + expect(evaluateThreshold(cond).met).toBe(false); + expect(evaluateThreshold(cond, { fundedAmount: 50000000n }).met).toBe(true); + expect(evaluateThreshold(cond, { fundedAmount: 60000000n }).met).toBe(true); + }); + }); + + describe("CustomCondition", () => { + it("executes custom predicate functions from evaluation context", () => { + const cond: CustomCondition = { + id: "c_cust_1", + type: "CUSTOM", + title: "KYC Verified", + predicateKey: "verifyKYC", + parameters: { user: "alice" }, + met: false, + }; + + const res = evaluateCustom(cond, { + customEvaluators: { + verifyKYC: (c) => c.parameters?.user === "alice", + }, + }); + expect(res.met).toBe(true); + + const failRes = evaluateCustom(cond, { + customEvaluators: { + verifyKYC: () => false, + }, + }); + expect(failRes.met).toBe(false); + expect(failRes.reason).toContain("returned false"); + }); + }); + + describe("Composite Condition Evaluation (evaluateConditions)", () => { + it("handles empty conditions array as release permissible", () => { + const res = evaluateConditions([]); + expect(res.canRelease).toBe(true); + expect(res.allMet).toBe(true); + expect(res.totalConditions).toBe(0); + }); + + it("evaluates logic ALL correctly", () => { + const cond1: TimeLockCondition = { + id: "c1", + type: "TIME_LOCK", + title: "Lock", + releaseTimestamp: 100, + met: false, + }; + const cond2: MilestoneCondition = { + id: "c2", + type: "MILESTONE", + title: "Delivery", + milestoneId: "m1", + completed: false, + met: false, + }; + + // Both pending + const res1 = evaluateConditions([cond1, cond2], "ALL", { + currentTimestamp: 50, + }); + expect(res1.canRelease).toBe(false); + expect(res1.metCount).toBe(0); + expect(res1.failureReasons.length).toBe(2); + + // 1 of 2 met + const res2 = evaluateConditions([cond1, cond2], "ALL", { + currentTimestamp: 150, + milestoneCompletions: { m1: false }, + }); + expect(res2.canRelease).toBe(false); + expect(res2.metCount).toBe(1); + + // Both met + const res3 = evaluateConditions([cond1, cond2], "ALL", { + currentTimestamp: 150, + milestoneCompletions: { m1: true }, + }); + expect(res3.canRelease).toBe(true); + expect(res3.allMet).toBe(true); + expect(res3.metCount).toBe(2); + }); + + it("evaluates logic ANY correctly", () => { + const cond1: TimeLockCondition = { + id: "c1", + type: "TIME_LOCK", + title: "Lock", + releaseTimestamp: 500, + met: false, + }; + const cond2: MilestoneCondition = { + id: "c2", + type: "MILESTONE", + title: "Milestone", + milestoneId: "m1", + completed: true, + met: false, + }; + + const res = evaluateConditions([cond1, cond2], "ANY", { + currentTimestamp: 100, + }); + expect(res.canRelease).toBe(true); + expect(res.metCount).toBe(1); + }); + + it("evaluates logic THRESHOLD correctly", () => { + const conds: EscrowCondition[] = [ + { id: "c1", type: "TIME_LOCK", title: "T1", releaseTimestamp: 100, met: false }, + { id: "c2", type: "TIME_LOCK", title: "T2", releaseTimestamp: 100, met: false }, + { id: "c3", type: "TIME_LOCK", title: "T3", releaseTimestamp: 300, met: false }, + ]; + + // At timestamp 200, c1 and c2 are met (2 of 3) + const res2Of3 = evaluateConditions(conds, "THRESHOLD", { currentTimestamp: 200 }, 2); + expect(res2Of3.canRelease).toBe(true); + expect(res2Of3.metCount).toBe(2); + + const res3Of3 = evaluateConditions(conds, "THRESHOLD", { currentTimestamp: 200 }, 3); + expect(res3Of3.canRelease).toBe(false); + }); + }); +}); diff --git a/src/lib/escrow/__tests__/disputeHold.test.ts b/src/lib/escrow/__tests__/disputeHold.test.ts new file mode 100644 index 00000000..fec7e8db --- /dev/null +++ b/src/lib/escrow/__tests__/disputeHold.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + isDisputeHoldActive, + raiseDispute, + resolveDispute, +} from "../disputeHold"; +import { EscrowStateError } from "../stateMachine"; +import type { EscrowContract } from "@/types/escrow"; + +function createMockEscrow(overrides: Partial = {}): EscrowContract { + return { + id: "escrow_disp_1", + depositor: "alice", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 1000n }], + token: "USDC", + targetAmount: 1000n, + fundedAmount: 1000n, + status: "Funded", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [], + disputeHold: false, + history: [], + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }; +} + +describe("Dispute Hold Mechanism", () => { + it("raises dispute, applies disputeHold flag, and transitions to Disputed", () => { + const escrow = createMockEscrow(); + const disputed = raiseDispute(escrow, { + initiator: "alice", + reason: "Defective delivery", + evidenceCids: ["QmHash1"], + }); + + expect(isDisputeHoldActive(disputed)).toBe(true); + expect(disputed.status).toBe("Disputed"); + expect(disputed.disputeHold).toBe(true); + expect(disputed.dispute?.initiator).toBe("alice"); + expect(disputed.dispute?.reason).toBe("Defective delivery"); + expect(disputed.dispute?.evidenceCids).toContain("QmHash1"); + }); + + it("prohibits raising dispute on finalized escrow", () => { + const released = createMockEscrow({ status: "Released" }); + expect(() => + raiseDispute(released, { initiator: "alice", reason: "Too late" }) + ).toThrow(EscrowStateError); + }); + + it("resolves dispute with outcome RELEASE", () => { + const escrow = createMockEscrow(); + const disputed = raiseDispute(escrow, { + initiator: "alice", + reason: "Delay", + }); + + const { escrow: resolved } = resolveDispute(disputed, { + resolver: "arbitrator_judy", + outcome: "RELEASE", + notes: "Goods delivered in full", + }); + + expect(resolved.disputeHold).toBe(false); + expect(resolved.status).toBe("ReadyForRelease"); + expect(resolved.dispute?.status).toBe("ResolvedRelease"); + expect(resolved.dispute?.resolvedBy).toBe("arbitrator_judy"); + }); + + it("resolves dispute with outcome REFUND", () => { + const escrow = createMockEscrow(); + const disputed = raiseDispute(escrow, { + initiator: "alice", + reason: "Item not sent", + }); + + const { escrow: resolved } = resolveDispute(disputed, { + resolver: "arbitrator_judy", + outcome: "REFUND", + notes: "Full refund to buyer", + }); + + expect(resolved.disputeHold).toBe(false); + expect(resolved.status).toBe("Refunded"); + expect(resolved.dispute?.status).toBe("ResolvedRefund"); + }); + + it("resolves dispute with outcome SPLIT", () => { + const escrow = createMockEscrow(); + const disputed = raiseDispute(escrow, { + initiator: "alice", + reason: "Partial completion", + }); + + const { escrow: resolved } = resolveDispute(disputed, { + resolver: "arbitrator_judy", + outcome: "SPLIT", + notes: "Split 50/50", + splitRatio: { + beneficiaryPercentage: 50, + depositorPercentage: 50, + }, + }); + + expect(resolved.disputeHold).toBe(false); + expect(resolved.status).toBe("ReadyForRelease"); + expect(resolved.dispute?.status).toBe("ResolvedSplit"); + expect(resolved.dispute?.splitRatio?.beneficiaryPercentage).toBe(50); + }); +}); diff --git a/src/lib/escrow/__tests__/escrowService.test.ts b/src/lib/escrow/__tests__/escrowService.test.ts new file mode 100644 index 00000000..540f2cc1 --- /dev/null +++ b/src/lib/escrow/__tests__/escrowService.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { EscrowService } from "../escrowService"; +import { EscrowDisputeHoldError } from "../stateMachine"; + +describe("EscrowService End-to-End Integration", () => { + it("creates an escrow and funds it", () => { + const service = new EscrowService([]); + const escrow = service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 5000n, + }); + + expect(escrow.status).toBe("Created"); + expect(escrow.fundedAmount).toBe(0n); + + const funded = service.fundEscrow(escrow.id, 5000n, "alice"); + expect(funded.status).toBe("Released"); // No conditions & AUTOMATIC trigger -> Released! + expect(funded.fundedAmount).toBe(5000n); + expect(funded.releaseExecution).toBeDefined(); + }); + + it("evaluates conditions and releases automatically once all conditions are satisfied", () => { + const service = new EscrowService([]); + const escrow = service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 1000n, + initialDeposit: 1000n, + conditions: [ + { + id: "m1", + type: "MILESTONE", + title: "Complete Backend", + milestoneId: "mile_backend", + completed: false, + met: false, + }, + ], + releaseTriggerType: "AUTOMATIC", + }); + + // Funded, but milestone is not complete -> not released yet + expect(escrow.status).toBe("Funded"); + + // Complete the milestone + const updated = service.completeMilestone(escrow.id, "m1", "auditor"); + + // Automatically triggered release! + expect(updated.status).toBe("Released"); + expect(updated.releaseExecution?.payouts[0].recipient).toBe("bob"); + expect(updated.releaseExecution?.payouts[0].amount).toBe(1000n); + }); + + it("handles multi-signature approvals and triggers release", () => { + const service = new EscrowService([]); + const escrow = service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 2000n, + initialDeposit: 2000n, + conditions: [ + { + id: "app1", + type: "APPROVAL", + title: "2-of-2 signoff", + requiredSigners: ["alice", "bob"], + minSignatures: 2, + currentSignatures: [], + met: false, + }, + ], + releaseTriggerType: "AUTOMATIC", + }); + + expect(escrow.status).toBe("Funded"); + + // First signer + const afterFirstSig = service.addApprovalSignature(escrow.id, "app1", "alice"); + expect(afterFirstSig.status).toBe("Funded"); + + // Second signer triggers release + const afterSecondSig = service.addApprovalSignature(escrow.id, "app1", "bob"); + expect(afterSecondSig.status).toBe("Released"); + }); + + it("ensures dispute hold prevents release until resolved", () => { + const service = new EscrowService([]); + const escrow = service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 3000n, + initialDeposit: 3000n, + releaseTriggerType: "MANUAL", + }); + + expect(escrow.status).toBe("Funded"); + + // Alice raises dispute + const disputed = service.raiseDispute(escrow.id, { + initiator: "alice", + reason: "Defective work delivered", + evidenceCids: ["QmDisputeEvidence"], + }); + + expect(disputed.status).toBe("Disputed"); + expect(disputed.disputeHold).toBe(true); + + // Attempting manual release while under dispute MUST throw + expect(() => service.triggerRelease(escrow.id, "bob")).toThrow( + EscrowDisputeHoldError + ); + + // Resolve dispute in favor of RELEASE + const resolved = service.resolveDispute(escrow.id, { + resolver: "mediator_judy", + outcome: "RELEASE", + notes: "Defects were addressed by contractor", + }); + + expect(resolved.status).toBe("Released"); + expect(resolved.disputeHold).toBe(false); + expect(resolved.dispute?.status).toBe("ResolvedRelease"); + }); + + it("refunds depositor properly", () => { + const service = new EscrowService([]); + const escrow = service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 1000n, + initialDeposit: 1000n, + releaseTriggerType: "MANUAL", + }); + + const refunded = service.refundEscrow(escrow.id, "alice", "Mutual cancellation"); + expect(refunded.status).toBe("Refunded"); + expect(refunded.refundExecution?.recipient).toBe("alice"); + expect(refunded.refundExecution?.amount).toBe(1000n); + }); + + it("produces aggregated analytics report", () => { + const service = new EscrowService([]); + service.createEscrow({ + depositor: "alice", + beneficiary: "bob", + targetAmount: 1000n, + initialDeposit: 1000n, + }); + service.createEscrow({ + depositor: "charlie", + beneficiary: "bob", + targetAmount: 2000n, + initialDeposit: 2000n, + releaseTriggerType: "MANUAL", + }); + + const analytics = service.getAnalytics(); + expect(analytics.summary.totalEscrows).toBe(2); + expect(analytics.summary.totalValueReleased).toBe(1000n); + expect(analytics.summary.totalValueLocked).toBe(2000n); + }); +}); diff --git a/src/lib/escrow/__tests__/releaseTrigger.test.ts b/src/lib/escrow/__tests__/releaseTrigger.test.ts new file mode 100644 index 00000000..9b81bd21 --- /dev/null +++ b/src/lib/escrow/__tests__/releaseTrigger.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + attemptReleaseTrigger, + calculatePayouts, + executeManualRelease, +} from "../releaseTrigger"; +import { EscrowDisputeHoldError } from "../stateMachine"; +import type { EscrowContract } from "@/types/escrow"; + +function createMockEscrow(overrides: Partial = {}): EscrowContract { + return { + id: "escrow_rel_1", + depositor: "alice", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 1000n }], + token: "USDC", + targetAmount: 1000n, + fundedAmount: 1000n, + status: "Funded", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [], + disputeHold: false, + history: [], + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }; +} + +describe("Release Trigger System", () => { + it("automatically releases funds when conditions are met and no dispute is active", () => { + const escrow = createMockEscrow(); + const { result, updatedEscrow } = attemptReleaseTrigger(escrow); + + expect(result.success).toBe(true); + expect(result.released).toBe(true); + expect(result.txHash).toBeDefined(); + expect(result.payouts?.length).toBe(1); + expect(result.payouts?.[0].recipient).toBe("bob"); + expect(result.payouts?.[0].amount).toBe(1000n); + expect(updatedEscrow.status).toBe("Released"); + expect(updatedEscrow.releaseExecution).toBeDefined(); + }); + + it("actively BLOCKS release when dispute is active", () => { + const disputedEscrow = createMockEscrow({ + status: "Disputed", + disputeHold: true, + }); + + const { result, updatedEscrow } = attemptReleaseTrigger(disputedEscrow); + expect(result.success).toBe(false); + expect(result.released).toBe(false); + expect(result.blockedByDispute).toBe(true); + expect(result.error).toContain("active dispute"); + expect(updatedEscrow.status).toBe("Disputed"); + + // Manual release must throw EscrowDisputeHoldError + expect(() => executeManualRelease(disputedEscrow, "alice")).toThrow( + EscrowDisputeHoldError + ); + }); + + it("halts release when conditions are pending", () => { + const escrow = createMockEscrow({ + conditions: [ + { + id: "c1", + type: "TIME_LOCK", + title: "Lock", + releaseTimestamp: 5000, + met: false, + }, + ], + }); + + const { result, updatedEscrow } = attemptReleaseTrigger(escrow, {}, { now: 1000 }); + expect(result.success).toBe(false); + expect(result.released).toBe(false); + expect(result.blockedByDispute).toBe(false); + expect(result.failureReasons?.length).toBe(1); + expect(updatedEscrow.status).toBe("Funded"); + }); + + it("sets ReadyForRelease when releaseTriggerType is MANUAL", () => { + const manualEscrow = createMockEscrow({ + releaseTriggerType: "MANUAL", + conditions: [], + }); + + const { result, updatedEscrow } = attemptReleaseTrigger(manualEscrow); + expect(result.success).toBe(true); + expect(result.released).toBe(false); + expect(result.readyForRelease).toBe(true); + expect(updatedEscrow.status).toBe("ReadyForRelease"); + + // Now execute manual release with authorized actor + const manualResult = executeManualRelease(updatedEscrow, "alice"); + expect(manualResult.result.success).toBe(true); + expect(manualResult.result.released).toBe(true); + expect(manualResult.updatedEscrow.status).toBe("Released"); + }); + + it("calculates multi-recipient payouts proportionally", () => { + const multiRecipientEscrow = createMockEscrow({ + fundedAmount: 1000n, + recipients: [ + { address: "bob", amount: 600n }, + { address: "carol", amount: 400n }, + ], + }); + + const payouts = calculatePayouts(multiRecipientEscrow, "0xtx", 1000); + expect(payouts.length).toBe(2); + expect(payouts[0].recipient).toBe("bob"); + expect(payouts[0].amount).toBe(600n); + expect(payouts[1].recipient).toBe("carol"); + expect(payouts[1].amount).toBe(400n); + }); + + it("calculates dispute split payouts when resolved with split", () => { + const splitEscrow = createMockEscrow({ + fundedAmount: 1000n, + dispute: { + id: "d1", + initiator: "alice", + reason: "dispute", + status: "ResolvedSplit", + createdAt: 1000, + splitRatio: { + beneficiaryPercentage: 70, + depositorPercentage: 30, + }, + }, + }); + + const payouts = calculatePayouts(splitEscrow, "0xtx", 1000); + expect(payouts.length).toBe(2); + expect(payouts[0].recipient).toBe("bob"); + expect(payouts[0].amount).toBe(700n); + expect(payouts[1].recipient).toBe("alice"); + expect(payouts[1].amount).toBe(300n); + }); +}); diff --git a/src/lib/escrow/__tests__/setup.ts b/src/lib/escrow/__tests__/setup.ts new file mode 100644 index 00000000..b4c53d00 --- /dev/null +++ b/src/lib/escrow/__tests__/setup.ts @@ -0,0 +1,26 @@ +if (!globalThis.navigator) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).navigator = { + clipboard: { + writeText: async () => {}, + }, + onLine: true, + }; +} + +if (!globalThis.localStorage) { + let store: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).localStorage = { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +} diff --git a/src/lib/escrow/__tests__/stateMachine.test.ts b/src/lib/escrow/__tests__/stateMachine.test.ts new file mode 100644 index 00000000..ab0dfee3 --- /dev/null +++ b/src/lib/escrow/__tests__/stateMachine.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + EscrowDisputeHoldError, + EscrowStateError, + isTransitionAllowed, + transitionEscrowState, + validateStateTransition, +} from "../stateMachine"; +import type { EscrowContract } from "@/types/escrow"; + +function createMockEscrow(overrides: Partial = {}): EscrowContract { + return { + id: "escrow_test_1", + depositor: "alice", + beneficiary: "bob", + recipients: [{ address: "bob", amount: 1000n }], + token: "USDC", + targetAmount: 1000n, + fundedAmount: 1000n, + status: "Funded", + releaseTriggerType: "AUTOMATIC", + conditionLogic: "ALL", + conditions: [], + disputeHold: false, + history: [], + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }; +} + +describe("Escrow State Machine", () => { + it("allows permissible transitions", () => { + expect(isTransitionAllowed("Created", "Funded")).toBe(true); + expect(isTransitionAllowed("Funded", "ReadyForRelease")).toBe(true); + expect(isTransitionAllowed("ReadyForRelease", "Released")).toBe(true); + expect(isTransitionAllowed("Funded", "Disputed")).toBe(true); + expect(isTransitionAllowed("Disputed", "Refunded")).toBe(true); + }); + + it("prohibits invalid transitions", () => { + expect(isTransitionAllowed("Created", "Released")).toBe(false); + expect(isTransitionAllowed("Released", "Funded")).toBe(false); + expect(isTransitionAllowed("Refunded", "Released")).toBe(false); + }); + + it("throws EscrowStateError on invalid state transition", () => { + const escrow = createMockEscrow({ status: "Created" }); + expect(() => validateStateTransition(escrow, "Released")).toThrow(EscrowStateError); + }); + + it("throws EscrowStateError when attempting to transition from terminal state", () => { + const escrow = createMockEscrow({ status: "Released" }); + expect(() => validateStateTransition(escrow, "Refunded")).toThrow(EscrowStateError); + }); + + it("throws EscrowDisputeHoldError when transitioning to Released or ReadyForRelease during active dispute", () => { + const disputedEscrow = createMockEscrow({ + status: "Disputed", + disputeHold: true, + }); + expect(() => validateStateTransition(disputedEscrow, "Released")).toThrow( + EscrowDisputeHoldError + ); + expect(() => validateStateTransition(disputedEscrow, "ReadyForRelease")).toThrow( + EscrowDisputeHoldError + ); + }); + + it("records state history entry and updates timestamps upon transition", () => { + const escrow = createMockEscrow({ status: "Funded" }); + const updated = transitionEscrowState(escrow, "ReadyForRelease", { + actor: "evaluator", + reason: "All conditions verified", + timestamp: 1200, + }); + + expect(updated.status).toBe("ReadyForRelease"); + expect(updated.updatedAt).toBe(1200); + expect(updated.history.length).toBe(1); + expect(updated.history[0].from).toBe("Funded"); + expect(updated.history[0].to).toBe("ReadyForRelease"); + expect(updated.history[0].actor).toBe("evaluator"); + expect(updated.history[0].reason).toBe("All conditions verified"); + }); +}); diff --git a/src/lib/escrow/analytics.ts b/src/lib/escrow/analytics.ts new file mode 100644 index 00000000..8fcff4bc --- /dev/null +++ b/src/lib/escrow/analytics.ts @@ -0,0 +1,292 @@ +/** + * Escrow Analytics & Reporting System + * + * Aggregates escrow metrics, TVL, condition pass rates, dispute resolution statistics, + * token volume, and generates audit reports. + */ + +import type { + EscrowAnalyticsFilter, + EscrowAnalyticsReport, + EscrowConditionType, + EscrowContract, + EscrowStatus, +} from "@/types/escrow"; + +const ALL_STATUSES: EscrowStatus[] = [ + "Created", + "Funded", + "Evaluating", + "ReadyForRelease", + "Released", + "Disputed", + "Refunded", + "Cancelled", +]; + +const ALL_CONDITION_TYPES: EscrowConditionType[] = [ + "TIME_LOCK", + "MILESTONE", + "APPROVAL", + "ORACLE", + "THRESHOLD", + "CUSTOM", +]; + +/** + * Filters an array of escrows by date, token, party, or status. + */ +export function filterEscrows( + escrows: EscrowContract[], + filter?: EscrowAnalyticsFilter +): EscrowContract[] { + if (!filter) return escrows; + + return escrows.filter((e) => { + if (filter.startDate && e.createdAt < filter.startDate) return false; + if (filter.endDate && e.createdAt > filter.endDate) return false; + if (filter.token && e.token.toLowerCase() !== filter.token.toLowerCase()) return false; + if (filter.depositor && e.depositor.toLowerCase() !== filter.depositor.toLowerCase()) return false; + if (filter.beneficiary && e.beneficiary.toLowerCase() !== filter.beneficiary.toLowerCase()) return false; + if (filter.status && e.status !== filter.status) return false; + return true; + }); +} + +/** + * Computes comprehensive analytics report from an array of escrows. + */ +export function computeEscrowAnalytics( + allEscrows: EscrowContract[], + filter?: EscrowAnalyticsFilter, + now: number = Math.floor(Date.now() / 1000) +): EscrowAnalyticsReport { + const escrows = filterEscrows(allEscrows, filter); + + // Status counters + const byStatus: Record = { + Created: 0, + Funded: 0, + Evaluating: 0, + ReadyForRelease: 0, + Released: 0, + Disputed: 0, + Refunded: 0, + Cancelled: 0, + }; + + ALL_STATUSES.forEach((s) => { + byStatus[s] = 0; + }); + + // Condition type counters + const byConditionType: Record< + EscrowConditionType, + { total: number; met: number; passRatePercentage: number } + > = { + TIME_LOCK: { total: 0, met: 0, passRatePercentage: 0 }, + MILESTONE: { total: 0, met: 0, passRatePercentage: 0 }, + APPROVAL: { total: 0, met: 0, passRatePercentage: 0 }, + ORACLE: { total: 0, met: 0, passRatePercentage: 0 }, + THRESHOLD: { total: 0, met: 0, passRatePercentage: 0 }, + CUSTOM: { total: 0, met: 0, passRatePercentage: 0 }, + }; + + ALL_CONDITION_TYPES.forEach((t) => { + byConditionType[t] = { total: 0, met: 0, passRatePercentage: 0 }; + }); + + // Token counters + const byToken: Record = {}; + + let totalValueLocked = 0n; + let totalValueReleased = 0n; + let totalValueRefunded = 0n; + let totalValueDisputed = 0n; + + let totalCompletedDurationSeconds = 0; + let completedCount = 0; + + let totalResolutionSeconds = 0; + let resolvedDisputeCount = 0; + let totalDisputeCount = 0; + let everFundedCount = 0; + + escrows.forEach((escrow) => { + byStatus[escrow.status] = (byStatus[escrow.status] || 0) + 1; + + // Token tracking + const tokenKey = escrow.token || "XLM"; + if (!byToken[tokenKey]) { + byToken[tokenKey] = { totalVolume: 0n, activeTvl: 0n, count: 0 }; + } + byToken[tokenKey].totalVolume += escrow.fundedAmount; + byToken[tokenKey].count += 1; + + // Active TVL check: states with funds locked + const isActive = ["Funded", "Evaluating", "ReadyForRelease", "Disputed"].includes( + escrow.status + ); + if (isActive) { + totalValueLocked += escrow.fundedAmount; + byToken[tokenKey].activeTvl += escrow.fundedAmount; + } + + if (escrow.status === "Released") { + totalValueReleased += escrow.fundedAmount; + } else if (escrow.status === "Refunded") { + totalValueRefunded += escrow.fundedAmount; + } + + if (escrow.status === "Disputed" || escrow.disputeHold) { + totalValueDisputed += escrow.fundedAmount; + } + + // Ever funded check + if (escrow.fundedAmount > 0n || escrow.status !== "Created") { + everFundedCount++; + } + + // Completed duration check + if (escrow.status === "Released" && escrow.releasedAt) { + totalCompletedDurationSeconds += Math.max(0, escrow.releasedAt - escrow.createdAt); + completedCount++; + } else if (escrow.status === "Refunded" && escrow.refundedAt) { + totalCompletedDurationSeconds += Math.max(0, escrow.refundedAt - escrow.createdAt); + completedCount++; + } + + // Dispute metrics + if (escrow.dispute) { + totalDisputeCount++; + if (escrow.dispute.resolvedAt && escrow.dispute.createdAt) { + totalResolutionSeconds += Math.max( + 0, + escrow.dispute.resolvedAt - escrow.dispute.createdAt + ); + resolvedDisputeCount++; + } + } + + // Condition evaluations + escrow.conditions?.forEach((cond) => { + if (byConditionType[cond.type]) { + byConditionType[cond.type].total += 1; + if (cond.met) { + byConditionType[cond.type].met += 1; + } + } + }); + }); + + // Calculate percentages + ALL_CONDITION_TYPES.forEach((t) => { + const stat = byConditionType[t]; + stat.passRatePercentage = + stat.total > 0 ? Math.round((stat.met / stat.total) * 10000) / 100 : 0; + }); + + const activeEscrows = + byStatus.Funded + byStatus.Evaluating + byStatus.ReadyForRelease + byStatus.Disputed; + const completedEscrows = byStatus.Released + byStatus.Refunded; + + const disputeRatePercentage = + everFundedCount > 0 + ? Math.round((totalDisputeCount / everFundedCount) * 10000) / 100 + : 0; + + const disputeResolutionRatePercentage = + totalDisputeCount > 0 + ? Math.round((resolvedDisputeCount / totalDisputeCount) * 10000) / 100 + : 0; + + const averageDurationSeconds = + completedCount > 0 ? Math.round(totalCompletedDurationSeconds / completedCount) : 0; + + const averageResolutionSeconds = + resolvedDisputeCount > 0 + ? Math.round(totalResolutionSeconds / resolvedDisputeCount) + : 0; + + return { + summary: { + totalEscrows: escrows.length, + activeEscrows, + completedEscrows, + releasedEscrows: byStatus.Released, + refundedEscrows: byStatus.Refunded, + disputedEscrows: byStatus.Disputed, + cancelledEscrows: byStatus.Cancelled, + totalValueLocked, + totalValueReleased, + totalValueRefunded, + totalValueDisputed, + disputeRatePercentage, + disputeResolutionRatePercentage, + averageDurationSeconds, + averageResolutionSeconds, + }, + byStatus, + byConditionType, + byToken, + generatedAt: now, + }; +} + +/** + * Generates an executive Markdown report from the escrow analytics. + */ +export function generateEscrowMarkdownReport(report: EscrowAnalyticsReport): string { + const { summary, byStatus, byConditionType, byToken, generatedAt } = report; + + const dateStr = new Date(generatedAt * 1000).toISOString(); + + let md = `# Escrow Analytics & Performance Report\n`; + md += `*Generated at: ${dateStr}*\n\n`; + + md += `## Executive Summary\n`; + md += `- **Total Escrows Tracked**: ${summary.totalEscrows}\n`; + md += `- **Active Escrows**: ${summary.activeEscrows}\n`; + md += `- **Completed Escrows**: ${summary.completedEscrows} (${summary.releasedEscrows} Released, ${summary.refundedEscrows} Refunded)\n`; + md += `- **Currently Disputed**: ${summary.disputedEscrows}\n`; + md += `- **Total Value Locked (TVL)**: ${summary.totalValueLocked.toString()} stroops\n`; + md += `- **Total Value Released**: ${summary.totalValueReleased.toString()} stroops\n`; + md += `- **Total Value Refunded**: ${summary.totalValueRefunded.toString()} stroops\n`; + md += `- **Dispute Rate**: ${summary.disputeRatePercentage}%\n`; + md += `- **Dispute Resolution Rate**: ${summary.disputeResolutionRatePercentage}%\n`; + md += `- **Average Completion Time**: ${Math.round(summary.averageDurationSeconds / 60)} minutes\n`; + md += `- **Average Dispute Resolution Time**: ${Math.round(summary.averageResolutionSeconds / 60)} minutes\n\n`; + + md += `## Status Breakdown\n`; + md += `| Status | Count |\n`; + md += `| :--- | :--- |\n`; + Object.entries(byStatus).forEach(([status, count]) => { + md += `| ${status} | ${count} |\n`; + }); + md += `\n`; + + md += `## Condition Evaluation Pass Rates\n`; + md += `| Condition Type | Total Evaluated | Met | Pass Rate |\n`; + md += `| :--- | :--- | :--- | :--- |\n`; + const conditionEntries = Object.entries(byConditionType) as [ + EscrowConditionType, + { total: number; met: number; passRatePercentage: number } + ][]; + conditionEntries.forEach(([type, stat]) => { + md += `| ${type} | ${stat.total} | ${stat.met} | ${stat.passRatePercentage}% |\n`; + }); + md += `\n`; + + md += `## Volume by Token\n`; + md += `| Token | Count | Total Volume | Active TVL |\n`; + md += `| :--- | :--- | :--- | :--- |\n`; + const tokenEntries = Object.entries(byToken) as [ + string, + { totalVolume: bigint; activeTvl: bigint; count: number } + ][]; + tokenEntries.forEach(([token, data]) => { + md += `| ${token} | ${data.count} | ${data.totalVolume.toString()} | ${data.activeTvl.toString()} |\n`; + }); + + return md; +} diff --git a/src/lib/escrow/conditionEngine.ts b/src/lib/escrow/conditionEngine.ts new file mode 100644 index 00000000..a062d597 --- /dev/null +++ b/src/lib/escrow/conditionEngine.ts @@ -0,0 +1,279 @@ +/** + * Escrow Condition Evaluation Engine + * + * Evaluates individual and composite conditions including time locks, + * milestone completions, multi-signature approvals, oracle metrics, + * funding thresholds, and custom predicates. + */ + +import type { + ApprovalCondition, + ConditionEvaluationContext, + ConditionEvaluationResult, + ConditionLogic, + CustomCondition, + EscrowCondition, + MilestoneCondition, + OracleCondition, + SingleConditionResult, + ThresholdCondition, + TimeLockCondition, +} from "@/types/escrow"; + +/** + * Evaluates a TimeLockCondition against the provided timestamp. + */ +export function evaluateTimeLock( + condition: TimeLockCondition, + currentTimestamp: number +): { met: boolean; reason?: string } { + if (currentTimestamp >= condition.releaseTimestamp) { + return { met: true }; + } + const remainingSeconds = condition.releaseTimestamp - currentTimestamp; + return { + met: false, + reason: `Time lock active: ${remainingSeconds}s remaining until release (unlocks at ${new Date( + condition.releaseTimestamp * 1000 + ).toISOString()})`, + }; +} + +/** + * Evaluates a MilestoneCondition. + */ +export function evaluateMilestone( + condition: MilestoneCondition, + context?: ConditionEvaluationContext +): { met: boolean; reason?: string } { + const isCompleted = + context?.milestoneCompletions?.[condition.milestoneId] ?? condition.completed; + + if (isCompleted) { + return { met: true }; + } + return { + met: false, + reason: `Milestone "${condition.title}" (ID: ${condition.milestoneId}) is not yet marked completed`, + }; +} + +/** + * Evaluates an ApprovalCondition (multi-signature / authorized approvals). + */ +export function evaluateApproval( + condition: ApprovalCondition, + context?: ConditionEvaluationContext +): { met: boolean; reason?: string } { + const contextSignatures = context?.approvals?.[condition.id]; + const combinedSignatures = Array.from( + new Set([...(condition.currentSignatures || []), ...(contextSignatures || [])]) + ); + + // Validate that signatures come from requiredSigners if requiredSigners is populated + const validSignatures = + condition.requiredSigners.length > 0 + ? combinedSignatures.filter((s) => condition.requiredSigners.includes(s)) + : combinedSignatures; + + if (validSignatures.length >= condition.minSignatures) { + return { met: true }; + } + + const missing = condition.minSignatures - validSignatures.length; + return { + met: false, + reason: `Approval requirement not met: collected ${validSignatures.length}/${condition.minSignatures} required signatures (${missing} missing)`, + }; +} + +/** + * Evaluates an OracleCondition against incoming oracle feeds. + */ +export function evaluateOracle( + condition: OracleCondition, + context?: ConditionEvaluationContext +): { met: boolean; reason?: string } { + const oracleFeed = context?.oracleData?.[condition.oracleAddress]; + const metricVal = + oracleFeed?.[condition.metricKey] ?? condition.currentValue; + + if (metricVal === undefined || metricVal === null) { + return { + met: false, + reason: `Oracle data unavailable for address ${condition.oracleAddress} metric ${condition.metricKey}`, + }; + } + + const expected = condition.expectedValue; + let matches = false; + + switch (condition.operator) { + case "eq": + matches = metricVal === expected; + break; + case "neq": + matches = metricVal !== expected; + break; + case "gte": + matches = Number(metricVal) >= Number(expected); + break; + case "lte": + matches = Number(metricVal) <= Number(expected); + break; + default: + matches = metricVal === expected; + } + + if (matches) { + return { met: true }; + } + + return { + met: false, + reason: `Oracle condition failed: expected ${condition.metricKey} ${condition.operator} ${expected}, got ${metricVal}`, + }; +} + +/** + * Evaluates a ThresholdCondition (e.g. funded balance). + */ +export function evaluateThreshold( + condition: ThresholdCondition, + context?: ConditionEvaluationContext +): { met: boolean; reason?: string } { + const current = context?.fundedAmount ?? condition.currentAmount; + if (current >= condition.targetAmount) { + return { met: true }; + } + return { + met: false, + reason: `Threshold not met: current amount (${current.toString()}) is less than target (${condition.targetAmount.toString()})`, + }; +} + +/** + * Evaluates a CustomCondition. + */ +export function evaluateCustom( + condition: CustomCondition, + context?: ConditionEvaluationContext +): { met: boolean; reason?: string } { + const customEvaluator = context?.customEvaluators?.[condition.predicateKey]; + if (customEvaluator) { + try { + const ok = customEvaluator(condition, context ?? {}); + return ok + ? { met: true } + : { met: false, reason: `Custom predicate "${condition.predicateKey}" returned false` }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { met: false, reason: `Custom predicate error: ${msg}` }; + } + } + + return condition.met + ? { met: true } + : { met: false, reason: `Custom condition "${condition.title}" is not met` }; +} + +/** + * Evaluates a single condition of any supported type. + */ +export function evaluateSingleCondition( + condition: EscrowCondition, + context: ConditionEvaluationContext, + now: number +): SingleConditionResult { + let evalResult: { met: boolean; reason?: string }; + + switch (condition.type) { + case "TIME_LOCK": + evalResult = evaluateTimeLock(condition, now); + break; + case "MILESTONE": + evalResult = evaluateMilestone(condition, context); + break; + case "APPROVAL": + evalResult = evaluateApproval(condition, context); + break; + case "ORACLE": + evalResult = evaluateOracle(condition, context); + break; + case "THRESHOLD": + evalResult = evaluateThreshold(condition, context); + break; + case "CUSTOM": + evalResult = evaluateCustom(condition, context); + break; + default: + evalResult = { met: (condition as EscrowCondition).met }; + } + + return { + conditionId: condition.id, + type: condition.type, + title: condition.title, + met: evalResult.met, + reason: evalResult.reason, + evaluatedAt: now, + }; +} + +/** + * Evaluates an array of conditions according to composite logic (ALL, ANY, THRESHOLD). + */ +export function evaluateConditions( + conditions: EscrowCondition[], + logic: ConditionLogic = "ALL", + context: ConditionEvaluationContext = {}, + thresholdCount?: number +): ConditionEvaluationResult { + const now = context.currentTimestamp ?? Math.floor(Date.now() / 1000); + + if (!conditions || conditions.length === 0) { + return { + canRelease: true, + allMet: true, + metCount: 0, + totalConditions: 0, + conditionResults: [], + failureReasons: [], + evaluatedAt: now, + }; + } + + const results: SingleConditionResult[] = conditions.map((cond) => + evaluateSingleCondition(cond, context, now) + ); + + const metCount = results.filter((r) => r.met).length; + const failureReasons = results.filter((r) => !r.met).map((r) => r.reason || `${r.title} failed`); + const allMet = metCount === conditions.length; + + let canRelease = false; + + switch (logic) { + case "ALL": + canRelease = allMet; + break; + case "ANY": + canRelease = metCount > 0; + break; + case "THRESHOLD": { + const required = thresholdCount ?? Math.ceil(conditions.length / 2); + canRelease = metCount >= required; + break; + } + } + + return { + canRelease, + allMet, + metCount, + totalConditions: conditions.length, + conditionResults: results, + failureReasons, + evaluatedAt: now, + }; +} diff --git a/src/lib/escrow/disputeHold.ts b/src/lib/escrow/disputeHold.ts new file mode 100644 index 00000000..c9d26428 --- /dev/null +++ b/src/lib/escrow/disputeHold.ts @@ -0,0 +1,148 @@ +/** + * Escrow Dispute Hold Mechanism + * + * Provides dispute initiation, dispute hold enforcement (blocking fund release), + * evidence attachment, and dispute arbitration resolution. + */ + +import type { + DisputeDetails, + DisputeStatus, + EscrowContract, + RaiseDisputeInput, + ResolveDisputeInput, +} from "@/types/escrow"; +import { + EscrowDisputeHoldError, + EscrowStateError, + transitionEscrowState, +} from "./stateMachine"; + +/** + * Checks whether an escrow is held under an active dispute. + */ +export function isDisputeHoldActive(escrow: EscrowContract): boolean { + return escrow.disputeHold === true || escrow.status === "Disputed"; +} + +/** + * Raises a dispute against an escrow contract, immediately locking all releases. + */ +export function raiseDispute( + escrow: EscrowContract, + input: RaiseDisputeInput, + now: number = Math.floor(Date.now() / 1000) +): EscrowContract { + if (["Released", "Refunded", "Cancelled"].includes(escrow.status)) { + throw new EscrowStateError( + `Cannot raise dispute on finalized escrow ${escrow.id} with status "${escrow.status}".` + ); + } + + if (isDisputeHoldActive(escrow)) { + // If already disputed, append evidence if provided + if (input.evidenceCids && escrow.dispute) { + const combined = Array.from( + new Set([...(escrow.dispute.evidenceCids || []), ...input.evidenceCids]) + ); + return { + ...escrow, + dispute: { + ...escrow.dispute, + evidenceCids: combined, + }, + updatedAt: now, + }; + } + return escrow; + } + + const dispute: DisputeDetails = { + id: `disp_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, + initiator: input.initiator, + reason: input.reason, + evidenceCids: input.evidenceCids || [], + createdAt: now, + status: "Open", + }; + + // Flag disputeHold before transition to ensure atomicity + const heldEscrow: EscrowContract = { + ...escrow, + disputeHold: true, + dispute, + }; + + return transitionEscrowState(heldEscrow, "Disputed", { + actor: input.initiator, + reason: `Dispute raised: ${input.reason}`, + timestamp: now, + }); +} + +/** + * Resolves an active dispute, lifting the hold and applying the resolution outcome. + */ +export function resolveDispute( + escrow: EscrowContract, + input: ResolveDisputeInput, + now: number = Math.floor(Date.now() / 1000) +): { + escrow: EscrowContract; + outcome: "RELEASE" | "REFUND" | "SPLIT"; +} { + if (!escrow.dispute || !isDisputeHoldActive(escrow)) { + throw new EscrowStateError( + `Escrow ${escrow.id} does not have an active dispute to resolve.` + ); + } + + let resolvedStatus: DisputeStatus; + switch (input.outcome) { + case "RELEASE": + resolvedStatus = "ResolvedRelease"; + break; + case "REFUND": + resolvedStatus = "ResolvedRefund"; + break; + case "SPLIT": + resolvedStatus = "ResolvedSplit"; + break; + default: + resolvedStatus = "ResolvedRelease"; + } + + const updatedDispute: DisputeDetails = { + ...escrow.dispute, + status: resolvedStatus, + arbitratorNotes: input.notes, + resolvedAt: now, + resolvedBy: input.resolver, + splitRatio: input.splitRatio, + }; + + // Lift the dispute hold + const holdLifted: EscrowContract = { + ...escrow, + disputeHold: false, + dispute: updatedDispute, + }; + + if (input.outcome === "REFUND") { + const refunded = transitionEscrowState(holdLifted, "Refunded", { + actor: input.resolver, + reason: `Dispute resolved with full refund to depositor: ${input.notes || "Arbitrated refund"}`, + timestamp: now, + }); + return { escrow: refunded, outcome: "REFUND" }; + } + + // Outcome is RELEASE or SPLIT: transition to ReadyForRelease so release trigger can execute payouts + const ready = transitionEscrowState(holdLifted, "ReadyForRelease", { + actor: input.resolver, + reason: `Dispute resolved in favor of release: ${input.notes || "Arbitrated release"}`, + timestamp: now, + }); + + return { escrow: ready, outcome: input.outcome }; +} diff --git a/src/lib/escrow/escrowService.ts b/src/lib/escrow/escrowService.ts new file mode 100644 index 00000000..9c616006 --- /dev/null +++ b/src/lib/escrow/escrowService.ts @@ -0,0 +1,482 @@ +/** + * Escrow Contract Service + * + * Implements IEscrowContractService coordinating lifecycle transitions, + * condition evaluation, automated/manual release triggers, dispute holds, + * persistence, and analytics. + */ + +import type { + ConditionEvaluationContext, + ConditionEvaluationResult, + CreateEscrowInput, + EscrowAnalyticsFilter, + EscrowAnalyticsReport, + EscrowCondition, + EscrowContract, + EscrowRecipient, + IEscrowContractService, + RaiseDisputeInput, + ReleaseResult, + ResolveDisputeInput, +} from "@/types/escrow"; +import { computeEscrowAnalytics } from "./analytics"; +import { evaluateConditions } from "./conditionEngine"; +import { isDisputeHoldActive, raiseDispute, resolveDispute } from "./disputeHold"; +import { attemptReleaseTrigger, executeManualRelease } from "./releaseTrigger"; +import { + EscrowDisputeHoldError, + EscrowStateError, + transitionEscrowState, +} from "./stateMachine"; + +export const ESCROW_STORAGE_KEY = "stellar_split_escrow_contracts"; + +export class EscrowService implements IEscrowContractService { + private escrows: Map = new Map(); + + constructor(initialEscrows?: EscrowContract[]) { + if (initialEscrows && initialEscrows.length > 0) { + initialEscrows.forEach((e) => this.escrows.set(e.id, e)); + } else { + this.loadFromStorage(); + } + } + + private loadFromStorage(): void { + if (typeof window === "undefined" || !window.localStorage) return; + try { + const data = localStorage.getItem(ESCROW_STORAGE_KEY); + if (data) { + const parsed = JSON.parse(data, (_, val) => { + if (typeof val === "string" && /^\d+n$/.test(val)) { + return BigInt(val.slice(0, -1)); + } + return val; + }) as EscrowContract[]; + + parsed.forEach((e) => { + // Ensure bigint fields are properly converted + e.targetAmount = BigInt(e.targetAmount); + e.fundedAmount = BigInt(e.fundedAmount); + if (e.recipients) { + e.recipients.forEach((r) => { + r.amount = BigInt(r.amount); + }); + } + if (e.releaseExecution?.payouts) { + e.releaseExecution.payouts.forEach((p) => { + p.amount = BigInt(p.amount); + }); + } + this.escrows.set(e.id, e); + }); + } + } catch { + // Storage failure graceful fallback to in-memory + } + } + + private saveToStorage(): void { + if (typeof window === "undefined" || !window.localStorage) return; + try { + const all = Array.from(this.escrows.values()); + const serialized = JSON.stringify(all, (_, val) => + typeof val === "bigint" ? `${val.toString()}n` : val + ); + localStorage.setItem(ESCROW_STORAGE_KEY, serialized); + } catch { + // Ignore storage write errors + } + } + + /** + * Creates a new escrow payment contract. + */ + public createEscrow(input: CreateEscrowInput): EscrowContract { + const now = Math.floor(Date.now() / 1000); + const id = `escrow_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; + + const initialDeposit = input.initialDeposit ?? 0n; + const isFullyFunded = initialDeposit >= input.targetAmount && input.targetAmount > 0n; + const initialStatus = isFullyFunded ? "Funded" : "Created"; + + // Setup recipients + let recipients: EscrowRecipient[] = input.recipients || []; + if (recipients.length === 0) { + recipients = [ + { + address: input.beneficiary, + amount: input.targetAmount, + name: "Primary Beneficiary", + sharePercentage: 100, + }, + ]; + } + + const escrow: EscrowContract = { + id, + invoiceId: input.invoiceId, + depositor: input.depositor, + beneficiary: input.beneficiary, + recipients, + token: input.token || "USDC", + targetAmount: input.targetAmount, + fundedAmount: initialDeposit, + status: initialStatus, + releaseTriggerType: input.releaseTriggerType || "AUTOMATIC", + conditionLogic: input.conditionLogic || "ALL", + thresholdCount: input.thresholdCount, + conditions: input.conditions || [], + disputeHold: false, + history: [ + { + id: `hist_init_${id}`, + from: "Created", + to: initialStatus, + timestamp: now, + actor: input.depositor, + reason: "Escrow payment contract initialized", + }, + ], + createdAt: now, + updatedAt: now, + fundedAt: isFullyFunded ? now : undefined, + metadata: input.metadata, + }; + + this.escrows.set(id, escrow); + + // If fully funded and automatic release, evaluate immediately + let finalEscrow = escrow; + if (isFullyFunded && escrow.releaseTriggerType === "AUTOMATIC") { + const { updatedEscrow } = attemptReleaseTrigger(escrow, {}, { now }); + finalEscrow = updatedEscrow; + this.escrows.set(id, finalEscrow); + } + + this.saveToStorage(); + return finalEscrow; + } + + public getEscrow(id: string): EscrowContract | null { + return this.escrows.get(id) || null; + } + + public listEscrows(filter?: EscrowAnalyticsFilter): EscrowContract[] { + const all = Array.from(this.escrows.values()); + if (!filter) return all; + + return all.filter((e) => { + if (filter.startDate && e.createdAt < filter.startDate) return false; + if (filter.endDate && e.createdAt > filter.endDate) return false; + if (filter.token && e.token !== filter.token) return false; + if (filter.depositor && e.depositor !== filter.depositor) return false; + if (filter.beneficiary && e.beneficiary !== filter.beneficiary) return false; + if (filter.status && e.status !== filter.status) return false; + return true; + }); + } + + /** + * Deposits funds toward an escrow. + */ + public fundEscrow(id: string, amount: bigint, payer: string): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + if (["Released", "Refunded", "Cancelled"].includes(escrow.status)) { + throw new EscrowStateError( + `Cannot fund escrow ${id} in terminal state "${escrow.status}".` + ); + } + + const now = Math.floor(Date.now() / 1000); + const newFundedAmount = escrow.fundedAmount + amount; + const isNowFunded = newFundedAmount >= escrow.targetAmount; + + let updated = { + ...escrow, + fundedAmount: newFundedAmount, + updatedAt: now, + }; + + if (updated.status === "Created" && isNowFunded) { + updated = transitionEscrowState(updated, "Funded", { + actor: payer, + reason: `Escrow target funding reached: ${newFundedAmount.toString()} stroops deposited`, + timestamp: now, + }); + } + + // If automatic release mode, check if conditions are met + if (updated.status === "Funded" && updated.releaseTriggerType === "AUTOMATIC") { + const { updatedEscrow } = attemptReleaseTrigger(updated, {}, { actor: payer, now }); + updated = updatedEscrow; + } + + this.escrows.set(id, updated); + this.saveToStorage(); + return updated; + } + + /** + * Updates an individual condition. + */ + public updateCondition( + id: string, + conditionId: string, + update: Partial, + actor?: string + ): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const now = Math.floor(Date.now() / 1000); + const updatedConditions = escrow.conditions.map((c) => { + if (c.id !== conditionId) return c; + const merged = { ...c, ...update } as EscrowCondition; + if (update.met && !c.met) { + merged.metAt = now; + } + return merged; + }); + + let updated: EscrowContract = { + ...escrow, + conditions: updatedConditions, + updatedAt: now, + }; + + // Auto-release check + if ( + ["Funded", "Evaluating", "ReadyForRelease"].includes(updated.status) && + updated.releaseTriggerType === "AUTOMATIC" && + !isDisputeHoldActive(updated) + ) { + const { updatedEscrow } = attemptReleaseTrigger(updated, {}, { actor, now }); + updated = updatedEscrow; + } + + this.escrows.set(id, updated); + this.saveToStorage(); + return updated; + } + + /** + * Adds approval signature to an APPROVAL condition. + */ + public addApprovalSignature( + id: string, + conditionId: string, + signer: string + ): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const cond = escrow.conditions.find((c) => c.id === conditionId); + if (!cond || cond.type !== "APPROVAL") { + throw new Error(`Approval condition ${conditionId} not found on escrow ${id}.`); + } + + const currentSigs = cond.currentSignatures || []; + if (!currentSigs.includes(signer)) { + currentSigs.push(signer); + } + + const met = currentSigs.length >= cond.minSignatures; + + return this.updateCondition( + id, + conditionId, + { + currentSignatures: currentSigs, + met, + }, + signer + ); + } + + /** + * Marks a milestone as completed. + */ + public completeMilestone( + id: string, + conditionId: string, + verifiedBy?: string, + proofUrl?: string + ): EscrowContract { + const now = Math.floor(Date.now() / 1000); + return this.updateCondition( + id, + conditionId, + { + completed: true, + completedAt: now, + verifiedBy, + proofUrl, + met: true, + }, + verifiedBy + ); + } + + /** + * Evaluates conditions and triggers automated release if conditions are satisfied. + */ + public evaluateConditions( + id: string, + context?: ConditionEvaluationContext + ): ConditionEvaluationResult { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const now = context?.currentTimestamp ?? Math.floor(Date.now() / 1000); + const evalResult = evaluateConditions( + escrow.conditions, + escrow.conditionLogic, + { + ...context, + currentTimestamp: now, + fundedAmount: escrow.fundedAmount, + }, + escrow.thresholdCount + ); + + // If automatic release and conditions met and not disputed, trigger release! + if ( + evalResult.canRelease && + escrow.releaseTriggerType === "AUTOMATIC" && + ["Funded", "Evaluating", "ReadyForRelease"].includes(escrow.status) && + !isDisputeHoldActive(escrow) + ) { + const { updatedEscrow } = attemptReleaseTrigger(escrow, context, { now }); + this.escrows.set(id, updatedEscrow); + this.saveToStorage(); + } + + return evalResult; + } + + /** + * Triggers release of escrow funds. + */ + public triggerRelease( + id: string, + actor: string, + force: boolean = false + ): ReleaseResult { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + if (isDisputeHoldActive(escrow)) { + throw new EscrowDisputeHoldError( + `Release blocked: Escrow ${id} is held under an active dispute.` + ); + } + + const { result, updatedEscrow } = force + ? attemptReleaseTrigger(escrow, {}, { actor, forceManual: true }) + : executeManualRelease(escrow, actor); + + this.escrows.set(id, updatedEscrow); + this.saveToStorage(); + return result; + } + + /** + * Raises a dispute against an escrow contract. + */ + public raiseDispute(id: string, input: RaiseDisputeInput): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const updated = raiseDispute(escrow, input); + this.escrows.set(id, updated); + this.saveToStorage(); + return updated; + } + + /** + * Resolves an active dispute. + */ + public resolveDispute(id: string, input: ResolveDisputeInput): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const { escrow: updated, outcome } = resolveDispute(escrow, input); + + let finalEscrow = updated; + if (outcome === "RELEASE" || outcome === "SPLIT") { + // Execute release for the resolved escrow + const { updatedEscrow } = attemptReleaseTrigger( + updated, + {}, + { actor: input.resolver, forceManual: true } + ); + finalEscrow = updatedEscrow; + } + + this.escrows.set(id, finalEscrow); + this.saveToStorage(); + return finalEscrow; + } + + /** + * Refunds escrow funds back to depositor. + */ + public refundEscrow(id: string, actor: string, reason?: string): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const now = Math.floor(Date.now() / 1000); + const txHash = `0xrefund_${escrow.id.replace(/[^a-zA-Z0-9]/g, "")}_${now.toString(16)}`; + + const refunded = transitionEscrowState(escrow, "Refunded", { + actor, + reason: reason || "Manual refund executed", + timestamp: now, + }); + + refunded.refundExecution = { + recipient: escrow.depositor, + amount: escrow.fundedAmount, + refundedBy: actor, + refundedAt: now, + reason, + txHash, + }; + + this.escrows.set(id, refunded); + this.saveToStorage(); + return refunded; + } + + /** + * Cancels an unfunded escrow. + */ + public cancelEscrow(id: string, actor: string, reason?: string): EscrowContract { + const escrow = this.getEscrow(id); + if (!escrow) throw new Error(`Escrow ${id} not found.`); + + const cancelled = transitionEscrowState(escrow, "Cancelled", { + actor, + reason: reason || "Escrow cancelled by depositor before funding", + }); + + this.escrows.set(id, cancelled); + this.saveToStorage(); + return cancelled; + } + + /** + * Returns analytics and reporting metrics. + */ + public getAnalytics(filter?: EscrowAnalyticsFilter): EscrowAnalyticsReport { + const all = Array.from(this.escrows.values()); + return computeEscrowAnalytics(all, filter); + } +} + +// Global default singleton instance +export const escrowService = new EscrowService(); diff --git a/src/lib/escrow/index.ts b/src/lib/escrow/index.ts new file mode 100644 index 00000000..ce15c98c --- /dev/null +++ b/src/lib/escrow/index.ts @@ -0,0 +1,7 @@ +export * from "@/types/escrow"; +export * from "./conditionEngine"; +export * from "./stateMachine"; +export * from "./disputeHold"; +export * from "./releaseTrigger"; +export * from "./analytics"; +export * from "./escrowService"; diff --git a/src/lib/escrow/releaseTrigger.ts b/src/lib/escrow/releaseTrigger.ts new file mode 100644 index 00000000..59676c9d --- /dev/null +++ b/src/lib/escrow/releaseTrigger.ts @@ -0,0 +1,294 @@ +/** + * Escrow Release Trigger System + * + * Implements condition evaluation verification, automated release execution, + * manual release enforcement, dispute safety gating, and payout distribution. + */ + +import type { + ConditionEvaluationContext, + EscrowContract, + EscrowPayout, + ReleaseExecution, + ReleaseResult, +} from "@/types/escrow"; +import { evaluateConditions } from "./conditionEngine"; +import { isDisputeHoldActive } from "./disputeHold"; +import { EscrowDisputeHoldError, transitionEscrowState } from "./stateMachine"; + +/** + * Calculates payout distributions across recipients. + */ +export function calculatePayouts( + escrow: EscrowContract, + txHashPrefix: string, + now: number +): EscrowPayout[] { + const totalFunded = escrow.fundedAmount; + + // Handle dispute split outcome if applicable + if ( + escrow.dispute?.status === "ResolvedSplit" && + escrow.dispute.splitRatio + ) { + const { beneficiaryPercentage, depositorPercentage } = + escrow.dispute.splitRatio; + + const beneficiaryAmount = + (totalFunded * BigInt(Math.floor(beneficiaryPercentage * 100))) / 10000n; + const depositorAmount = totalFunded - beneficiaryAmount; + + return [ + { + recipient: escrow.beneficiary, + amount: beneficiaryAmount, + txHash: `${txHashPrefix}_beneficiary`, + timestamp: now, + }, + { + recipient: escrow.depositor, + amount: depositorAmount, + txHash: `${txHashPrefix}_depositor_refund`, + timestamp: now, + }, + ]; + } + + // Multi-recipient distribution + if (escrow.recipients && escrow.recipients.length > 0) { + const totalRecipientsTarget: bigint = escrow.recipients.reduce( + (acc: bigint, r) => acc + BigInt(r.amount), + 0n + ); + + if (totalRecipientsTarget > 0n) { + let distributed = 0n; + const payouts: EscrowPayout[] = escrow.recipients.map((r, idx) => { + const isLast = idx === escrow.recipients.length - 1; + const share: bigint = isLast + ? totalFunded - distributed + : (totalFunded * BigInt(r.amount)) / totalRecipientsTarget; + + distributed += share; + return { + recipient: r.address, + amount: share, + txHash: `${txHashPrefix}_rec_${idx + 1}`, + timestamp: now, + }; + }); + return payouts; + } + } + + // Single beneficiary default + return [ + { + recipient: escrow.beneficiary, + amount: totalFunded, + txHash: `${txHashPrefix}_payout`, + timestamp: now, + }, + ]; +} + +/** + * Evaluates conditions and triggers automated release if conditions are satisfied and no dispute exists. + */ +export function attemptReleaseTrigger( + escrow: EscrowContract, + context: ConditionEvaluationContext = {}, + options: { + actor?: string; + forceManual?: boolean; + now?: number; + } = {} +): { + result: ReleaseResult; + updatedEscrow: EscrowContract; +} { + const now = options.now ?? Math.floor(Date.now() / 1000); + + // 1. Invariant check: DISPUTES PREVENT RELEASE + if (isDisputeHoldActive(escrow)) { + return { + result: { + success: false, + released: false, + blockedByDispute: true, + error: `Release blocked: Escrow ${escrow.id} is held under an active dispute. All payouts are halted.`, + }, + updatedEscrow: escrow, + }; + } + + // Already released check + if (escrow.status === "Released") { + return { + result: { + success: true, + released: true, + txHash: escrow.releaseExecution?.txHash, + payouts: escrow.releaseExecution?.payouts, + }, + updatedEscrow: escrow, + }; + } + + // Funding requirement check + if (escrow.fundedAmount <= 0n) { + return { + result: { + success: false, + released: false, + error: `Cannot release escrow ${escrow.id}: No funds deposited (fundedAmount is 0).`, + }, + updatedEscrow: escrow, + }; + } + + // 2. Condition evaluation + const evalResult = evaluateConditions( + escrow.conditions, + escrow.conditionLogic, + { + ...context, + currentTimestamp: now, + fundedAmount: escrow.fundedAmount, + }, + escrow.thresholdCount + ); + + // Update met flags on condition objects + const updatedConditions = escrow.conditions.map((c) => { + const singleResult = evalResult.conditionResults.find( + (r) => r.conditionId === c.id + ); + if (!singleResult) return c; + return { + ...c, + met: singleResult.met, + evaluatedAt: singleResult.evaluatedAt, + failureReason: singleResult.reason, + metAt: singleResult.met ? c.metAt ?? now : undefined, + }; + }); + + const withUpdatedConditions: EscrowContract = { + ...escrow, + conditions: updatedConditions, + }; + + if (!evalResult.canRelease) { + return { + result: { + success: false, + released: false, + blockedByDispute: false, + failureReasons: evalResult.failureReasons, + error: `Release conditions not met: ${evalResult.failureReasons.join("; ")}`, + }, + updatedEscrow: withUpdatedConditions, + }; + } + + // 3. Trigger Mode Handling + const shouldExecuteRelease = + escrow.releaseTriggerType === "AUTOMATIC" || options.forceManual; + + if (shouldExecuteRelease) { + const txHash = `0xescrow_${escrow.id.replace(/[^a-zA-Z0-9]/g, "")}_${now.toString(16)}`; + const payouts = calculatePayouts(escrow, txHash, now); + + const releaseExecution: ReleaseExecution = { + triggeredBy: options.actor ?? "system_auto_trigger", + triggeredAt: now, + triggerType: options.forceManual ? "MANUAL" : escrow.releaseTriggerType, + txHash, + payouts, + }; + + const releasedEscrow = transitionEscrowState( + { + ...withUpdatedConditions, + releaseExecution, + }, + "Released", + { + actor: options.actor ?? "automated_release_engine", + reason: options.forceManual + ? "Manual release trigger executed after conditions verified" + : "Automated release trigger: all conditions satisfied and no dispute hold", + timestamp: now, + } + ); + + return { + result: { + success: true, + released: true, + txHash, + payouts, + }, + updatedEscrow: releasedEscrow, + }; + } + + // Manual trigger mode: Mark ReadyForRelease if in Funded or Evaluating + let readyEscrow = withUpdatedConditions; + if ( + withUpdatedConditions.status === "Funded" || + withUpdatedConditions.status === "Evaluating" + ) { + readyEscrow = transitionEscrowState(withUpdatedConditions, "ReadyForRelease", { + actor: options.actor ?? "condition_evaluator", + reason: "All release conditions satisfied; awaiting manual release trigger", + timestamp: now, + }); + } + + return { + result: { + success: true, + released: false, + readyForRelease: true, + }, + updatedEscrow: readyEscrow, + }; +} + +/** + * Manually executes release for an authorized actor (depositor, beneficiary, or recipient). + */ +export function executeManualRelease( + escrow: EscrowContract, + actor: string, + context: ConditionEvaluationContext = {}, + now?: number +): { + result: ReleaseResult; + updatedEscrow: EscrowContract; +} { + if (isDisputeHoldActive(escrow)) { + throw new EscrowDisputeHoldError( + `Cannot manually release escrow ${escrow.id}: Active dispute hold is in effect.` + ); + } + + const isAuthorized = + escrow.depositor === actor || + escrow.beneficiary === actor || + escrow.recipients?.some((r) => r.address === actor); + + if (!isAuthorized) { + throw new Error( + `Unauthorized: Address "${actor}" is not a party to escrow ${escrow.id}.` + ); + } + + return attemptReleaseTrigger(escrow, context, { + actor, + forceManual: true, + now, + }); +} diff --git a/src/lib/escrow/stateMachine.ts b/src/lib/escrow/stateMachine.ts new file mode 100644 index 00000000..85c18f0f --- /dev/null +++ b/src/lib/escrow/stateMachine.ts @@ -0,0 +1,139 @@ +/** + * Escrow State Machine & Lifecycle Management + * + * Enforces valid state transitions, state transition guards, + * dispute hold blocks, and lifecycle event logging. + */ + +import type { + EscrowContract, + EscrowStateHistoryEntry, + EscrowStatus, +} from "@/types/escrow"; + +export class EscrowStateError extends Error { + constructor(message: string) { + super(message); + this.name = "EscrowStateError"; + } +} + +export class EscrowDisputeHoldError extends Error { + constructor(message: string) { + super(message); + this.name = "EscrowDisputeHoldError"; + } +} + +/** + * Transition validation mapping from each state to permissible next states. + */ +export const ALLOWED_TRANSITIONS: Record = { + Created: ["Funded", "Cancelled"], + Funded: ["Evaluating", "ReadyForRelease", "Released", "Disputed", "Refunded"], + Evaluating: ["ReadyForRelease", "Released", "Disputed", "Refunded"], + ReadyForRelease: ["Released", "Disputed", "Refunded"], + Disputed: ["ReadyForRelease", "Released", "Refunded"], + Released: [], + Refunded: [], + Cancelled: [], +}; + +/** + * Checks if a transition is structurally permitted by the state transition graph. + */ +export function isTransitionAllowed(from: EscrowStatus, to: EscrowStatus): boolean { + if (from === to) return true; + return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false; +} + +/** + * Validates transition invariants and throws appropriate errors if invalid. + */ +export function validateStateTransition( + escrow: EscrowContract, + to: EscrowStatus +): void { + const current = escrow.status; + + // Terminal state check + if (["Released", "Refunded", "Cancelled"].includes(current)) { + throw new EscrowStateError( + `Cannot transition from terminal state "${current}" to "${to}". Escrow is finalized.` + ); + } + + // Graph validity check + if (!isTransitionAllowed(current, to)) { + throw new EscrowStateError( + `Invalid state transition: Cannot transition from "${current}" to "${to}".` + ); + } + + // Dispute hold invariant: Cannot transition to Released or ReadyForRelease while disputeHold is active + if ((to === "Released" || to === "ReadyForRelease") && escrow.disputeHold) { + throw new EscrowDisputeHoldError( + `Release blocked: Escrow ${escrow.id} is held under an active dispute. All releases are prohibited until resolved.` + ); + } + + // Funding check for release + if (to === "Released" && escrow.fundedAmount <= 0n) { + throw new EscrowStateError( + `Cannot transition to "Released": Escrow ${escrow.id} has 0 funded amount.` + ); + } +} + +/** + * Applies a state transition to an escrow contract, recording history and updating timestamps. + */ +export function transitionEscrowState( + escrow: EscrowContract, + to: EscrowStatus, + options: { + actor?: string; + reason?: string; + timestamp?: number; + } = {} +): EscrowContract { + if (escrow.status === to) { + return escrow; + } + + validateStateTransition(escrow, to); + + const now = options.timestamp ?? Math.floor(Date.now() / 1000); + const historyEntry: EscrowStateHistoryEntry = { + id: `hist_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, + from: escrow.status, + to, + timestamp: now, + actor: options.actor, + reason: options.reason, + }; + + const updated: EscrowContract = { + ...escrow, + status: to, + history: [...(escrow.history || []), historyEntry], + updatedAt: now, + }; + + switch (to) { + case "Funded": + if (!updated.fundedAt) updated.fundedAt = now; + break; + case "Released": + updated.releasedAt = now; + break; + case "Refunded": + updated.refundedAt = now; + break; + case "Cancelled": + updated.cancelledAt = now; + break; + } + + return updated; +} diff --git a/src/types/escrow.ts b/src/types/escrow.ts new file mode 100644 index 00000000..e476ceff --- /dev/null +++ b/src/types/escrow.ts @@ -0,0 +1,311 @@ +/** + * Escrow Contract Types & Interface Definitions + * + * Provides the data structures, condition definitions, state machine types, + * dispute hold models, release trigger configurations, and contract interface + * for conditional escrow payments. + */ + +export type EscrowStatus = + | "Created" + | "Funded" + | "Evaluating" + | "ReadyForRelease" + | "Released" + | "Disputed" + | "Refunded" + | "Cancelled"; + +export type EscrowConditionType = + | "TIME_LOCK" + | "MILESTONE" + | "APPROVAL" + | "ORACLE" + | "THRESHOLD" + | "CUSTOM"; + +export type ConditionLogic = "ALL" | "ANY" | "THRESHOLD"; + +export type ReleaseTriggerType = "AUTOMATIC" | "MANUAL" | "EVENT"; + +export interface EscrowRecipient { + address: string; + amount: bigint; + name?: string; + sharePercentage?: number; +} + +export interface BaseEscrowCondition { + id: string; + type: EscrowConditionType; + title: string; + description?: string; + met: boolean; + metAt?: number; + evaluatedAt?: number; + failureReason?: string; +} + +export interface TimeLockCondition extends BaseEscrowCondition { + type: "TIME_LOCK"; + /** Unix timestamp in seconds after which release is permissible */ + releaseTimestamp: number; +} + +export interface MilestoneCondition extends BaseEscrowCondition { + type: "MILESTONE"; + milestoneId: string; + completed: boolean; + completedAt?: number; + verifiedBy?: string; + proofUrl?: string; +} + +export interface ApprovalCondition extends BaseEscrowCondition { + type: "APPROVAL"; + requiredSigners: string[]; + minSignatures: number; + currentSignatures: string[]; + signedAt?: Record; +} + +export interface OracleCondition extends BaseEscrowCondition { + type: "ORACLE"; + oracleAddress: string; + metricKey: string; + expectedValue: string | number | boolean; + operator: "eq" | "gte" | "lte" | "neq"; + currentValue?: string | number | boolean; + lastAttestationTimestamp?: number; +} + +export interface ThresholdCondition extends BaseEscrowCondition { + type: "THRESHOLD"; + targetAmount: bigint; + currentAmount: bigint; +} + +export interface CustomCondition extends BaseEscrowCondition { + type: "CUSTOM"; + predicateKey: string; + parameters?: Record; +} + +export type EscrowCondition = + | TimeLockCondition + | MilestoneCondition + | ApprovalCondition + | OracleCondition + | ThresholdCondition + | CustomCondition; + +export type DisputeStatus = + | "Open" + | "UnderReview" + | "ResolvedRelease" + | "ResolvedRefund" + | "ResolvedSplit"; + +export interface DisputeDetails { + id: string; + initiator: string; + reason: string; + evidenceCids?: string[]; + createdAt: number; + status: DisputeStatus; + arbitratorNotes?: string; + resolvedAt?: number; + resolvedBy?: string; + splitRatio?: { + beneficiaryPercentage: number; + depositorPercentage: number; + }; +} + +export interface EscrowPayout { + recipient: string; + amount: bigint; + txHash?: string; + timestamp: number; +} + +export interface ReleaseExecution { + triggeredBy?: string; + triggeredAt: number; + triggerType: ReleaseTriggerType; + txHash: string; + payouts: EscrowPayout[]; +} + +export interface EscrowRefundExecution { + recipient: string; + amount: bigint; + refundedBy: string; + refundedAt: number; + reason?: string; + txHash: string; +} + +export interface EscrowStateHistoryEntry { + id: string; + from: EscrowStatus; + to: EscrowStatus; + timestamp: number; + actor?: string; + reason?: string; +} + +export interface EscrowContract { + id: string; + invoiceId?: string; + depositor: string; + beneficiary: string; + recipients: EscrowRecipient[]; + token: string; + targetAmount: bigint; + fundedAmount: bigint; + status: EscrowStatus; + releaseTriggerType: ReleaseTriggerType; + conditionLogic: ConditionLogic; + thresholdCount?: number; + conditions: EscrowCondition[]; + disputeHold: boolean; + dispute?: DisputeDetails; + releaseExecution?: ReleaseExecution; + refundExecution?: EscrowRefundExecution; + history: EscrowStateHistoryEntry[]; + createdAt: number; + updatedAt: number; + fundedAt?: number; + releasedAt?: number; + refundedAt?: number; + cancelledAt?: number; + metadata?: Record; +} + +export interface CreateEscrowInput { + invoiceId?: string; + depositor: string; + beneficiary: string; + recipients?: EscrowRecipient[]; + token?: string; + targetAmount: bigint; + initialDeposit?: bigint; + releaseTriggerType?: ReleaseTriggerType; + conditionLogic?: ConditionLogic; + thresholdCount?: number; + conditions?: EscrowCondition[]; + metadata?: Record; +} + +export interface ConditionEvaluationContext { + currentTimestamp?: number; + fundedAmount?: bigint; + approvals?: Record; + oracleData?: Record>; + milestoneCompletions?: Record; + customEvaluators?: Record< + string, + (condition: CustomCondition, context: ConditionEvaluationContext) => boolean + >; +} + +export interface SingleConditionResult { + conditionId: string; + type: EscrowConditionType; + title: string; + met: boolean; + reason?: string; + evaluatedAt: number; +} + +export interface ConditionEvaluationResult { + canRelease: boolean; + allMet: boolean; + metCount: number; + totalConditions: number; + conditionResults: SingleConditionResult[]; + failureReasons: string[]; + evaluatedAt: number; +} + +export interface ReleaseResult { + success: boolean; + released: boolean; + blockedByDispute?: boolean; + readyForRelease?: boolean; + error?: string; + failureReasons?: string[]; + txHash?: string; + payouts?: EscrowPayout[]; +} + +export interface RaiseDisputeInput { + initiator: string; + reason: string; + evidenceCids?: string[]; +} + +export interface ResolveDisputeInput { + resolver: string; + outcome: "RELEASE" | "REFUND" | "SPLIT"; + notes?: string; + splitRatio?: { + beneficiaryPercentage: number; + depositorPercentage: number; + }; +} + +export interface EscrowAnalyticsFilter { + startDate?: number; + endDate?: number; + token?: string; + depositor?: string; + beneficiary?: string; + status?: EscrowStatus; +} + +export interface EscrowAnalyticsReport { + summary: { + totalEscrows: number; + activeEscrows: number; + completedEscrows: number; + releasedEscrows: number; + refundedEscrows: number; + disputedEscrows: number; + cancelledEscrows: number; + totalValueLocked: bigint; + totalValueReleased: bigint; + totalValueRefunded: bigint; + totalValueDisputed: bigint; + disputeRatePercentage: number; + disputeResolutionRatePercentage: number; + averageDurationSeconds: number; + averageResolutionSeconds: number; + }; + byStatus: Record; + byConditionType: Record; + byToken: Record; + generatedAt: number; +} + +/** + * Escrow Contract Interface + * + * Defines the contract methods for lifecycle management, condition evaluation, + * release execution, dispute management, and analytics queries. + */ +export interface IEscrowContractService { + createEscrow(input: CreateEscrowInput): EscrowContract; + getEscrow(id: string): EscrowContract | null; + listEscrows(filter?: EscrowAnalyticsFilter): EscrowContract[]; + fundEscrow(id: string, amount: bigint, payer: string): EscrowContract; + updateCondition(id: string, conditionId: string, update: Partial, actor?: string): EscrowContract; + evaluateConditions(id: string, context?: ConditionEvaluationContext): ConditionEvaluationResult; + triggerRelease(id: string, actor: string, force?: boolean): ReleaseResult; + raiseDispute(id: string, input: RaiseDisputeInput): EscrowContract; + resolveDispute(id: string, input: ResolveDisputeInput): EscrowContract; + refundEscrow(id: string, actor: string, reason?: string): EscrowContract; + cancelEscrow(id: string, actor: string, reason?: string): EscrowContract; + getAnalytics(filter?: EscrowAnalyticsFilter): EscrowAnalyticsReport; +}