From 1f12082b27a1fa45d8cadba9f4248bef4c16a07e Mon Sep 17 00:00:00 2001 From: Martin Young Date: Sat, 27 Jun 2026 18:20:14 +0000 Subject: [PATCH] feat: compliance endpoints, invoice indexer, token allowlist UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #66 POST /compliance/allow — admin-only, calls allow_address (+ allow_address_until if until provided), returns updated status - #68 POST /compliance/block — admin-only with 401 guard, calls block_address, audit logs admin identity + timestamp - #69 Invoice event indexer — cursor-based Soroban polling for invoice_created, invoice_paid, invoice_expired, invoice_cancelled, escrow_released; persistTransition stub + startIndexer() embeddable and CLI entry - #67 Token allowlist UI — TokenAllowlist component (table, add form with validation, remove with confirmation dialog); treasury utility for add/remove/get_allowed_tokens; wired as new tab in App.tsx --- comebackhere-backend/src/app.ts | 2 + comebackhere-backend/src/indexer.ts | 189 ++++++++++++++ comebackhere-backend/src/routes/compliance.ts | 243 ++++++++++++++++++ comebackhere-frontend/src/App.tsx | 11 +- .../src/components/TokenAllowlist.tsx | 237 +++++++++++++++++ comebackhere-frontend/src/utils/treasury.ts | 101 ++++++++ 6 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 comebackhere-backend/src/indexer.ts create mode 100644 comebackhere-backend/src/routes/compliance.ts create mode 100644 comebackhere-frontend/src/components/TokenAllowlist.tsx create mode 100644 comebackhere-frontend/src/utils/treasury.ts diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 9c8fd51..1e82869 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -1,9 +1,11 @@ import express from "express" import invoicesRouter from "./routes/invoices.js" +import complianceRouter from "./routes/compliance.js" export function createApp() { const app = express() app.use(express.json()) app.use("/invoices", invoicesRouter) + app.use("/compliance", complianceRouter) return app } diff --git a/comebackhere-backend/src/indexer.ts b/comebackhere-backend/src/indexer.ts new file mode 100644 index 0000000..b8c8e67 --- /dev/null +++ b/comebackhere-backend/src/indexer.ts @@ -0,0 +1,189 @@ +/** + * Invoice event indexer — #69 + * + * Polls Soroban for invoice contract events (invoice_created, invoice_paid, + * invoice_expired, invoice_cancelled, escrow_released) using cursor-based + * pagination so missed events and re-org recovery are handled automatically. + * + * Usage (standalone): + * SOROBAN_RPC_URL=... INVOICE_CONTRACT_ID=... node dist/indexer.js + * + * Usage (embedded): call startIndexer() from index.ts or a worker. + */ + +import { SorobanRpc, xdr } from "stellar-sdk" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type InvoiceEventType = + | "invoice_created" + | "invoice_paid" + | "invoice_expired" + | "invoice_cancelled" + | "escrow_released" + +export interface InvoiceStateTransition { + event_type: InvoiceEventType + invoice_id: string + ledger: number + ledger_closed_at: string + transaction_hash: string + contract_id: string + raw_topics: string[] + raw_value: string +} + +const TRACKED_EVENTS = new Set([ + "invoice_created", + "invoice_paid", + "invoice_expired", + "invoice_cancelled", + "escrow_released", +]) + +// --------------------------------------------------------------------------- +// Cursor persistence (in-memory with optional env override for restarts) +// --------------------------------------------------------------------------- + +let cursor: string = process.env.INDEXER_START_CURSOR ?? "0" + +function saveCursor(next: string): void { + cursor = next + // In production swap this for a DB or Redis write so restarts resume cleanly. + // e.g.: await redis.set("invoice_indexer_cursor", next) +} + +// --------------------------------------------------------------------------- +// Event parsing +// --------------------------------------------------------------------------- + +function parseEventType(topics: xdr.ScVal[]): InvoiceEventType | null { + // Soroban contract events encode the event name as the first topic symbol. + const name = topics[0]?.sym()?.toString() + if (!name || !TRACKED_EVENTS.has(name)) return null + return name as InvoiceEventType +} + +function parseInvoiceId(topics: xdr.ScVal[]): string { + // Convention: second topic is the invoice_id (u32 or u64). + const id = topics[1]?.u32() ?? topics[1]?.u64() + return id?.toString() ?? "unknown" +} + +function scValToString(val: xdr.ScVal): string { + try { + return val.toXDR("base64") + } catch { + return "" + } +} + +// --------------------------------------------------------------------------- +// Persistence stub +// --------------------------------------------------------------------------- + +/** + * Persist a state transition record. Replace with real DB writes in production. + * e.g.: await db.invoiceEvents.insert(transition) + */ +export function persistTransition(transition: InvoiceStateTransition): void { + console.log( + `[indexer] ${transition.event_type} invoice_id=${transition.invoice_id}` + + ` ledger=${transition.ledger} tx=${transition.transaction_hash}` + ) +} + +// --------------------------------------------------------------------------- +// Core poll loop +// --------------------------------------------------------------------------- + +export async function pollOnce( + server: SorobanRpc.Server, + contractId: string +): Promise { + const response = await (server as any).getEvents({ + startLedger: cursor === "0" ? undefined : undefined, + cursor: cursor === "0" ? undefined : cursor, + filters: [ + { + type: "contract", + contractIds: [contractId], + }, + ], + limit: 100, + }) + + const events: any[] = response?.events ?? [] + + for (const event of events) { + const topics: xdr.ScVal[] = (event.topic ?? []).map((t: string) => + xdr.ScVal.fromXDR(t, "base64") + ) + const eventType = parseEventType(topics) + if (!eventType) continue + + const rawValue = event.value?.xdr ?? "" + const transition: InvoiceStateTransition = { + event_type: eventType, + invoice_id: parseInvoiceId(topics), + ledger: event.ledger, + ledger_closed_at: event.ledgerClosedAt ?? new Date().toISOString(), + transaction_hash: event.txHash ?? "", + contract_id: contractId, + raw_topics: (event.topic ?? []) as string[], + raw_value: rawValue, + } + + persistTransition(transition) + } + + // Advance cursor to the last seen event's paging token for re-org safety. + if (events.length > 0) { + saveCursor(events[events.length - 1].pagingToken) + } +} + +// --------------------------------------------------------------------------- +// Start function — exported for embedding; also runs as CLI entry point +// --------------------------------------------------------------------------- + +export async function startIndexer(options?: { + rpcUrl?: string + contractId?: string + pollIntervalMs?: number + onError?: (err: unknown) => void +}): Promise { + const rpcUrl = options?.rpcUrl ?? process.env.SOROBAN_RPC_URL + const contractId = options?.contractId ?? process.env.INVOICE_CONTRACT_ID + const pollIntervalMs = options?.pollIntervalMs ?? 5_000 + + if (!rpcUrl || !contractId) { + throw new Error("startIndexer: SOROBAN_RPC_URL and INVOICE_CONTRACT_ID are required") + } + + const server = new SorobanRpc.Server(rpcUrl) + + console.log(`[indexer] starting — contract=${contractId} cursor=${cursor} interval=${pollIntervalMs}ms`) + + const loop = async () => { + try { + await pollOnce(server, contractId) + } catch (err) { + const handler = options?.onError ?? ((e) => console.error("[indexer] poll error", e)) + handler(err) + } + setTimeout(loop, pollIntervalMs) + } + + loop() +} + +// Run as standalone entry point +if (import.meta.url === new URL(process.argv[1], import.meta.url).href) { + startIndexer().catch((err) => { + console.error("[indexer] fatal", err) + process.exit(1) + }) +} diff --git a/comebackhere-backend/src/routes/compliance.ts b/comebackhere-backend/src/routes/compliance.ts new file mode 100644 index 0000000..c83a978 --- /dev/null +++ b/comebackhere-backend/src/routes/compliance.ts @@ -0,0 +1,243 @@ +import { Router, type Request, type Response } from "express" +import { + Keypair, + Networks, + TransactionBuilder, + BASE_FEE, + Contract, + nativeToScVal, + SorobanRpc, +} from "stellar-sdk" + +const router = Router() + +// --------------------------------------------------------------------------- +// Shared Soroban client type — mirrors invoices.ts convention +// --------------------------------------------------------------------------- + +export type SorobanClient = { + getAccount: (publicKey: string) => Promise[0]> + simulateTransaction: (tx: Parameters[0]) => ReturnType + sendTransaction: (tx: Parameters[0]) => ReturnType + getTransaction: (hash: string) => ReturnType +} + +function buildSorobanClient(rpcUrl: string): SorobanClient { + const server = new SorobanRpc.Server(rpcUrl) + return { + getAccount: (pk) => server.getAccount(pk), + simulateTransaction: (tx) => server.simulateTransaction(tx), + sendTransaction: (tx) => server.sendTransaction(tx), + getTransaction: (hash) => server.getTransaction(hash), + } +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function isValidStellarAddress(addr: string): boolean { + try { + Keypair.fromPublicKey(addr) + return true + } catch { + return false + } +} + +function envOrError(): { rpcUrl: string; contractId: string; signerSecret: string; networkPassphrase: string } | null { + const rpcUrl = process.env.SOROBAN_RPC_URL + const contractId = process.env.COMPLIANCE_CONTRACT_ID + const signerSecret = process.env.SIGNER_SECRET_KEY + const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? Networks.STANDALONE + if (!rpcUrl || !contractId || !signerSecret) return null + return { rpcUrl, contractId, signerSecret, networkPassphrase } +} + +// --------------------------------------------------------------------------- +// Core call — submit a compliance operation and return updated status +// --------------------------------------------------------------------------- + +export async function callComplianceOp( + operation: "allow_address" | "block_address" | "allow_address_until", + args: ReturnType[], + client: SorobanClient, + contractId: string, + signerSecret: string, + networkPassphrase: string +): Promise<{ address: string; status: string; hash: string }> { + const keypair = Keypair.fromSecret(signerSecret) + const contract = new Contract(contractId) + + const account = await client.getAccount(keypair.publicKey()) + const tx = new TransactionBuilder(account as any, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call(operation, ...args)) + .setTimeout(30) + .build() + + const simulated = await client.simulateTransaction(tx) + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw Object.assign( + new Error(`Soroban simulation failed: ${(simulated as any).error}`), + { status: 422 } + ) + } + + const prepared = SorobanRpc.assembleTransaction(tx, simulated as any).build() + prepared.sign(keypair) + + const sendResult = await client.sendTransaction(prepared) + if (sendResult.status === "ERROR") { + throw Object.assign( + new Error(`Soroban submission failed: ${(sendResult as any).errorResult?.toXDR("base64")}`), + { status: 422 } + ) + } + + const hash = sendResult.hash + let getResult: SorobanRpc.Api.GetTransactionResponse | null = null + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 1000)) + getResult = await client.getTransaction(hash) + if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) break + } + + if (!getResult || getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + throw Object.assign(new Error("Transaction confirmation timeout"), { status: 504 }) + } + if (getResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw Object.assign(new Error("Soroban transaction failed"), { status: 422 }) + } + + const statusMap: Record = { + allow_address: "Allowed", + block_address: "Blocked", + allow_address_until: "AllowedUntil", + } + + return { + address: (args[0] as any).address?.toString() ?? "", + status: statusMap[operation], + hash, + } +} + +// --------------------------------------------------------------------------- +// POST /compliance/allow (#66) +// Admin-only — calls allow_address (or allow_address_until if until provided) +// --------------------------------------------------------------------------- + +export interface AllowBody { + address: string + until?: number // optional Unix timestamp for time-bounded allowance +} + +/** + * POST /compliance/allow + * Body: { address: string, until?: number } + * Returns: { address, status, hash } + */ +router.post("/allow", async (req: Request, res: Response) => { + const adminKey = req.headers["x-admin-key"] + if (!adminKey || adminKey !== process.env.ADMIN_KEY) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const { address, until } = req.body as Partial + if (!address || !isValidStellarAddress(address)) { + res.status(400).json({ error: "address must be a valid Stellar public key" }) + return + } + if (until !== undefined && (typeof until !== "number" || !Number.isInteger(until) || until <= 0)) { + res.status(400).json({ error: "until must be a positive Unix timestamp" }) + return + } + + const env = envOrError() + if (!env) { + res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) + return + } + + try { + const client = buildSorobanClient(env.rpcUrl) + const operation = until ? "allow_address_until" : "allow_address" + const args = until + ? [nativeToScVal(address, { type: "address" }), nativeToScVal(until, { type: "u64" })] + : [nativeToScVal(address, { type: "address" })] + + const result = await callComplianceOp( + operation as "allow_address" | "allow_address_until", + args, + client, + env.contractId, + env.signerSecret, + env.networkPassphrase + ) + res.status(200).json(result) + } catch (err: unknown) { + const status = (err as any)?.status ?? 500 + const message = err instanceof Error ? err.message : String(err) + res.status(status).json({ error: message }) + } +}) + +// --------------------------------------------------------------------------- +// POST /compliance/block (#68) +// Admin-only — calls block_address; logs admin identity and timestamp +// --------------------------------------------------------------------------- + +export interface BlockBody { + address: string +} + +/** + * POST /compliance/block + * Body: { address: string } + * Returns: { address, status, hash } + */ +router.post("/block", async (req: Request, res: Response) => { + const adminKey = req.headers["x-admin-key"] + if (!adminKey || adminKey !== process.env.ADMIN_KEY) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const { address } = req.body as Partial + if (!address || !isValidStellarAddress(address)) { + res.status(400).json({ error: "address must be a valid Stellar public key" }) + return + } + + const env = envOrError() + if (!env) { + res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) + return + } + + // Audit log — admin identity + timestamp + console.log(`[compliance] block_address admin="${adminKey}" address="${address}" ts="${new Date().toISOString()}"`) + + try { + const client = buildSorobanClient(env.rpcUrl) + const result = await callComplianceOp( + "block_address", + [nativeToScVal(address, { type: "address" })], + client, + env.contractId, + env.signerSecret, + env.networkPassphrase + ) + res.status(200).json(result) + } catch (err: unknown) { + const status = (err as any)?.status ?? 500 + const message = err instanceof Error ? err.message : String(err) + res.status(status).json({ error: message }) + } +}) + +export default router diff --git a/comebackhere-frontend/src/App.tsx b/comebackhere-frontend/src/App.tsx index 7fb27af..e362248 100644 --- a/comebackhere-frontend/src/App.tsx +++ b/comebackhere-frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { InvoicePayment } from "./components/InvoicePayment" import { RefundRequest } from "./components/RefundRequest" import { ComplianceManager } from "./components/ComplianceManager" +import { TokenAllowlist } from "./components/TokenAllowlist" import { WalletBar } from "./components/WalletBar" import { useInvoice } from "./hooks/useInvoice" import { useTheme } from "./hooks/useTheme" @@ -10,7 +11,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" | "tokens" function RefundTab() { const { invoice, loading, error, loadInvoice, refund } = useInvoice() @@ -137,6 +138,12 @@ export default function App() { > Compliance +
@@ -144,6 +151,8 @@ export default function App() { ) : tab === "refund" ? ( + ) : tab === "tokens" ? ( + ) : ( )} diff --git a/comebackhere-frontend/src/components/TokenAllowlist.tsx b/comebackhere-frontend/src/components/TokenAllowlist.tsx new file mode 100644 index 0000000..d76598a --- /dev/null +++ b/comebackhere-frontend/src/components/TokenAllowlist.tsx @@ -0,0 +1,237 @@ +import { useState, useCallback } from "react" +import { getAllowedTokens, addAllowedToken, removeAllowedToken } from "../utils/treasury" + +// Stellar contract address: C... (56 chars) or G... (56 chars) +function isValidContractAddress(value: string): boolean { + return /^[CG][A-Z2-7]{55}$/.test(value.trim()) +} + +interface ConfirmDialog { + token: string +} + +export function TokenAllowlist() { + const [tokens, setTokens] = useState([]) + const [loaded, setLoaded] = useState(false) + const [loadError, setLoadError] = useState(null) + const [loading, setLoading] = useState(false) + + const [newToken, setNewToken] = useState("") + const [addError, setAddError] = useState(null) + const [addSuccess, setAddSuccess] = useState(null) + const [adding, setAdding] = useState(false) + + const [confirm, setConfirm] = useState(null) + const [removing, setRemoving] = useState(false) + const [removeError, setRemoveError] = useState(null) + + const loadTokens = useCallback(async () => { + setLoadError(null) + setLoading(true) + try { + const list = await getAllowedTokens() + setTokens(list) + setLoaded(true) + } catch (err: any) { + setLoadError(err?.message ?? "Failed to load tokens") + } finally { + setLoading(false) + } + }, []) + + const handleAdd = async () => { + setAddError(null) + setAddSuccess(null) + if (!isValidContractAddress(newToken)) { + setAddError("Enter a valid Stellar contract address (C... or G..., 56 chars)") + return + } + setAdding(true) + try { + const result = await addAllowedToken(newToken.trim()) + if (!result.success) throw new Error(result.error ?? "Add failed") + setAddSuccess(`Token added. tx: ${result.hash}`) + setNewToken("") + await loadTokens() + } catch (err: any) { + setAddError(err?.message ?? "Add failed") + } finally { + setAdding(false) + } + } + + const handleRemoveConfirmed = async () => { + if (!confirm) return + setRemoveError(null) + setRemoving(true) + try { + const result = await removeAllowedToken(confirm.token) + if (!result.success) throw new Error(result.error ?? "Remove failed") + setConfirm(null) + await loadTokens() + } catch (err: any) { + setRemoveError(err?.message ?? "Remove failed") + } finally { + setRemoving(false) + } + } + + return ( +
+

