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 eb5ad041..d9ff8f91 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,202 +1,223 @@ -### Admin and Fee Recipient Rotation - -The contract supports secure rotation of the admin and fee recipient addresses after initialization: - -| Function | Description | -| --- | --- | -| `set_admin(new_admin)` | Admin-only. Rotates the contract admin to `new_admin`. Emits an event. | -| `set_fee_recipient(new_fee_recipient)` | Admin-only. Rotates the protocol fee recipient. Emits an event. | - -Both functions require the current admin to authorize the call. Rotation is rejected if the contract is not initialized. Events are emitted for auditability. # CommitLabs Soroban Contracts -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. +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 # Cargo workspace (members = ["escrow"]) -└── escrow/ - ├── Cargo.toml # commitlabs-escrow crate (cdylib + rlib) - └── src/ - ├── lib.rs # EscrowContract implementation - └── test.rs # Unit tests (cfg(test)) +├── 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` contract -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. ### Lifecycle -``` -create_commitment ──► fund_escrow ──► release (matured: principal back to owner) - └──► refund (early exit: principal − penalty) - └──► dispute ──► resolve_dispute (admin adjudication) +```text +create_commitment ──► fund_escrow ──► release + └──► refund + └──► dispute ──► resolve_dispute ``` -### Marketplace transfer flow (secondary trading) +### Marketplace transfer flow `transfer_ownership(commitment_id, new_owner)` updates ownership for a **funded** commitment. -**Flow** 1. Marketplace buyer proposes `new_owner`. -2. The current commitment owner calls `transfer_ownership` and must authorize via `require_auth()`. -3. The contract verifies the commitment is `Funded` (transfers are blocked for non-funded states). -4. The contract updates: - - `Commitment.owner` - - `OwnerIndex` for both `old_owner` and `new_owner` -5. The commitment is now eligible for subsequent `release` / `refund` / dispute handling under the 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 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_commitment_with_default_penalty(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_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. | + +### Attestation history -### Attestation History +Compliance scores recorded via `record_attestation` are appended to an on-chain historical log. Use `get_attestations` to retrieve the full timeline. -Compliance scores recorded via `record_attestation` are appended to an on-chain historical log. This allows clients to query the timeline of scores for a given commitment rather than just reading the latest value. Use `get_attestations` to retrieve a list of `AttestationRecord` structures, each containing the attestor address, the compliance score, and the timestamp. +### `early_exit_commitment` entrypoint -### `early_exit_commitment` entrypoint details +ABI signature: -#### ABI Signature ```rust -pub fn early_exit_commitment( - env: Env, - commitment_id: u64, - caller: Address, -) -> Result +pub fn early_exit_commitment(env: Env, commitment_id: u64, caller: Address) -> Result ``` -#### Response Struct Format (`EarlyExitResult`) -When returned from the contract, the result is serialized as a map/object containing: -* **`exitAmount`** (`i128`): The final amount returned to the commitment owner (principal minus penalty). -* **`penaltyAmount`** (`i128`): The penalty fee amount deducted and paid to the fee recipient. -* **`finalStatus`** (`EscrowStatus`): The final status of the commitment (always `Refunded`). - -#### Field Descriptions -| Field | Type | Description | -| --- | --- | --- | -| `exitAmount` | `i128` | The absolute quantity of tokens transferred back to the commitment owner. | -| `penaltyAmount` | `i128` | The absolute quantity of tokens transferred to the fee recipient as an early-exit penalty. | -| `finalStatus` | `EscrowStatus` | The post-exit state of the escrow commitment, represented as `Refunded`. | - -#### Example Usage -An invocator (e.g., the backend service layer) calls this entrypoint and retrieves the structured receipt: -```typescript -const result = await invokeContractMethod( - contractId, - "early_exit_commitment", - [commitmentId, ownerAddress], - "write" -); -console.log(`Exit Amount: ${result.exitAmount}, Penalty: ${result.penaltyAmount}`); -``` +Returned `EarlyExitResult` fields: -#### Grace period behavior -The contract supports a configurable penalty-free window before commitment maturity. If a funded commitment is refunded while the ledger time is within the configured grace period before maturity, the early-exit penalty is waived and the full principal is returned. +- `exitAmount` (`i128`) +- `penaltyAmount` (`i128`) +- `finalStatus` (`EscrowStatus`) + +### Grace period behavior + +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 -Matured `release` payouts now return the locked principal plus the commitment's accrued yield. Yield is calculated at commitment creation using a simple annualized model based on the selected `RiskProfile` and the commitment duration. +Matured `release` payouts return locked principal plus accrued yield. Current annualized rates: -- `Safe`: 5.00% annualized -- `Balanced`: 7.00% annualized -- `Aggressive`: 10.00% annualized +- `Safe`: 5.00% +- `Balanced`: 7.00% +- `Aggressive`: 10.00% -Yield is funded by the admin through `deposit_yield_pool(admin, amount)`. The contract maintains a dedicated yield pool balance, and a matured release will fail if the pool has insufficient funds to pay the accrued yield. +Yield is funded via `deposit_yield_pool(admin, amount)`. -### Risk profiles & penalties +### 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: -`AlreadyInitialized`, `NotInitialized`, `NotFound`, `Unauthorized`, -`InvalidAmount`, `InvalidState`, `NotMatured`, `InvalidDuration`, -`PenaltyTooHigh`, `Paused`, `AssetMismatch`, `InsufficientYieldPool`, -`InvalidWasmHash`, `CommitmentViolated`. +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. + +### What the script does + +`contracts/scripts/deploy-testnet.sh`: + +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 + +The script updates: + +- `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`. -## Build & test +### Required environment variables -Requires the `stellar` CLI (v23) and the `wasm32v1-none` / `wasm32-unknown-unknown` -target. +| 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` | + +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 +``` + +This dry-run smoke check validates the env-file upsert behavior and the missing-input guardrails without requiring a live deployer account. + +## Build and test + +Requires the `stellar` CLI and the `wasm32v1-none` / `wasm32-unknown-unknown` targets. ```bash # from contracts/ -cargo test # run unit tests in escrow/src/test.rs +cargo test stellar contract build ``` -> Note: this workspace is scaffolded to ground the contract issue backlog. -> Verify a local toolchain before deploying to testnet/mainnet. - -## Continuous Integration +## Continuous integration -A GitHub Actions CI workflow is configured in `.github/workflows/contracts.yml`. -On every push and pull request touching the `contracts/` directory or the workflow file, the CI will: -1. Set up the stable Rust toolchain with the `wasm32-unknown-unknown` target. -2. Cache Cargo registries and dependency builds via `Swatinem/rust-cache` to ensure fast execution. -3. Install the required version of the `stellar-cli` (v23.0.0). -4. Run `cargo test --locked` to execute the escrow contract unit tests. -5. Execute `stellar contract build` to verify smart contract compilation to WebAssembly. +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 48bff99d..a6c101b8 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]/history/route.ts b/src/app/api/commitments/[id]/history/route.ts index 8e475a85..27f71a94 100644 --- a/src/app/api/commitments/[id]/history/route.ts +++ b/src/app/api/commitments/[id]/history/route.ts @@ -66,6 +66,7 @@ const DEFAULT_HISTORY_PAGE_SIZE = 20; export const GET = withApiHandler(async ( req: NextRequest, context: { params: Record }, + correlationId: string, ) => { const commitmentId = context.params.id; @@ -101,9 +102,14 @@ export const GET = withApiHandler(async ( // Paginate const page = paginateArray(events, pagination); - return ok({ - commitmentId, - events: page.data, - meta: page.meta, - }); + return ok( + { + commitmentId, + events: page.data, + meta: page.meta, + }, + undefined, + 200, + correlationId, + ); }); diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index c39965fa..dab79414 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -101,12 +101,20 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat txHash: settlementResult.txHash, reference: settlementResult.reference, settledAt: new Date().toISOString(), - }, { requestId: correlationId }, - 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']); -export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; \ No newline at end of file +export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; diff --git a/src/app/api/commitments/[id]/status/route.ts b/src/app/api/commitments/[id]/status/route.ts index 8ed97237..ba83e180 100644 --- a/src/app/api/commitments/[id]/status/route.ts +++ b/src/app/api/commitments/[id]/status/route.ts @@ -46,6 +46,7 @@ export function getDaysRemaining(expiresAt?: string): number { export const GET = withApiHandler(async ( req: NextRequest, context: { params: Record }, + correlationId: string, ) => { const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; const isAllowed = await checkRateLimit(ip, 'api/commitments/status'); @@ -80,5 +81,5 @@ export const GET = withApiHandler(async ( expiresAt: commitment.expiresAt ?? null, }; - return ok(response); -}); \ No newline at end of file + return ok(response, undefined, 200, correlationId); +}); diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 56a44b81..0cc67762 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({ @@ -101,11 +101,6 @@ export const POST = withApiHandler(async (req: NextRequest, _context, correlatio if (!ownerAddress || typeof ownerAddress !== "string") { return fail("BAD_REQUEST", "Invalid ownerAddress", undefined, 400, correlationId); } - try { - validateStellarAddress(ownerAddress, "ownerAddress"); - } catch { - throw new ValidationError("Invalid ownerAddress: must be a valid Stellar address (G... format)."); - } if (!asset || typeof asset !== "string") { return fail("BAD_REQUEST", "Invalid asset", undefined, 400, correlationId); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 08db6364..6db12f0f 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,6 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; -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'; diff --git a/src/lib/backend/apiResponse.ts b/src/lib/backend/apiResponse.ts index b2507ea8..1e1cee3d 100644 --- a/src/lib/backend/apiResponse.ts +++ b/src/lib/backend/apiResponse.ts @@ -140,9 +140,5 @@ export function fail( response.headers.set("x-request-id", correlationId); } - return NextResponse.json(body, { - status, - headers: Object.keys(headers).length > 0 ? headers : undefined, - }); return response; } diff --git a/src/lib/backend/auditLog.ts b/src/lib/backend/auditLog.ts index 7962d467..665112a0 100644 --- a/src/lib/backend/auditLog.ts +++ b/src/lib/backend/auditLog.ts @@ -39,7 +39,8 @@ export function getAuditLog(commitmentId: string): AuditLogEntry[] { } export function clearAuditLog(): void { - auditLogStore.length = 0; + auditLogStore.length = 0; +} /** * Audit Event Store * diff --git a/src/lib/backend/cache/index.ts b/src/lib/backend/cache/index.ts index df12e4d1..461cc0a4 100644 --- a/src/lib/backend/cache/index.ts +++ b/src/lib/backend/cache/index.ts @@ -29,6 +29,7 @@ export const CacheKey = { `commitlabs:user-commitments:${ownerAddress}`, marketplaceListings: (queryHash: string) => `commitlabs:marketplace:listings:${queryHash}`, + marketplaceStats: () => "commitlabs:marketplace:stats", commitmentSearch: (queryHash: string) => `commitlabs:commitment-search:${queryHash}`, } as const; @@ -38,6 +39,7 @@ export const CacheTTL = { COMMITMENT_DETAIL: 30, USER_COMMITMENTS: 20, MARKETPLACE_LISTINGS: 15, + MARKETPLACE_STATS: 30, /** Short TTL for search results — keeps filters responsive while avoiding stale data. */ COMMITMENT_SEARCH: 15, } as const; diff --git a/src/lib/backend/etag.ts b/src/lib/backend/etag.ts index 3dd84e9d..5eabafe5 100644 --- a/src/lib/backend/etag.ts +++ b/src/lib/backend/etag.ts @@ -1,6 +1,27 @@ // @ts-ignore import { createHash } from 'crypto'; +function stableSerialize(value: unknown): string { + if (value === undefined) { + return 'null'; + } + + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; + } + + 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(',')}}`; +} + /** * Generates a stable ETag from a serialized payload. * Uses SHA-256 hash of the JSON-stringified data. @@ -9,7 +30,7 @@ import { createHash } from 'crypto'; * @returns A quoted ETag string suitable for HTTP headers */ export function generateETag(data: unknown): string { - const serialized = JSON.stringify(data); + const serialized = stableSerialize(data); const hash = createHash('sha256').update(serialized).digest('hex'); return `"${hash}"`; } diff --git a/src/lib/backend/preferences.ts b/src/lib/backend/preferences.ts index 42c1579e..9adb3fcd 100644 --- a/src/lib/backend/preferences.ts +++ b/src/lib/backend/preferences.ts @@ -47,6 +47,13 @@ export const userPreferencesSchema = z.object({ * (opt-in). Extend this when new notification types are introduced. */ notifications: z + .object({ + email: z.boolean().optional(), + push: z.boolean().optional(), + sms: z.boolean().optional(), + }) + .optional(), + notificationCategories: z .object({ expiry: z.boolean().optional(), violation: z.boolean().optional(), @@ -233,4 +240,4 @@ export function filterNotificationsByPreferences( prefs: UserPreferences | null, ): T[] { return notifications.filter((n) => isNotificationCategoryEnabled(n.type, prefs)); -} \ No newline at end of file +} diff --git a/src/lib/backend/requireAuth.ts b/src/lib/backend/requireAuth.ts index b3dc05f3..6704d748 100644 --- a/src/lib/backend/requireAuth.ts +++ b/src/lib/backend/requireAuth.ts @@ -1,133 +1,110 @@ import { NextRequest } from 'next/server'; -import { verifySessionToken } from '@/lib/backend/auth'; -import { UnauthorizedError, ForbiddenError } from '@/lib/backend/errors'; +import { verifySessionToken } from './auth'; +import { ForbiddenError, UnauthorizedError } from './errors'; const ADMIN_ADDRESSES = new Set( - process.env.ADMIN_ADDRESSES?.split(',').map(a => a.trim()).filter(Boolean) ?? [] + process.env.ADMIN_ADDRESSES?.split(',').map((address) => address.trim()).filter(Boolean) ?? [], ); -export interface AuthenticatedRequest { +export interface VerifiedAuth { + address: string; + isAdmin: boolean; +} + +export interface AuthenticatedRequest extends NextRequest { + user: { address: string; - isAdmin: boolean; + csrfToken: string; + }; } -export function verifyAuth(req: NextRequest): AuthenticatedRequest { - const authHeader = req.headers.get('authorization'); - if (!authHeader?.startsWith('Bearer ')) { - throw new UnauthorizedError('Bearer token required'); - } +export function verifyAuth(req: NextRequest): VerifiedAuth { + 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); + const token = authHeader.slice(7); + const session = verifySessionToken(token); - if (!session.valid || !session.address) { - throw new UnauthorizedError('Invalid or expired session'); - } + if (!session.valid || !session.address) { + throw new UnauthorizedError('Invalid or expired session'); + } - return { - address: session.address, - isAdmin: ADMIN_ADDRESSES.has(session.address), - }; + return { + address: session.address, + isAdmin: ADMIN_ADDRESSES.has(session.address), + }; } -export function requireAdmin(req: NextRequest): AuthenticatedRequest { - const auth = verifyAuth(req); - - if (!auth.isAdmin) { - throw new ForbiddenError('Admin access required'); - } +export function requireAdmin(req: NextRequest): VerifiedAuth { + const auth = verifyAuth(req); - return auth; -import { verifySessionToken } from './auth'; -import { UnauthorizedError } from './errors'; + if (!auth.isAdmin) { + throw new ForbiddenError('Admin access required'); + } -export interface AuthenticatedRequest extends NextRequest { - user: { - address: string; - csrfToken: string; - }; + return auth; } -/** - * Middleware to require authentication for protected routes. - * Extracts and validates the session token from HTTP-only cookie. - */ export function requireAuth(req: NextRequest): AuthenticatedRequest { - // Get session token from HTTP-only cookie - const sessionToken = req.cookies.get('session')?.value; - - if (!sessionToken) { - throw new UnauthorizedError('No session token provided'); - } - - // Verify the session token - const verification = verifySessionToken(sessionToken); - - if (!verification.valid) { - throw new UnauthorizedError(verification.error || 'Invalid session token'); - } - - // Add user info to request object - const authenticatedReq = req as AuthenticatedRequest; - authenticatedReq.user = { - address: verification.address!, - csrfToken: verification.csrfToken!, - }; - - return authenticatedReq; + const sessionToken = req.cookies.get('session')?.value; + + if (!sessionToken) { + throw new UnauthorizedError('No session token provided'); + } + + const verification = verifySessionToken(sessionToken); + + if (!verification.valid || !verification.address || !verification.csrfToken) { + throw new UnauthorizedError(verification.error || 'Invalid session token'); + } + + const authenticatedReq = req as AuthenticatedRequest; + authenticatedReq.user = { + address: verification.address, + csrfToken: verification.csrfToken, + }; + + return authenticatedReq; } -/** - * Validate CSRF token for state-changing requests. - * For browser-based requests with cookie authentication. - */ export function validateCsrfToken(req: NextRequest, expectedCsrfToken: string): void { - const method = req.method; - - // Only validate CSRF for state-changing methods - if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { - return; - } - - // Get CSRF token from header (preferred) or fallback to body - 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'); - } + 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'); + } } -/** - * Validate Origin header for additional CSRF protection. - * This is a defense-in-depth measure. - */ export function validateOrigin(req: NextRequest): void { - const origin = req.headers.get('origin'); - const host = req.headers.get('host'); - const referer = req.headers.get('referer'); - - // Skip validation for same-origin requests - if (!origin && !referer) { - return; - } - - // Check if origin matches current host (basic same-origin check) - if (origin && host) { - const originHost = new URL(origin).host; - if (originHost !== host) { - throw new UnauthorizedError('Cross-origin request not allowed'); - } + 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'); } - - // Fallback to referer check if origin is not available - if (referer && host && !origin) { - const refererHost = new URL(referer).host; - if (refererHost !== 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 ffa78a0a..01887d8b 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -55,6 +55,7 @@ export interface ChainCommitment { violationCount: number; createdAt?: string; expiresAt?: string; + contractVersion?: string; } export interface CreateCommitmentOnChainResult { @@ -126,7 +127,6 @@ export interface ResolveDisputeOnChainResult { resolvedAt: string; } -type ContractCallMode = 'read' | 'write'; export interface EarlyExitCommitmentOnChainParams { commitmentId: string; callerAddress?: string; @@ -166,6 +166,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") { @@ -515,6 +555,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: (getBackendConfig() as { activeVersion?: string }).activeVersion, }; } @@ -656,7 +697,10 @@ async function invokeContractMethod( const contract = new Contract(contractId); const account = mode === "write" - ? await server.getAccount(sourcePublicKey) + ? await withRpcTimeout( + server.getAccount(sourcePublicKey), + `${methodName}.getAccount`, + ) : new Account(sourcePublicKey, "0"); const operation = contract.call( methodName, @@ -671,7 +715,10 @@ async function invokeContractMethod( .setTimeout(30) .build(); - const simulation = await server.simulateTransaction(tx); + const simulation = await withRpcTimeout( + server.simulateTransaction(tx), + `${methodName}.simulateTransaction`, + ); if (SorobanRpc.Api.isSimulationError(simulation)) { throw normalizeContractError(new Error(simulation.error), { code: "BLOCKCHAIN_CALL_FAILED", @@ -697,7 +744,10 @@ async function invokeContractMethod( }); } - const preparedTx = await server.prepareTransaction(tx); + const preparedTx = await withRpcTimeout( + server.prepareTransaction(tx), + `${methodName}.prepareTransaction`, + ); preparedTx.sign(sourceKeypair); const sendResult = await server.sendTransaction(preparedTx); const txHash = sendResult.hash; @@ -730,7 +780,12 @@ async function invokeReadContractMethod( invokeContractMethod(contractId, methodName, params, "read", attempt), { ...READ_RETRY_CONFIG, - isRetryable: isRetryableContractError, + isRetryable: (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, @@ -1172,16 +1227,11 @@ export async function fundEscrowOnChain( export async function openDisputeOnChain( params: DisputeOnChainParams, ): 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.", - message: "Missing commitment id for early exit.", status: 400, }); } @@ -1192,13 +1242,6 @@ export async function earlyExitCommitmentOnChain( throw new BackendError({ code: "CONFLICT", message: "Cannot dispute a commitment that is already settled or exited.", - 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, }); } @@ -1207,10 +1250,6 @@ export async function earlyExitCommitmentOnChain( throw new BackendError({ code: "CONFLICT", message: "Commitment is already in dispute.", - if (commitment.status === "EARLY_EXIT") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment has already been exited early.", status: 409, }); } @@ -1255,6 +1294,94 @@ export async function earlyExitCommitmentOnChain( } } +export async function earlyExitCommitmentOnChain( + params: EarlyExitCommitmentOnChainParams, + loggingContext?: LoggingContext, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for early exit.", + 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 === "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"), + "early_exit_commitment", + [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], + "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"); + + await cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + await cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + + return { + exitAmount, + penaltyAmount, + finalStatus, + txHash: invocation.txHash, + reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_EARLY_EXIT", + }; + } catch (error) { + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to exit commitment early on chain.", + status: 502, + details: { + method: "early_exit_commitment", + commitmentId: params.commitmentId, + }, + }); + } +} + export async function resolveDisputeOnChain( params: ResolveDisputeOnChainParams, ): Promise { @@ -1273,10 +1400,6 @@ export async function resolveDisputeOnChain( throw new BackendError({ code: "CONFLICT", message: "Can only resolve a commitment that is currently in dispute.", - if (commitment.status === "VIOLATED") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment has been violated and cannot be exited early.", status: 409, }); } @@ -1284,9 +1407,12 @@ export async function resolveDisputeOnChain( const invocation = await invokeContractMethod( getContractId("commitmentCore"), "resolve_dispute", - [params.commitmentId, params.resolution, params.notes ?? ""], - "early_exit_commitment", - [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], + [ + params.commitmentId, + params.resolution, + params.notes ?? "", + params.resolverAddress, + ], "write", ); @@ -1310,17 +1436,6 @@ export async function resolveDisputeOnChain( finalStatus, txHash: invocation.txHash, resolvedAt: new Date().toISOString(), - const exitAmount = asString(result.exitAmount, "0"); - const penaltyAmount = asString(result.penaltyAmount, "0"); - const finalStatus = asString(result.finalStatus, "EARLY_EXIT"); - - return { - exitAmount, - penaltyAmount, - finalStatus, - txHash: invocation.txHash, - contractVersion: invocation.version, - reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT`, }; } catch (error) { throw normalizeContractError(error, { @@ -1329,12 +1444,8 @@ export async function resolveDisputeOnChain( status: 502, details: { method: "resolve_dispute", - message: "Unable to exit commitment early on chain.", - status: 502, - details: { - method: "early_exit_commitment", commitmentId: params.commitmentId, }, }); } -} \ No newline at end of file +} diff --git a/src/lib/backend/validation.ts b/src/lib/backend/validation.ts index 5525a33f..bf0ef88f 100644 --- a/src/lib/backend/validation.ts +++ b/src/lib/backend/validation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { StrKey } from "@stellar/stellar-sdk"; import { PARAMETER_BOUNDS, SUPPORTED_ASSETS } from "./config"; +import { ValidationError } from "./errors"; // ─── Warning types ──────────────────────────────────────────────────────────── @@ -46,6 +47,58 @@ 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((addr) => StrKey.isValidEd25519PublicKey(addr), { + message: "Must be a valid Stellar address (G... format).", + }); + +const amountSchema = z.coerce + .number() + .positive("Amount must be a positive number"); + +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() + .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() + .min(PARAMETER_BOUNDS.durationDays.min) + .max(PARAMETER_BOUNDS.durationDays.max), + maxLossBps: z.coerce.number().min(0), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export const createMarketplaceListingSchema = z.object({ + title: z.string().trim().min(1, "Title is required"), + description: z.string().trim().optional(), + price: amountSchema, + category: z.string().trim().min(1, "Category is required"), + sellerAddress: addressSchema, +}); + export const createAttestationSchema = z.object({ commitmentId: z.string().min(1, "Commitment ID is required"), attesterAddress: addressSchema, @@ -303,12 +356,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 }; // Validate amount (positive number, can be string or number) export function validateAmount(amount: string | number): number { diff --git a/tests/setup/vitest.d.ts b/tests/setup/vitest.d.ts new file mode 100644 index 00000000..bfdbe71d --- /dev/null +++ b/tests/setup/vitest.d.ts @@ -0,0 +1,13 @@ +import 'vitest'; + +declare module 'vitest' { + interface Assertion { + toStartWith(expected: string): T; + toEndWith(expected: string): T; + } + + interface AsymmetricMatchersContaining { + toStartWith(expected: string): void; + toEndWith(expected: string): void; + } +} diff --git a/tests/setup/vitest.setup.ts b/tests/setup/vitest.setup.ts new file mode 100644 index 00000000..fac54a51 --- /dev/null +++ b/tests/setup/vitest.setup.ts @@ -0,0 +1,22 @@ +import { expect } from 'vitest'; + +expect.extend({ + toStartWith(received: string, expected: string) { + const pass = typeof received === 'string' && received.startsWith(expected); + + return { + pass, + message: () => + `expected ${JSON.stringify(received)} ${pass ? 'not ' : ''}to start with ${JSON.stringify(expected)}`, + }; + }, + toEndWith(received: string, expected: string) { + const pass = typeof received === 'string' && received.endsWith(expected); + + return { + pass, + message: () => + `expected ${JSON.stringify(received)} ${pass ? 'not ' : ''}to end with ${JSON.stringify(expected)}`, + }; + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index c5797a82..0dfa7515 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import path from 'path'; export default defineConfig({ test: { globals: true, + setupFiles: ['./tests/setup/vitest.setup.ts'], include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], coverage: { all: true,