From f299bdd0e0e401769c819b6615fc701be74d9291 Mon Sep 17 00:00:00 2001 From: K-K Date: Sat, 27 Jun 2026 18:42:11 +0000 Subject: [PATCH] feat: batch expire UI, escrow release UI, compliance test, architecture diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #54: BatchExpireInvoices component — table with row/select-all checkboxes, batch_expire contract call, progress bar, and per-invoice error summary - #56: EscrowRelease component — load invoice by ID, release_escrow call, inline status badge transition to Released on success - #57: add test_permanent_allow_unaffected_by_time to compliance contract verifying allow_address is not gated by ledger timestamp - #55: Architecture section with Mermaid sequenceDiagram added to README Co-Authored-By: Claude Sonnet 4.6 --- .../contracts/compliance/src/lib.rs | 7 + README.md | 25 ++ comebackhere-frontend/src/App.css | 162 +++++++++++ comebackhere-frontend/src/App.tsx | 22 +- .../src/components/BatchExpireInvoices.tsx | 268 ++++++++++++++++++ .../src/components/EscrowRelease.tsx | 135 +++++++++ comebackhere-frontend/src/hooks/useInvoice.ts | 19 +- comebackhere-frontend/src/utils/soroban.ts | 83 ++++++ 8 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 comebackhere-frontend/src/components/BatchExpireInvoices.tsx create mode 100644 comebackhere-frontend/src/components/EscrowRelease.tsx diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index 108969f..091ac8a 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -152,4 +152,11 @@ mod tests { c.allow_address_until(&admin, &addr, &1000u64); assert!(!c.is_allowed(&addr)); } + + #[test] + fn test_permanent_allow_unaffected_by_time() { + let (_e, c, admin, addr) = setup(9999); + c.allow_address(&admin, &addr); + assert!(c.is_allowed(&addr)); + } } diff --git a/README.md b/README.md index 83f4de3..398f86d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,31 @@ Tooling, deployment scripts, ABIs, and integration resources for COMEBACKHERE Protocol. +## Architecture + +The following sequence diagram illustrates the primary payment flow. + +```mermaid +sequenceDiagram + actor Merchant + actor Payer + participant InvoiceContract + participant TreasuryContract + + Merchant->>InvoiceContract: create_invoice(amount, expires_at) + InvoiceContract-->>Merchant: invoice_id + + Payer->>InvoiceContract: mark_paid(invoice_id) + InvoiceContract->>TreasuryContract: hold funds + InvoiceContract-->>Payer: payment confirmed + + Merchant->>TreasuryContract: propose_settlement(invoice_id) + TreasuryContract-->>Merchant: settlement_id + + Merchant->>TreasuryContract: approve_settlement(settlement_id) + TreasuryContract->>Merchant: release payout +``` + ## Workspace - `abis/`: committed ABI metadata consumed by `comebackhere-backend` diff --git a/comebackhere-frontend/src/App.css b/comebackhere-frontend/src/App.css index a7897bd..002b8e9 100644 --- a/comebackhere-frontend/src/App.css +++ b/comebackhere-frontend/src/App.css @@ -517,6 +517,168 @@ h3 { margin-bottom: 20px; } +/* Compliance */ +.compliance-manager h1 { + margin-bottom: 20px; +} + +.compliance-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.compliance-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +.compliance-form input { + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 0.95rem; + background: var(--color-surface); + color: var(--color-text); + outline: none; + transition: border-color 0.15s; +} + +.compliance-form input:focus { + border-color: var(--color-primary); +} + +.compliance-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.status-summary { + margin-bottom: 20px; + padding: 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.badge--compliance-allowed { + background: var(--color-success-bg); + color: var(--color-success); +} + +.badge--compliance-alloweduntil { + background: var(--color-info-bg); + color: var(--color-info-text); +} + +.badge--compliance-blocked { + background: var(--color-error-bg); + color: var(--color-danger); +} + +.badge--compliance-cleared { + background: var(--color-muted-bg); + color: var(--color-muted-text); +} + +/* Shared table */ +.managed-table-wrapper { + overflow-x: auto; + margin-top: 16px; +} + +.managed-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.managed-table th, +.managed-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.managed-table th { + background: var(--color-surface-muted); + font-weight: 600; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.managed-table tr:last-child td { + border-bottom: none; +} + +.address-cell { + font-family: monospace; + font-size: 0.82rem; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-row td { + text-align: center; + color: var(--color-text-secondary); + padding: 24px; +} + +.row--disabled { + opacity: 0.5; +} + +/* Batch expire */ +.batch-expire h1 { + margin-bottom: 20px; +} + +.batch-expire__actions { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.error-list { + margin-top: 6px; + padding-left: 20px; +} + +.error-list li { + margin-top: 4px; +} + +.progress-bar-wrapper { + margin-bottom: 16px; +} + +.progress-bar { + height: 8px; + background: var(--color-border); + border-radius: 999px; + overflow: hidden; + margin-top: 6px; +} + +.progress-bar__fill { + height: 100%; + background: var(--color-primary); + border-radius: 999px; + transition: width 0.2s ease; +} + +/* Escrow release */ +.escrow-release h1 { + margin-bottom: 20px; +} + @media (max-width: 640px) { .app-header, .header-actions { diff --git a/comebackhere-frontend/src/App.tsx b/comebackhere-frontend/src/App.tsx index 7fb27af..7679244 100644 --- a/comebackhere-frontend/src/App.tsx +++ b/comebackhere-frontend/src/App.tsx @@ -2,6 +2,8 @@ import { useState } from "react" import { InvoicePayment } from "./components/InvoicePayment" import { RefundRequest } from "./components/RefundRequest" import { ComplianceManager } from "./components/ComplianceManager" +import { BatchExpireInvoices } from "./components/BatchExpireInvoices" +import { EscrowRelease } from "./components/EscrowRelease" import { WalletBar } from "./components/WalletBar" import { useInvoice } from "./hooks/useInvoice" import { useTheme } from "./hooks/useTheme" @@ -10,7 +12,7 @@ import "./App.css" const EXPECTED_NETWORK = import.meta.env.VITE_NETWORK_PASSPHRASE as string ?? "Standalone Network ; February 2025" -type Tab = "payment" | "refund" | "compliance" +type Tab = "payment" | "refund" | "compliance" | "batch-expire" | "escrow" function RefundTab() { const { invoice, loading, error, loadInvoice, refund } = useInvoice() @@ -137,6 +139,18 @@ export default function App() { > Compliance + +
@@ -144,8 +158,12 @@ export default function App() { ) : tab === "refund" ? ( - ) : ( + ) : tab === "compliance" ? ( + ) : tab === "batch-expire" ? ( + + ) : ( + )}
diff --git a/comebackhere-frontend/src/components/BatchExpireInvoices.tsx b/comebackhere-frontend/src/components/BatchExpireInvoices.tsx new file mode 100644 index 0000000..3b250e5 --- /dev/null +++ b/comebackhere-frontend/src/components/BatchExpireInvoices.tsx @@ -0,0 +1,268 @@ +import { useState } from "react" +import { StatusBadge } from "./StatusBadge" +import { fetchInvoice, batchExpireInvoices } from "../utils/soroban" +import type { Invoice } from "../types" +import { InvoiceStatus } from "../types" + +const CONTRACT_ID = import.meta.env.VITE_INVOICE_CONTRACT_ID as string + +interface BatchExpireInvoicesProps { + walletAddress: string | null +} + +export function BatchExpireInvoices({ walletAddress }: BatchExpireInvoicesProps) { + const [idInput, setIdInput] = useState("") + const [invoices, setInvoices] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [loadingIds, setLoadingIds] = useState(false) + const [loadError, setLoadError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) + const [batchResult, setBatchResult] = useState<{ + success: boolean + hash?: string + errorMsg?: string + } | null>(null) + const [errors, setErrors] = useState<{ id: string; msg: string }[]>([]) + + const pendingInvoices = invoices.filter((inv) => inv.status === InvoiceStatus.Pending) + const allSelected = + pendingInvoices.length > 0 && pendingInvoices.every((inv) => selected.has(inv.id)) + + const handleLoadInvoices = async () => { + setLoadError(null) + setInvoices([]) + setSelected(new Set()) + setBatchResult(null) + setErrors([]) + setProgress(null) + + const ids = idInput + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n) && n > 0) + + if (ids.length === 0) { + setLoadError("Enter at least one valid invoice ID.") + return + } + + setLoadingIds(true) + const loaded: Invoice[] = [] + for (const id of ids) { + try { + const inv = await fetchInvoice(CONTRACT_ID, id) + loaded.push(inv) + } catch { + // skip invoices that can't be fetched + } + } + setLoadingIds(false) + setInvoices(loaded) + + if (loaded.length === 0) { + setLoadError("No invoices found for the given IDs.") + } + } + + const toggleSelectAll = () => { + if (allSelected) { + setSelected(new Set()) + } else { + setSelected(new Set(pendingInvoices.map((inv) => inv.id))) + } + } + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const handleBatchExpire = async () => { + if (!walletAddress || selected.size === 0) return + + const selectedIds = Array.from(selected).map(Number) + setBatchResult(null) + setErrors([]) + setProgress({ done: 0, total: selectedIds.length }) + setSubmitting(true) + + const result = await batchExpireInvoices(CONTRACT_ID, selectedIds, walletAddress) + + if (!result.success) { + setSubmitting(false) + setProgress(null) + setBatchResult({ success: false, errorMsg: result.error }) + return + } + + const errorList: { id: string; msg: string }[] = [] + let done = 0 + + for (const id of selectedIds) { + try { + const updated = await fetchInvoice(CONTRACT_ID, id) + setInvoices((prev) => + prev.map((inv) => (inv.id === updated.id ? updated : inv)) + ) + if (updated.status !== InvoiceStatus.Expired) { + errorList.push({ + id: String(id), + msg: `Invoice #${id} was not expired (status: ${updated.status})`, + }) + } + } catch (err: any) { + errorList.push({ + id: String(id), + msg: `Invoice #${id}: ${err?.message ?? "failed to verify"}`, + }) + } + done++ + setProgress({ done, total: selectedIds.length }) + } + + setSubmitting(false) + setErrors(errorList) + setBatchResult({ success: true, hash: result.transaction_hash }) + setSelected(new Set()) + } + + return ( +
+

Batch Expire Invoices

+ +
+ setIdInput(e.target.value)} + /> + +
+ + {loadError &&
{loadError}
} + + {batchResult && ( +
+ {batchResult.success ? ( + <> + Batch expire submitted. +
+ Transaction hash:{" "} + {batchResult.hash} + + ) : ( + <>Batch expire failed: {batchResult.errorMsg} + )} +
+ )} + + {errors.length > 0 && ( +
+ Some invoices could not be expired: +
    + {errors.map((e) => ( +
  • {e.msg}
  • + ))} +
+
+ )} + + {progress && ( +
+

+ Verifying {progress.done} of {progress.total} invoices... +

+
+
+
+
+ )} + + {invoices.length > 0 && ( + <> +
+ + {!walletAddress && ( +

Connect wallet to batch expire.

+ )} +
+ +
+ + + + + + + + + + + + + {invoices.map((inv) => { + const isPending = inv.status === InvoiceStatus.Pending + return ( + + + + + + + + + ) + })} + +
+ + IDMerchantAmount (USDC)Expires AtStatus
+ toggleSelect(inv.id)} + disabled={!isPending} + aria-label={`Select invoice ${inv.id}`} + /> + #{inv.id}{inv.merchant}{inv.amount_usdc}{new Date(inv.expires_at * 1000).toLocaleString()} + +
+
+ + )} +
+ ) +} diff --git a/comebackhere-frontend/src/components/EscrowRelease.tsx b/comebackhere-frontend/src/components/EscrowRelease.tsx new file mode 100644 index 0000000..521602b --- /dev/null +++ b/comebackhere-frontend/src/components/EscrowRelease.tsx @@ -0,0 +1,135 @@ +import { useState } from "react" +import { useInvoice } from "../hooks/useInvoice" +import { useWallet } from "../hooks/useWallet" +import { StatusBadge } from "./StatusBadge" +import { InvoiceStatus } from "../types" + +export function EscrowRelease() { + const { invoice, loading, error, loadInvoice, release } = useInvoice() + const { address, connected, connecting, connect } = useWallet() + const [invoiceId, setInvoiceId] = useState("") + const [submitting, setSubmitting] = useState(false) + const [result, setResult] = useState<{ + success: boolean + hash?: string + errorMsg?: string + } | null>(null) + + const handleLoadInvoice = async () => { + setResult(null) + await loadInvoice(Number(invoiceId)) + } + + const handleRelease = async () => { + if (!address) return + setSubmitting(true) + setResult(null) + const res = await release(address) + setSubmitting(false) + setResult({ + success: res.success, + hash: res.transaction_hash, + errorMsg: res.error, + }) + } + + const canRelease = connected && invoice?.status === InvoiceStatus.Paid + + return ( +
+

Escrow Release

+ +
+ setInvoiceId(e.target.value)} + /> + +
+ + {loading &&

Loading invoice...

} + + {error &&
{error}
} + + {result && ( +
+ {result.success ? ( + <> + Escrow released successfully! +
+ Transaction hash:{" "} + {result.hash} + + ) : ( + <>Release failed: {result.errorMsg} + )} +
+ )} + + {invoice && ( +
+
+

Invoice #{invoice.id}

+ +
+ +
+
+ Merchant + + {invoice.merchant} + +
+
+ Amount (USDC) + {invoice.amount_usdc} +
+
+ Status + +
+
+ +
+ {!connected && ( + + )} + + {connected && canRelease && ( + + )} + + {connected && invoice.status !== InvoiceStatus.Paid && ( +

+ Escrow release is available on Paid invoices + (current status: {invoice.status}). +

+ )} +
+
+ )} +
+ ) +} diff --git a/comebackhere-frontend/src/hooks/useInvoice.ts b/comebackhere-frontend/src/hooks/useInvoice.ts index 0a86ae0..0956823 100644 --- a/comebackhere-frontend/src/hooks/useInvoice.ts +++ b/comebackhere-frontend/src/hooks/useInvoice.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from "react" import type { Invoice, PaymentResult } from "../types" -import { fetchInvoice, payInvoice, requestRefund } from "../utils/soroban" +import { fetchInvoice, payInvoice, requestRefund, releaseEscrow } from "../utils/soroban" const CONTRACT_ID = import.meta.env.VITE_INVOICE_CONTRACT_ID as string @@ -11,6 +11,7 @@ interface UseInvoiceReturn { loadInvoice: (id: number) => Promise pay: (publicKey: string) => Promise refund: (publicKey: string) => Promise + release: (publicKey: string) => Promise } export function useInvoice(): UseInvoiceReturn { @@ -63,5 +64,19 @@ export function useInvoice(): UseInvoiceReturn { [invoice, loadInvoice] ) - return { invoice, loading, error, loadInvoice, pay, refund } + const release = useCallback( + async (publicKey: string): Promise => { + if (!invoice) { + return { success: false, error: "No invoice loaded" } + } + const result = await releaseEscrow(CONTRACT_ID, Number(invoice.id), publicKey) + if (result.success) { + await loadInvoice(Number(invoice.id)) + } + return result + }, + [invoice, loadInvoice] + ) + + return { invoice, loading, error, loadInvoice, pay, refund, release } } diff --git a/comebackhere-frontend/src/utils/soroban.ts b/comebackhere-frontend/src/utils/soroban.ts index def915c..29219dd 100644 --- a/comebackhere-frontend/src/utils/soroban.ts +++ b/comebackhere-frontend/src/utils/soroban.ts @@ -157,3 +157,86 @@ export async function requestRefund( } } } + +export async function batchExpireInvoices( + contractId: string, + invoiceIds: number[], + publicKey: string +): Promise { + const server = getServer() + const contract = new Contract(contractId) + + const args = [ + xdr.ScVal.scvVec(invoiceIds.map((id) => nativeToScVal(id, { type: "u64" }))), + ] + + try { + const account = await server.getAccount(publicKey) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call("batch_expire", ...args)) + .setTimeout(30) + .build() + + const simulated = await server.simulateTransaction(tx) + const { SorobanRpc } = window as any + + const prepare = SorobanRpc.assembleTransaction(tx, simulated) + const signed = await (window as any).freighterApi.signTransaction( + prepare.toXDR(), + { networkPassphrase: getNetworkPassphrase() } + ) + + const txHash = await server.sendTransaction(signed) + return { success: true, transaction_hash: txHash.hash } + } catch (err: any) { + return { + success: false, + error: err?.message ?? err?.toString() ?? "Batch expire failed", + } + } +} + +export async function releaseEscrow( + contractId: string, + invoiceId: number, + publicKey: string +): Promise { + const server = getServer() + const contract = new Contract(contractId) + + const args = [ + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(publicKey, { type: "address" }), + ] + + try { + const account = await server.getAccount(publicKey) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call("release_escrow", ...args)) + .setTimeout(30) + .build() + + const simulated = await server.simulateTransaction(tx) + const { SorobanRpc } = window as any + + const prepare = SorobanRpc.assembleTransaction(tx, simulated) + const signed = await (window as any).freighterApi.signTransaction( + prepare.toXDR(), + { networkPassphrase: getNetworkPassphrase() } + ) + + const txHash = await server.sendTransaction(signed) + return { success: true, transaction_hash: txHash.hash } + } catch (err: any) { + return { + success: false, + error: err?.message ?? err?.toString() ?? "Release escrow failed", + } + } +}