Token Allowlist

+ + {/* Load button */} + {!loaded && ( + + )} + + {loadError &&
{loadError}
} + + {/* Add token form */} +
+ +
+ + {loaded && ( + + )} +
+
+ + {addError &&
{addError}
} + {addSuccess &&
{addSuccess}
} + {removeError &&
{removeError}
} + + {/* Token table */} + {loaded && ( +
+

Allowlisted Tokens

+ + + + + + + + + {tokens.length === 0 ? ( + + + + ) : ( + tokens.map((token) => ( + + + + + )) + )} + +
Contract AddressAction
+ No tokens allowlisted yet. +
{token} + +
+
+ )} + + {/* Confirmation dialog */} + {confirm && ( +
+
+

+ Remove token? +

+

+ {confirm.token} +

+

+ This will call remove_allowed_token on the treasury contract. + The token will no longer be accepted. +

+ {removeError && ( +
+ {removeError} +
+ )} +
+ + +
+
+
+ )} +
+ ) +} diff --git a/comebackhere-frontend/src/utils/treasury.ts b/comebackhere-frontend/src/utils/treasury.ts new file mode 100644 index 0000000..79e736d --- /dev/null +++ b/comebackhere-frontend/src/utils/treasury.ts @@ -0,0 +1,101 @@ +import { + Contract, + TransactionBuilder, + Networks, + BASE_FEE, + xdr, + nativeToScVal, +} from "soroban-client" + +const TREASURY_CONTRACT_ID = + import.meta.env.VITE_TREASURY_CONTRACT_ID as string +const SOROBAN_RPC = import.meta.env.VITE_SOROBAN_RPC as string +const NETWORK_PASSPHRASE = import.meta.env.VITE_NETWORK_PASSPHRASE as string + +function getNetworkPassphrase(): string { + return NETWORK_PASSPHRASE || Networks.STANDALONE +} + +function getServer() { + const { SorobanRpc } = window as any + return new SorobanRpc.Server(SOROBAN_RPC) +} + +async function getPublicKey(): Promise { + const { address } = await (window as any).freighterApi.getAddress() + return address +} + +/** + * Returns the list of currently allowlisted token contract addresses. + */ +export async function getAllowedTokens(): Promise { + const server = getServer() + const contract = new Contract(TREASURY_CONTRACT_ID) + + const result = await server.simulateTransaction( + new TransactionBuilder(await server.getAccount(TREASURY_CONTRACT_ID), { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call("get_allowed_tokens")) + .setTimeout(30) + .build() + ) + + if (!result.result?.retval) return [] + + const vec: xdr.ScVal[] = result.result.retval.vec() ?? [] + return vec.map((v) => { + try { + return v.address().toString() + } catch { + return v.toString() + } + }) +} + +async function submitTokenOp( + operation: "add_allowed_token" | "remove_allowed_token", + tokenAddress: string +): Promise<{ success: boolean; error?: string; hash?: string }> { + try { + const server = getServer() + const contract = new Contract(TREASURY_CONTRACT_ID) + const publicKey = await getPublicKey() + const args = [nativeToScVal(tokenAddress, { type: "address" })] + + const tx = new TransactionBuilder(await server.getAccount(publicKey), { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call(operation, ...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, hash: txHash.hash } + } catch (err: any) { + return { success: false, error: err?.message ?? "Transaction failed" } + } +} + +export function addAllowedToken( + tokenAddress: string +): Promise<{ success: boolean; error?: string; hash?: string }> { + return submitTokenOp("add_allowed_token", tokenAddress) +} + +export function removeAllowedToken( + tokenAddress: string +): Promise<{ success: boolean; error?: string; hash?: string }> { + return submitTokenOp("remove_allowed_token", tokenAddress) +}