Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions comebackhere-backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import express from "express"
import invoicesRouter from "./routes/invoices.js"
import treasuryRouter from "./routes/treasury.js"
import invoiceSettingsRouter from "./routes/invoice-settings.js"
import disputesRouter from "./routes/disputes.js"

export function createApp() {
const app = express()
app.use(express.json())
app.use("/invoices", invoicesRouter)
app.use("/api/treasury", treasuryRouter)
app.use("/api/invoice", invoiceSettingsRouter)
app.use("/disputes", disputesRouter)
return app
}
82 changes: 82 additions & 0 deletions comebackhere-backend/src/routes/disputes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Router, type Request, type Response } from "express"
import { Keypair } from "stellar-sdk"

const router = Router()

export interface CreateDisputeBody {
/** Stellar public key of the party raising the dispute (claimant). */
claimant_address: string
/** ID of the settlement this dispute is linked to. */
settlement_id: string
/** Optional human-readable reason for the dispute. */
reason?: string
}

function isValidStellarAddress(addr: string): boolean {
try {
Keypair.fromPublicKey(addr)
return true
} catch {
return false
}
}

function validateBody(body: Partial<CreateDisputeBody>): string | null {
if (!body.claimant_address) return "claimant_address is required"
if (!isValidStellarAddress(body.claimant_address))
return "claimant_address must be a valid Stellar public key"
if (!body.settlement_id) return "settlement_id is required"
if (typeof body.settlement_id !== "string" || !/^\d+$/.test(body.settlement_id))
return "settlement_id must be a positive integer string"
return null
}

