diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 7f01a9c..1e82869 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -1,17 +1,11 @@ import express from "express" import invoicesRouter from "./routes/invoices.js" -import disputesRouter from "./routes/disputes.js" -import treasuryRouter from "./routes/treasury.js" import complianceRouter from "./routes/compliance.js" -import invoiceSettingsRouter from "./routes/invoice-settings.js" export function createApp() { const app = express() app.use(express.json()) app.use("/invoices", invoicesRouter) - app.use("/disputes", disputesRouter) - app.use("/api/treasury", treasuryRouter) - app.use("/api/compliance", complianceRouter) - app.use("/api/invoice", invoiceSettingsRouter) + 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 index 445ebe5..c83a978 100644 --- a/comebackhere-backend/src/routes/compliance.ts +++ b/comebackhere-backend/src/routes/compliance.ts @@ -1,9 +1,41 @@ import { Router, type Request, type Response } from "express" -import { Keypair, Networks, nativeToScVal, Address, xdr } from "stellar-sdk" -import { buildSorobanClient, getNetworkPassphrase, simulateContractRead } from "../lib/soroban.js" +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) @@ -13,75 +45,194 @@ function isValidStellarAddress(addr: string): boolean { } } -type ComplianceStatus = "Allowed" | "AllowedUntil" | "Blocked" | "Cleared" - -interface ComplianceResult { - address: string - status: ComplianceStatus - allowed: boolean - expires_at: number | null +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 } } -function parseAddressStatus(retval: xdr.ScVal): { status: ComplianceStatus; expiresAt: number | null } { - const vec = retval.vec() - const variant = (vec?.[0]?.sym()?.toString() ?? "Cleared") as ComplianceStatus - if (variant === "AllowedUntil") { - const raw = vec?.[1]?.u64() - const expiresAt = raw ? Number(raw.toString()) : null - return { status: variant, expiresAt } +// --------------------------------------------------------------------------- +// 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, } - return { status: variant, expiresAt: null } +} + +// --------------------------------------------------------------------------- +// 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 } /** - * GET /compliance/:address - * Returns compliance status for a Stellar address: allowed, blocked, or expired allowance. + * POST /compliance/allow + * Body: { address: string, until?: number } + * Returns: { address, status, hash } */ -router.get("/:address", async (req: Request, res: Response) => { - const { address } = req.params - - if (!isValidStellarAddress(address)) { - res.status(400).json({ error: "Invalid Stellar address format" }) +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 rpcUrl = process.env.SOROBAN_RPC_URL - const complianceContractId = process.env.COMPLIANCE_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY + 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 + } - if (!rpcUrl || !complianceContractId || !signerSecret) { + const env = envOrError() + if (!env) { res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) return } try { - const client = buildSorobanClient(rpcUrl) - const { Keypair: KP } = await import("stellar-sdk") - const sourceAccount = KP.fromSecret(signerSecret).publicKey() - const networkPassphrase = getNetworkPassphrase() - - const retval = await simulateContractRead( + 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, - complianceContractId, - "get_address_status", - [nativeToScVal(Address.fromString(address), { type: "address" })], - sourceAccount, - networkPassphrase, + 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 +} - const { status, expiresAt } = parseAddressStatus(retval) +/** + * 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 now = Math.floor(Date.now() / 1000) - const expired = status === "AllowedUntil" && expiresAt !== null && expiresAt < now + 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 result: ComplianceResult = { - address, - status: expired ? "Blocked" : status, - allowed: (status === "Allowed" || (status === "AllowedUntil" && !expired)), - expires_at: expiresAt, - } + 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()}"`) - res.json(result) + 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) diff --git a/comebackhere-frontend/src/App.tsx b/comebackhere-frontend/src/App.tsx index fd2c2a3..ff26af5 100644 --- a/comebackhere-frontend/src/App.tsx +++ b/comebackhere-frontend/src/App.tsx @@ -2,9 +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 { TreasuryManager } from "./components/TreasuryManager" +import { TokenAllowlist } from "./components/TokenAllowlist" +import { WalletBar } from "./components/WalletBar" import { useInvoice } from "./hooks/useInvoice" import { useTheme } from "./hooks/useTheme" import { useWallet } from "./hooks/useWallet" @@ -12,7 +11,9 @@ import { CopyableText } from "./components/CopyableText" import "./App.css" import "./components/ErrorBoundary.css" -type Tab = "payment" | "refund" | "compliance" | "batch-expire" | "escrow" | "treasury" +const EXPECTED_NETWORK = import.meta.env.VITE_NETWORK_PASSPHRASE as string ?? "Standalone Network ; February 2025" + +type Tab = "payment" | "refund" | "compliance" | "tokens" function RefundTab() { const { invoice, loading, error, loadInvoice, refund } = useInvoice() @@ -146,22 +147,10 @@ export default function App() { Compliance - - @@ -170,7 +159,9 @@ export default function App() { ) : tab === "refund" ? ( - ) : tab === "compliance" ? ( + ) : tab === "tokens" ? ( + + ) : ( ) : tab === "batch-expire" ? ( 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) +}