diff --git a/.env.example b/.env.example index 2fdc5e15..8cd62525 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # ============================================================================= -# Commitlabs — Environment Variable Reference +# Commitlabs - Environment Variable Reference # Copy this file to .env.local (git-ignored) and fill in your values. # All validation is enforced by src/lib/backend/env.ts (Zod schema). # ============================================================================= # ----------------------------------------------------------------------------- -# Soroban RPC (required in development / production) +# Soroban RPC (required in development / production) # Use the private var for server-only routes; the NEXT_PUBLIC_ var is exposed # to the browser. The private var takes precedence when both are set. # Must be a valid URL when provided. @@ -18,8 +18,13 @@ NEXT_PUBLIC_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 # SOROBAN_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 # ----------------------------------------------------------------------------- -# Contract addresses (required in non-test environments) +# Contract addresses (required in non-test environments) # Provide either the private or the NEXT_PUBLIC_ variant. +# Deployment note: +# - contracts/scripts/deploy-testnet.sh upserts the deployed id into .env.local. +# - Never commit a real deployer secret or signer credential. +# - If server-side routes submit writes, keep COMMITMENT_CORE_CONTRACT or +# SOROBAN_COMMITMENT_CORE_CONTRACT aligned with the public contract id. # ----------------------------------------------------------------------------- NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT= NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT= @@ -35,7 +40,7 @@ NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v1 # NEXT_PUBLIC_CONTRACTS_JSON={"v1":{"commitmentNFT":{"address":"C..."},"commitmentCore":{"address":"C..."}}} # ----------------------------------------------------------------------------- -# Signing credentials (server-side only — NEVER expose to the browser) +# Signing credentials (server-side only - NEVER expose to the browser) # SOROBAN_SERVER_SECRET_KEY is used for on-chain write operations. # Values are ALWAYS redacted from error messages and logs. # ----------------------------------------------------------------------------- @@ -47,25 +52,25 @@ NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v1 # Per-call timeout (ms) for Soroban RPC interactions (default: 30000 = 30 s). # When a call exceeds this limit an AbortController fires and the route returns -# HTTP 504 GATEWAY_TIMEOUT with retryable: true. Increase for high-latency +# HTTP 504 GATEWAY_TIMEOUT with retryable: true. Increase for high-latency # testnets; decrease for strict latency budgets. # SOROBAN_RPC_TIMEOUT_MS=30000 # ----------------------------------------------------------------------------- -# Session secret (REQUIRED in production) +# Session secret (REQUIRED in production) # Used to sign session tokens. Generate with: openssl rand -hex 32 # Must be at least 32 characters. Value is ALWAYS redacted from error messages. # ----------------------------------------------------------------------------- -# SESSION_SECRET= +# SESSION_SECRET= # ----------------------------------------------------------------------------- -# Storage connection (optional; required if blob/DB storage is used) +# Storage connection (optional; required if blob/DB storage is used) # Value is ALWAYS redacted from error messages. # ----------------------------------------------------------------------------- # STORAGE_CONNECTION= # ----------------------------------------------------------------------------- -# RPC URL allowlist (REQUIRED in production) +# RPC URL allowlist (REQUIRED in production) # Comma-separated list of permitted Soroban RPC endpoint URLs. # The active SOROBAN_RPC_URL must be present in this list in production. # Example: @@ -74,46 +79,29 @@ NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v1 # SOROBAN_RPC_URL_ALLOWLIST= # ----------------------------------------------------------------------------- -# Feature flags (all default to false) +# Feature flags (all default to false) # Individual boolean strings or a single JSON override blob (JSON takes precedence). # ----------------------------------------------------------------------------- COMMITLABS_FEATURE_ANALYTICS_USER=false COMMITLABS_FEATURE_MARKETPLACE=false -# COMMITLABS_FEATURE_FLAGS_JSON={"analyticsUser":false,"marketplace":false} - -# Optional JSON override for feature flags (takes precedence). Example: +# Example: # COMMITLABS_FEATURE_FLAGS_JSON={"analyticsUser":true,"marketplace":false} -# ── Rate limiting ───────────────────────────────────────────────────────────── -# +# -- Rate limiting ------------------------------------------------------------- # Controls the fixed-window rate limits applied to API routes. -# Write-heavy routes (commitment create, settle, early-exit) use the WRITE vars. -# All other routes use the DEFAULT vars. -# -# RATE_LIMIT_WRITE_MAX_REQUESTS Max requests per window for write routes (default: 10) -# RATE_LIMIT_WRITE_WINDOW_SECONDS Window size in seconds for write routes (default: 60) -# RATE_LIMIT_DEFAULT_MAX_REQUESTS Max requests per window for all other routes (default: 20) -# RATE_LIMIT_DEFAULT_WINDOW_SECONDS Window size in seconds for all other routes (default: 60) -# +# Write-heavy routes use the WRITE vars; all other routes use the DEFAULT vars. # RATE_LIMIT_WRITE_MAX_REQUESTS=10 # RATE_LIMIT_WRITE_WINDOW_SECONDS=60 # RATE_LIMIT_DEFAULT_MAX_REQUESTS=20 # RATE_LIMIT_DEFAULT_WINDOW_SECONDS=60 -# ── Cache layer ──────────────────────────────────────────────────────────────── -# -# CACHE_ADAPTER Override adapter selection. -# "memory" forces in-memory (default for dev/test). -# "redis" forces Redis regardless of NODE_ENV. -# Omit to use the default: memory in dev/test, Redis in production -# when REDIS_URL is set. +# -- Cache layer --------------------------------------------------------------- # CACHE_ADAPTER=memory - -# REDIS_URL Standard Redis connection string used by RedisAdapter in production. -# Credentials embedded in the URL are parsed automatically by ioredis. -# Requires `npm install ioredis`. -# Examples: -# redis://localhost:6379 -# redis://:mypassword@redis.example.com:6379/0 -# rediss://:mypassword@redis.example.com:6380/0 (TLS) # REDIS_URL=redis://localhost:6379 + +# Examples: +# redis://localhost:6379 +# redis://:mypassword@redis.example.com:6379/0 +# rediss://:mypassword@redis.example.com:6380/0 + +NEXT_PUBLIC_USE_MOCKS=true diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/contracts/README.md b/contracts/README.md index 5d24447e..03a696dd 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,36 +1,35 @@ # CommitLabs Soroban Contracts -Soroban smart contracts backing the CommitLabs liquidity commitment lifecycle. -The `escrow` contract is the primary on-chain component used by the frontend -and backend services to create, fund, release, refund, and dispute -commitments. +Soroban (Rust) smart-contract workspace backing the CommitLabs liquidity commitment protocol. The frontend and Next.js backend service layer (`src/lib/backend/services/contracts.ts`) interact with these contracts via the Stellar Soroban RPC. ## Workspace layout ```text contracts/ -|-- Cargo.toml -`-- escrow/ - |-- Cargo.toml - `-- src/ - |-- lib.rs - `-- test.rs +├── Cargo.toml # Cargo workspace (members = ["escrow"]) +├── escrow/ +│ ├── Cargo.toml # commitlabs-escrow crate (cdylib + rlib) +│ └── src/ +│ ├── lib.rs # EscrowContract implementation +│ └── test.rs # Unit tests (cfg(test)) +└── scripts/ + ├── deploy-testnet.sh # Build + deploy + initialize helper + └── deploy-testnet.smoke.mjs # Dry-run smoke validation ``` ## Escrow lifecycle -The escrow contract manages the on-chain lifecycle of a liquidity commitment. -Assets are deposited under a chosen risk profile and held in escrow until the -commitment matures, is exited early, or is disputed. +The escrow contract manages the on-chain lifecycle of a liquidity commitment. Assets are deposited under a chosen risk profile and held in escrow until the commitment matures, is exited early, or is disputed. ### Security: Checks-Effects-Interactions To prevent reentrancy and similar vulnerabilities when interacting with external tokens, the escrow contract enforces the **Checks-Effects-Interactions** pattern. Specifically, within operations that transfer tokens (`release`, `refund`, and `resolve_dispute`): + 1. **Checks**: Validate caller authorization, commitment status, and ledger time. -2. **Effects**: Update the commitment state (e.g., transition `Funded` -> `Released` or `Refunded`) and persist it to storage. +2. **Effects**: Update the commitment state and persist it to storage. 3. **Interactions**: Perform cross-contract calls to the asset's token contract. -This strict ordering guarantees the contract's internal state is fully resolved before execution control is temporarily handed over to external logic. +This ordering guarantees contract state is fully resolved before control is handed to external logic. ## EscrowStatus State Machine @@ -93,185 +92,191 @@ This strict ordering guarantees the contract's internal state is fully resolved ### Lifecycle +```text +create_commitment ──► fund_escrow ──► release + └──► refund + └──► dispute ──► resolve_dispute ``` -create_commitment ──► fund_escrow ──► release (matured: principal back to owner) - └──► refund (early exit: principal − penalty) - └──► dispute ──► resolve_dispute (admin adjudication) -``` - -### Persistent storage TTL strategy - -Commitment records and owner-index entries live in persistent Soroban storage, so -they need explicit TTL management for long-duration escrows. -- `save` bumps each `Commitment(id)` entry when its remaining TTL no longer covers the commitment maturity horizon. -- `index_owner` recomputes the latest maturity still referenced by an owner's id list and bumps `OwnerIndex(owner)` to that horizon. -- The target TTL is the remaining time to maturity plus a small post-maturity ledger buffer so release/refund can still execute after the unlock point. -- Bumps are thresholded instead of unconditional to avoid paying rent-extension fees when an entry already has enough TTL. +### Marketplace transfer flow -This keeps active commitments readable for their full lifecycle while keeping -Soroban fee overhead under control. +`transfer_ownership(commitment_id, new_owner)` updates ownership for a **funded** commitment. -### Marketplace transfer flow (secondary trading) +1. Marketplace buyer proposes `new_owner`. +2. The current commitment owner calls `transfer_ownership` and authorizes it. +3. The contract verifies the commitment is `Funded`. +4. The contract updates ownership and owner indexes. +5. The commitment remains eligible for later lifecycle actions under the new owner. -## Public entrypoints +### Public functions | Function | Description | | --- | --- | -| `initialize(admin, token, fee_recipient, safe_default_penalty_bps, balanced_default_penalty_bps, aggressive_default_penalty_bps)` | One-time setup of admin, escrow token (SAC), fee recipient, and default penalties for each risk profile. | -| `create_commitment(owner, asset, amount, risk, duration_days, penalty_bps)` | Create an unfunded commitment with explicit penalty; returns its `id`. | -| `create_default_commitment(owner, asset, amount, risk, duration_days)` | Create an unfunded commitment using the default penalty for the risk profile; returns its `id`. | -| `fund_escrow(commitment_id)` | Transfer `amount` from owner into the contract (`Created → Funded`). | -| `transfer_ownership(commitment_id, new_owner)` | Transfer marketplace ownership for secondary trading (`Funded` only). Current owner must authorize and the contract updates both `Commitment.owner` and `OwnerIndex`. | -| `release(commitment_id, caller)` | Return principal to owner once matured (`Funded → Released`). | -| `refund(commitment_id)` | Early-exit refund of principal minus `penalty_bps` (`Funded → Refunded`). | +| `initialize(admin, token, fee_recipient, safe_default_penalty_bps, balanced_default_penalty_bps, aggressive_default_penalty_bps)` | One-time setup of admin, escrow token, fee recipient, and default penalties. | +| `create_commitment(owner, asset, amount, risk, duration_days, penalty_bps)` | Create an unfunded commitment with explicit penalty. | +| `create_commitment_with_default_penalty(owner, asset, amount, risk, duration_days)` | Create an unfunded commitment using the risk profile default penalty. | +| `fund_escrow(commitment_id)` | Move a commitment from `Created` to `Funded`. | +| `transfer_ownership(commitment_id, new_owner)` | Transfer marketplace ownership for a funded commitment. | +| `release(commitment_id, caller)` | Return principal plus accrued yield once matured. | +| `refund(commitment_id)` | Early-exit refund of principal minus penalty. | +| `refund_partial(commitment_id, amount)` | Partial early-exit while keeping the remainder escrowed. | | `dispute(commitment_id, caller, reason)` | Freeze a funded commitment pending admin resolution. | - -| `deposit_yield_pool(admin, amount)` | Admin-only deposit of yield tokens into the contract yield pool. | -| `get_yield_pool_balance()` | Read the yield pool balance available for matured release payouts. | -| `release(commitment_id, caller)` | Return principal plus accrued yield to owner once matured (`Funded → Released`). | -| `refund(commitment_id)` | Early-exit refund of principal minus `penalty_bps` (`Funded → Refunded`). | -| `set_grace_period(admin, grace_period_seconds)` | Admin-only configuration of the penalty-free grace window before maturity. | -| `get_grace_period()` | Read the currently configured penalty-free grace period in seconds. | -| `dispute(commitment_id, caller, reason)` | Freeze a funded commitment pending admin resolution. The reason is automatically categorized. | -| `resolve_dispute(commitment_id, release_to_owner)` | Admin-only settlement of a disputed commitment. | -| `get_dispute(commitment_id)` | Read the dispute record for a commitment (category, reason, timestamp, initiator). | -| `get_default_penalty(risk)` | Read the default penalty for a specific risk profile. | -| `record_attestation(commitment_id, attestor, compliance_score)` | Record a 0–100 compliance score. | -| `pause()` | Admin-only emergency pause for write operations. | -| `unpause()` | Admin-only resume for paused contract writes. | -| `is_paused()` | Read the current paused state. | -| `get_commitment(commitment_id)` | Read a single commitment record. | -| `get_user_commitments(owner)` | Read up to `MAX_USER_COMMITMENTS_READ` full `Commitment` records for `owner`. This is the primary backend read path and is intentionally bounded to keep Soroban read responses within practical limits. | -| `get_user_commitment_ids(owner)` | Read all commitment ids for `owner`. The backend uses this as its fallback path when it needs to hydrate records one by one. | -| `get_owner_commitments(owner)` | List commitment ids owned by an address. | -| `get_attestations(commitment_id)` | Retrieve the timeline of `AttestationRecord`s for a commitment. | -| `refund_partial(commitment_id, amount)` | Partial early-exit: withdraw `amount` from the principal, apply the proportional penalty to that portion, keep the remainder escrowed. | -| `set_violation_threshold(threshold)` | Admin-only. Set the compliance score threshold (0–100) below which a funded commitment is auto-violated. 0 disables auto-violation. | +| `resolve_dispute(commitment_id, release_to_owner)` | Admin-only disputed settlement. | +| `record_attestation(commitment_id, attestor, compliance_score)` | Record a 0-100 compliance score. | +| `deposit_yield_pool(admin, amount)` | Admin-only yield funding. | +| `get_yield_pool_balance()` | Read available yield pool balance. | +| `set_grace_period(admin, grace_period_seconds)` | Admin-only grace window configuration. | +| `get_grace_period()` | Read the grace period in seconds. | +| `set_violation_threshold(threshold)` | Admin-only automatic violation threshold. | | `get_violation_threshold()` | Read the current violation threshold. | +| `pause()` | Admin-only emergency pause. | +| `unpause()` | Admin-only resume writes. | +| `is_paused()` | Read pause state. | +| `get_commitment(commitment_id)` | Read a single commitment. | +| `get_owner_commitments(owner)` | List commitment ids for an owner. | +| `get_attestations(commitment_id)` | Read historical attestation records. | +| `get_default_penalty(risk)` | Read the default penalty for a risk profile. | +| `set_admin(new_admin)` | Rotate the admin address. | +| `set_fee_recipient(new_fee_recipient)` | Rotate the fee recipient address. | -## Lifecycle event schema - -The backend indexer depends on the lifecycle event topics staying stable. -`contracts/escrow/src/lib.rs` includes an explicit comment on the shared helper -that should not be changed without coordinating an indexer update. - -### User commitment readers - -The backend first tries `get_user_commitments(owner)` so it can read a user's commitments in one typed call. That reader now returns full `Commitment` records directly from the owner index and intentionally caps the response size with `MAX_USER_COMMITMENTS_READ` to avoid oversized Soroban read payloads. +### Attestation history -For compatibility and fallback hydration, the contract also keeps an id-only reader at `get_user_commitment_ids(owner)`. The older `get_owner_commitments(owner)` name remains available as a legacy alias for the same owner index. +Compliance scores recorded via `record_attestation` are appended to an on-chain historical log. Use `get_attestations` to retrieve the full timeline. -### `early_exit_commitment` entrypoint details +### `early_exit_commitment` entrypoint -All primary lifecycle events use the same topic order: +ABI signature: -```text -(event_name, owner, commitment_id) +```rust +pub fn early_exit_commitment(env: Env, commitment_id: u64, caller: Address) -> Result ``` -- `event_name`: `create_commitment`, `fund_escrow`, `release`, `refund`, `dispute` -- `owner`: the stored commitment owner, even when another authorized actor opens - the dispute -- `commitment_id`: the unique escrow commitment id +Returned `EarlyExitResult` fields: -### Event payloads +- `exitAmount` (`i128`) +- `penaltyAmount` (`i128`) +- `finalStatus` (`EscrowStatus`) -| Event | Payload fields | -| --- | --- | -| `create_commitment` | `asset`, `amount`, `risk`, `maturity`, `penalty_bps` | -| `fund_escrow` | `asset`, `amount`, `risk` | -| `release` | `asset`, `amount`, `accrued_yield`, `payout`, `risk` | -| `refund` | `asset`, `amount`, `refunded_amount`, `penalty`, `risk` | -| `dispute` | `asset`, `amount`, `risk`, `reason_category`, `reason_text`, `disputed_by` | -| `resolve_dispute` | `asset`, `amount`, `payout`, `penalty`, `risk`, `release_to_owner` | +### Grace period behavior -This schema makes it possible to index by owner/id from topics while still -including risk profile and amount in the event data for downstream analytics. +If a funded commitment is refunded within the configured grace period before maturity, the early-exit penalty is waived and the full principal is returned. ## Yield model -Accrued yield is computed at commitment creation using annualized basis-point -rates: +Matured `release` payouts return locked principal plus accrued yield. Current annualized rates: -- `Safe`: `500` bps -- `Balanced`: `700` bps -- `Aggressive`: `1000` bps +- `Safe`: 5.00% +- `Balanced`: 7.00% +- `Aggressive`: 10.00% -The admin must fund the yield pool before matured releases can pay yield. +Yield is funded via `deposit_yield_pool(admin, amount)`. -## Testing +### Risk profiles and penalties -`RiskProfile` is `Safe | Balanced | Aggressive`, matching the frontend -`CommitmentType`. The early-exit penalty is supplied at creation time in basis -points (`penalty_bps`, max `10_000`) and is paid to the configured fee -recipient on `refund` / adverse `resolve_dispute`. +`RiskProfile` is `Safe | Balanced | Aggressive`, matching the frontend `CommitmentType`. ### Commitment limits -To prevent arithmetic overflow (e.g. during maturity timestamp calculations) and ensure input sanity, the following upper-bound limits are enforced in `create_commitment`: -- **Maximum Amount (`MAX_AMOUNT`)**: `1_000_000_000_000` (1T units) -- **Maximum Duration (`MAX_DURATION_DAYS`)**: `365` days (1 year) -- **Maximum Penalty (`MAX_PENALTY_BPS`)**: `10_000` bps (100%) - -Attempts to exceed these limits will return `InvalidAmount` or `InvalidDuration` errors, respectively. +Upper-bound limits enforced in `create_commitment`: +- `MAX_AMOUNT`: `1_000_000_000_000` +- `MAX_DURATION_DAYS`: `365` +- `MAX_PENALTY_BPS`: `10_000` ### Errors -Stable numeric error codes (`#[contracterror]`) are surfaced so the backend -`normalizeContractError` mapper can translate them into HTTP responses. +Stable contract error codes are surfaced for backend mapping, including `AlreadyInitialized`, `NotInitialized`, `NotFound`, `Unauthorized`, `InvalidAmount`, `InvalidState`, `NotMatured`, `InvalidDuration`, `PenaltyTooHigh`, `Paused`, `AssetMismatch`, `InsufficientYieldPool`, `InvalidWasmHash`, and `CommitmentViolated`. + +## Testnet deploy flow + +This repository now includes a scripted testnet deploy path for the escrow contract. -| Code | Variant | Triggered When | -|------|---------|----------------| -| 1 | `AlreadyInitialized` | `initialize()` called more than once | -| 2 | `NotInitialized` | Contract not initialized; admin or token not set | -| 3 | `NotFound` | Commitment id does not exist | -| 4 | `Unauthorized` | Caller not authorized for the operation (e.g., non-owner calling `refund()`) | -| 5 | `InvalidAmount` | Amount is ≤ 0, exceeds `MAX_AMOUNT`, or insufficient balance | -| 6 | `InvalidState` | Commitment in wrong state for the operation (e.g., `refund()` on `Released`) | -| 7 | `NotMatured` | `release()` called before maturity timestamp | -| 8 | `InvalidDuration` | Duration is 0, exceeds `MAX_DURATION_DAYS`, or causes timestamp overflow | -| 9 | `PenaltyTooHigh` | Penalty exceeds `MAX_PENALTY_BPS` (10,000 basis points = 100%) | -| 10 | `Paused` | Contract is paused; write operations blocked | -| 11 | `AssetMismatch` | Commitment asset does not match configured escrow token | -| 12 | `InsufficientYieldPool` | Yield pool balance insufficient to pay matured commitment yield | -| 13 | `InvalidWasmHash` | WASM hash provided for upgrade is zero or invalid | -| 14 | `CommitmentViolated` | Commitment in `Violated` status; release and refund blocked until resolved | +### What the script does -### Error Handling Best Practices +`contracts/scripts/deploy-testnet.sh`: -- **InvalidState**: Check commitment status before calling state-transition functions. Use `get_commitment()` to verify current state. -- **NotMatured**: For `release()`, check the commitment's maturity timestamp against the current ledger time. -- **InsufficientYieldPool**: Ensure the admin has deposited sufficient yield via `deposit_yield_pool()` before matured commitments are released. -- **CommitmentViolated**: If a commitment is violated, the admin must call `resolve_dispute()` to transition it back to a usable state. -- **Paused**: If the contract is paused, wait for the admin to call `unpause()` before retrying write operations. +1. Builds from `contracts/Cargo.toml` using `stellar contract build` +2. Deploys the compiled WASM to Stellar testnet +3. Invokes `initialize(admin, token, fee_recipient)` +4. Upserts the resulting contract id into the frontend env file -## Keeping This Document in Sync +The script updates: -This README documents the escrow contract's state machine, authorization model, and error codes. It must be updated whenever: +- `NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT` +- `COMMITMENT_CORE_CONTRACT` +- `SOROBAN_COMMITMENT_CORE_CONTRACT` + +This keeps the deployed address aligned with `src/lib/backend/config.ts` and `src/lib/backend/services/contracts.ts`. + +### Required environment variables + +| Variable | Purpose | +| --- | --- | +| `STELLAR_ACCOUNT` | CLI source account used for build/deploy/invoke signing. Prefer an identity alias or secure storage-backed signer. | +| `COMMITLABS_ADMIN_ADDRESS` | Admin `G...` address passed to `initialize` | +| `COMMITLABS_TOKEN_CONTRACT_ID` | Token `C...` contract id passed to `initialize` | +| `COMMITLABS_FEE_RECIPIENT_ADDRESS` | Fee recipient `G...` address passed to `initialize` | -- A new `EscrowStatus` variant is added or removed -- A new public entrypoint is added or removed -- Authorization rules change (e.g., a function becomes admin-only) -- New error codes are added to the `#[contracterror]` enum -- State transitions change (e.g., a function now transitions to a different state) +Optional overrides: + +- `STELLAR_RPC_URL` +- `STELLAR_NETWORK_PASSPHRASE` +- `COMMITLABS_ENV_FILE` +- `COMMITLABS_CONTRACT_MANIFEST` +- `COMMITLABS_CONTRACT_PACKAGE` +- `COMMITLABS_WASM_PATH` +- `COMMITLABS_CONTRACT_ALIAS` +- `DRY_RUN` + +### Usage + +Dry run: + +```bash +DRY_RUN=1 \ +STELLAR_ACCOUNT=deployer \ +COMMITLABS_ADMIN_ADDRESS=G... \ +COMMITLABS_TOKEN_CONTRACT_ID=C... \ +COMMITLABS_FEE_RECIPIENT_ADDRESS=G... \ +./contracts/scripts/deploy-testnet.sh +``` + +Real testnet deploy: + +```bash +STELLAR_ACCOUNT=deployer \ +COMMITLABS_ADMIN_ADDRESS=G... \ +COMMITLABS_TOKEN_CONTRACT_ID=C... \ +COMMITLABS_FEE_RECIPIENT_ADDRESS=G... \ +./contracts/scripts/deploy-testnet.sh +``` + +### Security notes + +- Keep secrets out of the script and source control; export them only in your shell session. +- The script never writes secret material into `.env.local`. +- Review the target env file before committing anything. + +### Verification + +Run: + +```bash +npm run test:contracts:deploy +``` -**Cross-reference**: `contracts/escrow/src/lib.rs` (source of truth for all contract logic) -**Test coverage**: `contracts/escrow/src/test.rs` (validates state transitions and authorization) +This dry-run smoke check validates the env-file upsert behavior and the missing-input guardrails without requiring a live deployer account. -## Build & test +## Build and test -Requires the `stellar` CLI (v23) and the `wasm32v1-none` / `wasm32-unknown-unknown` -target. +Requires the `stellar` CLI and the `wasm32v1-none` / `wasm32-unknown-unknown` targets. ```bash +# from contracts/ cargo test +stellar contract build ``` -The lifecycle event tests assert: +## Continuous integration -- stable topic ordering -- stable event names -- risk/amount fields in payloads -- event emission across create, fund, release, refund, and dispute +The contracts CI validates contract tests and WebAssembly build output on pushes and pull requests touching the contract workspace. diff --git a/contracts/scripts/deploy-testnet.sh b/contracts/scripts/deploy-testnet.sh new file mode 100755 index 00000000..8f0923a8 --- /dev/null +++ b/contracts/scripts/deploy-testnet.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONTRACTS_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${CONTRACTS_DIR}/.." && pwd)" + +MANIFEST_PATH="${COMMITLABS_CONTRACT_MANIFEST:-${CONTRACTS_DIR}/Cargo.toml}" +ENV_FILE="${COMMITLABS_ENV_FILE:-${REPO_ROOT}/.env.local}" +CONTRACT_PACKAGE="${COMMITLABS_CONTRACT_PACKAGE:-}" +WASM_OVERRIDE="${COMMITLABS_WASM_PATH:-}" +CONTRACT_ALIAS="${COMMITLABS_CONTRACT_ALIAS:-}" +DRY_RUN="${DRY_RUN:-0}" + +STELLAR_RPC_URL="${STELLAR_RPC_URL:-https://soroban-testnet.stellar.org:443}" +STELLAR_NETWORK_PASSPHRASE="${STELLAR_NETWORK_PASSPHRASE:-Test SDF Network ; September 2015}" +DRY_RUN_CONTRACT_ID="${DRY_RUN_CONTRACT_ID:-CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4}" + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +require_env() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + fail "Required environment variable ${key} is not set." + fi +} + +require_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + fail "Required command '${command_name}' is not available on PATH." + fi +} + +validate_stellar_address() { + local value="$1" + local label="$2" + if [[ ! "${value}" =~ ^G[A-Z2-7]{55}$ ]]; then + fail "${label} must be a Stellar public key starting with G." + fi +} + +validate_contract_id() { + local value="$1" + local label="$2" + if [[ ! "${value}" =~ ^C[A-Z2-7]{55}$ ]]; then + fail "${label} must be a Soroban contract id starting with C." + fi +} + +upsert_env_var() { + local key="$1" + local value="$2" + local dir + dir="$(dirname "${ENV_FILE}")" + mkdir -p "${dir}" + touch "${ENV_FILE}" + + local tmp_file + tmp_file="$(mktemp "${ENV_FILE}.XXXXXX")" + grep -v -E "^${key}=" "${ENV_FILE}" > "${tmp_file}" || true + printf '%s=%s\n' "${key}" "${value}" >> "${tmp_file}" + mv "${tmp_file}" "${ENV_FILE}" +} + +resolve_wasm_path() { + if [[ -n "${WASM_OVERRIDE}" ]]; then + [[ -f "${WASM_OVERRIDE}" ]] || fail "COMMITLABS_WASM_PATH points to a missing file: ${WASM_OVERRIDE}" + printf '%s\n' "${WASM_OVERRIDE}" + return 0 + fi + + local release_dir="${CONTRACTS_DIR}/target/wasm32-unknown-unknown/release" + [[ -d "${release_dir}" ]] || fail "Expected build output directory is missing: ${release_dir}" + + mapfile -t wasm_files < <(find "${release_dir}" -maxdepth 1 -type f -name '*.wasm' | sort) + + if [[ "${#wasm_files[@]}" -eq 0 ]]; then + fail "No wasm artifacts were found in ${release_dir}. Set COMMITLABS_WASM_PATH if your build output lives elsewhere." + fi + + if [[ "${#wasm_files[@]}" -gt 1 ]]; then + fail "Multiple wasm artifacts were found in ${release_dir}. Set COMMITLABS_WASM_PATH to choose the escrow contract artifact explicitly." + fi + + printf '%s\n' "${wasm_files[0]}" +} + +build_contract() { + local build_cmd=(stellar contract build --manifest-path "${MANIFEST_PATH}") + + if [[ -n "${CONTRACT_PACKAGE}" ]]; then + build_cmd+=(--package "${CONTRACT_PACKAGE}") + fi + + if [[ "${DRY_RUN}" == "1" ]]; then + printf '[dry-run] %s\n' "${build_cmd[*]}" >&2 + return 0 + fi + + "${build_cmd[@]}" +} + +deploy_contract() { + local wasm_path="$1" + local deploy_cmd=( + stellar contract deploy + --wasm "${wasm_path}" + --source-account "${STELLAR_ACCOUNT}" + --rpc-url "${STELLAR_RPC_URL}" + --network-passphrase "${STELLAR_NETWORK_PASSPHRASE}" + ) + + if [[ -n "${CONTRACT_ALIAS}" ]]; then + deploy_cmd+=(--alias "${CONTRACT_ALIAS}") + fi + + if [[ "${DRY_RUN}" == "1" ]]; then + printf '[dry-run] %s\n' "${deploy_cmd[*]}" >&2 + printf '%s\n' "${DRY_RUN_CONTRACT_ID}" + return 0 + fi + + "${deploy_cmd[@]}" +} + +initialize_contract() { + local contract_id="$1" + local init_cmd=( + stellar contract invoke + --id "${contract_id}" + --source-account "${STELLAR_ACCOUNT}" + --rpc-url "${STELLAR_RPC_URL}" + --network-passphrase "${STELLAR_NETWORK_PASSPHRASE}" + --send yes + -- + initialize + --admin "${COMMITLABS_ADMIN_ADDRESS}" + --token "${COMMITLABS_TOKEN_CONTRACT_ID}" + --fee_recipient "${COMMITLABS_FEE_RECIPIENT_ADDRESS}" + ) + + if [[ "${DRY_RUN}" == "1" ]]; then + printf '[dry-run] %s\n' "${init_cmd[*]}" >&2 + return 0 + fi + + "${init_cmd[@]}" +} + +main() { + require_env STELLAR_ACCOUNT + require_env COMMITLABS_ADMIN_ADDRESS + require_env COMMITLABS_TOKEN_CONTRACT_ID + require_env COMMITLABS_FEE_RECIPIENT_ADDRESS + + [[ -f "${MANIFEST_PATH}" ]] || fail "Contract manifest not found at ${MANIFEST_PATH}" + + validate_stellar_address "${COMMITLABS_ADMIN_ADDRESS}" "COMMITLABS_ADMIN_ADDRESS" + validate_contract_id "${COMMITLABS_TOKEN_CONTRACT_ID}" "COMMITLABS_TOKEN_CONTRACT_ID" + validate_stellar_address "${COMMITLABS_FEE_RECIPIENT_ADDRESS}" "COMMITLABS_FEE_RECIPIENT_ADDRESS" + + if [[ "${DRY_RUN}" != "1" ]]; then + require_command stellar + fi + + printf 'Building contract workspace from %s\n' "${MANIFEST_PATH}" + build_contract + + local wasm_path + wasm_path="${WASM_OVERRIDE}" + if [[ "${DRY_RUN}" != "1" ]]; then + wasm_path="$(resolve_wasm_path)" + printf 'Deploying wasm artifact %s\n' "${wasm_path}" + else + printf '[dry-run] skipping wasm artifact resolution\n' >&2 + fi + + local contract_id + contract_id="$(deploy_contract "${wasm_path}")" + validate_contract_id "${contract_id}" "Deployed contract id" + + printf 'Initializing contract %s\n' "${contract_id}" + initialize_contract "${contract_id}" + + upsert_env_var NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT "${contract_id}" + upsert_env_var COMMITMENT_CORE_CONTRACT "${contract_id}" + upsert_env_var SOROBAN_COMMITMENT_CORE_CONTRACT "${contract_id}" + + printf '\nDeployment complete.\n' + printf 'NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT=%s\n' "${contract_id}" + printf 'COMMITMENT_CORE_CONTRACT=%s\n' "${contract_id}" + printf 'SOROBAN_COMMITMENT_CORE_CONTRACT=%s\n' "${contract_id}" + printf 'Updated env file: %s\n' "${ENV_FILE}" +} + +main "$@" diff --git a/contracts/scripts/deploy-testnet.smoke.mjs b/contracts/scripts/deploy-testnet.smoke.mjs new file mode 100644 index 00000000..b116031e --- /dev/null +++ b/contracts/scripts/deploy-testnet.smoke.mjs @@ -0,0 +1,91 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const repoRoot = process.cwd() +const bashPath = 'C:/Program Files/Git/bin/bash.exe' +const scriptPath = 'contracts/scripts/deploy-testnet.sh' + +function toMsysPath(windowsPath) { + return windowsPath.replace(/^([A-Za-z]):\\/, (_, drive) => `/${drive.toLowerCase()}/`).replace(/\\/g, '/') +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message) + } +} + +function run() { + const tempDir = mkdtempSync(join(tmpdir(), 'commitlabs-deploy-test-')) + const envFile = join(tempDir, '.env.local') + writeFileSync(envFile, 'NEXT_PUBLIC_USE_MOCKS=true\n', 'utf8') + + try { + const success = spawnSync(bashPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + DRY_RUN: '1', + DRY_RUN_CONTRACT_ID: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + STELLAR_ACCOUNT: 'deployer', + COMMITLABS_ADMIN_ADDRESS: 'GBQ6M5OBU64ATKSRH4OKW2IFQCB5R6Q73F4VMK6KQ37C5G6GQ6FJTYA3', + COMMITLABS_TOKEN_CONTRACT_ID: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + COMMITLABS_FEE_RECIPIENT_ADDRESS: 'GC3C4X5R7N2X7CII7SPRD4U6ZLKZKAJZDW6N4Q4QAV3FJ7Q3N7GJ5P6L', + COMMITLABS_ENV_FILE: toMsysPath(envFile), + }, + }) + + assert(success.status === 0, `dry-run deploy failed:\n${success.stderr}`) + assert( + success.stdout.includes( + 'NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT=CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + ), + 'dry-run output did not include NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT', + ) + + const writtenEnv = readFileSync(envFile, 'utf8') + assert( + writtenEnv.includes( + 'NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT=CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + ), + 'env file did not contain NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT', + ) + assert( + writtenEnv.includes('COMMITMENT_CORE_CONTRACT=CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'), + 'env file did not contain COMMITMENT_CORE_CONTRACT', + ) + assert( + writtenEnv.includes( + 'SOROBAN_COMMITMENT_CORE_CONTRACT=CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + ), + 'env file did not contain SOROBAN_COMMITMENT_CORE_CONTRACT', + ) + + const missingInput = spawnSync(bashPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + DRY_RUN: '1', + STELLAR_ACCOUNT: 'deployer', + COMMITLABS_ADMIN_ADDRESS: 'GBQ6M5OBU64ATKSRH4OKW2IFQCB5R6Q73F4VMK6KQ37C5G6GQ6FJTYA3', + COMMITLABS_FEE_RECIPIENT_ADDRESS: 'GC3C4X5R7N2X7CII7SPRD4U6ZLKZKAJZDW6N4Q4QAV3FJ7Q3N7GJ5P6L', + }, + }) + + assert(missingInput.status !== 0, 'missing-input run should have failed') + assert( + missingInput.stderr.includes('COMMITLABS_TOKEN_CONTRACT_ID'), + 'missing-input run did not explain the missing token contract id', + ) + + console.log('deploy-testnet.sh dry-run smoke test passed') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} + +run() diff --git a/docs/config.md b/docs/config.md index 50e8d603..9236edb6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,20 +2,22 @@ This project supports multiple smart contract versions and addresses via a centralized configuration accessor. -Config sources +## Config sources + - `NEXT_PUBLIC_CONTRACTS_JSON` (preferred): JSON string mapping versions to contract entries. -- Legacy env vars: `NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT`, `NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT`, `NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT` — mapped to `v1` automatically for backward compatibility. +- Legacy env vars: `NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT`, `NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT`, `NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT` mapped to `v1` automatically for backward compatibility. -Structure +## Structure The JSON should be an object where keys are versions and values map contract keys to entries. Each entry may contain: + - `address` (required) - `network` (optional) - `abi` (optional) Example: -``` +```json { "v1": { "commitmentNFT": { "address": "0xabc..." }, @@ -27,62 +29,59 @@ Example: } ``` -How to add a new contract version -1. Add a new key to the JSON (e.g., `v2`) and include the contract entries and addresses. +## How to add a new contract version + +1. Add a new key to the JSON (for example `v2`) and include the contract entries and addresses. 2. Optionally set `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` to the new version. -How to switch versions safely -1. Add and validate the new version in `NEXT_PUBLIC_CONTRACTS_JSON` (or set equivalent env vars for that version). -2. Set `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` to the desired version (e.g., `v2`). -3. Restart the application to pick up new environment variables. +## How to switch versions safely + +1. Add and validate the new version in `NEXT_PUBLIC_CONTRACTS_JSON` or set equivalent env vars for that version. +2. Set `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` to the desired version. +3. Restart the application to pick up the new environment variables. + +## Fallback behavior -Fallback behavior - If `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` is not set, the application defaults to `v1`. -- If `NEXT_PUBLIC_CONTRACTS_JSON` is not set, the application falls back to parsing legacy environment variables (`NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT`, etc.) and treating them as `v1` contracts. -- If a requested contract entry or key is missing in a version, the application will throw an error during contract resolution. +- If `NEXT_PUBLIC_CONTRACTS_JSON` is not set, the application falls back to parsing legacy environment variables and treating them as `v1` contracts. +- If a requested contract entry or key is missing in a version, the application throws during contract resolution. -Invalid version handling -- If `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` points to a version not defined in `NEXT_PUBLIC_CONTRACTS_JSON`, the application will throw an error: "Active contract version 'X' not found". -- Invalid JSON in `NEXT_PUBLIC_CONTRACTS_JSON` will cause a parse error at startup; check JSON syntax and proper escaping. -- Incomplete contract entries (missing `address` field) in a version will throw an error when that contract is accessed. +## Invalid version handling -Example `.env` entries +- If `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` points to a version not defined in `NEXT_PUBLIC_CONTRACTS_JSON`, startup throws `Active contract version 'X' not found`. +- Invalid JSON in `NEXT_PUBLIC_CONTRACTS_JSON` causes a parse error at startup. +- Incomplete contract entries without an `address` field throw when that contract is accessed. -``` -NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v2 +## Example `.env` entries -NEXT_PUBLIC_CONTRACTS_JSON={ - "v1": { - "commitmentCore": { - "address": "0xv1core" - } - }, - "v2": { - "commitmentCore": { - "address": "0xv2core" - } - } -} +```bash +NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v2 +NEXT_PUBLIC_CONTRACTS_JSON={"v1":{"commitmentCore":{"address":"0xv1core"}},"v2":{"commitmentCore":{"address":"0xv2core"}}} ``` -Common misconfiguration errors and fixes -- "Active contract version \"X\" not found": the `NEXT_PUBLIC_CONTRACTS_JSON` does not contain that version. -- "Contract entry for key \"Y\" in version \"X\" is missing or has no address": the selected version lacks a required contract address. -- "Failed to parse NEXT_PUBLIC_CONTRACTS_JSON": the JSON in the environment variable is invalid; check quoting and escaping. +## Common misconfiguration errors and fixes + +- `Active contract version "X" not found`: the configured JSON does not contain that version. +- `Contract entry for key "Y" in version "X" is missing or has no address`: the selected version lacks a required contract address. +- `Failed to parse NEXT_PUBLIC_CONTRACTS_JSON`: the JSON in the environment variable is invalid. + +## Notes -Notes - The runtime accessor lives at `src/lib/backend/config.ts` and provides `getActiveContracts()` and `getContractAddress(key)`. - Legacy single-variable env configuration is still supported and automatically mapped to `v1` to avoid breaking changes. +- For testnet escrow deployments, `contracts/scripts/deploy-testnet.sh` upserts `NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT`, `COMMITMENT_CORE_CONTRACT`, and `SOROBAN_COMMITMENT_CORE_CONTRACT` into the chosen env file, which defaults to `.env.local`. # Backend CORS Configuration Browser-facing API routes use an explicit CORS policy helper. -Environment variables +## Environment variables + - `COMMITLABS_FIRST_PARTY_ORIGINS`: comma-separated allowlist for trusted app origins that can call first-party routes with credentials. - `COMMITLABS_PUBLIC_API_ORIGINS`: comma-separated allowlist for public browser routes, or `*`. Default: `*`. -Notes +## Notes + - `COMMITLABS_FIRST_PARTY_ORIGINS` must never be `*`. - Development always allows `http://localhost:3000` and `http://127.0.0.1:3000`. - If present, `APP_URL`, `NEXT_PUBLIC_APP_URL`, `SITE_URL`, `NEXT_PUBLIC_SITE_URL`, `VERCEL_PROJECT_PRODUCTION_URL`, and `VERCEL_URL` are folded into the first-party allowlist. diff --git a/package.json b/package.json index f9a27d0a..3362e52a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", + "test:contracts:deploy": "node contracts/scripts/deploy-testnet.smoke.mjs", "seed:mock": "tsx scripts/seed-backend-mock.ts" }, "dependencies": { diff --git a/src/app/api/commitments/[id]/early-exit/preview/route.ts b/src/app/api/commitments/[id]/early-exit/preview/route.ts index 25aa7cc4..f03961ed 100644 --- a/src/app/api/commitments/[id]/early-exit/preview/route.ts +++ b/src/app/api/commitments/[id]/early-exit/preview/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { ok, badRequest, notFound } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/apiResponse'; import { BackendError, BackendErrorCode } from '@/lib/backend/errors'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { checkRateLimit } from '@/lib/backend/rateLimit'; diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index b930a381..19ca1619 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -97,11 +97,19 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat txHash: settlementResult.txHash, reference: settlementResult.reference, settledAt: new Date().toISOString(), - }, - undefined, - 200, - correlationId, - ); + }; + + if (idempotencyKey) { + await idempotencyService.complete(idempotencyKey, responseData, 200); + } + + return ok(responseData, undefined, 200, correlationId); + } catch (error) { + if (idempotencyKey) { + await idempotencyService.fail(idempotencyKey); + } + throw error; + } }, { cors: COMMITMENT_SETTLE_CORS_POLICY }); const _405 = methodNotAllowed(['POST']); diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 5978da06..0b083cfd 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -7,7 +7,7 @@ import { getClientIp } from '@/lib/backend/getClientIp'; import { parseJsonWithLimit, JSON_BODY_LIMITS } from "@/lib/backend/jsonBodyLimit"; import { checkRateLimit, getRateLimitWindowSeconds } from "@/lib/backend/rateLimit"; import { getUserCommitmentsFromChain, createCommitmentOnChain } from "@/lib/backend/services/contracts"; -import { validateStellarAddress, validateSupportedAsset } from "@/lib/backend/validation"; +import { validateSupportedAsset } from "@/lib/backend/validation"; import { withApiHandler } from "@/lib/backend/withApiHandler"; const CommitmentsQuerySchema = z.object({ diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index dcb468a2..7911538b 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/backend/withApiHandler"; -import { ok, methodNotAllowed } from "@/lib/backend/apiResponse"; -import { logInfo } from "@/lib/backend/logger"; -import { attachSecurityHeaders } from "@/utils/response"; +import { NextRequest } from 'next/server'; +import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; +import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors'; +import { logInfo } from '@/lib/backend/logger'; +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { attachSecurityHeaders } from '@/utils/response'; export const GET = withApiHandler(async (req: NextRequest) => { logInfo(req, "Healthcheck requested"); diff --git a/src/lib/backend/auditLog.ts b/src/lib/backend/auditLog.ts index 9b20a1ab..23be900c 100644 --- a/src/lib/backend/auditLog.ts +++ b/src/lib/backend/auditLog.ts @@ -15,6 +15,51 @@ export interface AuditLogEntry { details: Record; } +const auditLogStore: AuditLogEntry[] = []; + +export function recordAuditEvent(entry: Omit): AuditLogEntry { + const logEntry: AuditLogEntry = { + id: randomUUID(), + timestamp: new Date().toISOString(), + ...entry, + }; + + auditLogStore.push(logEntry); + + console.log(JSON.stringify({ + event: 'AuditLog', + ...logEntry, + })); + + return logEntry; +} + +export function getAuditLog(commitmentId: string): AuditLogEntry[] { + return auditLogStore.filter(entry => entry.commitmentId === commitmentId); +} + +export function clearAuditLog(): void { + auditLogStore.length = 0; +} +/** + * Audit Event Store + * + * Provides a typed schema for audit events and a pluggable store interface. + * + * Storage strategy: + * - Development / test: in-memory ring buffer (last MAX_BUFFER_SIZE events). + * - Production: swap `activeStore` for a durable backend (Postgres, Redis Streams, + * Datadog Logs, etc.) by implementing the `AuditStore` interface. + * + * Sensitive fields (ownerAddress, verifiedBy, callerAddress, ip) are redacted + * before events leave this module so that callers never need to remember to do it. + * + * Feature flag: COMMITLABS_FEATURE_AUDIT_LOG (env var, default off). + * When disabled, `appendAuditEvent` is a no-op and `getRecentAuditEvents` returns []. + */ + +// ─── Schema ─────────────────────────────────────────────────────────────────── + export type AuditEventCategory = | "commitment" | "attestation" diff --git a/src/lib/backend/cache/index.ts b/src/lib/backend/cache/index.ts index 524d561b..568d277b 100644 --- a/src/lib/backend/cache/index.ts +++ b/src/lib/backend/cache/index.ts @@ -29,7 +29,7 @@ export const CacheKey = { `commitlabs:user-commitments:${ownerAddress}`, marketplaceListings: (queryHash: string) => `commitlabs:marketplace:listings:${queryHash}`, - marketplaceStats: () => `commitlabs:marketplace:stats`, + marketplaceStats: () => "commitlabs:marketplace:stats", commitmentSearch: (queryHash: string) => `commitlabs:commitment-search:${queryHash}`, marketplaceStats: () => "commitlabs:marketplace:stats", diff --git a/src/lib/backend/etag.ts b/src/lib/backend/etag.ts index b245144a..c7f384b7 100644 --- a/src/lib/backend/etag.ts +++ b/src/lib/backend/etag.ts @@ -1,20 +1,24 @@ import { createHash } from 'crypto'; -function stableStringify(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(",")}]`; +function stableSerialize(value: unknown): string { + if (value === undefined) { + return 'null'; } - if (value && typeof value === "object") { - const entries = Object.entries(value as Record).sort(([a], [b]) => - a.localeCompare(b), - ); - return `{${entries - .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`) - .join(",")}}`; + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; } - return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]`; + } + + const entries = Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .filter(([, entryValue]) => entryValue !== undefined) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`); + + return `{${entries.join(',')}}`; } /** @@ -25,7 +29,7 @@ function stableStringify(value: unknown): string { * @returns A quoted ETag string suitable for HTTP headers */ export function generateETag(data: unknown): string { - const serialized = stableStringify(data); + const serialized = stableSerialize(data); const hash = createHash('sha256').update(serialized).digest('hex'); return `"${hash}"`; } diff --git a/src/lib/backend/requireAuth.ts b/src/lib/backend/requireAuth.ts index eb85a2e2..6704d748 100644 --- a/src/lib/backend/requireAuth.ts +++ b/src/lib/backend/requireAuth.ts @@ -1,34 +1,34 @@ -import { NextRequest } from "next/server"; -import { verifySessionToken } from "@/lib/backend/auth"; -import { ForbiddenError, UnauthorizedError } from "@/lib/backend/errors"; +import { NextRequest } from 'next/server'; +import { verifySessionToken } from './auth'; +import { ForbiddenError, UnauthorizedError } from './errors'; const ADMIN_ADDRESSES = new Set( - process.env.ADMIN_ADDRESSES?.split(",").map((value) => value.trim()).filter(Boolean) ?? [], + process.env.ADMIN_ADDRESSES?.split(',').map((address) => address.trim()).filter(Boolean) ?? [], ); +export interface VerifiedAuth { + address: string; + isAdmin: boolean; +} + export interface AuthenticatedRequest extends NextRequest { user: { address: string; - csrfToken?: string; + csrfToken: string; }; } -export interface VerifiedAuth { - address: string; - isAdmin: boolean; -} - export function verifyAuth(req: NextRequest): VerifiedAuth { - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - throw new UnauthorizedError("Bearer token required"); + const authHeader = req.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Bearer token required'); } const token = authHeader.slice(7); const session = verifySessionToken(token); if (!session.valid || !session.address) { - throw new UnauthorizedError("Invalid or expired session"); + throw new UnauthorizedError('Invalid or expired session'); } return { @@ -41,21 +41,23 @@ export function requireAdmin(req: NextRequest): VerifiedAuth { const auth = verifyAuth(req); if (!auth.isAdmin) { - throw new ForbiddenError("Admin access required"); + throw new ForbiddenError('Admin access required'); } return auth; } export function requireAuth(req: NextRequest): AuthenticatedRequest { - const sessionToken = req.cookies.get("session")?.value; + const sessionToken = req.cookies.get('session')?.value; + if (!sessionToken) { - throw new UnauthorizedError("No session token provided"); + throw new UnauthorizedError('No session token provided'); } const verification = verifySessionToken(sessionToken); - if (!verification.valid || !verification.address) { - throw new UnauthorizedError(verification.error || "Invalid session token"); + + if (!verification.valid || !verification.address || !verification.csrfToken) { + throw new UnauthorizedError(verification.error || 'Invalid session token'); } const authenticatedReq = req as AuthenticatedRequest; @@ -63,5 +65,46 @@ export function requireAuth(req: NextRequest): AuthenticatedRequest { address: verification.address, csrfToken: verification.csrfToken, }; + return authenticatedReq; } + +export function validateCsrfToken(req: NextRequest, expectedCsrfToken: string): void { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + return; + } + + const providedCsrfToken = req.headers.get('x-csrf-token'); + + if (!providedCsrfToken) { + throw new UnauthorizedError('CSRF token required for state-changing requests'); + } + + if (providedCsrfToken !== expectedCsrfToken) { + throw new UnauthorizedError('Invalid CSRF token'); + } +} + +export function validateOrigin(req: NextRequest): void { + const origin = req.headers.get('origin'); + const host = req.headers.get('host'); + const referer = req.headers.get('referer'); + + if (!origin && !referer) { + return; + } + + if (origin && host) { + const originHost = new URL(origin).host; + if (originHost !== host) { + throw new UnauthorizedError('Cross-origin request not allowed'); + } + } + + if (referer && host && !origin) { + const refererHost = new URL(referer).host; + if (refererHost !== host) { + throw new UnauthorizedError('Cross-origin request not allowed'); + } + } +} diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 2d6e23d9..0009b54d 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -167,6 +167,46 @@ function getNetworkPassphrase(): string { return getBackendConfig().networkPassphrase; } +function getRpcTimeoutMs(): number { + const raw = Number(process.env.SOROBAN_RPC_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 15_000; +} + +async function withRpcTimeout( + promise: Promise, + methodName: string, +): Promise { + const timeoutMs = getRpcTimeoutMs(); + let timer: ReturnType | undefined; + + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new BackendError({ + code: "GATEWAY_TIMEOUT", + message: + "The blockchain operation timed out. It may still be processed later.", + status: 504, + details: { + methodName, + timeoutMs, + retryable: true, + }, + }), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + function getContractId(kind: "commitmentCore" | "attestationEngine"): string { const config = getBackendConfig(); if (kind === "commitmentCore") { @@ -559,10 +599,7 @@ function parseChainCommitment(value: unknown): ChainCommitment { violationCount: asNumber(raw.violationCount ?? raw.violation_count), createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, - contractVersion: - asString(raw.contractVersion ?? raw.contract_version) || - (getBackendConfig() as { activeVersion?: string }).activeVersion || - undefined, + contractVersion: (getBackendConfig() as { activeVersion?: string }).activeVersion, }; } @@ -748,7 +785,10 @@ async function invokeContractMethod( const contract = new Contract(contractId); const account = mode === "write" - ? await withRpcTimeout(server.getAccount(sourcePublicKey), `${methodName}:getAccount`) + ? await withRpcTimeout( + server.getAccount(sourcePublicKey), + `${methodName}.getAccount`, + ) : new Account(sourcePublicKey, "0"); const operation = contract.call( methodName, @@ -765,7 +805,7 @@ async function invokeContractMethod( const simulation = await withRpcTimeout( server.simulateTransaction(tx), - `${methodName}:simulateTransaction`, + `${methodName}.simulateTransaction`, ); if (SorobanRpc.Api.isSimulationError(simulation)) { throw normalizeContractError(new Error(simulation.error), { @@ -795,7 +835,7 @@ async function invokeContractMethod( const preparedTx = await withRpcTimeout( server.prepareTransaction(tx), - "prepareTransaction", + `${methodName}.prepareTransaction`, ); preparedTx.sign(sourceKeypair); const sendResult = await withRpcTimeout( @@ -833,8 +873,11 @@ async function invokeReadContractMethod( { ...READ_RETRY_CONFIG, isRetryable: (error) => - !(error instanceof BackendError && error.code === "GATEWAY_TIMEOUT") && - isRetryableContractError(error), + !( + error instanceof BackendError && + error.code === "GATEWAY_TIMEOUT" && + asRecord(error.details).timeoutMs !== undefined + ) && isRetryableContractError(error), onRetry: ({ attempt, delayMs, error }) => { logInfo(undefined, "[soroban] retrying read after transient failure", { methodName, @@ -1346,134 +1389,156 @@ export async function openDisputeOnChain( } } -export async function resolveDisputeOnChain( - params: ResolveDisputeOnChainParams, -): Promise { +export async function earlyExitCommitmentOnChain( + params: EarlyExitCommitmentOnChainParams, + loggingContext?: LoggingContext, +): Promise { try { if (!params.commitmentId) { throw new BackendError({ code: "BAD_REQUEST", - message: "Missing commitment id for dispute resolution.", + message: "Missing commitment id for early exit.", status: 400, }); } - const commitment = await getCommitmentFromChain(params.commitmentId); + const commitment = await getCommitmentFromChain( + params.commitmentId, + loggingContext, + ); - if (commitment.status !== "DISPUTED") { + if (commitment.status === "SETTLED") { throw new BackendError({ code: "CONFLICT", - message: "Can only resolve a commitment that is currently in dispute.", + message: + "Commitment has already been settled and cannot be exited early.", + status: 409, + }); + } + + if (commitment.status === "DISPUTED") { + throw new BackendError({ + code: "CONFLICT", + message: "Commitment is already in dispute.", + status: 409, + }); + } + + if (commitment.status === "VIOLATED") { + throw new BackendError({ + code: "CONFLICT", + message: "Commitment has been violated and cannot be exited early.", + status: 409, + }); + } + + if (commitment.status === "EARLY_EXIT") { + throw new BackendError({ + code: "CONFLICT", + message: "Commitment has already been exited early.", status: 409, }); } const invocation = await invokeContractMethod( getContractId("commitmentCore"), - "resolve_dispute", - [params.commitmentId, params.resolution, params.notes ?? ""], + "early_exit_commitment", + [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], "write", ); const result = asRecord(invocation.value); - const disputeId = asString(result.disputeId ?? result.id) || `dsp-${params.commitmentId}`; - const finalStatus = asString(result.finalStatus, "ACTIVE"); + const exitAmount = asString(result.exitAmount, "0"); + const penaltyAmount = asString(result.penaltyAmount, "0"); + const finalStatus = asString(result.finalStatus, "EARLY_EXIT"); await cache.delete(CacheKey.commitment(params.commitmentId)); if (commitment.ownerAddress) { await cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); } - logInfo(undefined, "[cache] invalidated commitment after dispute resolution", { - commitmentId: params.commitmentId, - }); - return { - commitmentId: params.commitmentId, - disputeId, - resolution: params.resolution, + exitAmount, + penaltyAmount, finalStatus, txHash: invocation.txHash, - resolvedAt: new Date().toISOString(), + reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_EARLY_EXIT", }; } catch (error) { throw normalizeContractError(error, { code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to resolve dispute on chain.", + message: "Unable to exit commitment early on chain.", status: 502, details: { - method: "resolve_dispute", + method: "early_exit_commitment", commitmentId: params.commitmentId, }, }); } } -export async function earlyExitCommitmentOnChain( - params: EarlyExitCommitmentOnChainParams, - loggingContext?: LoggingContext, -): Promise { +export async function resolveDisputeOnChain( + params: ResolveDisputeOnChainParams, +): Promise { try { if (!params.commitmentId) { throw new BackendError({ code: "BAD_REQUEST", - message: "Missing commitment id for early exit.", + message: "Missing commitment id for dispute resolution.", status: 400, }); } - const commitment = await getCommitmentFromChain(params.commitmentId, loggingContext); - - if (commitment.status === "SETTLED") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment has already been settled and cannot be exited early.", - status: 409, - }); - } - - if (commitment.status === "EARLY_EXIT") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment has already been exited early.", - status: 409, - }); - } + const commitment = await getCommitmentFromChain(params.commitmentId); - if (commitment.status === "VIOLATED") { + if (commitment.status !== "DISPUTED") { throw new BackendError({ code: "CONFLICT", - message: "Commitment has been violated and cannot be exited early.", + message: "Can only resolve a commitment that is currently in dispute.", status: 409, }); } const invocation = await invokeContractMethod( getContractId("commitmentCore"), - "early_exit_commitment", - [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], + "resolve_dispute", + [ + params.commitmentId, + params.resolution, + params.notes ?? "", + params.resolverAddress, + ], "write", ); const result = asRecord(invocation.value); - const exitAmount = asString(result.exitAmount, "0"); - const penaltyAmount = asString(result.penaltyAmount, "0"); - const finalStatus = asString(result.finalStatus, "EARLY_EXIT"); + const disputeId = asString(result.disputeId ?? result.id); + const finalStatus = asString(result.finalStatus, "ACTIVE"); + + // Status changed — invalidate detail and owner list. + await cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + await cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + logInfo(undefined, "[cache] invalidated commitment after dispute resolution", { + commitmentId: params.commitmentId, + }); return { - exitAmount, - penaltyAmount, + commitmentId: params.commitmentId, + disputeId: disputeId || `dsp-${params.commitmentId}`, + resolution: params.resolution, finalStatus, txHash: invocation.txHash, - reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_EARLY_EXIT", + resolvedAt: new Date().toISOString(), }; } catch (error) { throw normalizeBackendError(error, { code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to exit commitment early on chain.", + message: "Unable to resolve dispute on chain.", status: 502, details: { - method: "early_exit_commitment", + method: "resolve_dispute", commitmentId: params.commitmentId, requestId: loggingContext?.requestId, }, diff --git a/src/lib/backend/validation.ts b/src/lib/backend/validation.ts index 1dc996b3..a4538bd1 100644 --- a/src/lib/backend/validation.ts +++ b/src/lib/backend/validation.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { StrKey } from "@stellar/stellar-sdk"; import { PARAMETER_BOUNDS, SUPPORTED_ASSETS } from "./config"; import { ValidationError } from "./errors"; -import type { PaginationParams } from "./pagination"; // ─── Warning types ──────────────────────────────────────────────────────────── @@ -126,11 +125,17 @@ const ResolveDisputeSchema = z.object({ export { DisputeReasonSchema, ResolveDisputeSchema }; export type DisputeReasonInput = z.infer; export type ResolveDisputeInput = z.infer; +export interface PaginationParams { + page: number; + limit: number; +} + +export type FilterParams = Record; const addressSchema = z .string() .trim() - .refine((address) => StrKey.isValidEd25519PublicKey(address), { + .refine((addr) => StrKey.isValidEd25519PublicKey(addr), { message: "Must be a valid Stellar address (G... format).", }); @@ -138,16 +143,33 @@ const amountSchema = z.coerce .number() .positive("Amount must be a positive number"); -const createCommitmentSchema = z.object({ +const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(10), +}); + +const supportedAssetCodes = SUPPORTED_ASSETS.map((asset) => asset.code); + +export const createCommitmentSchema = z.object({ ownerAddress: addressSchema, - asset: z.string().trim().min(1, "Asset is required"), + asset: z + .string() + .trim() + .transform((asset) => asset.toUpperCase()) + .refine((asset) => supportedAssetCodes.includes(asset), { + message: `Asset is not supported. Supported assets: ${supportedAssetCodes.join(", ")}.`, + }), amount: amountSchema, - durationDays: z.coerce.number().int().positive("Duration must be a positive integer"), - maxLossBps: z.coerce.number().min(0, "Max loss must be a non-negative number"), + durationDays: z.coerce + .number() + .int() + .min(PARAMETER_BOUNDS.durationDays.min) + .max(PARAMETER_BOUNDS.durationDays.max), + maxLossBps: z.coerce.number().min(0), metadata: z.record(z.string(), z.unknown()).optional(), }); -const createMarketplaceListingSchema = z.object({ +export const createMarketplaceListingSchema = z.object({ title: z.string().trim().min(1, "Title is required"), description: z.string().trim().optional(), price: amountSchema, @@ -415,12 +437,7 @@ export function validateSupportedAsset( * @example * z.object({ ownerAddress: stellarAddressSchema }) */ -export const stellarAddressSchema = z - .string() - .trim() - .refine((addr) => StrKey.isValidEd25519PublicKey(addr), { - message: "Must be a valid Stellar address (G... format).", - }); +export { addressSchema as stellarAddressSchema }; // Backwards-compatible alias expected by some modules/tests export const addressSchema = stellarAddressSchema; diff --git a/tests/setup/vitest.d.ts b/tests/setup/vitest.d.ts index 3ac5f966..bfdbe71d 100644 --- a/tests/setup/vitest.d.ts +++ b/tests/setup/vitest.d.ts @@ -1,6 +1,6 @@ -import "vitest"; +import 'vitest'; -declare module "vitest" { +declare module 'vitest' { interface Assertion { toStartWith(expected: string): T; toEndWith(expected: string): T; diff --git a/tests/setup/vitest.setup.ts b/tests/setup/vitest.setup.ts index d4b5c09d..fac54a51 100644 --- a/tests/setup/vitest.setup.ts +++ b/tests/setup/vitest.setup.ts @@ -1,20 +1,22 @@ -import { expect } from "vitest"; +import { expect } from 'vitest'; expect.extend({ toStartWith(received: string, expected: string) { - const pass = received.startsWith(expected); + const pass = typeof received === 'string' && received.startsWith(expected); + return { pass, message: () => - `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to start with ${JSON.stringify(expected)}`, + `expected ${JSON.stringify(received)} ${pass ? 'not ' : ''}to start with ${JSON.stringify(expected)}`, }; }, toEndWith(received: string, expected: string) { - const pass = received.endsWith(expected); + const pass = typeof received === 'string' && received.endsWith(expected); + return { pass, message: () => - `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to end with ${JSON.stringify(expected)}`, + `expected ${JSON.stringify(received)} ${pass ? 'not ' : ''}to end with ${JSON.stringify(expected)}`, }; }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 5f7c8605..0c9c8c1b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, test: { globals: true, - setupFiles: ['tests/setup/vitest.setup.ts'], + setupFiles: ['./tests/setup/vitest.setup.ts'], include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], coverage: { all: true,