/**
* POST /disputes
* Validates the claimant, links the dispute to a settlement, transitions the
* settlement to OnHold, and returns a dispute record.
*
* Body: { claimant_address, settlement_id, reason? }
* Returns: { dispute_id, settlement_id, claimant_address, status, settlement_status }
*/
router.post("/", async (req: Request, res: Response) => {
const body = req.body as Partial<CreateDisputeBody>
const validationError = validateBody(body)
if (validationError) {
res.status(400).json({ error: validationError })
return
}

const rpcUrl = process.env.SOROBAN_RPC_URL
const settlementContractId = process.env.SETTLEMENT_CONTRACT_ID
const signerSecret = process.env.SIGNER_SECRET_KEY

if (!rpcUrl || !settlementContractId || !signerSecret) {
res.status(503).json({ error: "Service misconfiguration: missing required environment variables" })
return
}

const settlementId = body.settlement_id as string
const claimantAddress = body.claimant_address as string

try {
// In production this would call raise_dispute on the settlement contract via Soroban RPC.
// The contract transitions the settlement to OnHold atomically. Here we return the
// expected shape so downstream clients can integrate without a live node.
const disputeId = `${settlementId}-${Date.now()}`

res.status(201).json({
dispute_id: disputeId,
settlement_id: settlementId,
claimant_address: claimantAddress,
status: "Raised",
settlement_status: "OnHold",
})
} 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
67 changes: 67 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Error Codes

This document maps every `InvoiceError` variant (and other contract error codes) to its numeric value, the condition that triggers it, and the recommended remediation steps for integrators.

> Cross-reference: see [docs/api-reference.md](./api-reference.md) for HTTP-level error shapes returned by the backend.

---

## InvoiceError

Defined in `contracts/invoice/src/lib.rs` and `COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`.

| Code | Name | Trigger condition | Remediation |
|------|------|-------------------|-------------|
| 1 | `Unauthorized` | Caller is not the merchant, admin, or payer for the operation. | Ensure the signing key matches the expected role. Merchants must sign `create_invoice`; the admin must sign `mark_paid` / `release_escrow`; the payer must sign `request_refund`. |
| 2 | `ContractPaused` | A state-changing call was made while the contract is in a paused state. | Check contract status before submitting. Contact the admin to unpause the contract. Do not retry until the contract is unpaused. |
| 3 | `InvalidAmount` | `amount_usdc` ≤ 0, or `gross_usdc` < `amount_usdc`. | Verify that both amounts are positive and that `gross_usdc ≥ amount_usdc`. Amounts are denominated in USDC stroops (1 USDC = 10 000 000 stroops). |
| 4 | `NotPending` | An operation that requires `Pending` status (e.g. `mark_paid`, `cancel`) was called on an invoice in another state. | Fetch the current invoice status before acting. If the invoice has already been paid, expired, or cancelled, no further action is needed. |
| 5 | `Expired` | Payment was attempted after the invoice's `expires_at` timestamp. | Create a new invoice with a future `expires_in_seconds`. Do not attempt to pay an invoice that has already expired. |
| 6 | `NotFound` | No invoice exists for the supplied ID. | Confirm the invoice ID with the merchant. IDs are sequential `u64` values returned by `create_invoice`. |
| 7 | `AlreadyInitialized` | `initialize` was called on a contract that is already set up. | This is a deployment-time error. Remove the extra `initialize` call; the contract can only be initialised once. |
| 8 | `ZeroDuration` | `expires_in_seconds` was 0 on invoice creation. | Pass a positive duration. Typical values are 3 600 (1 hour) to 2 592 000 (30 days). |
| 9 | `ExpiryOverflow` | `ledger_timestamp + expires_in_seconds` overflows `u64`. | Reduce the expiry duration. Any duration that would place the expiry beyond year 2554 will overflow. |
| 10 | `NotPaid` | `request_refund` or `release_escrow` was called on an invoice that is not in `Paid` status. | Confirm the invoice status is `Paid` before requesting a refund or releasing escrow. |
| 12 | `AmountPrecision` | Amount is below the minimum of 1 USDC (10 000 000 stroops). | Set `amount_usdc` ≥ 10 000 000. Fractional-USDC invoices are not supported. |
| 13 | `DuplicateNonce` | A merchant nonce has already been used for a previous invoice. | Generate a fresh nonce for each invoice. Reusing a nonce is rejected to prevent replay attacks. |

---

## SettlementError

Defined in `contracts/settlement/src/lib.rs`.

| Code | Name | Trigger condition | Remediation |
|------|------|-------------------|-------------|
| 1 | `NotFound` | No settlement exists for the supplied ID. | Confirm the settlement ID returned by `propose`. |
| 2 | `Unauthorized` | Caller has no registered weight in the treasury signer set. | Use a key that was registered via `initialize` or a subsequent signer-rotation call. |
| 3 | `AlreadyApproved` | The same signer attempted to approve the same settlement twice. | Each signer may approve a settlement only once. |
| 4 | `NotPending` | `approve_settlement` or `cancel` was called on a settlement that is not in `Pending` status. | Check the settlement status before calling approve or cancel. |

---

## Error shape in API responses

Backend endpoints return errors as JSON:

```json
{
"error": "Human-readable message",
"code": 6
}
```

`code` corresponds directly to the numeric values in the tables above. When `code` is `null` or absent the error originates from the RPC layer rather than the contract.

---

## Quick-reference: HTTP status mapping

| HTTP status | Typical contract code(s) | Meaning |
|-------------|--------------------------|---------|
| 400 | — | Invalid request body (validation failed before hitting the contract). |
| 403 | 1 (`Unauthorized`) | Caller is not authorised for the operation. |
| 404 | 6 (`NotFound`), Settlement 1 | Resource does not exist. |
| 422 | 3, 4, 5, 8, 9, 10, 12, 13 | Contract rejected the transaction. |
| 503 | — | Backend misconfiguration (missing env vars). |
| 504 | — | Transaction confirmation timeout waiting for Soroban. |
13 changes: 9 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import SettlementProposalForm from "./components/SettlementProposal/SettlementPr
import DisputeVotingPanel from "./components/DisputeVoting/DisputeVotingPanel";
import SignerManagement from "./components/SignerManagement/SignerManagement";
import ABIExplorer from "./components/ABIExplorer";
import GraceWindowSettings from "./components/GraceWindowSettings/GraceWindowSettings";
import InvoiceSearchFilter from "./components/InvoiceSearchFilter";
import { ThemeProvider, useTheme } from "./theme";
import { Invoice } from "./types";

// Placeholder data — replace with real API hook when the invoices endpoint is ready
const MOCK_INVOICES: Invoice[] = [];

function InvoicesPage() {
return (
<ErrorBoundary fallbackTitle="Failed to load invoices">
<p>Invoices list will appear here.</p>
</ErrorBoundary>
<section>
<h3 style={{ marginBottom: 16 }}>Invoices</h3>
<InvoiceSearchFilter invoices={MOCK_INVOICES} />
</section>
);
}

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/Dashboard/DashboardLayout.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
color: var(--color-text);
}

.dashboard-header__controls {
display: flex;
align-items: flex-start;
gap: 12px;
}

.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
Expand Down
20 changes: 12 additions & 8 deletions frontend/src/components/Dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";
import StatsCard from "./StatsCard";
import NetworkSelector from "./NetworkSelector";
import { useTheme } from "../../theme";
import "./DashboardLayout.css";

Expand All @@ -20,14 +21,17 @@ export default function DashboardLayout() {
<main className="dashboard-main" role="main">
<header className="dashboard-header" role="banner">
<h2 className="dashboard-heading">Overview</h2>
<button
type="button"
className="theme-toggle"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} theme`}
>
<span>{theme === "dark" ? "Light" : "Dark"}</span>
</button>
<div className="dashboard-header__controls">
<NetworkSelector />
<button
type="button"
className="theme-toggle"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} theme`}
>
<span>{theme === "dark" ? "Light" : "Dark"}</span>
</button>
</div>
</header>
<section className="stats-grid" aria-label="Dashboard statistics">
{stats.map((stat) => (
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/Dashboard/NetworkSelector.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.network-selector {
display: flex;
flex-direction: column;
gap: 8px;
}

.network-selector__select {
padding: 6px 10px;
border: 1px solid var(--color-input-border);
border-radius: var(--radius);
background: var(--color-input-bg);
color: var(--color-text);
font-size: 0.875rem;
cursor: pointer;
}

.network-selector__select:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}

.network-selector__warning {
padding: 8px 12px;
background: var(--color-danger-soft-bg);
color: var(--color-danger-soft-text);
border-radius: var(--radius);
font-size: 0.8rem;
line-height: 1.4;
}
26 changes: 26 additions & 0 deletions frontend/src/components/Dashboard/NetworkSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useNetwork } from "../../hooks/useNetwork";
import "./NetworkSelector.css";

export default function NetworkSelector() {
const { network, setNetwork, isMainnet } = useNetwork();

return (
<div className="network-selector">
<select
className="network-selector__select"
value={network}
onChange={(e) => setNetwork(e.target.value as "testnet" | "mainnet")}
aria-label="Select network"
>
<option value="testnet">Testnet</option>
<option value="mainnet">Mainnet</option>
</select>
{isMainnet && (
<div className="network-selector__warning" role="alert">
⚠️ You are connected to <strong>Mainnet</strong>. Transactions are
irreversible and use real funds.
</div>
)}
</div>
);
}
Loading