diff --git a/docs/stellar-asset-and-soroban-design.md b/docs/stellar-asset-and-soroban-design.md new file mode 100644 index 00000000..7b14203a --- /dev/null +++ b/docs/stellar-asset-and-soroban-design.md @@ -0,0 +1,801 @@ +# ChainMove Stellar Asset & Soroban Contract Design + +> **Status:** Draft · **Target:** Stellar Testnet first, then Mainnet +> **Audience:** Contributors implementing Stellar/Soroban integration +> **Prerequisites:** See `docs/contributor-safety-and-stellar-roadmap.md` milestones 1-3 + +--- + +## Table of Contents + +1. [Asset Model Overview](#1-asset-model-overview) +2. [Asset Code Strategy](#2-asset-code-strategy) +3. [Issuer & Distribution Account Model](#3-issuer--distribution-account-model) +4. [Soroban Contract Architecture](#4-soroban-contract-architecture) +5. [Pool Creation (Contract Interface)](#5-pool-creation-contract-interface) +6. [Investor Ownership Record (Contract Interface)](#6-investor-ownership-record-contract-interface) +7. [Repayment Recording (Contract Interface)](#7-repayment-recording-contract-interface) +8. [Payout Distribution (Contract Interface)](#8-payout-distribution-contract-interface) +9. [Treasury Authorization (Contract Interface)](#9-treasury-authorization-contract-interface) +10. [Governance Execution (Contract Interface)](#10-governance-execution-contract-interface) +11. [Horizon & RPC Indexing Strategy](#11-horizon--rpc-indexing-strategy) +12. [Offchain ↔ Onchain Data Mapping](#12-offchain--onchain-data-mapping) +13. [Security Assumptions & Risks](#13-security-assumptions--risks) +14. [Open Questions](#14-open-questions) +15. [Testnet vs Mainnet Separation](#15-testnet-vs-mainnet-separation) +16. [Glossary](#16-glossary) + +--- + +## 1. Asset Model Overview + +ChainMove uses **two layers** of onchain representation: + +### Layer 1 — Stellar Issued Assets (token level) + +Each **vehicle pool** is represented by a **Stellar issued asset** on the trustline model. Investors who contribute to a pool receive trustline access to that pool's asset, which encodes their fractional ownership. + +> Pool assets are **not** freely tradable on the decentralized exchange (DEX) by default — transferability is gated by the pool's Soroban contract to enforce offchain KYC/AML and platform rules. + +### Layer 2 — Soroban Contract State (program level) + +Soroban contracts store: +- Pool metadata (vehicle type, target amount, status) +- Per-investor ownership records (address, units, basis points) +- Per-investor cumulative payout and repayment receipts +- Repayment schedules and applied amounts per driver +- Treasury authorizations and withdrawal limits +- Governance proposals and vote tallies +- Idempotency ledger for event ingestion + +### Diagram + +``` + Offchain (MongoDB) Onchain (Stellar) + ┌─────────────────┐ ┌────────────────────────────┐ + │ InvestmentPool │◄──────────►│ Pool Soroban Contract │ + │ PoolInvestment │◄──────────►│ ownership ledger │ + │ DriverPayment │◄──────────►│ repayment ledger │ + │ InvestorCredit │◄──────────►│ payout distribution │ + │ PlatformSetting │◄──────────►│ treasury auth │ + │ User (stellar) │◄──────────►│ governance + votes │ + └─────────────────┘ └────────────────────────────┘ + │ + ┌────────▼────────┐ + │ Stellar Assets │ + │ CMOVE | POOL-xxx │ + │ (trustlines) │ + └─────────────────┘ +``` + +--- + +## 2. Asset Code Strategy + +### Option A — Single Platform Asset (`CMOVE`) + +| Property | Value | +|----------|-------| +| Asset code | `CMOVE` | +| Issuer | Single ChainMove platform issuer account | +| Use case | Represents "platform ownership units" — fungible across pools | +| Trustlines | Every investor opens one trustline | +| DEX trading | Possible, but gated contract-side | +| Complexity | Lower — one asset to manage | + +**Tradeoff:** Fungibility means all pools share the same asset code. Distinguishing which pool an investor owns requires querying the Soroban contract state. + +### Option B — Per-Pool Asset Codes (`CMOVE:POOL-{ID}`) + +| Property | Value | +|----------|-------| +| Asset code | `POOL-{4-char-id}` (max 12 chars per Stellar) or human-readable like `CMOVE-01` | +| Issuer | Per-pool issuer accounts **or** a single issuer with per-pool asset codes | +| Use case | Each vehicle pool is a distinct asset | +| Trustlines | One per-pool per investor (many trustlines) | +| DEX trading | Naturally isolated per pool | +| Complexity | Higher — asset lifecycle management, multiple trustlines | + +### Recommendation for Testnet + +Start with **Option A (`CMOVE`)** for Testnet: +- Single asset code `CMOVE` issued by the platform issuer account +- Pool identity is a `(issuer, assetCode, poolId)` tuple stored in the Soroban ownership contract +- Investors open one trustline to `CMOVE` +- Migration to per-pool codes can occur before Mainnet if the product requires isolated pool tradability + +### Stellar Asset Code Constraints + +- Length: 1–12 alphanumeric characters (`[A-Z][A-Z0-9]{0,11}`) +- Case-sensitive (Stellar recommends uppercase) +- If per-pool codes are chosen, ensure the code is deterministic from the pool ID (e.g., `CMOVE` + short hash prefix) + +### `stellar.toml` Requirements + +Before listing assets publicly on Mainnet: +- Publish `/.well-known/stellar.toml` at the platform domain +- Include each pool asset under `[[CURRENCIES]]`: + - `code`, `issuer`, `display_decimals` (e.g., `7` for fractional units) + - `name`, `desc`, `conditions`, `is_asset_anchored`, `anchor_asset_type` + - `regulated` flag and `approval_server` if KYC gating is required + +--- + +## 3. Issuer & Distribution Account Model + +### Account Roles + +| Account | Responsibility | Signer Model | Security | +|---------|---------------|-------------|----------| +| **Issuer** | Creates pool assets, manages trustlines, freezes if needed | Multisig (2-of-3 or 3-of-5) | Cold-key threshold; never in CI | +| **Distribution** | Holds asset float for operational disbursements | Platform server-signer (key in encrypted env) | Hot key; rotated regularly | +| **Treasury** | Receives platform fees, holds reserves | Multisig (2-of-3) | Medium-warm; governance-overridable | +| **Pool-specific (if per-pool)** | Manages one pool's asset issuance | Delegated from issuer | Optional; adds complexity | + +### Lifecycle + +1. **Issuer account** is created on Stellar Testnet (Mainnet later) with a minimum XLM balance +2. **Distribution account** is created and establishes a trustline to the issuer's pool asset +3. The issuer sends the pool's asset tokens to the distribution account at pool funding time +4. The distribution account allocates tokens to investor accounts on pool close +5. On repayment, tokens flow back through the distribution account or directly to investor trustlines + +### Testnet-Only Simplifications + +- Use a single Stellar account for both issuer and distribution (Funded via Friendbot) +- Soroban contract owner set to the same account +- All asset operations are manually verifiable on stellar.expert + +### Mainnet Requirements + +- Separate issuer, distribution, and treasury Stellar accounts +- Multisig on issuer: 2-of-3 with hardware signing for cold storage +- Distribution key stored encrypted in the platform's secret manager (not in `.env`) +- Treasury operations require a governance vote or admin multisig approval + +--- + +## 4. Soroban Contract Architecture + +### Deployment Plan + +| Contract | Name | Responsibility | Testnet | Mainnet | +|----------|------|---------------|---------|---------| +| `pool_manager` | Pool Manager | Create pools, manage state | Single contract | Upgradeable proxy | +| `ownership` | Ownership Ledger | Record per-investor units | Same as pool_manager or separate | Separate | +| `repayment` | Repayment Ledger | Record driver payments, compute distributions | Same contract | Separate | +| `payout` | Payout Engine | Distribute repayments to investors | Same contract | Separate | +| `treasury` | Treasury Actions | Fee collection, reserve management | Same contract | Separate | +| `governance` | Governance | Proposal/vote execution | Same contract | Separate | +| `factory` | Factory | Deploy new pool instances | Optional | Recommended | + +### Testnet Phase — Monolithic Contract + +During Testnet iteration, a **single Soroban contract** (`pool_manager`) handles all responsibilities to reduce deployment complexity and cross-contract calls. The interface is organized into modules (see sections 5–10). This contract is **not upgradeable** on Testnet (redeploy on change). + +### Mainnet Phase — Modular + Upgradeable + +Split into separate contracts communicating via Soroban cross-contract calls. Each contract is deployed behind an **upgradeable proxy** (using Soroban's `Admin`/`Upgrade` pattern or a custom proxy). + +### Contract Data Storage + +Soroban contract data is stored as key-value pairs using `env.storage()`: + +```rust +// Namespace prefixes for contract data keys +const POOL_PREFIX: Symbol = Symbol::new("pool"); +const OWNER_PREFIX: Symbol = Symbol::new("owner"); +const REPAYMENT_PREFIX: Symbol = Symbol::new("repay"); +const PAYOUT_PREFIX: Symbol = Symbol::new("payout"); +const TREASURY_PREFIX: Symbol = Symbol::new("treasury"); +const GOV_PREFIX: Symbol = Symbol::new("gov"); + +// Example pool struct (serialized) +struct Pool { + id: u64, + asset_code: Bytes, + issuer: Address, + target_amount: i128, // in stroops (1 XLM = 10_000_000 stroops) + min_contribution: i128, + status: u32, // 0=Open, 1=Funded, 2=Closed + investor_count: u32, + created_at: u64, +} +``` + +--- + +## 5. Pool Creation (Contract Interface) + +### `create_pool` + +Creates a new vehicle pool and (optionally) issues the pool's onchain asset. + +```rust +fn create_pool( + env: Env, + admin: Address, // authorized admin address + asset_code: String, // e.g., "CMOVE" or "POOL-A1B2" + target_amount: i128, // in stroops + min_contribution: i128, // in stroops + metadata_uri: String, // offchain pointer to pool details (IPFS or platform URL) +) -> u64; // returns pool_id +``` + +**Preconditions:** +- `admin` must be an authorized treasury/admin address +- Pool ID is auto-incremented (Testnet) or deterministic (Mainnet) +- `target_amount` and `min_contribution` are in stroops (1 stroop = 0.0000001 XLM) + +**Events emitted:** +``` +PoolCreated(pool_id: u64, admin: Address, asset_code: String, target_amount: i128) +``` + +### `get_pool` + +Reads pool state. + +```rust +fn get_pool(env: Env, pool_id: u64) -> Pool; +``` + +### `update_pool_status` + +Moves a pool through its lifecycle: `Open → Funded → Closed`. + +```rust +fn update_pool_status( + env: Env, + admin: Address, + pool_id: u64, + new_status: u32, +); +``` + +**Events:** +``` +PoolStatusUpdated(pool_id: u64, old_status: u32, new_status: u32) +``` + +--- + +## 6. Investor Ownership Record (Contract Interface) + +### `contribute` + +Records an investor's contribution to a pool and mints pool asset tokens to the investor's trustline. + +```rust +fn contribute( + env: Env, + investor: Address, + pool_id: u64, + amount_stroops: i128, +) -> OwnershipReceipt; +``` + +**Postconditions:** +- `investor` record updated with contribution amount and basis points +- Pool asset tokens transferred (or escrowed) to `investor` address via trustline +- Pool's `current_raised` and `investor_count` updated + +**Events:** +``` +ContributionRecorded(pool_id: u64, investor: Address, amount_stroops: i128, units: i128) +``` + +### `get_ownership` + +Returns an investor's holdings in a specific pool. + +```rust +fn get_ownership( + env: Env, + pool_id: u64, + investor: Address, +) -> Option; + +struct OwnershipRecord { + pool_id: u64, + investor: Address, + contributed_stroops: i128, + ownership_bps: u32, // basis points (1-10000) + units: i128, // pool asset token units held + cumulative_payout: i128, // total stroops paid out to this investor + cumulative_repayment_share: i128, // total repayment share credited +} +``` + +### `get_investors` + +Paginated list of investors in a pool. + +```rust +fn get_investors( + env: Env, + pool_id: u64, + cursor: u32, + limit: u32, +) -> Vec
; +``` + +--- + +## 7. Repayment Recording (Contract Interface) + +### `record_repayment` + +Record a driver repayment attributed to a vehicle pool. + +```rust +fn record_repayment( + env: Env, + caller: Address, // admin, driver, or automation account + pool_id: u64, + driver_id: Address, // driver's Stellar address (or identifier) + amount_stroops: i128, + repayment_id: String, // unique idempotency key (e.g., MongoDB ObjectId) +) -> RepaymentReceipt; +``` + +**Preconditions:** +- `repayment_id` must be unique (prevents double-recording from webhooks) +- `caller` must be authorized (admin or the contract's automation address) + +**Events:** +``` +RepaymentRecorded(pool_id: u64, driver_id: Address, amount_stroops: i128, repayment_id: String) +``` + +### `get_pending_distribution` + +What repayments are queued but not yet distributed as payouts. + +```rust +fn get_pending_distribution( + env: Env, + pool_id: u64, +) -> i128; // stroops awaiting distribution +``` + +### `get_repayment_history` + +Returns repayment records for a pool or driver. + +```rust +fn get_repayment_history( + env: Env, + pool_id: u64, + driver_id: Option
, + cursor: u32, + limit: u32, +) -> Vec; +``` + +--- + +## 8. Payout Distribution (Contract Interface) + +### `distribute_payouts` + +Distribute accrued repayments to pool investors proportionally. + +```rust +fn distribute_payouts( + env: Env, + caller: Address, // admin or automation account + pool_id: u64, +) -> DistributionResult; +``` + +**Distribution logic:** +1. Read pending repayment total for the pool +2. For each investor, compute `investor_share = pending_total * investor.ownership_bps / 10000` +3. Transfer `investor_share` stroops from distribution account to investor's Stellar account +4. Update `cumulative_payout` for each investor +5. Zero the pending distribution balance + +**Events:** +``` +PayoutDistributed(pool_id: u64, total_amount: i128, investor_count: u32) +PayoutSent(pool_id: u64, investor: Address, amount: i128) +``` + +### `get_payout_history` + +Returns payout records for an investor across a pool. + +```rust +fn get_payout_history( + env: Env, + pool_id: u64, + investor: Address, + cursor: u32, + limit: u32, +) -> Vec; +``` + +### `get_unclaimed_payouts` + +Returns pending payouts not yet withdrawn. + +```rust +fn get_unclaimed_payouts( + env: Env, + investor: Address, +) -> i128; +``` + +--- + +## 9. Treasury Authorization (Contract Interface) + +### Treasury commands manage platform-level funds: fees, reserves, and operational disbursements. + +### `authorize_withdrawal` + +Authorize a platform withdrawal from the treasury. + +```rust +fn authorize_withdrawal( + env: Env, + admin: Address, // must satisfy treasury auth threshold + recipient: Address, + amount_stroops: i128, + reason: String, + withdrawal_id: String, // unique idempotency key +) -> bool; +``` + +**Events:** +``` +WithdrawalAuthorized(withdrawal_id: String, recipient: Address, amount_stroops: i128) +``` + +### `execute_withdrawal` + +Execute a previously authorized withdrawal. + +```rust +fn execute_withdrawal( + env: Env, + caller: Address, + withdrawal_id: String, +) -> bool; +``` + +### `set_treasury_admin` + +Add or remove an admin address from the treasury authority set. + +```rust +fn set_treasury_admin( + env: Env, + caller: Address, // current admin + target: Address, + active: bool, // true = add, false = remove +); +``` + +### `get_treasury_balance` + +View treasury holdings. + +```rust +fn get_treasury_balance(env: Env) -> i128; +``` + +### `withdraw_fees` + +Platform fee withdrawal (revenue extraction). + +```rust +fn withdraw_fees( + env: Env, + admin: Address, + recipient: Address, + amount_stroops: i128, +) -> bool; +``` + +--- + +## 10. Governance Execution (Contract Interface) + +Governance covers proposals that change contract parameters, upgrade contracts, or execute admin-level actions through a voting process. + +### `submit_proposal` + +Create a new governance proposal. + +```rust +fn submit_proposal( + env: Env, + proposer: Address, + title: String, + description: String, + actions: Vec, + voting_deadline: u64, // ledger sequence or timestamp +) -> u64; // proposal_id +``` + +**`GovernanceAction` types:** +- `UpdatePoolParam { pool_id, param, value }` +- `UpgradeContract { new_wasm_hash }` +- `TreasuryWithdrawal { recipient, amount }` +- `AddAdmin { address }` +- `RemoveAdmin { address }` +- `FreezePool { pool_id }` +- `UnfreezePool { pool_id }` + +### `cast_vote` + +Vote on an active proposal. + +```rust +fn cast_vote( + env: Env, + voter: Address, + proposal_id: u64, + vote: bool, // true = yes, false = no + voting_power: i128, // weight (must match contract-calculated) +); +``` + +Voting power is proportional to the voter's total pool ownership (summed across all pools). + +### `execute_proposal` + +Execute an approved proposal after the voting deadline passes. + +```rust +fn execute_proposal( + env: Env, + caller: Address, + proposal_id: u64, +) -> bool; +``` + +**Preconditions:** +- Voting deadline has passed +- `yes_votes > no_votes` (simple majority) +- Quorum reached (e.g., minimum 10% of total voting power) +- Proposal not already executed + +### `get_proposal` + +Read proposal state. + +```rust +fn get_proposal(env: Env, proposal_id: u64) -> Option; +``` + +### Governance Parameters + +Configurable by admin (or governance itself): + +| Parameter | Default (Testnet) | Description | +|-----------|-------------------|-------------| +| `voting_period` | 7 days | Duration proposals remain open | +| `quorum_bps` | 1000 (10%) | Minimum participation as bps of total voting power | +| `approval_threshold_bps` | 5000 (50%) | Minimum yes vote proportion | +| `min_proposal_deposit` | 100 XLM | Anti-spam bond (returned if proposal passes) | + +--- + +## 11. Horizon & RPC Indexing Strategy + +### Indexing Architecture + +``` +Stellar Network + │ + ├── Horizon REST API ───┐ + │ (account payments, │ + │ transactions, ops) │ + │ ├──► ChainMove Indexer Service + │ │ (cron job / background worker) + │ │ 1. Poll Horizon for new ledgers + │ │ 2. Filter relevant accounts/contracts + │ │ 3. Idempotency check (ledger+op key) + │ │ 4. Write to MongoDB + │ │ 5. Fire domain events + │ │ + └── Soroban RPC ─────────┘ + (contract events, + `getLedgerEntries`, + `simulateTransaction`) +``` + +### What to Index + +| Data Source | Data | Destination | Frequency | +|-------------|------|-------------|-----------| +| Horizon /accounts | Trustline changes, XLM balances | MongoDB `stellar_accounts` cache | Every 30s | +| Horizon /payments | Asset transfers, path payments | MongoDB `transactions` (Stellar type) | Every 30s | +| Horizon /operations | ManageBuyOffer, ManageSellOffer | MongoDB (if DEX used) | On event | +| Soroban RPC events | Contract events (pool, repayment, payout) | MongoDB `stellar_events` | Every 60s or on webhook | +| Soroban RPC `getLedgerEntries` | Contract data state | MongoDB cache | On demand | + +### Idempotency Model + +Each onchain event is uniquely identified by `(ledger_sequence, operation_index, event_type)`. The indexer skips records already present in the `stellar_events` collection (MongoDB unique compound index). + +### Repayment Flow (End-to-End) + +``` +1. Driver pays via Paystack → webhook hits ChainMove backend +2. Backend records DriverPayment in MongoDB (status: CONFIRMED) +3. Backend calls record_repayment on the Soroban contract +4. Soroban contract emits RepaymentRecorded event +5. Indexer picks up the event → updates MongoDB investor_credits +6. Backend (or cron) calls distribute_payouts on contract +7. Soroban contract transfers pool assets → investors +8. Indexer records payout DistributionSent events → MongoDB +``` + +### Event / Webhook Alternatives + +- **Polling** (recommended for Testnet): cron job polls Horizon and Soroban RPC every 30–60 seconds +- **Stellar Webhooks** (Mainnet): Consider using a Stellar ecosystem service or building a webhook receiver attached to a Horizon stream +- **MongoDB change streams**: Optionally notify dashboards in real time when indexed data arrives + +--- + +## 12. Offchain ↔ Onchain Data Mapping + +### InvestmentPool (MongoDB) → Pool (Soroban) + +| MongoDB Field | Soroban Field | Notes | +|---------------|--------------|-------| +| `_id` | `pool_id` (u64) | Mapping stored in MongoDB or derived deterministically | +| `assetType` (SHUTTLE/KEKE) | `metadata_uri` or enum | Offchain metadata pointer | +| `assetPriceNgn` | Not stored onchain | Offchain fiat price | +| `targetAmountNgn` | `target_amount` (stroops) | Converted at pool creation rate | +| `minContributionNgn` | `min_contribution` (stroops) | Converted at pool creation rate | +| `status` (OPEN/FUNDED/CLOSED) | `status` (u32) | Mapped (0=Open, 1=Funded, 2=Closed) | +| `currentRaisedNgn` | Computed from contributions | Derived by summing `OwnershipRecord` stroops | +| `investorCount` | `investor_count` | Mirrored on contract | + +### PoolInvestment (MongoDB) → OwnershipRecord (Soroban) + +| MongoDB Field | Soroban Field | Notes | +|---------------|--------------|-------| +| `_id` | — | Used as `repayment_id` for idempotency | +| `poolId` | `pool_id` | — | +| `userId` | `investor` (Address) | Mapped from User.stellarPublicKey | +| `amountNgn` | `contributed_stroops` | Converted using rate at contribution time | +| `ownershipUnits` | `units` | Pool asset tokens minted | +| `ownershipBps` | `ownership_bps` | Direct mapping (1–10000) | +| `txRef` | — | Stored in offchain metadata | + +### DriverPayment (MongoDB) → RepaymentRecord (Soroban) + +| MongoDB Field | Soroban Field | Notes | +|---------------|--------------|-------| +| `_id` | `repayment_id` | Idempotency key | +| `contractId` | `pool_id` | Resolved via hire-purchase contract → pool | +| `driverUserId` | `driver_id` | Mapped from User.stellarPublicKey | +| `amountNgn` | `amount_stroops` | Converted | +| `appliedAmountNgn` | `amount_stroops` | Used for distribution calculation | +| `status` | — | Soroban only records confirmed payments | + +### InvestorCredit (MongoDB) → PayoutRecord (Soroban) + +| MongoDB Field | Soroban Field | Notes | +|---------------|--------------|-------| +| `_id` | — | — | +| `paymentId` | Links to `RepaymentRecord` | — | +| `investorUserId` | `investor` | Mapped from User.stellarPublicKey | +| `amountNgn` | `amount` (stroops) | Converted | +| `ownershipBps` | Matched from `OwnershipRecord` | Consistency check | + +--- + +## 13. Security Assumptions & Risks + +### Assumptions + +| # | Assumption | Rationale | +|---|-----------|-----------| +| A1 | Platform backend controls all Stellar accounts marked as "distribution" | Required to automate payouts; mitigation: use low-balance operational accounts | +| A2 | Investors control their own Stellar private keys | Self-custody model; ChainMove never stores investor private keys | +| A3 | Soroban contract admin is the platform multisig | Single admin during Testnet, governance-controlled on Mainnet | +| A4 | Offchain MongoDB is the system of record for fiat amounts | Onchain records stroops; conversion rates are offchain | +| A5 | Repayment originates from offchain (Paystack) before hitting the chain | Fiat-to-stroop conversion happens server-side | + +### Risks + +| # | Risk | Severity | Mitigation | +|---|------|----------|------------| +| R1 | Distribution private key compromise | Critical | Use short-lived distribution keys; rotate monthly; never store in CI; use env-specific keys | +| R2 | Issuer account frozen or locked | High | Multisig on issuer; separate from distribution; governance-overridable freeze | +| R3 | Soroban contract bug loses funds | Critical | Testnet fuzzing; audit before Mainnet; upgradeable proxy pattern; circuit breaker | +| R4 | Offchain ↔ onchain state divergence | High | Reconciliation cron job; alert on mismatch > 1%; manual override function guarded by multisig | +| R5 | Replay attack on `record_repayment` | Medium | Idempotency key (`repayment_id = MongoDB _id`); unique index on contract | +| R6 | Governance takeover | High | Quorum + approval threshold; time-locked execution on Mainnet; admin veto during transition | +| R7 | Oracle manipulation (conversion rate) | Medium | Use trusted offchain rate feed; log all conversions; rate sanity checks | +| R8 | Indexer falls behind | Medium | Alert if last indexed ledger is > 10 ledgers behind; backfill on restart | +| R9 | Trustline exhaustion (investor) | Low | UI prompts investor to establish trustline before contribution | +| R10 | Stellar network congestion | Low | Monitor `max_fee`; retry with backoff; offchain queue | + +### Key Management Rules + +- **Never store signing keys in source code, `.env.local`, or CI secrets** for Mainnet +- Testnet keys may be in `.env.local` for development convenience +- Distribution account keys stored encrypted in deployment environment variable store (Vercel/Infisical) +- Issuer keys are cold-signed (hardware wallet or offline) for all Mainnet operations +- Governance can rotate admin addresses on-chain, but timelock delays the rotation + +--- + +## 14. Open Questions + +These should be resolved before Mainnet deployment: + +| # | Question | Decision Needed From | Impact | +|---|----------|---------------------|--------| +| Q1 | Single asset code (`CMOVE`) vs per-pool asset codes? | Product + Community | Affects trustline UX, DEX strategy, stellar.toml | +| Q2 | Should pool asset tokens be transferable between investors? | Legal + Product | If yes, requires DEX integration and KYC gating | +| Q3 | What is the fiat-to-stroop conversion rate model? | Finance | Fixed rate per pool (set at creation) vs floating | +| Q4 | Are there platform fees on payouts? If so, what rate? | Business | Affects `distribute_payouts` logic | +| Q5 | Governance: who is eligible to vote? All investors? Minimum holdings? | Community | Affects `VotingPower` computation | +| Q6 | Governance: what is the timelock period for executed proposals? | Security | Affects `execute_proposal` flow | +| Q7 | Should Soroban contracts be a monolith or modular on Mainnet? | Engineering | Affects contract architecture, upgrade cost, cross-call complexity | +| Q8 | What indexing service (if any) should be used for Mainnet? | Engineering | Self-hosted cron vs Stellar ecosystem event service | +| Q9 | How are distressed pools handled (e.g., driver defaults)? | Product + Legal | Affects pool status model, payout rules | +| Q10 | Is there a minimum investment lockup period? | Product | Affects `transfer` restrictions on pool assets | +| Q11 | Should repayment receipts be NFTs or non-transferable records? | Product | Affects `RepaymentReceipt` data model | +| Q12 | What is the disaster recovery plan if the Soroban contract needs to be paused or replaced? | Engineering | Emergency stop, proxy migration, data migration | +| Q13 | How does ChainMove handle Stellar account creation for investors who do not have one? | Product | Embedded wallet vs external wallet requirement | +| Q14 | What happens to the offchain fiat (NGN) when onchain stroop transfers revert? | Engineering | Offchain rollback, reconciliation | + +--- + +## 15. Testnet vs Mainnet Separation + +| Concern | Testnet | Mainnet | +|---------|---------|---------| +| **Network** | `https://horizon-testnet.stellar.org` + `https://soroban-testnet.stellar.org` | `https://horizon.stellar.org` + `https://soroban-mainnet.stellar.org` | +| **Asset code** | `CMOVE` | `CMOVE` (or per-pool codes) | +| **Issuer account** | Single account (Friendbot-funded) | Multisig cold-wallet | +| **Distribution account** | Same as issuer (for speed) | Separate hot-wallet, rotated regularly | +| **Soroban contract** | Single monolith, redeploy on change | Modular, upgradeable proxy | +| **Contract admin** | Platform dev key | Multisig → governance | +| **Signing keys** | `.env.local` (dev only) | Hardware wallet / secret manager | +| **stellar.toml** | Not required | Required (published at `/.well-known/stellar.toml`) | +| **Indexing** | Polling every 60s | Polling + webhooks | +| **Investor KYC** | Optional (sandbox only) | Required before trustline | +| **Audit** | None (internal review only) | Third-party security audit | +| **Governance** | Admin-only (no voting) | Fully onchain voting | +| **Emergency stop** | Backend-level pause | Contract-level circuit breaker | +| **Conversion rate** | Fixed (1 NGN = 1_000_000 stroops test) | Market-based or oracle-fed | + +### Runtime Environment Detection + +The Stellar config layer (`lib/stellar/config.ts`) reads `STELLAR_NETWORK` to switch between Testnet and Mainnet. Contract clients and the indexer use this value to: +- Target the correct Horizon/RPC endpoint +- Select the correct set of contract IDs +- Enable/disable governance voting UI +- Show appropriate network badges in the dashboard +- Block Mainnet contract writes when on Testnet config (and vice versa) + +--- + +## 16. Glossary + +| Term | Definition | +|------|------------| +| **Stroop** | Smallest unit on Stellar (1 stroop = 0.0000001 XLM) | +| **Trustline** | An account's declaration of trust in an asset issuer, required to hold a non-XLM asset | +| **Soroban** | Stellar's smart contract platform (Turing-complete, WASM-based) | +| **Pool asset** | Stellar issued asset representing fractional ownership in a vehicle pool | +| **Ownership basis points (bps)** | An investor's share of a pool, expressed as 1/10000th (e.g., 500 bps = 5%) | +| **Distribution** | Scrubbing repayments across pool investors proportional to ownership | +| **Idempotency key** | Unique identifier preventing duplicate onchain records of the same offchain event | +| **Multisig** | Stellar account requiring N-of-M signatures for operations | +| **Governance action** | An onchain operation submitted via proposal and executed after vote approval | +| **Factory contract** | A Soroban contract that deploys new contract instances | +| **Upgradeable proxy** | A contract pattern separating logic from state to allow upgrades | +| **Circuit breaker** | An emergency pause mechanism for smart contracts | diff --git a/package-lock.json b/package-lock.json index 0ca78c31..c798d306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.0.1", + "@types/jest": "^30.0.0", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", @@ -1943,6 +1944,118 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -7877,6 +7990,13 @@ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -9533,6 +9653,60 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9596,6 +9770,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -9623,6 +9804,23 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -13085,6 +13283,22 @@ "node": ">= 6" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -14953,6 +15167,24 @@ "node": ">=0.8.x" } }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -15469,6 +15701,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -16387,6 +16626,283 @@ "node": ">= 0.4" } }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -18571,6 +19087,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -19432,6 +19964,16 @@ "dev": true, "license": "ISC" }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slow-redact": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", @@ -19528,6 +20070,29 @@ "dev": true, "license": "MIT" }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",