diff --git a/contracts/ARCHITECTURE.md b/contracts/ARCHITECTURE.md new file mode 100644 index 000000000..7abc5c689 --- /dev/null +++ b/contracts/ARCHITECTURE.md @@ -0,0 +1,351 @@ +# Nestera Smart Contract Architecture + +Nestera is a single Soroban smart contract (`NesteraContract`) deployed on Stellar. Rather than a collection of separate deployed contracts, the system is organized as one contract binary composed of tightly coupled internal modules. The only external contract boundary is the **Yield Strategy interface** — pluggable third-party contracts that `NesteraContract` calls via cross-contract invocation. + +--- + +## High-Level Architecture Diagram + +``` + ┌─────────────────────────────────────────────────────────────────────────┐ + │ NesteraContract (lib.rs) │ + │ Single deployed Soroban contract │ + │ │ + │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ + │ │ Savings │ │ Governance │ │ Treasury │ │ Staking │ │ + │ │ Plans │ │ (governance │ │ (treasury/ │ │ (staking/) │ │ + │ │ │ │ .rs) │ │ mod.rs) │ │ │ │ + │ │ • flexi.rs │ │ │ │ │ │ │ │ + │ │ • lock.rs │ │ • Proposals │ │ • Fee pools │ │ • Stake │ │ + │ │ • goal.rs │ │ • Voting │ │ • Allocation │ │ • Unstake │ │ + │ │ • group.rs │ │ • Timelock │ │ • Withdrawal │ │ • Rewards │ │ + │ │ • autosave.rs│ │ • Actions │ │ limits │ │ │ │ + │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ │ + │ │ │ │ │ │ + │ ┌──────▼─────────────────▼──────────────────▼────────────────▼──────┐ │ + │ │ Shared Infrastructure │ │ + │ │ │ │ + │ │ users.rs config.rs rates.rs token.rs security.rs │ │ + │ │ storage_ invariants ttl.rs upgrade.rs views.rs │ │ + │ │ types.rs .rs errors.rs │ │ + │ └──────────────────────────────┬─────────────────────────────────────┘ │ + │ │ │ + │ ┌──────────────────────────────▼─────────────────────────────────────┐ │ + │ │ Rewards Module │ │ + │ │ │ │ + │ │ config.rs storage.rs ranking.rs redemption.rs events.rs │ │ + │ └────────────────────────────────────────────────────────────────────┘ │ + │ │ + │ ┌──────────────────────────────────────────────────────────────────┐ │ + │ │ Strategy Module │ │ + │ │ │ │ + │ │ registry.rs routing.rs interface.rs │ │ + │ └──────────────────────────────┬───────────────────────────────────┘ │ + └─────────────────────────────────┼───────────────────────────────────────┘ + │ cross-contract call (Soroban) + ┌───────────────▼───────────────┐ + │ External Yield Strategy │ + │ (any registered contract │ + │ implementing YieldStrategy) │ + │ │ + │ strategy_deposit() │ + │ strategy_withdraw() │ + │ strategy_harvest() │ + │ strategy_balance() │ + └────────────────────────────────┘ +``` + +--- + +## Module Responsibilities + +### `lib.rs` — Contract Entry Point + +The top-level file that owns the `NesteraContract` struct and the `#[contractimpl]` block. Every public function callable from outside the contract lives here. It acts as a thin routing layer — it validates the pause state, acquires the reentrancy guard, delegates to the appropriate internal module, then releases the guard. + +It also owns two small pieces of logic that are used everywhere: +- `ensure_not_paused()` — checks `DataKey::Paused` before any write +- `calculate_fee(amount, fee_bps)` — floor-division fee math used by all deposit/withdrawal paths + +--- + +### Savings Plan Modules + +These four modules implement the core savings products. They share the same storage schema (`storage_types.rs`) and all call into `rewards::storage` to award points on every deposit. + +#### `flexi.rs` — Flexi Save +No lock, no target. Deposits and withdrawals are available at any time. Balances are stored per-user at `DataKey::FlexiBalance(user)`. Protocol fees are deducted on both deposit and withdrawal and routed to the fee recipient. + +Calls into: +- `rewards::storage::award_deposit_points` on every deposit +- `treasury::record_fee` when a fee is collected +- `invariants::assert_sufficient_balance` before withdrawal +- `ttl::extend_user_ttl` on every read/write + +#### `lock.rs` — Lock Save +Time-locked savings. Funds are locked until `maturity_time = start_time + duration`. Yield is calculated using simple interest at a fixed 5% APY rate set at creation. No early withdrawal — the contract enforces `ledger.timestamp() >= maturity_time`. + +Calls into: +- `rewards::storage::award_deposit_points` and `award_long_lock_bonus` on creation +- `ttl::extend_lock_ttl` on every access + +#### `goal.rs` — Goal Save +Target-based savings. The plan tracks `current_amount` vs `target_amount`. Deposits are accepted until the target is reached. Early exit via `break_goal_save` applies an admin-configurable penalty fee (`DataKey::EarlyBreakFeeBps`). + +Calls into: +- `rewards::storage::award_deposit_points` on every deposit +- `rewards::storage::award_goal_completion_bonus` when target is reached +- `treasury::record_fee` on deposit, withdrawal, and early-break fees + +#### `group.rs` — Group Save +Collaborative savings pool. Multiple users contribute toward a shared `target_amount`. Each member's contribution is tracked individually at `DataKey::GroupMemberContribution(group_id, user)`. Members can leave before completion via `break_group_save`, which refunds their full contribution with no penalty. + +Calls into: +- `rewards::storage::award_deposit_points` on every contribution +- `ttl::extend_group_ttl` on every access + +#### `autosave.rs` — AutoSave +Recurring deposit scheduler. Stores `AutoSave` structs with an `interval_seconds` and `next_execution_time`. Execution is triggered externally (by a relayer bot) via `execute_autosave` or `execute_due_autosaves`. Each execution calls `flexi::flexi_deposit` internally. + +Calls into: +- `flexi::flexi_deposit` on every execution +- `users::user_exists` at creation time + +--- + +### `governance.rs` — Governance + +On-chain proposal and voting system. Manages two proposal types: +- `Proposal` — plain text, no on-chain action +- `ActionProposal` — carries a `ProposalAction` enum that executes a state change when the proposal passes + +The lifecycle is: create → vote → queue (after voting period) → execute (after timelock). Voting power is derived from `UserRewards.lifetime_deposited`, making it proportional to historical savings activity. + +`ProposalAction` variants that can be executed: +- `SetFlexiRate`, `SetGoalRate`, `SetGroupRate`, `SetLockRate` — write directly to `DataKey::*Rate` storage +- `PauseContract`, `UnpauseContract` — write to `DataKey::Paused` + +Calls into: +- `rewards::storage::get_user_rewards` to calculate voting power +- `governance_events.rs` to emit `ProposalCreated`, `VoteCast`, `ProposalQueued`, `ProposalExecuted`, `ProposalCanceled` + +Used by: +- `rates.rs` — `validate_admin_or_governance` guards all rate setters +- `strategy/registry.rs` — same guard on `register_strategy` and `disable_strategy` +- `lib.rs` — `pause` and `unpause` both call `validate_admin_or_governance` +- `lib.rs` — `mint_tokens` checks governance authorization + +--- + +### `treasury/` — Treasury + +Tracks all protocol fee income and yield. The `Treasury` struct holds six counters: + +| Field | Meaning | +|---|---| +| `total_fees_collected` | Cumulative fees ever collected | +| `total_yield_earned` | Cumulative yield ever credited to users | +| `treasury_balance` | Unallocated fees awaiting allocation | +| `reserve_balance` | Allocated reserve sub-pool | +| `rewards_balance` | Allocated rewards sub-pool | +| `operations_balance` | Allocated operations sub-pool | + +The admin calls `allocate_treasury(reserve_%, rewards_%, operations_%)` to split `treasury_balance` into the three pools. Withdrawals from any pool are subject to per-transaction and daily caps enforced by `TreasurySecurityConfig`. + +Called by: +- `flexi.rs`, `goal.rs`, `lock.rs` — `record_fee` on every fee event +- `strategy/routing.rs` — `record_fee` for performance fees, `record_yield` for user yield after harvest + +--- + +### `rewards/` — Rewards Module + +Five-file module managing the points economy. + +| File | Responsibility | +|---|---| +| `config.rs` | `RewardsConfig` storage — points rate, streak bonus, lock bonus, goal bonus, anti-farming limits | +| `storage.rs` | Per-user `UserRewards` ledger — points, streak, lifetime deposits, unclaimed tokens. Core logic for `award_deposit_points`, `award_long_lock_bonus`, `award_goal_completion_bonus`, `claim_rewards`, `convert_points_to_tokens` | +| `ranking.rs` | Leaderboard — tracks all users with points, sorts by `total_points`, exposes `get_top_users`, `get_user_rank`, `get_user_ranking_details` | +| `redemption.rs` | `redeem_points` — deducts points from user balance and emits `PointsRedeemed` | +| `events.rs` | Emits `PointsAwarded`, `BonusAwarded`, `StreakUpdated`, `PointsRedeemed`, `RewardsClaimed` | + +`storage.rs` also calls `soroban_sdk::token::Client` directly when `claim_rewards` transfers the reward token to the user — this is the only place inside the rewards module that touches an external token contract. + +Called by: +- `flexi.rs`, `lock.rs`, `goal.rs`, `group.rs` — `award_deposit_points` on every deposit +- `lock.rs` — `award_long_lock_bonus` on lock creation +- `goal.rs` — `award_goal_completion_bonus` on goal completion +- `users.rs` — `initialize_user_rewards` on user registration +- `governance.rs` — `get_user_rewards` to compute voting power + +--- + +### `staking/` — Staking Module + +Two-file module for NST token staking. + +| File | Responsibility | +|---|---| +| `storage.rs` | Core staking logic — `stake`, `unstake`, `claim_staking_rewards`, `update_rewards` (global reward-per-token accumulator), `calculate_pending_rewards` | +| `storage_types.rs` | `Stake` struct, `StakingConfig`, `StakingDataKey` enum | +| `events.rs` | Emits `StakeCreated`, `StakeWithdrawn`, `StakingRewardsClaimed` | + +Reward accrual uses a standard reward-per-token accumulator pattern: + +``` +new_rewards = total_staked × reward_rate_bps × time_elapsed + ────────────────────────────────────────────── + 10_000 × 365 × 24 × 60 × 60 + +reward_per_token += new_rewards + +pending_rewards = stake.amount × (reward_per_token - stake.reward_per_share) + ───────────────────────────────────────────────────────── + 1_000_000_000 +``` + +This module is self-contained — it does not call into savings plan modules or rewards modules. + +--- + +### `strategy/` — Yield Strategy Module + +Three-file module managing external yield integrations. + +#### `interface.rs` — YieldStrategy Trait +Defines the four functions any external strategy contract must implement: + +``` +strategy_deposit(from, amount) → shares +strategy_withdraw(to, amount) → returned_amount +strategy_harvest(to) → yield_amount +strategy_balance(addr) → total_balance +``` + +The `#[contractclient]` macro generates `YieldStrategyClient` used for cross-contract calls. + +#### `registry.rs` — Strategy Registry +Stores `StrategyInfo { address, enabled, risk_level }` for each registered strategy. Registration and disabling require admin or active governance. Maintains a `StrategyKey::AllStrategies` list. + +#### `routing.rs` — Deposit / Withdraw / Harvest Routing +Implements the Checks-Effects-Interactions (CEI) pattern for all external strategy calls: + +1. Validate strategy is registered and enabled +2. Write `StrategyPosition` and update `StrategyTotalPrincipal` in storage (Effects) +3. Call the external strategy contract (Interaction) +4. Validate the response is positive + +On `harvest_strategy`: +- Computes `profit = strategy_balance - recorded_principal` +- Calls `strategy_harvest` +- Splits yield: `treasury_fee` (performance fee bps) → `treasury::record_fee`, remainder → `treasury::record_yield` and `DataKey::StrategyYield` + +--- + +### Shared Infrastructure + +#### `users.rs` +Manages `User { total_balance, savings_count }` records. `initialize_user` creates the record and calls `rewards::storage::initialize_user_rewards`. All savings modules call `user_exists` as a precondition check. + +#### `config.rs` +Stores and retrieves the global `Config` struct (admin, treasury address, fee rates, paused flag). `initialize_config` is a one-time setup that also calls `treasury::initialize_treasury`. + +#### `rates.rs` +Stores per-plan-type interest rates in instance storage (`DataKey::FlexiRate`, `DataKey::GoalRate`, `DataKey::GroupRate`, `DataKey::LockRate(duration_days)`). All setters call `governance::validate_admin_or_governance`. + +#### `token.rs` +Manages the native NST protocol token metadata (`TokenMetadata { name, symbol, decimals, total_supply, treasury }`). Provides `mint` and `burn` functions that update `total_supply` and emit events. Does not manage individual user balances — that is handled by the rewards module's `unclaimed_tokens` field. + +#### `security.rs` +Implements the reentrancy guard using `DataKey::ReentrancyGuard` in instance storage. `acquire_reentrancy_guard` sets it to `true` and returns `ReentrancyDetected` if already locked. `release_reentrancy_guard` clears it. Called by `lib.rs` around every function that touches external strategy contracts. + +#### `invariants.rs` +Three pure validation helpers called throughout the codebase: +- `assert_non_negative(amount)` — rejects zero/negative amounts +- `assert_valid_fee(fee_bps)` — rejects fees > 10,000 bps +- `assert_sufficient_balance(balance, amount)` — rejects withdrawals exceeding balance + +#### `ttl.rs` +Centralizes all Soroban ledger TTL extension logic. Every module calls the appropriate helper (`extend_user_ttl`, `extend_lock_ttl`, `extend_goal_ttl`, etc.) on every read and write to prevent ledger entries from expiring. Active plans get a 180-day extension; completed/withdrawn plans get a shorter 30-day extension. + +#### `storage_types.rs` +Single source of truth for all `#[contracttype]` structs and the `DataKey` enum. Every module imports from here. Key types: `User`, `SavingsPlan`, `LockSave`, `GoalSave`, `GroupSave`, `AutoSave`, `PlanType`, `DataKey`, `MintPayload`, `StrategyPerformance`. + +#### `views.rs` +Read-only query helpers that iterate a user's `SavingsPlan` list and filter by plan type and status (ongoing, matured, completed). Used by frontends to fetch filtered plan lists without needing to know plan IDs upfront. + +#### `upgrade.rs` +Handles WASM upgrades via `env.deployer().update_current_contract_wasm(new_wasm_hash)`. Enforces monotonically increasing version numbers and provides a `migrate` hook for future storage migrations. + +#### `errors.rs` +Single `SavingsError` enum with 39 error codes covering authorization, user state, plan state, balance, timing, interest, group, and general contract errors. All modules import from here. + +--- + +## Module Interaction Map + +``` + ┌─────────────────┐ + │ lib.rs │ + │ (entry point) │ + └────────┬────────┘ + │ delegates to + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ┌──────▼──────┐ ┌───────▼──────┐ ┌───────▼──────┐ + │ flexi.rs │ │ governance │ │ treasury/ │ + │ lock.rs │ │ .rs │ │ mod.rs │ + │ goal.rs │ └───────┬──────┘ └───────┬──────┘ + │ group.rs │ │ │ + │ autosave.rs│ ┌───────▼──────┐ │ + └──────┬──────┘ │ rates.rs │ │ + │ │ (guarded by │ │ + │ │ governance) │ │ + │ └──────────────┘ │ + │ │ + │ award_*_points │ record_fee + ▼ │ record_yield + ┌─────────────────────────────────────────────────────▼──────────────┐ + │ rewards/ │ + │ config.rs storage.rs ranking.rs redemption.rs events.rs │ + └─────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────────────────────────────────────────────────────┐ + │ strategy/ │ + │ registry.rs ──► routing.rs ──► interface.rs │ + │ │ │ + │ └──► treasury::record_fee / record_yield │ + └──────────────────────────────────────────────────────────────────┘ + │ + cross-contract call (Soroban) + │ + ┌─────────────▼──────────────┐ + │ External Strategy Contract │ + └─────────────────────────────┘ + + ┌──────────────────────────────────────────────────────────────────┐ + │ Shared (used by all) │ + │ users.rs config.rs storage_types.rs invariants.rs │ + │ security.rs ttl.rs errors.rs token.rs views.rs │ + └──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Design Decisions + +### Single contract, not a multi-contract system +All modules compile into one WASM binary. There are no internal cross-contract calls between savings plans, governance, treasury, or rewards. This eliminates inter-contract call overhead and simplifies authorization — there is one admin, one pause flag, one storage namespace. + +### External boundary is the strategy interface only +The only cross-contract calls Nestera makes are to registered yield strategy contracts via `YieldStrategyClient`. This boundary is explicitly protected by the reentrancy guard and the CEI pattern in `routing.rs`. + +### Governance gates admin actions +Rate changes, strategy registration, and pause/unpause all go through `governance::validate_admin_or_governance`. Before governance is activated, only the admin can call these. After `activate_governance`, governance proposals can execute them autonomously via `ProposalAction`. + +### Rewards are passive +No user needs to call a separate "claim points" function. Points are awarded automatically inside every deposit and plan-creation call. The user only needs to act when converting points to tokens or claiming token rewards. + +### TTL is managed proactively +Every read and write extends the relevant ledger entry's TTL. Active plans get 180 days; completed plans get 30 days. This prevents user data from silently expiring on a live network. diff --git a/contracts/CONTRACT_REFERENCE.md b/contracts/CONTRACT_REFERENCE.md new file mode 100644 index 000000000..3facade58 --- /dev/null +++ b/contracts/CONTRACT_REFERENCE.md @@ -0,0 +1,1521 @@ +# Nestera Contract — Public Function Reference + +Contract: `NesteraContract` (Soroban / Stellar) +Package: `Nestera` · Language: Rust · SDK: `soroban-sdk` + +--- + +## Table of Contents + +1. [Initialization](#initialization) +2. [User Management](#user-management) +3. [Flexi Save](#flexi-save) +4. [Lock Save](#lock-save) +5. [Goal Save](#goal-save) +6. [Group Save](#group-save) +7. [AutoSave](#autosave) +8. [Staking](#staking) +9. [Rewards & Ranking](#rewards--ranking) +10. [Governance](#governance) +11. [Treasury](#treasury) +12. [Strategy (Yield)](#strategy-yield) +13. [Token](#token) +14. [Admin & Config](#admin--config) +15. [Emergency](#emergency) + +--- + +## Initialization + +### `initialize` +Sets up the contract for the first time. Stores the admin address and public key, initializes the protocol token, and sets the contract as active. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address (must authorize) | +| `admin_public_key` | `BytesN<32>` | Ed25519 public key for off-chain signature verification | + +Returns: `()` + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- initialize \ + --admin GADMIN... \ + --admin_public_key <32-byte-hex> +``` + +--- + +### `is_initialized` +Returns whether the contract has been initialized. + +Returns: `bool` + +```bash +stellar contract invoke --id --network testnet -- is_initialized +``` + +--- + +### `initialize_config` +Sets protocol fee rates and treasury address. Can only be called once. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `treasury` | `Address` | Treasury address for fee collection | +| `deposit_fee_bps` | `u32` | Deposit fee in basis points (e.g. 100 = 1%) | +| `withdrawal_fee_bps` | `u32` | Withdrawal fee in basis points | +| `performance_fee_bps` | `u32` | Performance/yield fee in basis points | + +Returns: `Result<(), SavingsError>` + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- initialize_config \ + --admin GADMIN... --treasury GTREASURY... \ + --deposit_fee_bps 50 --withdrawal_fee_bps 50 --performance_fee_bps 100 +``` + +--- + +### `verify_signature` +Verifies an off-chain Ed25519 admin signature against a `MintPayload`. Used for authorized minting flows. + +| Parameter | Type | Description | +|---|---|---| +| `payload` | `MintPayload` | Payload containing user, amount, timestamp, expiry | +| `signature` | `BytesN<64>` | Ed25519 signature from admin | + +Returns: `bool` + +--- + +## User Management + +### `init_user` +Creates a new user record with zero balances. Panics if the contract is paused or user already exists. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Address to register | + +Returns: `User` + +```bash +stellar contract invoke --id --source user --network testnet \ + -- init_user --user GUSER... +``` + +--- + +### `initialize_user` +Same as `init_user` but returns a `Result` instead of panicking. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Address to register | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_user` +Retrieves a user's record (total balance and savings count). + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address to look up | + +Returns: `Result` + +```bash +stellar contract invoke --id --network testnet \ + -- get_user --user GUSER... +``` + +--- + +### `user_exists` +Checks whether a user has been registered. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Address to check | + +Returns: `bool` + +--- + +## Flexi Save + +Flexible savings with no lock period. Deposits and withdrawals are available at any time. + +### `deposit_flexi` +Deposits funds into the user's Flexi Save pool. A protocol deposit fee is deducted. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Depositing user (must authorize) | +| `amount` | `i128` | Amount to deposit (must be > 0) | + +Returns: `Result<(), SavingsError>` + +```bash +stellar contract invoke --id --source user --network testnet \ + -- deposit_flexi --user GUSER... --amount 1000000 +``` + +--- + +### `withdraw_flexi` +Withdraws funds from the user's Flexi Save pool. A protocol withdrawal fee is deducted. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Withdrawing user (must authorize) | +| `amount` | `i128` | Amount to withdraw (must be > 0 and ≤ balance) | + +Returns: `Result<(), SavingsError>` + +```bash +stellar contract invoke --id --source user --network testnet \ + -- withdraw_flexi --user GUSER... --amount 500000 +``` + +--- + +### `get_flexi_balance` +Returns the user's current Flexi Save balance. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `i128` (0 if user has no balance) + +--- + +## Lock Save + +Time-locked savings that earn yield. Funds cannot be withdrawn before the maturity time. + +### `create_lock_save` +Creates a new Lock Save plan. Funds are locked for the specified duration. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `amount` | `i128` | Amount to lock (must be > 0) | +| `duration` | `u64` | Lock duration in seconds (must be > 0) | + +Returns: `u64` (lock plan ID) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- create_lock_save --user GUSER... --amount 5000000 --duration 2592000 +``` + +--- + +### `withdraw_lock_save` +Withdraws a matured Lock Save plan with accrued yield. Fails if the plan has not yet matured. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `lock_id` | `u64` | ID of the lock plan | + +Returns: `i128` (final amount including yield) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- withdraw_lock_save --user GUSER... --lock_id 1 +``` + +--- + +### `check_matured_lock` +Returns whether a Lock Save plan has reached its maturity time. + +| Parameter | Type | Description | +|---|---|---| +| `lock_id` | `u64` | Lock plan ID | + +Returns: `bool` + +--- + +### `get_user_lock_saves` +Returns all Lock Save plan IDs belonging to a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Vec` + +--- + +## Goal Save + +Savings plans with a target amount. Earn yield and optionally break early with a fee. + +### `create_goal_save` +Creates a new Goal Save plan with an optional initial deposit. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `goal_name` | `Symbol` | Short label for the goal | +| `target_amount` | `i128` | Target savings amount (must be > 0) | +| `initial_deposit` | `i128` | Initial deposit amount (0 or more) | + +Returns: `u64` (goal plan ID) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- create_goal_save --user GUSER... --goal_name vacation \ + --target_amount 10000000 --initial_deposit 1000000 +``` + +--- + +### `deposit_to_goal_save` +Adds funds to an existing Goal Save plan. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `goal_id` | `u64` | Goal plan ID | +| `amount` | `i128` | Amount to deposit (must be > 0) | + +Returns: `()` (panics on error) + +--- + +### `withdraw_completed_goal_save` +Withdraws from a completed (target reached) Goal Save plan. A withdrawal fee is deducted. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `goal_id` | `u64` | Goal plan ID | + +Returns: `i128` (net amount after fee) + +--- + +### `break_goal_save` +Exits a Goal Save plan before completion. An early-break fee is deducted. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Plan owner (must authorize) | +| `goal_id` | `u64` | Goal plan ID | + +Returns: `i128` (net amount after early-break fee) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- break_goal_save --user GUSER... --goal_id 2 +``` + +--- + +### `get_goal_save_detail` +Returns the full details of a Goal Save plan. + +| Parameter | Type | Description | +|---|---|---| +| `goal_id` | `u64` | Goal plan ID | + +Returns: `GoalSave` + +--- + +### `get_user_goal_saves` +Returns all Goal Save plan IDs for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Vec` + +--- + +## Group Save + +Collaborative savings pools where multiple users contribute toward a shared target. + +### `create_group_save` +Creates a new Group Save plan. The creator is automatically added as the first member. + +| Parameter | Type | Description | +|---|---|---| +| `creator` | `Address` | Group creator | +| `title` | `String` | Group title (non-empty) | +| `description` | `String` | Group description (non-empty) | +| `category` | `String` | Category label (non-empty) | +| `target_amount` | `i128` | Total savings target (must be > 0) | +| `contribution_type` | `u32` | 0 = fixed, 1 = flexible, 2 = percentage | +| `contribution_amount` | `i128` | Per-member contribution amount (must be > 0) | +| `is_public` | `bool` | Whether anyone can join | +| `start_time` | `u64` | Unix timestamp for group start | +| `end_time` | `u64` | Unix timestamp for group end (must be > start_time) | + +Returns: `Result` (group ID) + +```bash +stellar contract invoke --id --source creator --network testnet \ + -- create_group_save \ + --creator GCREATOR... --title "House Fund" --description "Saving for a house" \ + --category housing --target_amount 50000000 --contribution_type 0 \ + --contribution_amount 1000000 --is_public true \ + --start_time 1700000000 --end_time 1710000000 +``` + +--- + +### `join_group_save` +Allows a registered user to join a public Group Save plan. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User joining the group | +| `group_id` | `u64` | Group plan ID | + +Returns: `Result<(), SavingsError>` + +--- + +### `contribute_to_group_save` +Adds a contribution to a Group Save plan. User must already be a member. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Contributing member | +| `group_id` | `u64` | Group plan ID | +| `amount` | `i128` | Contribution amount (must be > 0) | + +Returns: `Result<(), SavingsError>` + +```bash +stellar contract invoke --id --source user --network testnet \ + -- contribute_to_group_save --user GUSER... --group_id 1 --amount 1000000 +``` + +--- + +### `break_group_save` +Removes a user from a Group Save plan and refunds their contributions. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Member leaving the group | +| `group_id` | `u64` | Group plan ID | + +Returns: `Result<(), SavingsError>` + +--- + +## AutoSave + +Automated recurring Flexi deposits executed on a schedule. + +### `create_autosave` +Creates a recurring deposit schedule for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Schedule owner (must authorize) | +| `amount` | `i128` | Amount to deposit per execution (must be > 0) | +| `interval_seconds` | `u64` | Seconds between executions (must be > 0) | +| `start_time` | `u64` | Unix timestamp for first execution | + +Returns: `Result` (schedule ID) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- create_autosave --user GUSER... --amount 100000 \ + --interval_seconds 604800 --start_time 1700000000 +``` + +--- + +### `execute_autosave` +Executes a single AutoSave schedule if it is due. + +| Parameter | Type | Description | +|---|---|---| +| `schedule_id` | `u64` | Schedule ID to execute | + +Returns: `Result<(), SavingsError>` + +--- + +### `execute_due_autosaves` +Batch-executes multiple AutoSave schedules in one call. Skips any that are inactive or not yet due without reverting the batch. + +| Parameter | Type | Description | +|---|---|---| +| `schedule_ids` | `Vec` | List of schedule IDs to attempt | + +Returns: `Vec` (true = executed, false = skipped) + +```bash +stellar contract invoke --id --network testnet \ + -- execute_due_autosaves --schedule_ids '[1, 2, 3]' +``` + +--- + +### `cancel_autosave` +Deactivates an AutoSave schedule. Only the schedule owner can cancel. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | Schedule owner (must authorize) | +| `schedule_id` | `u64` | Schedule ID to cancel | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_autosave` +Returns an AutoSave schedule by ID. + +| Parameter | Type | Description | +|---|---|---| +| `schedule_id` | `u64` | Schedule ID | + +Returns: `Option` + +--- + +### `get_user_autosaves` +Returns all AutoSave schedule IDs for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Vec` + +--- + +## Staking + +Token staking for additional rewards and governance power. + +### `init_staking_config` +Initializes staking parameters. Admin only, called once. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address (must authorize) | +| `config` | `StakingConfig` | Staking configuration struct | + +Returns: `Result<(), SavingsError>` + +--- + +### `update_staking_config` +Updates staking parameters. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address (must authorize) | +| `config` | `StakingConfig` | New staking configuration | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_staking_config` +Returns the current staking configuration. + +Returns: `Result` + +--- + +### `stake` +Stakes tokens for a user. Increases their staking position and starts accruing rewards. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User staking tokens (must authorize) | +| `amount` | `i128` | Amount to stake (must be > 0) | + +Returns: `Result` (new total staked) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- stake --user GUSER... --amount 1000000 +``` + +--- + +### `unstake` +Unstakes tokens for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User unstaking (must authorize) | +| `amount` | `i128` | Amount to unstake | + +Returns: `Result<(i128, i128), SavingsError>` (remaining staked, rewards accrued) + +--- + +### `claim_staking_rewards` +Claims all pending staking rewards for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User claiming rewards (must authorize) | + +Returns: `Result` (amount claimed) + +--- + +### `get_user_stake` +Returns a user's current stake information. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Stake` + +--- + +### `get_pending_staking_rewards` +Returns the pending (unclaimed) staking rewards for a user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Result` + +--- + +### `get_staking_stats` +Returns global staking statistics. + +Returns: `Result<(i128, i128, i128), SavingsError>` — `(total_staked, total_rewards, reward_per_token)` + +--- + +## Rewards & Ranking + +Points-based rewards system for user activity. + +### `init_rewards_config` +Initializes the rewards configuration. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `points_per_token` | `u32` | Points awarded per token deposited | +| `streak_bonus_bps` | `u32` | Streak bonus in basis points | +| `long_lock_bonus_bps` | `u32` | Long lock bonus in basis points | +| `goal_completion_bonus` | `u32` | Bonus points for completing a goal | +| `enabled` | `bool` | Whether rewards are active | +| `min_deposit_for_rewards` | `i128` | Minimum deposit to earn points | +| `action_cooldown_seconds` | `u64` | Cooldown between reward-earning actions | +| `max_daily_points` | `u128` | Daily points cap per user | +| `max_streak_multiplier` | `u32` | Maximum streak multiplier | + +Returns: `Result<(), SavingsError>` + +--- + +### `update_rewards_config` +Updates the rewards configuration. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `config` | `RewardsConfig` | New rewards configuration | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_rewards_config` +Returns the current rewards configuration. + +Returns: `Result` + +--- + +### `get_user_rewards` +Returns a user's accumulated rewards data. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `UserRewards` + +```bash +stellar contract invoke --id --network testnet \ + -- get_user_rewards --user GUSER... +``` + +--- + +### `update_streak` +Updates the deposit streak for a user and applies streak bonuses. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address (must authorize) | + +Returns: `Result` (new streak count) + +--- + +### `get_top_users` +Returns the top N users ranked by reward points. + +| Parameter | Type | Description | +|---|---|---| +| `limit` | `u32` | Number of top users to return | + +Returns: `Vec<(Address, u128)>` + +```bash +stellar contract invoke --id --network testnet \ + -- get_top_users --limit 10 +``` + +--- + +### `get_user_rank` +Returns the rank of a specific user (1-indexed). Returns 0 if unranked. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `u32` + +--- + +### `get_user_ranking_details` +Returns detailed ranking info for a user: rank, total points, and total ranked users. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Option<(u32, u128, u32)>` + +--- + +### `redeem_points` +Redeems accumulated reward points for protocol benefits. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User redeeming points (must authorize) | +| `amount` | `u128` | Points to redeem | + +Returns: `Result<(), SavingsError>` + +--- + +### `convert_points_to_tokens` +Converts accumulated points into claimable token rewards. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User (must authorize) | +| `points_to_convert` | `u128` | Points to convert | +| `tokens_per_point` | `i128` | Conversion rate | + +Returns: `Result` (tokens queued for claim) + +--- + +### `claim_rewards` +Claims all pending token rewards and transfers them to the user. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User claiming (must authorize) | + +Returns: `Result` (amount claimed) + +```bash +stellar contract invoke --id --source user --network testnet \ + -- claim_rewards --user GUSER... +``` + +--- + +### `set_reward_token` +Sets the token contract address used for distributing native token rewards. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `token` | `Address` | Token contract address | + +Returns: `Result<(), SavingsError>` + +--- + +## Governance + +On-chain proposal and voting system with timelock execution. + +### `init_voting_config` +Initializes governance voting parameters. Admin only, one-time. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `quorum` | `u32` | Minimum participation threshold | +| `voting_period` | `u64` | Duration of voting window in seconds | +| `timelock_duration` | `u64` | Delay between queue and execution in seconds | +| `proposal_threshold` | `u128` | Minimum voting power to create action proposals | +| `max_voting_power` | `u128` | Cap on a single voter's weight | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_voting_config` +Returns the current voting configuration. + +Returns: `Result` + +--- + +### `activate_governance` +Enables governance mode. Admin only, one-time. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address (must authorize) | + +Returns: `Result<(), SavingsError>` + +--- + +### `is_governance_active` +Returns whether governance has been activated. + +Returns: `bool` + +--- + +### `create_proposal` +Creates a plain governance proposal (no on-chain action). + +| Parameter | Type | Description | +|---|---|---| +| `creator` | `Address` | Proposal creator (must authorize) | +| `description` | `String` | Human-readable proposal description | + +Returns: `Result` (proposal ID) + +```bash +stellar contract invoke --id --source creator --network testnet \ + -- create_proposal --creator GCREATOR... --description "Increase flexi rate to 6%" +``` + +--- + +### `create_action_proposal` +Creates a proposal that executes an on-chain action if passed. Requires minimum voting power. + +| Parameter | Type | Description | +|---|---|---| +| `creator` | `Address` | Proposal creator (must authorize) | +| `description` | `String` | Proposal description | +| `action` | `ProposalAction` | Action to execute (e.g. `SetFlexiRate(600)`) | + +Returns: `Result` (proposal ID) + +Available `ProposalAction` variants: +- `SetFlexiRate(i128)` +- `SetGoalRate(i128)` +- `SetGroupRate(i128)` +- `SetLockRate(u64, i128)` +- `PauseContract` +- `UnpauseContract` + +--- + +### `get_proposal` +Returns a proposal by ID. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | + +Returns: `Option` + +--- + +### `get_action_proposal` +Returns an action proposal by ID. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | + +Returns: `Option` + +--- + +### `list_proposals` +Returns all proposal IDs. + +Returns: `Vec` + +--- + +### `get_voting_power` +Returns a user's voting power based on their lifetime deposited funds. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `u128` + +--- + +### `vote` +Casts a weighted vote on a proposal. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal to vote on | +| `vote_type` | `u32` | 1 = for, 2 = against, 3 = abstain | +| `voter` | `Address` | Voter address (must authorize) | + +Returns: `Result<(), SavingsError>` + +```bash +stellar contract invoke --id --source voter --network testnet \ + -- vote --proposal_id 1 --vote_type 1 --voter GVOTER... +``` + +--- + +### `has_voted` +Returns whether a user has already voted on a proposal. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | +| `voter` | `Address` | Voter address | + +Returns: `bool` + +--- + +### `queue_proposal` +Queues a passed proposal for execution after the timelock period. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | + +Returns: `Result<(), SavingsError>` + +--- + +### `execute_proposal` +Executes a queued proposal after the timelock has elapsed. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_active_proposals` +Returns all proposal IDs that are currently within their voting window. + +Returns: `Vec` + +--- + +### `get_proposal_votes` +Returns the vote tallies for a proposal. + +| Parameter | Type | Description | +|---|---|---| +| `proposal_id` | `u64` | Proposal ID | + +Returns: `(u128, u128, u128)` — `(for_votes, against_votes, abstain_votes)` + +--- + +### `get_user_voted_proposals` +Returns all proposal IDs a user has voted on. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `Address` | User address | + +Returns: `Vec` + +--- + +## Treasury + +Protocol fee collection, allocation, and withdrawal management. + +### `get_treasury` +Returns the full treasury state struct. + +Returns: `Treasury` + +--- + +### `get_treasury_balance` +Returns the unallocated treasury balance (fees pending allocation). + +Returns: `i128` + +--- + +### `get_total_fees` +Returns the cumulative total of all protocol fees collected. + +Returns: `i128` + +--- + +### `get_total_yield` +Returns the cumulative total of all yield credited to users. + +Returns: `i128` + +--- + +### `get_reserve_balance` +Returns the current reserve sub-balance. + +Returns: `i128` + +--- + +### `get_treasury_limits` +Returns the current treasury withdrawal safety limits. + +Returns: `TreasurySecurityConfig` + +--- + +### `set_treasury_limits` +Updates treasury withdrawal limits. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `max_withdrawal_per_tx` | `i128` | Max amount per single withdrawal | +| `daily_withdrawal_cap` | `i128` | Max total withdrawals per 24-hour window | + +Returns: `Result` + +--- + +### `withdraw_treasury` +Withdraws from a treasury sub-pool. Subject to per-tx and daily caps. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `pool` | `TreasuryPool` | Pool to withdraw from: `Reserve`, `Rewards`, or `Operations` | +| `amount` | `i128` | Amount to withdraw | + +Returns: `Result` + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- withdraw_treasury --admin GADMIN... --pool Reserve --amount 500000 +``` + +--- + +### `allocate_treasury` +Allocates the unallocated treasury balance into reserve, rewards, and operations pools. Percentages must sum to 10,000 bps (100%). Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `reserve_percent` | `u32` | Reserve allocation in bps | +| `rewards_percent` | `u32` | Rewards allocation in bps | +| `operations_percent` | `u32` | Operations allocation in bps | + +Returns: `Result` + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- allocate_treasury --admin GADMIN... \ + --reserve_percent 4000 --rewards_percent 3000 --operations_percent 3000 +``` + +--- + +## Strategy (Yield) + +External yield strategy routing for Lock and Group Save plans. + +### `register_strategy` +Registers a new yield strategy contract. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `strategy_address` | `Address` | Strategy contract address | +| `risk_level` | `u32` | Risk classification (e.g. 1 = low, 3 = high) | + +Returns: `Result<(), SavingsError>` + +--- + +### `disable_strategy` +Disables a registered strategy. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `strategy_address` | `Address` | Strategy to disable | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_strategy` +Returns info about a registered strategy. + +| Parameter | Type | Description | +|---|---|---| +| `strategy_address` | `Address` | Strategy contract address | + +Returns: `Result` + +--- + +### `get_all_strategies` +Returns all registered strategy addresses. + +Returns: `Vec
` + +--- + +### `route_lock_to_strategy` +Routes a Lock Save deposit to a yield strategy. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Caller (must authorize) | +| `lock_id` | `u64` | Lock plan ID | +| `strategy_address` | `Address` | Target strategy | +| `amount` | `i128` | Amount to route | + +Returns: `Result` (shares received) + +--- + +### `route_group_to_strategy` +Routes a Group Save pooled deposit to a yield strategy. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Caller (must authorize) | +| `group_id` | `u64` | Group plan ID | +| `strategy_address` | `Address` | Target strategy | +| `amount` | `i128` | Amount to route | + +Returns: `Result` + +--- + +### `get_lock_strategy_position` +Returns the strategy position for a lock plan. + +| Parameter | Type | Description | +|---|---|---| +| `lock_id` | `u64` | Lock plan ID | + +Returns: `Option` + +--- + +### `get_group_strategy_position` +Returns the strategy position for a group plan. + +| Parameter | Type | Description | +|---|---|---| +| `group_id` | `u64` | Group plan ID | + +Returns: `Option` + +--- + +### `withdraw_lock_strategy` +Withdraws funds from a lock plan's strategy position. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Caller (must authorize) | +| `lock_id` | `u64` | Lock plan ID | +| `to` | `Address` | Recipient address | + +Returns: `Result` + +--- + +### `withdraw_group_strategy` +Withdraws funds from a group plan's strategy position. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Caller (must authorize) | +| `group_id` | `u64` | Group plan ID | +| `to` | `Address` | Recipient address | + +Returns: `Result` + +--- + +### `harvest_strategy` +Harvests yield from a strategy. Allocates the protocol performance fee to treasury and credits the remainder to users. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Caller (must authorize) | +| `strategy_address` | `Address` | Strategy to harvest from | + +Returns: `Result` (total yield harvested) + +--- + +## Token + +Native protocol token (NST) management. + +### `get_token_metadata` +Returns the protocol token metadata: name, symbol, decimals, total supply, and treasury address. + +Returns: `Result` + +```bash +stellar contract invoke --id --network testnet -- get_token_metadata +``` + +--- + +### `mint_tokens` +Mints new NST tokens to an address. Only callable by admin or governance. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address (must authorize) | +| `to` | `Address` | Recipient address | +| `amount` | `i128` | Amount to mint (must be > 0) | + +Returns: `Result` (new total supply) + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- mint_tokens --caller GADMIN... --to GUSER... --amount 1000000 +``` + +--- + +### `burn` +Burns NST tokens from an address. Reduces total supply. + +| Parameter | Type | Description | +|---|---|---| +| `from` | `Address` | Address to burn from (must authorize) | +| `amount` | `i128` | Amount to burn (must be > 0) | + +Returns: `Result` (new total supply) + +--- + +## Admin & Config + +### `set_admin` +Transfers admin rights to a new address. + +| Parameter | Type | Description | +|---|---|---| +| `current_admin` | `Address` | Current admin (must authorize) | +| `new_admin` | `Address` | New admin address | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_treasury` +Updates the protocol treasury address. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `new_treasury` | `Address` | New treasury address | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_fees` +Updates all protocol fee rates. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `deposit_fee` | `u32` | New deposit fee in bps | +| `withdrawal_fee` | `u32` | New withdrawal fee in bps | +| `performance_fee` | `u32` | New performance fee in bps | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_flexi_rate` +Sets the Flexi Save interest rate. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `rate` | `i128` | New rate in basis points (e.g. 500 = 5%) | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_goal_rate` +Sets the Goal Save interest rate. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `rate` | `i128` | New rate in basis points | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_group_rate` +Sets the Group Save interest rate. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `rate` | `i128` | New rate in basis points | + +Returns: `Result<(), SavingsError>` + +--- + +### `set_lock_rate` +Sets the Lock Save interest rate for a specific duration. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address | +| `duration_days` | `u64` | Lock duration in days | +| `rate` | `i128` | New rate in basis points | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_flexi_rate` / `get_goal_rate` / `get_group_rate` +Returns the current interest rate for the respective plan type. + +Returns: `i128` + +--- + +### `get_lock_rate` +Returns the interest rate for a specific lock duration. + +| Parameter | Type | Description | +|---|---|---| +| `duration_days` | `u64` | Lock duration in days | + +Returns: `Result` + +--- + +### `set_early_break_fee_bps` +Sets the early-break penalty fee for Goal Save plans. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `bps` | `u32` | Fee in basis points (0–10000) | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_early_break_fee_bps` +Returns the current early-break fee in basis points. + +Returns: `u32` + +--- + +### `set_fee_recipient` +Sets the address that receives protocol fees. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `recipient` | `Address` | Fee recipient address | + +Returns: `Result<(), SavingsError>` + +--- + +### `get_fee_recipient` +Returns the current fee recipient address. + +Returns: `Option
` + +--- + +### `get_protocol_fee_balance` +Returns the accumulated protocol fee balance for a given recipient. + +| Parameter | Type | Description | +|---|---|---| +| `recipient` | `Address` | Fee recipient address | + +Returns: `i128` + +--- + +### `get_config` +Returns the full protocol configuration. + +Returns: `Result` + +--- + +### `pause` / `unpause` +Pauses or unpauses the contract. Admin or governance only. + +| Parameter | Type | Description | +|---|---|---| +| `caller` | `Address` | Admin or governance address (must authorize) | + +Returns: `Result<(), SavingsError>` + +--- + +### `pause_contract` / `unpause_contract` +Alternative pause/unpause via the config module. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | + +Returns: `Result<(), SavingsError>` + +--- + +### `is_paused` +Returns whether the contract is currently paused. + +Returns: `bool` + +--- + +### `upgrade` +Upgrades the contract WASM. Admin only. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address | +| `new_wasm_hash` | `BytesN<32>` | Hash of the new WASM binary | + +Returns: `()` + +--- + +### `version` +Returns the current contract version number. + +Returns: `u32` + +--- + +## Emergency + +### `emergency_withdraw` +Forces a withdrawal from any plan type and disables the strategy. Admin only. Bypasses normal withdrawal restrictions. + +| Parameter | Type | Description | +|---|---|---| +| `admin` | `Address` | Admin address (must authorize) | +| `user` | `Address` | User whose plan is being withdrawn | +| `plan_type` | `PlanType` | Plan type: `Flexi`, `Lock(u64)`, `Goal(...)`, `Group(...)` | +| `plan_id` | `u64` | Plan ID | + +Returns: `Result` (amount withdrawn) + +```bash +stellar contract invoke --id --source admin --network testnet \ + -- emergency_withdraw \ + --admin GADMIN... --user GUSER... --plan_type Flexi --plan_id 0 +``` + +--- + +### `is_strategy_disabled` +Returns whether a strategy has been disabled via emergency withdraw. + +| Parameter | Type | Description | +|---|---|---| +| `plan_type` | `PlanType` | Plan type | +| `plan_id` | `u64` | Plan ID | + +Returns: `bool` + +--- + +## Error Reference + +All functions that return `Result` use `SavingsError`. Key codes: + +| Code | Name | Description | +|---|---|---| +| 1 | `Unauthorized` | Caller lacks permission | +| 10 | `UserNotFound` | User not registered | +| 11 | `UserAlreadyExists` | User already registered | +| 20 | `PlanNotFound` | Plan ID does not exist | +| 23 | `PlanCompleted` | Plan already completed or withdrawn | +| 40 | `InsufficientBalance` | Withdrawal exceeds balance | +| 41 | `InvalidAmount` | Amount is zero or negative | +| 42 | `AmountExceedsLimit` | Amount exceeds configured cap | +| 50 | `InvalidTimestamp` | Timestamp is invalid or inconsistent | +| 51 | `TooEarly` | Operation attempted before allowed time | +| 60 | `InvalidInterestRate` | Rate is negative or out of range | +| 71 | `NotGroupMember` | User is not a member of the group | +| 82 | `Overflow` | Arithmetic overflow | +| 83 | `Underflow` | Arithmetic underflow | +| 84 | `ContractPaused` | Contract is paused | +| 90 | `InvalidFeeBps` | Fee exceeds 10,000 bps | +| 91 | `ConfigAlreadyInitialized` | Config already set | +| 92 | `StrategyDisabled` | Strategy has been emergency-disabled | +| 97 | `ReentrancyDetected` | Reentrant call detected | diff --git a/contracts/SAVINGS_LIFECYCLE.md b/contracts/SAVINGS_LIFECYCLE.md new file mode 100644 index 000000000..71dfffa7b --- /dev/null +++ b/contracts/SAVINGS_LIFECYCLE.md @@ -0,0 +1,945 @@ +# Nestera Savings Lifecycle + +This document walks through the complete lifecycle of a user's savings on Nestera — from account creation through every plan type, covering normal flows, edge cases, and early exit conditions. + +--- + +## Table of Contents + +1. [Account Setup](#1-account-setup) +2. [Flexi Save Lifecycle](#2-flexi-save-lifecycle) +3. [Lock Save Lifecycle](#3-lock-save-lifecycle) +4. [Goal Save Lifecycle](#4-goal-save-lifecycle) +5. [Group Save Lifecycle](#5-group-save-lifecycle) +6. [AutoSave Lifecycle](#6-autosave-lifecycle) +7. [Staking Lifecycle](#7-staking-lifecycle) +8. [Rewards Lifecycle](#8-rewards-lifecycle) +9. [Emergency & Edge Cases](#9-emergency--edge-cases) +10. [State Transition Summary](#10-state-transition-summary) + +--- + +## 1. Account Setup + +Every user must register before they can interact with any savings plan. This is a one-time operation. + +### Normal Flow + +``` +1. User calls: initialize_user(user) + - user.require_auth() — wallet signature required + - Checks: user must NOT already exist (UserAlreadyExists if they do) + - Creates: User { total_balance: 0, savings_count: 0 } + - Creates: UserRewards record (zero points, zero streak) + - Stored at: DataKey::User(user_address) + +2. User is now active in the system. +``` + +### Edge Cases + +| Situation | Result | +|---|---| +| Call `initialize_user` twice | `UserAlreadyExists` error (code 11) | +| Call any deposit before registering | `UserNotFound` error (code 10) | +| Contract is paused | `ContractPaused` error (code 84) | +| Missing wallet signature | Transaction rejected at network level | + +--- + +## 2. Flexi Save Lifecycle + +Flexi Save is the simplest plan — no lock, no target, deposit and withdraw freely at any time. + +### Deposit + +``` +User calls: deposit_flexi(user, amount) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ amount > 0 + ✓ User exists (UserNotFound if not) + +Effects: + fee = floor(amount × deposit_fee_bps / 10_000) + net_amount = amount - fee + + FlexiBalance(user) += net_amount + User.total_balance += net_amount + TotalBalance(fee_recip) += fee (if fee > 0) + Treasury.total_fees += fee (if fee > 0) + + Reward points awarded based on deposit amount. + TTL extended on user storage. +``` + +### Withdrawal + +``` +User calls: withdraw_flexi(user, amount) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ amount > 0 + ✓ FlexiBalance(user) >= amount (InsufficientBalance if not) + +Effects: + fee = floor(amount × withdrawal_fee_bps / 10_000) + net_amount = amount - fee + + FlexiBalance(user) -= amount + User.total_balance -= amount + TotalBalance(fee_recip) += fee (if fee > 0) + Treasury.total_fees += fee (if fee > 0) + + Tokens transferred: contract → user wallet (net_amount) +``` + +### Flexi Save Edge Cases + +| Situation | Result | +|---|---| +| Withdraw more than balance | `InsufficientBalance` (code 40) | +| Deposit amount = 0 | `InvalidAmount` (code 41) | +| Fee rounds to 0 on tiny amounts | No fee charged (floor division) | +| Multiple deposits, partial withdrawal | Each deposit/withdrawal is independent | +| No fee configured | Full amount credited / returned | + +### Flexi Save State Diagram + +``` +[Registered] ──deposit──► [Has Balance] ──withdraw──► [Zero Balance] + ▲ │ + └────deposit─────┘ + (repeatable) +``` + +--- + +## 3. Lock Save Lifecycle + +Lock Save locks funds for a fixed duration. Funds earn yield and cannot be withdrawn until the maturity time is reached. + +### Creating a Lock + +``` +User calls: create_lock_save(user, amount, duration) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ amount > 0 (InvalidAmount if not) + ✓ duration > 0 (InvalidTimestamp if not) + ✓ User exists (UserNotFound if not) + +Effects: + lock_id = next auto-incrementing ID + start_time = ledger.timestamp() + maturity_time = start_time + duration (checked_add, Overflow if wraps) + + Stores: LockSave { + id, owner, amount, + interest_rate: 500, ← 5% APY (fixed at creation) + start_time, + maturity_time, + is_withdrawn: false + } + + User.total_balance += amount + User.savings_count += 1 + UserLockSaves(user) appended with lock_id + + Reward points awarded. + Long-lock bonus awarded if duration > threshold. + TTL extended. +``` + +### The Lock Period + +While locked, the funds are held by the contract. No withdrawal is possible. + +``` +check_matured_lock(lock_id) → bool + Returns: ledger.timestamp() >= lock_save.maturity_time +``` + +Calling `withdraw_lock_save` before maturity returns `TooEarly` (code 51). + +### Yield Calculation + +Interest accrues using simple interest from `start_time` to the moment of withdrawal: + +``` +duration_years = (current_time - start_time) / (365.25 × 24 × 3600) +rate_decimal = interest_rate / 10_000 ← e.g. 500 / 10000 = 0.05 +multiplier = 1.0 + (rate_decimal × duration_years) +final_amount = floor(amount × multiplier) +``` + +Example: 1,000 USDC locked for 1 year at 5% APY → final_amount = 1,050 USDC. + +### Withdrawal (Matured) + +``` +User calls: withdraw_lock_save(user, lock_id) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ lock_save.owner == user (Unauthorized if not) + ✓ lock_save.is_withdrawn == false (PlanCompleted if already done) + ✓ ledger.timestamp() >= maturity_time (TooEarly if not) + +Effects: + final_amount = principal + accrued_yield (simple interest) + + lock_save.is_withdrawn = true + User.total_balance -= lock_save.amount (principal removed) + + Tokens transferred: contract → user wallet (final_amount) + Event emitted: ("withdraw", user, lock_id) → final_amount +``` + +### Lock Save Edge Cases + +| Situation | Result | +|---|---| +| Withdraw before maturity | `TooEarly` (code 51) | +| Withdraw twice | `PlanCompleted` (code 23) | +| Another user tries to withdraw | `Unauthorized` (code 1) | +| Lock ID does not exist | `PlanNotFound` (code 20) | +| Duration causes timestamp overflow | `Overflow` (code 82) | +| Lock routed to yield strategy | Yield harvested separately via `harvest_strategy` | + +### Lock Save State Diagram + +``` +[Created] ──time passes──► [Matured] ──withdraw──► [Withdrawn / Closed] + │ + └── withdraw_lock_save before maturity → TooEarly ✗ +``` + +--- + +## 4. Goal Save Lifecycle + +Goal Save is a target-based plan. The user sets a savings target and deposits toward it over time. The plan completes when `current_amount >= target_amount`. Early exit is supported with a configurable penalty fee. + +### Creating a Goal + +``` +User calls: create_goal_save(user, goal_name, target_amount, initial_deposit) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ target_amount > 0 (InvalidAmount if not) + ✓ initial_deposit >= 0 (InvalidAmount if negative) + ✓ User exists (UserNotFound if not) + +Effects: + fee = floor(initial_deposit × deposit_fee_bps / 10_000) + net_deposit = initial_deposit - fee + + Stores: GoalSave { + id, owner, goal_name, + target_amount, + current_amount: net_deposit, + interest_rate: 500, + start_time: ledger.timestamp(), + is_completed: net_deposit >= target_amount, + is_withdrawn: false + } + + Fee credited to fee_recipient and treasury. + Goal completion bonus awarded immediately if target met on creation. + Reward points awarded. + TTL extended. +``` + +### Depositing Toward a Goal + +``` +User calls: deposit_to_goal_save(user, goal_id, amount) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ amount > 0 + ✓ goal_save.owner == user (Unauthorized if not) + ✓ goal_save.is_completed == false (PlanCompleted if already done) + +Effects: + fee = floor(amount × deposit_fee_bps / 10_000) + net_amount = amount - fee + + goal_save.current_amount += net_amount + + If current_amount >= target_amount: + goal_save.is_completed = true + Goal completion bonus awarded (if not already completed) + + Fee credited to fee_recipient and treasury. + Reward points awarded. + TTL extended. +``` + +### Withdrawing a Completed Goal + +``` +User calls: withdraw_completed_goal_save(user, goal_id) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ User exists + ✓ goal_save.owner == user (Unauthorized if not) + ✓ goal_save.is_completed == true (TooEarly if not) + ✓ goal_save.is_withdrawn == false (PlanCompleted if already done) + +Effects: + fee = floor(current_amount × withdrawal_fee_bps / 10_000) + net_amount = current_amount - fee + + goal_save.is_withdrawn = true + User.total_balance += net_amount + + Fee credited to fee_recipient and treasury. + Tokens transferred: contract → user wallet (net_amount) +``` + +### Early Break (Before Goal is Reached) + +This is the early withdrawal path. The user exits before hitting the target and pays an early-break penalty. + +``` +User calls: break_goal_save(user, goal_id) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ User exists + ✓ goal_save.owner == user + ✓ goal_save.is_completed == false (PlanCompleted if already done — cannot break a completed goal) + ✓ goal_save.is_withdrawn == false (PlanCompleted if already withdrawn) + +Effects: + early_break_fee_bps set by admin (default 0) + fee = floor(current_amount × early_break_fee_bps / 10_000) + net_amount = current_amount - fee + + goal_save.is_withdrawn = true + User.total_balance += net_amount + + Fee credited to fee_recipient. + Goal removed from UserGoalSaves list. + Tokens transferred: contract → user wallet (net_amount) + Event: ("goal_brk", user, goal_id) → net_amount +``` + +### Goal Save Edge Cases + +| Situation | Result | +|---|---| +| Deposit to already-completed goal | `PlanCompleted` (code 23) | +| Withdraw incomplete goal | `TooEarly` (code 51) | +| Break a completed goal | `PlanCompleted` (code 23) | +| Withdraw twice | `PlanCompleted` (code 23) | +| Another user tries to withdraw | `Unauthorized` (code 1) | +| `early_break_fee_bps` = 0 | Full refund, no penalty | +| `early_break_fee_bps` > 10,000 | `InvalidAmount` (code 41) | +| Initial deposit meets target immediately | Goal marked complete at creation | + +### Goal Save State Diagram + +``` +[Created / In Progress] + │ + ├── deposit_to_goal_save (repeatable) + │ │ + │ ▼ + │ current_amount >= target_amount? + │ │ yes + │ ▼ + │ [Completed] ──withdraw_completed_goal_save──► [Withdrawn / Closed] + │ + └── break_goal_save (early exit, penalty applies) + │ + ▼ + [Withdrawn / Closed] +``` + +--- + +## 5. Group Save Lifecycle + +Group Save is a collaborative savings plan. Multiple users pool contributions toward a shared target. The group has a defined start and end time. + +### Creating a Group + +``` +User calls: create_group_save(creator, title, description, category, + target_amount, contribution_type, + contribution_amount, is_public, + start_time, end_time) + +Checks: + ✓ Contract not paused + ✓ target_amount > 0 + ✓ contribution_amount > 0 + ✓ start_time < end_time (InvalidTimestamp if not) + ✓ contribution_type in [0,1,2] (InvalidGroupConfig if not) + ✓ title, description, category not empty + +contribution_type values: + 0 = fixed amount per member + 1 = flexible (any amount) + 2 = percentage-based + +Effects: + group_id = next auto-incrementing ID + Stores: GroupSave { id, creator, title, ..., member_count: 1, current_amount: 0, is_completed: false } + Creator added to GroupMembers list + Creator's contribution initialized to 0 + SavingsPlan created for creator (balance: 0, interest_rate: 500) + TTL extended. + Event: ("grp_new", creator) → group_id +``` + +### Joining a Group + +Only public groups can be joined freely. Private groups require the creator to manage membership off-chain. + +``` +User calls: join_group_save(user, group_id) + +Checks: + ✓ Contract not paused + ✓ User exists + ✓ Group exists + ✓ group.is_public == true (InvalidGroupConfig if private) + ✓ User not already a member (InvalidGroupConfig if duplicate) + +Effects: + User added to GroupMembers list + group.member_count += 1 + User's contribution initialized to 0 + SavingsPlan created for user + TTL extended. + Event: ("grp_join", user) → group_id +``` + +### Contributing to a Group + +``` +User calls: contribute_to_group_save(user, group_id, amount) + +Checks: + ✓ Contract not paused + ✓ amount > 0 + ✓ Group exists + ✓ User is a member of the group (NotGroupMember if not) + +Effects: + GroupMemberContribution(group_id, user) += amount + group.current_amount += amount + + If current_amount >= target_amount: + group.is_completed = true + + SavingsPlan(user, group_id).balance += amount + SavingsPlan.last_deposit = ledger.timestamp() + + Reward points awarded. + TTL extended. + Event: ("grp_cont", user, group_id) → amount +``` + +### Leaving a Group (Early Break) + +A member can leave before the group completes. Their contributions are refunded in full — there is no early-break fee for group saves. + +``` +User calls: break_group_save(user, group_id) + +Checks: + ✓ Contract not paused + ✓ User exists + ✓ Group exists + ✓ group.is_completed == false (PlanCompleted if already done) + ✓ User is a member (NotGroupMember if not) + +Effects: + User removed from GroupMembers list + group.member_count -= 1 + group.current_amount -= user_contribution (saturating_sub) + + GroupMemberContribution(group_id, user) removed + UserGroupSaves(user) updated (group_id removed) + SavingsPlan(user, group_id) deleted + + Tokens refunded: contract → user wallet (user_contribution, full amount) + TTL extended for remaining group. + Event: ("grp_leave", user, group_id) → user_contribution +``` + +### Group Save Edge Cases + +| Situation | Result | +|---|---| +| Join a private group | `InvalidGroupConfig` (code 73) | +| Join a group twice | `InvalidGroupConfig` (code 73) | +| Contribute without being a member | `NotGroupMember` (code 71) | +| Leave a completed group | `PlanCompleted` (code 23) | +| Creator leaves | Creator is treated as any other member — no special restriction | +| Group reaches target mid-contribution | `is_completed` set to true, further contributions blocked | +| Group end_time passes | No automatic action — group stays open until manually resolved | + +### Group Save State Diagram + +``` +[Created] ──join_group_save──► [Member Joined] + │ + contribute_to_group_save + │ + current_amount >= target? + │ yes + ▼ + [Completed] + │ + (no withdrawal function + currently — funds remain + until admin or governance + action) + +At any point before completion: + break_group_save → [Member Exits, contribution refunded] +``` + +--- + +## 6. AutoSave Lifecycle + +AutoSave automates recurring Flexi deposits on a time interval. An external relayer or bot calls `execute_autosave` when the schedule is due. + +### Creating a Schedule + +``` +User calls: create_autosave(user, amount, interval_seconds, start_time) + +Checks: + ✓ user.require_auth() + ✓ amount > 0 + ✓ interval_seconds > 0 (InvalidTimestamp if not) + ✓ User exists + +Effects: + schedule_id = next auto-incrementing ID + Stores: AutoSave { + id, user, amount, interval_seconds, + next_execution_time: start_time, + is_active: true + } + UserAutoSaves(user) appended with schedule_id + TTL extended. +``` + +### Execution + +Anyone can trigger execution — typically a relayer bot. The contract checks whether the schedule is due. + +``` +Relayer calls: execute_autosave(schedule_id) + OR +Relayer calls: execute_due_autosaves([id1, id2, ...]) + +Checks (per schedule): + ✓ Schedule exists and is_active == true + ✓ ledger.timestamp() >= next_execution_time (InvalidTimestamp if not) + +Effects: + flexi_deposit(schedule.user, schedule.amount) executed + → deposit fee applied, net credited to FlexiBalance + schedule.next_execution_time += interval_seconds + +Batch behavior (execute_due_autosaves): + - Each schedule is attempted independently + - A failed or skipped schedule does NOT revert the batch + - Returns Vec: true = executed, false = skipped +``` + +### Cancellation + +``` +User calls: cancel_autosave(user, schedule_id) + +Checks: + ✓ user.require_auth() + ✓ Schedule exists + ✓ schedule.user == user (Unauthorized if not) + +Effects: + schedule.is_active = false + (Schedule record remains in storage but will be skipped on future execution attempts) +``` + +### AutoSave Edge Cases + +| Situation | Result | +|---|---| +| Execute before `next_execution_time` | `InvalidTimestamp` (code 50) | +| Execute a cancelled schedule | `InvalidPlanConfig` (code 25) | +| Execute a non-existent schedule | `PlanNotFound` (code 20) | +| User has insufficient token balance | Flexi deposit fails, batch marks as `false` | +| Relayer skips an execution window | Next execution still uses `next_execution_time += interval` — missed windows are not back-filled | +| Cancel then re-create | Must create a new schedule (cancelled ones cannot be reactivated) | + +--- +## 7. Staking Lifecycle + +Staking lets users lock NST (the native protocol token) to earn continuous yield. Unlike savings plans, staking rewards accrue per-second based on a global reward rate and the user's share of the total staked pool. + +### Initializing Staking (Admin) + +Before any user can stake, an admin must configure the staking module: + +``` +Admin calls: init_staking_config(admin, StakingConfig { + min_stake_amount, ← minimum tokens to stake + max_stake_amount, ← upper cap per user + reward_rate_bps, ← annual reward rate (e.g. 500 = 5% APY) + enabled, ← true/false kill switch + lock_period_seconds, ← 0 = no lock, >0 = lock before unstake +}) +``` + +### Staking + +``` +User calls: stake(user, amount) + +Checks: + ✓ Contract not paused + ✓ user.require_auth() + ✓ staking config enabled + ✓ amount >= min_stake_amount (AmountBelowMinimum if not) + +Effects: + update_rewards() called first — accrues global reward_per_token + based on time elapsed since last update: + + new_rewards = total_staked × reward_rate_bps × time_elapsed + ───────────────────────────────────────────── + 10_000 × 365 × 24 × 60 × 60 + + reward_per_token += new_rewards + + Pending rewards calculated for existing stake (if any). + + Stake.amount += amount + Stake.start_time = ledger.timestamp() (first stake only) + Stake.reward_per_share = current reward_per_token (checkpoint) + + total_staked += amount + Event: StakeCreated(user, amount, new_total_staked) +``` + +### Claiming Rewards + +Rewards can be claimed at any time without unstaking. + +``` +User calls: claim_staking_rewards(user) + +Checks: + ✓ user.require_auth() + ✓ Contract not paused + ✓ Stake.amount > 0 (InsufficientBalance if not) + +Effects: + update_rewards() called — accrues up to current timestamp + + pending_rewards = Stake.amount × (reward_per_token - Stake.reward_per_share) + ──────────────────────────────────────────────────────────── + 1_000_000_000 + + Stake.reward_per_share = current reward_per_token (checkpoint reset) + total_rewards_distributed += pending_rewards + + Tokens transferred: contract → user wallet (pending_rewards) + Event: StakingRewardsClaimed(user, pending_rewards) +``` + +### Unstaking + +``` +User calls: unstake(user, amount) + +Checks: + ✓ user.require_auth() + ✓ Contract not paused + ✓ staking config enabled + ✓ Stake.amount >= amount (InsufficientBalance if not) + ✓ If lock_period_seconds > 0: + ledger.timestamp() >= Stake.start_time + lock_period_seconds + (TooEarly if not) + +Effects: + update_rewards() called + pending_rewards calculated (same formula as claim) + + Stake.amount -= amount + Stake.reward_per_share = current reward_per_token + total_staked -= amount + total_rewards_distributed += pending_rewards + + Returns: (amount_unstaked, pending_rewards) + Event: StakeWithdrawn(user, amount, new_total_staked) +``` + +### Staking Edge Cases + +| Situation | Result | +|---|---| +| Stake below `min_stake_amount` | `AmountBelowMinimum` (code 43) | +| Unstake before lock period ends | `TooEarly` (code 51) | +| Unstake more than staked | `InsufficientBalance` (code 40) | +| Claim with zero stake | `InsufficientBalance` (code 40) | +| Claim when pending rewards = 0 | `InsufficientBalance` (code 40) | +| Staking disabled via config | `ContractPaused` (code 84) | +| `lock_period_seconds` = 0 | Unstake allowed immediately at any time | + +### Staking State Diagram + +``` +[No Stake] ──stake()──► [Staking / Accruing] + │ + ┌──────────┴──────────┐ + │ │ + claim_staking_rewards unstake() + │ │ + ▼ ▼ + [Still Staking] [Partially/Fully Unstaked] + (rewards reset) +``` + +--- + +## 8. Rewards Lifecycle + +The rewards system tracks user activity across all plan types and awards points for deposits, streaks, long locks, and goal completions. Points can be redeemed or converted to claimable NST tokens. + +### How Points Are Earned + +Points are awarded automatically inside deposit and plan-creation functions — no separate call needed. + +| Action | Points Awarded | +|---|---| +| Any deposit | `floor(amount × points_per_token)` | +| Deposit streak maintained | Streak bonus applied as `streak_bonus_bps` multiplier | +| Lock duration > threshold | Long-lock bonus: `floor(base_points × long_lock_bonus_bps / 10_000)` | +| Goal completed | Flat `goal_completion_bonus` points | + +Points are only awarded when: +- `rewards_config.enabled == true` +- `amount >= min_deposit_for_rewards` +- Cooldown period has elapsed since last action (`action_cooldown_seconds`) +- Daily cap not exceeded (`max_daily_points`) + +### Streak Mechanics + +``` +User calls: update_streak(user) + ✓ user.require_auth() + +A streak increments when the user deposits within the expected window. +Streak multiplier is capped at max_streak_multiplier. +Streak resets to 0 if the window is missed. +``` + +### Redeeming Points + +``` +User calls: redeem_points(user, amount) + ✓ user.require_auth() + ✓ UserRewards.total_points >= amount (InsufficientBalance if not) + +Effects: + UserRewards.total_points -= amount + Event: PointsRedeemed(user, amount) +``` + +### Converting Points to Tokens + +``` +User calls: convert_points_to_tokens(user, points_to_convert, tokens_per_point) + ✓ user.require_auth() + ✓ Sufficient points balance + +Effects: + tokens_queued = points_to_convert × tokens_per_point + UserRewards.unclaimed_tokens += tokens_queued + UserRewards.total_points -= points_to_convert +``` + +### Claiming Token Rewards + +``` +User calls: claim_rewards(user) + ✓ user.require_auth() + ✓ Contract not paused + ✓ UserRewards.unclaimed_tokens > 0 + +Effects: + Tokens transferred from contract to user wallet + UserRewards.unclaimed_tokens = 0 + Event: RewardsClaimed(user, amount) +``` + +### Rewards Edge Cases + +| Situation | Result | +|---|---| +| Rewards module not initialized | Points silently not awarded (no error) | +| Rewards disabled in config | No points awarded for any action | +| Deposit below `min_deposit_for_rewards` | No points awarded | +| Action within cooldown window | No points awarded for that action | +| Daily cap reached | No further points until next day | +| Redeem more points than balance | `InsufficientBalance` (code 40) | +| Claim with zero unclaimed tokens | Fails (InsufficientBalance) | + +--- + +## 9. Emergency & Edge Cases + +### Contract Pause + +The admin or governance can pause the contract at any time. When paused, all state-changing functions (`deposit_flexi`, `withdraw_flexi`, `create_lock_save`, etc.) return `ContractPaused` (code 84). Read-only functions (`get_user`, `get_flexi_balance`, etc.) continue to work. + +``` +Admin calls: pause(caller) → all writes blocked +Admin calls: unpause(caller) → normal operation resumes +``` + +### Emergency Withdraw + +If a vulnerability is detected in a specific plan, the admin can force-exit any user's position and permanently disable that strategy. + +``` +Admin calls: emergency_withdraw(admin, user, plan_type, plan_id) + + ✓ admin.require_auth() + ✓ Caller must be the stored admin + ✓ Strategy must not already be disabled + +Effects (by plan type): + Flexi → FlexiBalance(user) zeroed, User.total_balance reduced + Lock → LockSave.is_withdrawn = true, User.total_balance reduced + Goal → GoalSave.is_withdrawn = true, User.total_balance reduced + Group → User's contribution zeroed, group.current_amount reduced + + DataKey::DisabledStrategy(plan_type, plan_id) = true + Event: ("emergency_withdraw", user, plan_id) → withdrawn_amount +``` + +After an emergency withdraw, `is_strategy_disabled(plan_type, plan_id)` returns `true` and the strategy cannot be used again. + +### Ledger TTL Expiry + +Soroban ledger entries have a Time-To-Live. If a user goes inactive for a very long time and TTL is not extended, their data could expire. Nestera mitigates this by extending TTL on every read and write. However: + +- If a user's data expires, it is treated as if the user does not exist (`UserNotFound`) +- Funds held by the contract account are not affected by TTL — only the accounting records are at risk +- Admins should monitor and extend TTLs for inactive accounts if needed + +### Reentrancy Protection + +All functions that interact with external yield strategy contracts are protected by a reentrancy guard: + +``` +DataKey::ReentrancyGuard = true (set before external call) +DataKey::ReentrancyGuard = false (cleared after) + +If a reentrant call is detected: ReentrancyDetected (code 97) +``` + +### Arithmetic Safety + +All balance math uses checked operations. Any overflow or underflow panics the transaction atomically — no partial state is written. + +| Operation | Protection | +|---|---| +| Balance addition | `checked_add` → `Overflow` (code 82) | +| Balance subtraction | `checked_sub` → `Underflow` (code 83) | +| Fee calculation | `checked_mul` → `Overflow` (code 82) | +| Timestamp addition | `checked_add` → `Overflow` (code 82) | + +--- + +## 10. State Transition Summary + +### Per-Plan Terminal States + +Every plan type has a terminal state after which no further operations are possible. + +| Plan | Terminal State | How Reached | +|---|---|---| +| Flexi Save | Balance = 0 | Full withdrawal | +| Lock Save | `is_withdrawn = true` | `withdraw_lock_save` after maturity | +| Goal Save | `is_withdrawn = true` | `withdraw_completed_goal_save` or `break_goal_save` | +| Group Save (member) | Removed from group | `break_group_save` | +| AutoSave | `is_active = false` | `cancel_autosave` | +| Staking | `Stake.amount = 0` | Full `unstake` | + +### Full User Journey (Happy Path) + +``` +Register + │ + ├──► Flexi Save + │ deposit_flexi → [balance grows] → withdraw_flexi + │ + ├──► Lock Save + │ create_lock_save → [locked, accruing yield] → withdraw_lock_save (after maturity) + │ + ├──► Goal Save + │ create_goal_save → deposit_to_goal_save (×N) → [completed] → withdraw_completed_goal_save + │ + ├──► Group Save + │ create_group_save / join_group_save → contribute_to_group_save (×N) → [completed] + │ + ├──► AutoSave + │ create_autosave → [relayer executes periodically] → cancel_autosave + │ + ├──► Staking + │ stake → [rewards accrue] → claim_staking_rewards → unstake + │ + └──► Rewards + [points earned automatically] → convert_points_to_tokens → claim_rewards +``` + +### Error Code Quick Reference + +| Code | Name | Common Trigger | +|---|---|---| +| 1 | `Unauthorized` | Wrong user calling another's plan | +| 10 | `UserNotFound` | Deposit before `initialize_user` | +| 11 | `UserAlreadyExists` | Calling `initialize_user` twice | +| 20 | `PlanNotFound` | Invalid plan/lock/goal/group ID | +| 23 | `PlanCompleted` | Double-withdraw or operating on closed plan | +| 40 | `InsufficientBalance` | Withdraw more than available | +| 41 | `InvalidAmount` | Zero or negative amount | +| 43 | `AmountBelowMinimum` | Stake below configured minimum | +| 50 | `InvalidTimestamp` | AutoSave not yet due; zero duration | +| 51 | `TooEarly` | Lock not matured; goal not completed; staking lock active | +| 71 | `NotGroupMember` | Contribute/leave without membership | +| 73 | `InvalidGroupConfig` | Join private group; duplicate join | +| 82 | `Overflow` | Arithmetic overflow in balance math | +| 83 | `Underflow` | Arithmetic underflow in balance math | +| 84 | `ContractPaused` | Any write while paused | +| 90 | `InvalidFeeBps` | Fee > 10,000 bps | +| 92 | `StrategyDisabled` | Emergency withdraw already executed | +| 97 | `ReentrancyDetected` | Reentrant call into strategy function | diff --git a/contracts/TOKEN_FLOW.md b/contracts/TOKEN_FLOW.md new file mode 100644 index 000000000..223464b60 --- /dev/null +++ b/contracts/TOKEN_FLOW.md @@ -0,0 +1,357 @@ +# Token Flow in Nestera + +This document explains how tokens (USDC or any Stellar asset) move through the Nestera protocol — from a user's wallet into the contract, how funds are held, and how they flow back out. + +--- + +## Stellar-Specific Concepts + +Before diving into flows, a few Stellar fundamentals that affect how Nestera works. + +### Trustlines + +On Stellar, an account cannot hold a token it hasn't explicitly opted into. A **trustline** is that opt-in — it tells the network "this account is willing to hold this asset." + +- Every user wallet must have a trustline to USDC (or whichever token they want to deposit) before interacting with Nestera. +- The Nestera contract account itself must also have a trustline to any token it holds on behalf of users. +- Without a trustline, any token transfer will fail at the network level, before the contract even executes. + +To establish a trustline via Stellar CLI: +```bash +stellar contract invoke \ + --id \ + --source \ + --network testnet \ + -- set_trustline \ + --account +``` + +Or via the Stellar SDK (JavaScript): +```js +const trustlineOp = StellarSdk.Operation.changeTrust({ + asset: new StellarSdk.Asset('USDC', USDC_ISSUER), + limit: '1000000', +}); +``` + +### Soroban Token Interface + +Nestera is built on Soroban (Stellar's smart contract platform). Token transfers on Soroban use the **SEP-41 token interface** — a standard interface that USDC and other Stellar tokens implement. The key calls are: + +- `transfer(from, to, amount)` — moves tokens between addresses +- `balance(address)` — reads a token balance +- `approve(from, spender, amount, expiry)` — grants a contract permission to spend on behalf of a user + +### Ledger Entries and TTL + +Soroban stores data in **ledger entries** with a Time-To-Live (TTL). Nestera actively extends TTLs on every read/write to prevent user data from expiring. If a ledger entry expires, the data is gone — so TTL management is critical for fund safety. + +### Authorization Model + +Soroban uses `require_auth()` rather than `msg.sender`. Every state-changing function in Nestera calls `user.require_auth()`, which means the user's Stellar account must have signed the transaction. There is no way to move funds without the owner's explicit signature. + +--- + +## How Nestera Holds Funds + +Nestera does **not** use a separate vault contract or escrow. Instead: + +- Token balances are tracked **internally in contract storage** as `i128` integers. +- The actual tokens are held by the **Nestera contract account** on the Stellar network. +- When a user deposits, they transfer tokens to the contract address. The contract records the credit in its persistent storage. +- When a user withdraws, the contract transfers tokens from its own account back to the user. + +This means the contract address must hold a trustline for every supported token, and the contract's on-chain token balance must always be ≥ the sum of all user balances it tracks internally. + +--- + +## Deposit Flow + +### 1. User Registers + +Before depositing, a user must be registered: + +``` +User calls: init_user(user: Address) + → Contract creates User { total_balance: 0, savings_count: 0 } + → Stored at DataKey::User(user_address) +``` + +### 2. Token Approval (off-chain, before deposit) + +The user must approve the Nestera contract to spend their tokens. This is done via the token contract directly, not through Nestera: + +``` +User calls on USDC contract: approve(from: user, spender: nestera_contract, amount, expiry) +``` + +### 3. Deposit Call + +The user calls a deposit function (e.g. `deposit_flexi`). The contract: + +1. Calls `user.require_auth()` — verifies the user signed the transaction +2. Validates the amount is > 0 +3. Calculates the protocol fee: `fee = floor(amount × fee_bps / 10_000)` +4. Computes the net amount: `net = amount - fee` +5. Calls the USDC token contract: `transfer(user, nestera_contract, amount)` +6. Updates internal storage: + - `DataKey::FlexiBalance(user)` += net + - `DataKey::User(user).total_balance` += net +7. If fee > 0, credits the fee recipient's internal balance and records it in the treasury struct +8. Awards reward points + +``` +User wallet ──[USDC transfer]──► Nestera contract account + │ + Internal storage update: + FlexiBalance(user) += net_amount + Treasury.total_fees += fee_amount +``` + +### Fee Calculation + +Fees are in **basis points** (bps). 100 bps = 1%. + +``` +deposit_amount = 10,000 USDC +deposit_fee_bps = 50 (0.5%) +fee = floor(10,000 × 50 / 10,000) = 50 USDC +net_credited = 9,950 USDC +``` + +Fees always round **down** (floor division), protecting users from rounding up. + +--- + +## How Funds Are Held Per Plan Type + +### Flexi Save + +Balances are stored directly in contract persistent storage: + +``` +DataKey::FlexiBalance(user_address) → i128 +DataKey::User(user_address) → User { total_balance, savings_count } +``` + +No lock. The user can withdraw at any time. + +### Lock Save + +A `LockSave` struct is stored per plan: + +``` +DataKey::LockSave(lock_id) → LockSave { + id, owner, amount, interest_rate, + start_time, maturity_time, is_withdrawn +} +DataKey::UserLockSaves(user) → Vec // list of lock IDs +``` + +The `amount` is also added to `User.total_balance`. Funds cannot be withdrawn until `ledger.timestamp() >= maturity_time`. + +### Goal Save + +``` +DataKey::GoalSave(goal_id) → GoalSave { + id, owner, goal_name, target_amount, current_amount, + interest_rate, start_time, is_completed, is_withdrawn +} +DataKey::UserGoalSaves(user) → Vec +``` + +`current_amount` grows with each deposit (net of fees). The plan is marked `is_completed` when `current_amount >= target_amount`. + +### Group Save + +``` +DataKey::GroupSave(group_id) → GroupSave { + id, creator, title, target_amount, current_amount, + member_count, is_completed, ... +} +DataKey::GroupMemberContribution(group_id, user) → i128 +DataKey::GroupMembers(group_id) → Vec
+``` + +Each member's contribution is tracked individually. The group's `current_amount` is the sum of all contributions. + +--- + +## Withdrawal Flow + +### Flexi Withdrawal + +``` +User calls: withdraw_flexi(user, amount) + +1. require_auth(user) +2. Check FlexiBalance(user) >= amount +3. fee = floor(amount × withdrawal_fee_bps / 10_000) +4. net = amount - fee +5. FlexiBalance(user) -= amount +6. User.total_balance -= amount +7. Fee credited to fee recipient internal balance +8. Token transfer: nestera_contract ──[USDC]──► user wallet (net amount) +``` + +### Lock Save Withdrawal + +``` +User calls: withdraw_lock_save(user, lock_id) + +1. require_auth(user) +2. Verify lock_save.owner == user +3. Verify is_withdrawn == false +4. Verify ledger.timestamp() >= maturity_time (else: TooEarly error) +5. Calculate final_amount = principal + accrued_yield + (simple interest: amount × (1 + rate × duration_years)) +6. Mark lock_save.is_withdrawn = true +7. User.total_balance -= lock_save.amount +8. Token transfer: nestera_contract ──[USDC]──► user wallet (final_amount) +``` + +### Goal Save Withdrawal (completed) + +``` +User calls: withdraw_completed_goal_save(user, goal_id) + +1. require_auth(user) +2. Verify goal_save.is_completed == true (else: TooEarly) +3. Verify goal_save.is_withdrawn == false +4. fee = floor(current_amount × withdrawal_fee_bps / 10_000) +5. net = current_amount - fee +6. Mark goal_save.is_withdrawn = true +7. User.total_balance += net +8. Fee credited to fee recipient +9. Token transfer: nestera_contract ──[USDC]──► user wallet (net) +``` + +### Goal Save Early Break + +``` +User calls: break_goal_save(user, goal_id) + +1. require_auth(user) +2. Verify goal is NOT completed +3. early_break_fee = floor(current_amount × early_break_fee_bps / 10_000) +4. net = current_amount - early_break_fee +5. Mark goal_save.is_withdrawn = true +6. Fee credited to fee recipient +7. Token transfer: nestera_contract ──[USDC]──► user wallet (net) +``` + +--- + +## Fee Flow + +Every deposit and withdrawal generates a protocol fee. Here is where it goes: + +``` +User deposit (10,000 USDC, 0.5% fee) + │ + ├── 9,950 USDC → credited to user's internal balance + └── 50 USDC → DataKey::TotalBalance(fee_recipient) += 50 + Treasury.total_fees_collected += 50 + Treasury.treasury_balance += 50 +``` + +The fee stays inside the contract as an internal accounting entry. The admin can later: + +1. **Allocate** it into sub-pools via `allocate_treasury(reserve_%, rewards_%, operations_%)` +2. **Withdraw** from a pool via `withdraw_treasury(pool, amount)` — subject to per-tx and daily caps + +``` +Treasury.treasury_balance (unallocated) + │ + ├── allocate_treasury(4000, 3000, 3000) + │ + ├── Reserve pool (40%) + ├── Rewards pool (30%) + └── Operations pool (30%) +``` + +--- + +## Yield Strategy Flow + +For Lock and Group Save plans, funds can optionally be routed to an external yield strategy contract. + +``` +Admin calls: route_lock_to_strategy(lock_id, strategy_address, amount) + +1. Validate strategy is registered and enabled +2. Record StrategyPosition in storage (CEI pattern — state before external call) +3. Call strategy_contract.strategy_deposit(nestera_address, amount) + → Tokens move: nestera_contract ──[USDC]──► strategy_contract +4. Strategy returns shares received +5. StrategyPosition.strategy_shares updated + +Later: harvest_strategy(strategy_address) + +1. Call strategy_contract.strategy_balance(nestera_address) +2. profit = strategy_balance - recorded_principal +3. Call strategy_contract.strategy_harvest(nestera_address) + → Yield tokens move: strategy_contract ──[USDC]──► nestera_contract +4. treasury_fee = floor(profit × performance_fee_bps / 10_000) +5. user_yield = profit - treasury_fee +6. Treasury.total_fees += treasury_fee +7. Treasury.total_yield_earned += user_yield +8. StrategyYield(strategy_address) += user_yield +``` + +--- + +## Complete Token Flow Diagram + +``` + ┌─────────────────────────────────────────┐ + │ Nestera Contract │ + │ │ +User Wallet │ Internal Storage │ + │ │ ┌──────────────────────────────────┐ │ + │ USDC transfer ──►│ │ FlexiBalance(user) │ │ + │ (deposit) │ │ LockSave(id) │ │ + │ │ │ GoalSave(id) │ │ + │ │ │ GroupSave(id) │ │ + │ │ │ Treasury { fees, yield, pools } │ │ + │ │ └──────────────────────────────────┘ │ + │ │ │ │ + │ │ │ (optional) │ + │ │ ▼ │ + │ │ ┌──────────────────────────────────┐ │ + │ │ │ Yield Strategy Contract │ │ + │ │ │ strategy_deposit / harvest │ │ + │ │ └──────────────────────────────────┘ │ + │ │ │ + │◄── USDC transfer ─│ (withdrawal: net amount after fees) │ + │ (withdrawal) │ │ + │ └─────────────────────────────────────────┘ + │ + │ Fee portion ──────────────────────────────────────────────► + Treasury Address + (fee_recipient) +``` + +--- + +## Key Invariants + +- `contract_token_balance >= sum(all user internal balances)` — the contract never owes more than it holds +- All arithmetic uses **checked math** (`checked_add`, `checked_sub`, `checked_mul`) — overflow/underflow panics rather than silently wrapping +- Fees always round **down** — users are never charged more than the stated rate +- A **reentrancy guard** (`DataKey::ReentrancyGuard`) prevents re-entrant calls during external strategy interactions +- The contract can be **paused** by admin or governance — all state-changing functions check `require_not_paused()` before executing +- **Emergency withdraw** allows admin to force-exit any plan and disable the associated strategy if a vulnerability is detected + +--- + +## Trustline Checklist + +Before a user can interact with Nestera: + +- [ ] User wallet has a trustline to the deposit token (e.g. USDC) +- [ ] Nestera contract account has a trustline to the deposit token +- [ ] User has approved the Nestera contract to spend their tokens (via the token contract's `approve` function) +- [ ] User account is registered via `init_user` or `initialize_user` + +If any of these are missing, the deposit transaction will fail — either at the Stellar network layer (missing trustline) or at the contract layer (user not found / insufficient allowance).