diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9715ff2..5f504c63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,10 @@ jobs: working-directory: smartcontract run: cargo build --target wasm32-unknown-unknown --release --manifest-path=contracts/governance/Cargo.toml + - name: Build Yield Vault contract + working-directory: smartcontract + run: cargo build --target wasm32-unknown-unknown --release --features mock --manifest-path=contracts/yield-vault/Cargo.toml + - name: Verify WASM artifacts working-directory: smartcontract run: | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b9204e28..e2c4ef94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -871,7 +871,7 @@ GitHub Actions workflows: - Builds all 5 core Soroban contracts - Verifies WASM artifacts - Runs contract unit tests, formatting checks, and clippy linting - - Runs a deterministic (fixed-seed) proptest fuzz suite across 7 of the 8 contracts + - Runs a deterministic (fixed-seed) proptest fuzz suite across 7 of the 9 contracts 2. Deploy Workflow (`.github/workflows/deploy.yml`): - Manual trigger only diff --git a/README.md b/README.md index b99c2595..eca67d59 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Across the world, millions of people rely on informal savings groups to pool mon - **πŸ”„ Rotational Mode** – Members take turns receiving the full pool payout - **🎯 Target Pool Mode** – Groups save toward a shared goal with automatic unlock - **πŸ’° Flexible Pool Mode** – Members deposit anytime with optional yield distribution +- **🏦 Vault-Backed Yield** – Flexible pools can opt in to allocating idle capital to an external yield vault through governance, behind a liquidity buffer ### Core Capabilities @@ -46,7 +47,7 @@ JointSave uses a light on-chain / off-chain split: - **Factory contract** – trusted registry that spawns pools and installs them by WASM hash - **Pool contracts** – Rotational (fixed deposits, rotating payouts), Target (goal + deadline), Flexible (variable deposits with yield options) -- **Supporting contracts** – Reputation (on-chain trust scores), Microloan (P2P lending), Governance (vote-weighted proposals), Yield Strategy (yield routing) +- **Supporting contracts** – Reputation (on-chain trust scores), Microloan (P2P lending), Governance (vote-weighted proposals), Yield Strategy (yield routing), Yield Vault (vault adapter + mock vault for vault-backed yield allocation) - **Frontend** – Next.js 16 (App Router) + Tailwind + shadcn/ui, connecting to Stellar via Stellar SDK and Wallets Kit - **Off-chain** – Supabase (Postgres) for user metadata, pool titles, notifications, and reusable views, with Row-Level Security - **Safeguards** – incident response / pause-authorization flows, pool archival, and dispute resolution (see [docs/INCIDENT_RESPONSE.md](docs/INCIDENT_RESPONSE.md)) @@ -55,7 +56,7 @@ A full technical walkthrough β€” contract layer, flows, and database schema β€” ## Current Status -- **On-chain:** factory + Rotational/Target/Flexible WASMs deployed to **Stellar Testnet** (see [Deployed Contracts](#deployed-contracts)). Governance, Microloan, and Yield Strategy have **no recorded deployment yet** – they are feature-gated via env vars. +- **On-chain:** factory + Rotational/Target/Flexible WASMs deployed to **Stellar Testnet** (see [Deployed Contracts](#deployed-contracts)). Governance, Microloan, Yield Strategy, and the Yield Vault have **no recorded deployment yet** – they are feature-gated via env vars. - **Frontend:** `joint-save-y4yx.vercel.app` is hosted on Vercel, linked to `JointSave-org/Joint_Save` (root directory `frontend`) β€” pushes to `main` auto-deploy to Production. - **CI/CD:** 4 GitHub Actions workflows – contracts build/test, frontend lint + format + build + component tests, Node unit tests, Playwright E2E (pull requests), plus a manual contract-deploy dispatcher. - **Known gaps under active work:** the admin emergency-withdrawal flow is still awaiting contract execution support ([#263](https://github.com/JointSave-org/Joint_Save/issues/263)); the admin incident-review UI is in progress ([#261](https://github.com/JointSave-org/Joint_Save/issues/261)). @@ -85,7 +86,7 @@ A full technical walkthrough β€” contract layer, flows, and database schema β€” ## Deployed Contracts -The codebase ships an **8-contract Soroban suite**. The following are deployed/uploaded on **Stellar Testnet**: +The codebase ships a **9-contract Soroban suite**. The following are deployed/uploaded on **Stellar Testnet**: | Contract | Address | | ------------------- | ------------------------------------------------------------------ | @@ -96,7 +97,7 @@ The codebase ships an **8-contract Soroban suite**. The following are deployed/u _Deployed on April 16, 2026 β€” see [`smartcontract/deployments/stellar-testnet.json`](smartcontract/deployments/stellar-testnet.json) for the source of truth._ -The full suite also includes **governance**, **microloan**, **reputation**, and **yield-strategy** contracts. Reputation is deployed by [`smartcontract/scripts/deploy.sh`](smartcontract/scripts/deploy.sh); governance, microloan, and yield-strategy have no testnet deployment recorded yet. Microloan and reputation are feature-gated through the `NEXT_PUBLIC_MICROLOAN_CONTRACT_ID` / `NEXT_PUBLIC_REPUTATION_CONTRACT_ID` env vars (leave blank to disable). +The full suite also includes **governance**, **microloan**, **reputation**, **yield-strategy**, and **yield-vault** contracts. Reputation and the mock yield-vault are deployed by [`smartcontract/scripts/deploy.sh`](smartcontract/scripts/deploy.sh); governance, microloan, yield-strategy, and the yield-vault have no testnet deployment recorded yet. Microloan and reputation are feature-gated through the `NEXT_PUBLIC_MICROLOAN_CONTRACT_ID` / `NEXT_PUBLIC_REPUTATION_CONTRACT_ID` env vars (leave blank to disable). For complete API documentation β€” functions, events, storage keys, error conditions, and CLI examples β€” see **[docs/contract-api.md](docs/contract-api.md)**. @@ -243,7 +244,7 @@ _Filter by date, pool, or activity type β€” then export to CSV_ - **DeFi Integration** – Connect flexible pools to Stellar DeFi protocols for yield (yield-strategy contract scaffolded; wire liquidity paths) - **Advanced Analytics** – Protocol-wide analytics dashboard (telemetry + insights beyond per-pool health) - **Group Communication** – In-app messaging and notifications -- **Mainnet Hardening** – Deploy the 8-contract suite to Stellar mainnet (governance, microloan, yield-strategy still have no testnet deployment recorded) +- **Mainnet Hardening** – Deploy the 9-contract suite to Stellar mainnet (governance, microloan, yield-strategy, and the yield vault still have no testnet deployment recorded) - **Admin Emergency Console** – Self-service pause/resume/emergency-withdraw UI with SEP-53 proof (backend exists; UI on the way) ### 🌍 Phase 3 - Scale @@ -279,6 +280,7 @@ We welcome contributions from the community! Here's how you can help: - **[Architecture Guide](ARCHITECTURE.md)** – Detailed technical documentation - **[Smart Contract API](smartcontract/README.md)** – Contract interfaces and usage +- **[Yield Allocation Threat Model](docs/security/yield-allocation-threat-model.md)** – Risks and mitigations for vault-backed yield - **[Deployment Guide](smartcontract/scripts/deploy.sh)** – How to deploy contracts - **[Frontend Setup](frontend/README.md)** – Development environment setup diff --git a/docs/plans/2026-10-16-yield-allocation.md b/docs/plans/2026-10-16-yield-allocation.md new file mode 100644 index 00000000..c1464f03 --- /dev/null +++ b/docs/plans/2026-10-16-yield-allocation.md @@ -0,0 +1,219 @@ +# Yield Allocation β€” Design Specification + +**Status:** Proposed +**Scope:** `smartcontract/contracts/yield-vault` (new), `smartcontract/contracts/flexible`, +`smartcontract/contracts/governance`, `frontend/` + +--- + +## 1. Goal + +Let a savings group **optionally** put idle pot capital to work in a yield vault, without +ever trapping member funds. The feature is: + +- **Opt-in per group.** Groups that do not enable it behave exactly as today. No state + migration is performed for non-participating groups. +- **Vault-agnostic.** The pool talks to any contract implementing the adapter interface; + no issuer, router, or asset is hard-coded. +- **Governed.** Moving pot capital into a vault requires a group vote, reusing the + existing `governance` contract membership/quorum/proposal conventions. +- **Conservative.** A configurable share of the pot always stays liquid so ordinary + withdrawals keep working. + +## 2. Existing pieces we build on + +| Piece | Location | What we reuse | +|---|---|---| +| Flexible pool yield plumbing | `contracts/flexible/src/lib.rs` | `YieldEnabled`, `YieldStrategy`, `DeployedToYield` keys, `deploy_to_yield`/`harvest_yield` CPI pattern | +| Strategy contract | `contracts/yield-strategy/` | Precedent for a pluggable on-chain yield target; **not** reused directly (it is a DEX-LP strategy with no share accounting) | +| Governance | `contracts/governance/src/lib.rs` | `create_proposal` / `vote` / `execute_proposal`, quorum math, `Custom` proposal type + `dispatch` | +| Governance UI | `frontend/components/governance/`, `frontend/hooks/useGovernance.ts`, `frontend/lib/governance.ts` | Create-proposal dialog, vote card, quorum helpers | +| Yield dashboard | `frontend/components/group/yield-dashboard.tsx` | Existing container we extend rather than duplicate | + +We deliberately **do not** touch the closed `ProposalType` enum; allocation rides on the +existing `Custom` type (see Β§6). + +## 3. Vault adapter interface + +A new crate `contracts/yield-vault` defines the boundary. The pool depends only on this +interface, never on a concrete vault. + +```rust +pub const SHARE_SCALE: i128 = 1_000_000_000; // 1e9, assets per SHARE_SCALE shares + +#[contractclient(name = "VaultClient")] +pub trait VaultAdapter { + /// Deposit `amount` of the vault's underlying asset on behalf of `from`. + /// The caller must have transferred `amount` tokens to the vault first. + /// Returns the number of shares minted (>= 0). + fn deposit(env: Env, from: Address, amount: i128) -> i128; + + /// Redeem `shares` and send the proceeds to `to`. + /// Returns the assets actually paid out. Callers must pass + /// `shares <= max_redeemable()`. + fn withdraw(env: Env, to: Address, shares: i128) -> i128; + + /// Assets per `SHARE_SCALE` shares, rounded down. + fn share_price(env: Env) -> i128; + + /// Maximum shares that can be redeemed right now (liquidity-limited). + fn max_redeemable(env: Env) -> i128; + + /// Total shares outstanding. + fn total_shares(env: Env) -> i128; + + /// Total assets under management. + fn total_assets(env: Env) -> i128; +} +``` + +Rules for any adapter implementation: + +- **Integer math only.** No floats anywhere in the settlement path. +- **Rounding favours the vault/pool, never the withdrawing member.** +- `deposit` mints `amount * SHARE_SCALE / share_price` shares (floor); the first deposit + mints `amount * SHARE_SCALE / SHARE_SCALE == amount` shares at price `SHARE_SCALE`. +- `withdraw` pays `shares * share_price / SHARE_SCALE` (floor) and burns exactly + `shares`. A vault that cannot honour a redemption must keep `max_redeemable` honest so + callers never request more than it can pay. +- Every state-changing entrypoint performs its auth check first and settles state before + external transfers (checks-effects-interactions). + +## 4. Mock vault (tests and local development) + +`contracts/yield-vault/src/mock.rs` implements the adapter for tests and for local +dry-runs. It is **not** a production vault. + +- `initialize(admin, token)`. +- `total_assets()` = live `token.balance(vault)`; tests simulate yield by minting the + underlying token to the vault, which raises `share_price` for existing holders. +- `set_locked_assets(admin, amount)` simulates illiquidity: locked assets are excluded + from `max_redeemable`, so the pool exercises partial-withdrawal handling deterministically. +- No hard-coded issuer/router/asset. + +## 5. Pool-side allocation state and math + +New storage keys on the flexible pool (additive; absent keys default to "yield +de-allocation disabled / not allocated"): + +| Key | Type | Meaning | +|---|---|---| +| `YieldVault` | `Address` | Active vault for this pool | +| `YieldShares` | `i128` | Shares the pool holds in the vault | +| `YieldCostBasis` | `i128` | Assets the pool deposited (for realised-yield accounting) | +| `YieldBufferBps` | `u32` | Minimum liquid share of the pot, basis points (default `2000` = 20%) | +| `YieldLocked` | `bool` | Emergency de-allocation-only mode | + +**Liquid balance** `L = token.balance(pool)`. +**Pot total** `T = TotalBalance` (includes value deployed to the vault at cost basis plus +unrealised yield only after a sync). + +**Maximum allocatable** (never breaches the buffer): + +``` +target_liquid = ceil(T * YieldBufferBps / 10_000) +max_allocatable = L - target_liquid (0 if negative) +``` + +A `allocate` request larger than `max_allocatable` reverts with `buffer would be +violated`. Allocation therefore always leaves at least `YieldBufferBps` of the pot +withdrawable immediately. + +**De-allocation** (member-triggered, `deallocate_yield(caller, amount)`): + +1. `caller` must be a pool member. +2. Map the requested asset `amount` to shares, rounded **up**: + `shares_needed = ceil(amount * SHARE_SCALE / share_price)`. +3. Cap `shares_needed` at `min(YieldShares, max_redeemable())`. +4. If the cap is `0`, revert with `vault illiquid` (clear error, funds stay safe). +5. Otherwise call `withdraw(pool, shares)` and add the returned assets to the liquid + balance, decrementing `YieldShares` by the shares burned. A partially-filled request + returns fewer assets than asked for **without reverting** β€” this is the graceful + "partial withdrawal" path. + +The pool never assumes it can redeem everything at once; it degrades to a partial +redemption instead of failing. + +## 6. Governance flow (reuse `Custom`) + +`ProposalType::Custom` already carries a free-form `parameters: Map`, and +`dispatch` currently does nothing for it. We extend `dispatch` so that a `Custom` proposal +whose parameters include an allocation action is routed to the pool's +`apply_yield_allocation`: + +| Parameter key | Encoding | Value | +|---|---|---| +| `action` | 4-byte symbol (`alloc`/`dealloc`) | requested operation | +| `amount` | big-endian i128 (existing `encodeParamHex`) | assets to move | +| `vault` | 32-byte address or SAC bytes | target vault (`alloc` only) | + +`dispatch` behaviour: + +- If `parameters` contains `action` == `alloc` β†’ CPI + `pool.apply_yield_allocation(gov, vault, amount)`. +- If `action` == `dealloc` β†’ CPI `pool.apply_yield_allocation(gov, zero, -amount)` (or an + explicit de-allocate entrypoint). +- Otherwise β†’ keep today's behaviour (return a `custom` result payload). + +This keeps the enum, the stored proposal schema, and the frontend `ProposalType` mirror +unchanged. Existing deployed governance contracts are upgraded with `migrate()`. + +## 7. Yield accounting and distribution + +- The pool's vault position is valued as `shares * share_price / SHARE_SCALE`. +- **Realised yield** = current position value βˆ’ `YieldCostBasis`. +- On `harvest`/`sync`, realised yield is credited to the pot and distributed pro-rata by + each member's principal `Balance`, using floor division so any rounding remainder stays + in the pot (favours the pot, never over-credits a member). +- `YieldCostBasis` is increased by the harvested amount so the same yield is not + double-counted; the remainder always stays in the pool. + +## 8. Safety requirements + +- **Auth** on every state-changing entrypoint: admin or the registered governance + contract only for allocation; any member for de-allocation. +- **Checked arithmetic** throughout (`checked_add`/`checked_sub`/`checked_mul`); overflow + reverts. Release profile already has `overflow-checks = true`. +- **Checks-effects-interactions**: update `YieldShares`, `YieldCostBasis`, and member + balances before any token/CPI transfer. +- **Reentrancy**: no external call happens before internal state is consistent; vault + calls are treated as untrusted. +- **Events** for every fund movement: `ya_alloc`, `ya_deall`, `ya_sync`, `ya_bufset`, + `ya_locked`. + +## 9. Risks (documented, not hidden) + +| Risk | Mitigation | +|---|---| +| **Vault custody** β€” funds sit in a third-party contract | Opt-in per group; vault address is proposed and voted on; emergency `YieldLocked` mode stops new allocations and forces de-allocation-only | +| **Share-value dependency** β€” a vault can lose value | `share_price` is read live; losses surface as reduced position value; pot never promises a return | +| **Illiquid withdrawal** β€” vault cannot redeem on demand | Liquidity buffer keeps a floor liquid; de-allocation degrades to partial and reverts with a clear error rather than trapping funds | +| **Rounding leakage** | All rounding favours the pot; remainders stay in the pool | +| **Unauthorised allocation** | Only the registered governance contract (or admin) may allocate; members can always de-allocate | + +## 10. Deployment additions + +- New wasm hash for `yield-vault` in `smartcontract/deployments/stellar-testnet.json`. +- `smartcontract/scripts/deploy.sh` builds and records it. +- Frontend env vars for the mock/real vault address (`NEXT_PUBLIC_YIELD_VAULT_ID`, + optional β€” groups carry their own vault id once allocated). +- Flexible pool wasm hash bumped once the new entrypoints ship. + +## 11. Commit mapping + +1. `docs:` this specification. +2. `feat(contract):` adapter interface + types/errors/events. +3. `feat(contract):` mock vault. +4. `feat(contract):` allocation proposals + voting (extend governance `dispatch`). +5. `feat(contract):` allocate idle pot balance (enforce liquidity buffer). +6. `feat(contract):` de-allocation + buffer-safe withdraw. +7. `feat(contract):` pro-rata yield accounting/distribution. +8. `test(contract):` edge cases (illiquidity, rounding, double-vote, unauthorised, zero). +9. `feat(frontend):` bindings + client. +10. `feat(frontend):` service layer + data hooks. +11. `feat(frontend):` proposal creation flow. +12. `feat(frontend):` voting UI. +13. `feat(frontend):` yield dashboard (extend existing). +14. `feat(frontend):` withdraw/de-allocate flow with illiquidity states. +15. `test(frontend):` hook + component tests. +16. `docs:` threat model, README, testnet deploy script. diff --git a/docs/security/yield-allocation-threat-model.md b/docs/security/yield-allocation-threat-model.md new file mode 100644 index 00000000..96a0ceec --- /dev/null +++ b/docs/security/yield-allocation-threat-model.md @@ -0,0 +1,132 @@ +# Vault-Backed Yield Allocation – Threat Model + +This document records the threat model for the optional **vault-backed yield +allocation** feature on `flexible` pools. It complements the design +specification in [`docs/plans/2026-10-16-yield-allocation.md`](../plans/2026-10-16-yield-allocation.md) +and the on-chain safety requirements described there. + +The feature lets a group vote to move idle pool capital into an external, +yield-bearing Soroban vault (the "vault") behind a `VaultAdapter` interface. +Capital not allocated stays liquid so member withdrawals always work. + +--- + +## 1. Scope and trust boundaries + +| Component | Trust level | Notes | +| --- | --- | --- | +| `flexible` pool contract | Trusted | Holds member principal; enforces the liquidity buffer | +| `governance` contract | Trusted | Only address allowed to allocate/de-allocate on behalf of the group | +| Group admin / members | Semi-trusted | Admin can allocate; any member can de-allocate | +| **Yield vault** (adapter target) | **Untrusted** | Third-party contract; may lie about share price, refuse redemption, or be upgraded by its own admin | +| Vault token / asset | Untrusted | Value can move independently of the pool | + +The pool treats every vault as untrusted: it reads the vault's reported +`share_price` and `max_redeemable`, never assumes a redemption will succeed, +and never holds a vault call before its own state is consistent. + +--- + +## 2. Assets at risk + +- **Member principal** (`TotalBalance`, per-member `Balance`) β€” the pool's first obligation. +- **Buffered liquidity** β€” the portion of the pot deliberately kept out of the vault. +- **Realised yield** β€” credited pro-rata only after it is actually available. +- **Governance integrity** β€” who may move capital, and under what threshold. + +--- + +## 3. Threats and mitigations + +### 3.1 Vault custody β€” funds sit in a third-party contract + +| | | +| --- | --- | +| **Threat** | A malicious or compromised vault keeps, freezes, or diverts deposited capital. | +| **Impact** | High β€” loss of allocated principal. | +| **Mitigation** | Allocation is **opt-in per group** and the vault address is proposed and passed through governance. A `YieldLocked` emergency mode stops new allocations and allows de-allocation only. Capital is never allocated above `max_allocatable` (buffer enforced on-chain). | + +### 3.2 Share-value dependency β€” a vault can lose value + +| | | +| --- | --- | +| **Threat** | The vault's share price falls; the position is worth less than cost basis. | +| **Impact** | Medium β€” members receive less than they deposited into the vault. | +| **Mitigation** | `share_price` is read live, so losses surface immediately as reduced position value. The pot **never promises a return**; yield is credited only when realised (position value above cost basis). | + +### 3.3 Illiquid withdrawal β€” vault cannot redeem on demand + +| | | +| --- | --- | +| **Threat** | Members try to withdraw while most capital is in the vault and the vault cannot redeem. | +| **Impact** | High β€” members temporarily cannot access their funds. | +| **Mitigation** | A configurable **liquidity buffer** (default 20%) keeps a floor liquid. On withdrawal the pool auto-pulls from the vault; if the vault cannot redeem the whole amount, the call **reverts with a clear `"vault illiquid"` error** rather than trapping or silently short-changing funds. De-allocation degrades to a partial withdrawal. | + +### 3.4 Rounding leakage + +| | | +| --- | --- | +| **Threat** | Division/rounding lets a member or the vault extract more than its share. | +| **Impact** | Low β€” dust leakage per operation. | +| **Mitigation** | All arithmetic is **integer-only** and rounds **in favour of the pot** (floor division for member credit, ceiling for allocation). remainders stay in the pool, never over-crediting a member. | + +### 3.5 Unauthorised allocation + +| | | +| --- | --- | +| **Threat** | An attacker (or a rogue admin) moves capital into an attacker-controlled vault. | +| **Impact** | High β€” theft of principal. | +| **Mitigation** | Every state-changing entrypoint calls `require_auth`. Allocation requires **admin or the registered governance contract**; the vault address must be a valid 56-byte strkey. Members can always de-allocate. Non-governance callers cannot forge a proposal. | + +### 3.6 Arithmetic overflow / underflow + +| | | +| --- | --- | +| **Threat** | Crafted amounts overflow `i128` maths and wrap. | +| **Impact** | High β€” mis-accounting. | +| **Mitigation** | `checked_add` / `checked_sub` / `checked_mul` throughout; the release profile sets `overflow-checks = true`; overflow reverts. | + +### 3.7 Reentrancy via the vault + +| | | +| --- | --- | +| **Threat** | A malicious vault re-enters the pool during a CPI and observes inconsistent state. | +| **Impact** | Medium–High β€” double-spend of accounting. | +| **Mitigation** | **Checks-effects-interactions**: `YieldShares`, `YieldCostBasis`, and member balances are updated **before** any token/CPI transfer. No external call happens before internal state is consistent. | + +### 3.8 De-allocation traps funds + +| | | +| --- | --- | +| **Threat** | A vault that only partially redeems causes the pool to strand capital. | +| **Impact** | Medium. | +| **Mitigation** | De-allocation performs a **partial** withdrawal and records what was actually received; the remainder stays recorded as vault shares, so accounting never assumes a full exit. | + +### 3.9 Event forgery / missing audit trail + +| | | +| --- | --- | +| **Threat** | Fund movements are not attributable after the fact. | +| **Impact** | Low–Medium. | +| **Mitigation** | Every movement emits an event: `ya_alloc`, `ya_deall`, `ya_sync`, `ya_bufset`, `ya_locked`. Vault-side mock events are gated so they never leak into consumers' wasm. | + +--- + +## 4. Out of scope + +- Compromise of the Stellar network or Soroban host. +- Economic/game-theoretic attacks on the **external vault's own** design (the + pool treats it as untrusted and caps exposure via the buffer). +- Frontend-only issues (phishing UI, wallet compromise) β€” see the general + security documentation. + +--- + +## 5. Residual risk + +Members accept that any capital allocated to a vault is exposed to that vault's +counterparty, liquidity, and smart-contract risk. The pool limits β€” but cannot +eliminate β€” this exposure: the buffer bounds the illiquid portion, governance +gates the vault choice, and emergency locking enables an exit. Groups that do +not want this risk simply never allocate (the feature is opt-in and a pool with +no vault behaves exactly as before). diff --git a/frontend/__tests__/vote-card-yield.test.tsx b/frontend/__tests__/vote-card-yield.test.tsx new file mode 100644 index 00000000..2007e3d5 --- /dev/null +++ b/frontend/__tests__/vote-card-yield.test.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { render, screen } from "@/test-utils" +import { VoteCard } from "@/components/governance/vote-card" +import { describe, it, expect, vi } from "vitest" +import type { GovernanceProposal } from "@/lib/governance" + +const hexAmount = (baseUnits: bigint) => baseUnits.toString(16).padStart(32, "0") +const hexUtf8 = (value: string) => Buffer.from(value, "utf8").toString("hex") + +function yieldProposal(overrides: Partial = {}): GovernanceProposal { + return { + id: "aa".repeat(32), + proposer: "GPROPOSERADDRESS", + proposalType: "Custom", + description: "Allocate 5 tokens to the yield vault", + votesFor: [], + votesAgainst: [], + status: "Active", + createdAt: 1, + expiresAt: 999999, + parameters: { + action: hexUtf8("alloc"), + amount: hexAmount(50_000_000n), // 5 tokens at 7 decimals + vault: hexUtf8("CVAULTCONTRACTADDRESS"), + }, + ...overrides, + } +} + +describe("VoteCard yield allocation details", () => { + it("shows the decoded action, amount and vault for an allocation proposal", () => { + render( + + ) + + expect(screen.getByText("Yield allocation")).toBeInTheDocument() + expect(screen.getByText("Allocate to vault")).toBeInTheDocument() + expect(screen.getByText("5")).toBeInTheDocument() + }) + + it("shows De-allocate for a dealloc proposal", () => { + render( + + ) + + expect(screen.getByText("De-allocate from vault")).toBeInTheDocument() + expect(screen.getByText("1")).toBeInTheDocument() + }) + + it("does not render the yield block for a plain custom proposal", () => { + render( + + ) + + expect(screen.queryByText("Yield allocation")).not.toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/yield-dashboard-vault.test.tsx b/frontend/__tests__/yield-dashboard-vault.test.tsx new file mode 100644 index 00000000..7b516ed2 --- /dev/null +++ b/frontend/__tests__/yield-dashboard-vault.test.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { render, screen } from "@/test-utils" +import { YieldDashboard } from "@/components/group/yield-dashboard" +import { vi, describe, it, expect, beforeEach } from "vitest" +import { useYieldAllocation } from "@/hooks/useYieldAllocation" + +vi.mock("@/hooks/useYieldAllocation") + +const mockUseYieldAllocation = vi.mocked(useYieldAllocation) + +function allocationState(overrides: Partial> = {}) { + return { + state: { + vault: "CVAULTCONTRACTADDRESS", + shares: 2_000_000_000n, + costBasis: 100_000_000n, + bufferBps: 2000, + locked: false, + position: 150_000_000n, // 15 tokens at 7 decimals + unrealised: 50_000_000n, // 5 tokens + maxAllocatable: 40_000_000n, // 4 tokens + }, + loading: false, + error: null, + refresh: vi.fn(), + maxAllocatable: 40_000_000n, + applyAllocation: vi.fn(), + deallocate: vi.fn(), + sync: vi.fn(), + setBuffer: vi.fn(), + setLocked: vi.fn(), + ...overrides, + } +} + +describe("YieldDashboard vault allocation", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders vault position, unrealised yield and max allocatable", async () => { + mockUseYieldAllocation.mockReturnValue(allocationState() as never) + + render() + + expect(screen.getByText("Vault Allocation")).toBeInTheDocument() + expect(screen.getByText("Position value")).toBeInTheDocument() + // 15 tokens position value (150_000_000 base units at 7 decimals) + expect(screen.getByText("15")).toBeInTheDocument() + expect(screen.getByText("Unrealised yield")).toBeInTheDocument() + expect(screen.getByText("5")).toBeInTheDocument() + expect(screen.getByText("Available to allocate")).toBeInTheDocument() + expect(screen.getByText("4")).toBeInTheDocument() + }) + + it("shows the illiquidity badge and lock toggle when the vault is locked", () => { + mockUseYieldAllocation.mockReturnValue( + allocationState({ + state: { + vault: "CVAULTCONTRACTADDRESS", + shares: 2_000_000_000n, + costBasis: 1_000_000_000n, + bufferBps: 2000, + locked: true, + position: 1_500_000_000n, + unrealised: 500_000_000n, + maxAllocatable: 0n, + }, + }) as never + ) + + render() + + expect(screen.getByText("Vault locked")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock vault/i })).toBeInTheDocument() + }) + + it("shows the empty state when no vault is configured", () => { + mockUseYieldAllocation.mockReturnValue( + allocationState({ + state: { + vault: null, + shares: 0n, + costBasis: 0n, + bufferBps: 2000, + locked: false, + position: 0n, + unrealised: 0n, + maxAllocatable: 0n, + }, + }) as never + ) + + render() + + expect( + screen.getByText("No capital allocated to a vault yet.") + ).toBeInTheDocument() + }) + + it("surfaces a load error when the hook reports one", () => { + mockUseYieldAllocation.mockReturnValue( + allocationState({ state: null, error: "Failed to load yield state" }) as never + ) + + render() + + expect(screen.getByText("Failed to load yield state")).toBeInTheDocument() + }) +}) diff --git a/frontend/app/api/v1/protocol/summary/route.ts b/frontend/app/api/v1/protocol/summary/route.ts new file mode 100644 index 00000000..2383c4b4 --- /dev/null +++ b/frontend/app/api/v1/protocol/summary/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" + +const SUBGRAPH_URL = + process.env.NEXT_PUBLIC_SUBGRAPH_URL || + "https://api.thegraph.com/subgraphs/name/protocol/analytics" + +const QUERY = ` + query GetProtocolSummary { + protocols(first: 1) { + totalValueLockedUSD + totalVolumeUSD + activePoolsCount + totalMembers + } + protocolDayDatas(first: 30, orderBy: date, orderDirection: desc) { + date + tvlUSD + dailyVolumeUSD + } + pools(first: 10, orderBy: totalValueLockedUSD, orderDirection: desc) { + id + totalValueLockedUSD + volumeUSD + isActive + } + } +` + +export async function GET() { + try { + const res = await fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: QUERY }), + next: { revalidate: 60 }, // Cache payload for 60 seconds + }) + + const { data, errors } = await res.json() + if (errors) throw new Error(JSON.stringify(errors)) + + return NextResponse.json({ success: true, data }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch protocol analytics" + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx new file mode 100644 index 00000000..a5bc9fde --- /dev/null +++ b/frontend/app/components/Dashboard.tsx @@ -0,0 +1,146 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { Activity, DollarSign, Layers, Users, ArrowUpRight, ShieldAlert } from "lucide-react" + +interface AnalyticsData { + protocol: { + totalValueLockedUSD: string + totalVolumeUSD: string + activePoolsCount: string + totalMembers: string + } + pools: Array<{ id: string; totalValueLockedUSD: string; volumeUSD: string; isActive: boolean }> +} + +export default function AnalyticsDashboard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + useEffect(() => { + async function fetchData() { + try { + const res = await fetch("/api/v1/protocol/summary") + const json = await res.json() + if (json.success && json.data.protocols[0]) { + setData({ + protocol: json.data.protocols[0], + pools: json.data.pools, + }) + } else { + setError(true) + } + } catch { + setError(true) + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + if (loading) + return ( +
Loading protocol state...
+ ) + if (error || !data) + return ( +
+ + Failed to fetch real-time analytics from Subgraph node. +
+ ) + + const { protocol, pools } = data + + return ( +
+
+
+

Protocol Analytics

+

Real-time metrics & liquidity overview

+
+ + Live Indexer + +
+ + {/* Primary KPI Metrics */} +
+ } + /> + } + /> + } + /> + } + /> +
+ + {/* Pool Breakdown Table */} +
+
+

Top Active Pools

+
+
+ + + + + + + + + + + {pools.map((pool) => ( + + + + + + + ))} + +
Pool AddressTVL (USD)Total VolumeStatus
+ {pool.id.slice(0, 6)}...{pool.id.slice(-4)} + + + ${Number(pool.totalValueLockedUSD).toLocaleString()} + ${Number(pool.volumeUSD).toLocaleString()} + + {pool.isActive ? "Active" : "Inactive"} + +
+
+
+
+ ) +} + +function KpiCard({ title, value, icon }: { title: string; value: string; icon: React.ReactNode }) { + return ( +
+
+ {title} + {icon} +
+
{value}
+
+ ) +} diff --git a/frontend/components/governance/create-proposal-dialog.tsx b/frontend/components/governance/create-proposal-dialog.tsx index 237bf43e..26a20dd3 100644 --- a/frontend/components/governance/create-proposal-dialog.tsx +++ b/frontend/components/governance/create-proposal-dialog.tsx @@ -25,8 +25,11 @@ import { Label } from "@/components/ui/label" import { GOVERNANCE_DESCRIPTION_MAX, PROPOSAL_TYPES, + buildYieldAllocationParams, encodeParamHex, + parseAmountToBaseUnits, type GovernanceProposalType, + type YieldAllocationAction, } from "@/lib/governance" interface CreateProposalDialogProps { @@ -46,8 +49,11 @@ const APPLICABLE_POOL_TYPES: Record = { AddPenalty: ["flexible", "rotational", "target"], RemovePenalty: ["flexible", "rotational", "target"], ChangeQuorum: ["flexible", "rotational", "target"], + YieldAllocation: ["flexible"], } +const YIELD_DECIMALS = 7 + export function CreateProposalDialog({ open, onOpenChange, @@ -64,9 +70,13 @@ export function CreateProposalDialog({ const [proposalType, setProposalType] = useState("") const [description, setDescription] = useState("") const [paramValue, setParamValue] = useState("") + const [yieldAction, setYieldAction] = useState("alloc") + const [vaultId, setVaultId] = useState("") + const [yieldAmount, setYieldAmount] = useState("") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const isYield = proposalType === "YieldAllocation" const selectedMeta = availableTypes.find((p) => p.value === proposalType) ?? null const needsParam = !!selectedMeta?.paramKey @@ -74,6 +84,9 @@ export function CreateProposalDialog({ setProposalType("") setDescription("") setParamValue("") + setYieldAction("alloc") + setVaultId("") + setYieldAmount("") setSubmitting(false) setError(null) } @@ -83,21 +96,35 @@ export function CreateProposalDialog({ onOpenChange(next) } + const canSubmit = isYield + ? description.trim().length > 0 && + !!yieldAmount && + (yieldAction === "dealloc" || vaultId.trim().length > 0) + : !!proposalType && description.trim().length > 0 && (!needsParam || !!paramValue) + const handleSubmit = async () => { - if (!proposalType || description.trim().length === 0 || (needsParam && !paramValue)) { + if (!canSubmit) { setError(t("fillRequired")) return } setSubmitting(true) setError(null) try { + let submitType: GovernanceProposalType = proposalType as GovernanceProposalType let params: Record = {} - if (needsParam) { + if (isYield) { + params = buildYieldAllocationParams({ + action: yieldAction, + vault: yieldAction === "alloc" ? vaultId.trim() : undefined, + amountBaseUnits: parseAmountToBaseUnits(yieldAmount, YIELD_DECIMALS), + }) + submitType = "Custom" + } else if (needsParam) { params = { [selectedMeta!.paramKey]: encodeParamHex(Number(paramValue.replace(/,/g, ""))), } } - const ok = await onSubmit(proposalType, description.trim(), params) + const ok = await onSubmit(submitType, description.trim(), params) if (!ok) { setError(t("toastError")) setSubmitting(false) @@ -105,8 +132,8 @@ export function CreateProposalDialog({ } reset() onOpenChange(false) - } catch { - setError(t("toastError")) + } catch (e) { + setError(e instanceof Error ? e.message : t("toastError")) setSubmitting(false) } } @@ -156,6 +183,51 @@ export function CreateProposalDialog({ )} + {isYield && ( + <> +
+ + +
+ + {yieldAction === "alloc" && ( +
+ + setVaultId(e.target.value)} + placeholder="C..." + spellCheck={false} + /> +
+ )} + +
+ + setYieldAmount(e.target.value)} + placeholder="0" + /> +
+ + )} +
@@ -187,12 +259,7 @@ export function CreateProposalDialog({
)} + {isFlexible && yieldAlloc.state?.locked && (yieldAlloc.state?.shares ?? 0n) > 0n && ( +
+ {t("vaultIlliquidBanner", { + amount: formatBaseUnits(yieldAlloc.state.shares, 7), + })} +
+ )} +

{t("quickActions")} {renderPendingBadge()} diff --git a/frontend/components/group/yield-dashboard.tsx b/frontend/components/group/yield-dashboard.tsx index 53355e5f..8be70447 100644 --- a/frontend/components/group/yield-dashboard.tsx +++ b/frontend/components/group/yield-dashboard.tsx @@ -22,9 +22,18 @@ import { Transaction, } from "@stellar/stellar-sdk" import { getRpc } from "@/hooks/useJointSaveContracts" +import { useYieldAllocation } from "@/hooks/useYieldAllocation" +import { parseAmountToBaseUnits, formatBaseUnits } from "@/lib/governance" +import { formatSharePrice } from "@/lib/yield-allocation" import { submitWithRetry } from "@/lib/tx-retry" import { TX_TIMEOUT } from "@/lib/constants" +const YIELD_DECIMALS = 7 + +function shorten(addr: string, head = 6, tail = 4): string { + return addr.length > head + tail + 1 ? `${addr.slice(0, head)}…${addr.slice(-tail)}` : addr +} + function addressVal(addr: string): xdr.ScVal { return nativeToScVal(addr.toUpperCase(), { type: "address" }) } @@ -82,6 +91,14 @@ export function YieldDashboard({ poolAddress }: YieldDashboardProps) { const isPending = !poolAddress || poolAddress === "pending_deployment" + // Vault-based allocation (share-based, buffer-safe). Distinct from the legacy + // yield-strategy flow above and only active for flexible pools. + const allocation = useYieldAllocation(isPending ? null : poolAddress) + const [vaultInput, setVaultInput] = useState("") + const [allocAmount, setAllocAmount] = useState("") + const [deallocAmount, setDeallocAmount] = useState("") + const [bufferInput, setBufferInput] = useState("") + const loadState = useCallback(async () => { if (isPending) return setLoading(true) @@ -232,8 +249,64 @@ export function YieldDashboard({ poolAddress }: YieldDashboardProps) { if (isPending) return null + const runAllocationAction = async ( + action: () => Promise, + successKey: string, + onSuccess?: () => void + ) => { + setBusy(true) + setMsg(null) + try { + const ok = await action() + if (ok) { + setMsg({ type: "success", text: t(successKey) }) + onSuccess?.() + } else { + setMsg({ type: "error", text: t("vaultError") }) + } + } catch (e: unknown) { + setMsg({ type: "error", text: (e as Error).message }) + } finally { + setBusy(false) + } + } + + const handleAllocate = () => + runAllocationAction( + () => allocation.applyAllocation(vaultInput.trim(), parseAmountToBaseUnits(allocAmount, YIELD_DECIMALS)), + "vaultAllocated", + () => { + setAllocAmount("") + } + ) + + const handleDeallocate = () => + runAllocationAction( + () => allocation.deallocate(parseAmountToBaseUnits(deallocAmount, YIELD_DECIMALS)), + "vaultDeallocated", + () => setDeallocAmount("") + ) + + const handleSync = () => runAllocationAction(() => allocation.sync(), "vaultSynced") + + const handleSetBuffer = () => + runAllocationAction( + () => allocation.setBuffer(Math.trunc(Number(bufferInput))), + "vaultBufferSet", + () => setBufferInput("") + ) + + const toggleLock = () => + runAllocationAction( + () => allocation.setLocked(!(allocation.state?.locked ?? false)), + allocation.state?.locked ? "vaultUnlockedSuccess" : "vaultLockedSuccess" + ) + const fmtXlm = (stroops: bigint) => (Number(stroops) / 10_000_000).toFixed(4) + const yState = allocation.state + const sharePriceText = yState ? formatSharePrice(yState.position, yState.shares, 4) : null + return (
@@ -359,6 +432,156 @@ export function YieldDashboard({ poolAddress }: YieldDashboardProps) {

{t("harvestHint")}

)} + + {/* Vault allocation (share-based, buffer-safe) */} +
+
+

{t("vaultTitle")}

+ {yState?.locked && ( + + {t("vaultIlliquid")} + + )} +
+ + {allocation.loading && !yState ? ( +

{t("processing")}

+ ) : allocation.error ? ( +

{allocation.error}

+ ) : !yState?.vault ? ( +

{t("vaultEmpty")}

+ ) : ( +
+
+

{t("vaultPosition")}

+

+ {formatBaseUnits(yState.position, YIELD_DECIMALS)} +

+
+
+

{t("vaultUnrealised")}

+

+ {formatBaseUnits(yState.unrealised, YIELD_DECIMALS)} +

+
+
+

{t("vaultSharePrice")}

+

{sharePriceText ?? "β€”"}

+
+
+

{t("vaultMaxAllocatable")}

+

+ {formatBaseUnits(allocation.maxAllocatable, YIELD_DECIMALS)} +

+
+
+ )} + + {yState?.vault && ( +
+ {t("vaultContract")} + + {shorten(yState.vault)} + + + {t("vaultBuffer")}: {(yState.bufferBps / 100).toFixed(2)}% + +
+ )} + +
+ {/* Allocate */} +
+ + setVaultInput(e.target.value)} + disabled={busy} + className="font-mono text-xs" + /> +
+ setAllocAmount(e.target.value)} + disabled={busy} + /> + +
+
+ + {/* De-allocate + buffer */} +
+ +
+ setDeallocAmount(e.target.value)} + disabled={busy} + /> + +
+
+ setBufferInput(e.target.value)} + disabled={busy} + /> + +
+
+
+ +
+ + +
+

{t("vaultHint")}

+
) } diff --git a/frontend/hooks/__mocks__/useJointSaveContracts.ts b/frontend/hooks/__mocks__/useJointSaveContracts.ts index a5e48001..b539e194 100644 --- a/frontend/hooks/__mocks__/useJointSaveContracts.ts +++ b/frontend/hooks/__mocks__/useJointSaveContracts.ts @@ -207,3 +207,41 @@ export const useBumpPoolState = vi.fn().mockReturnValue({ bumpPoolState: vi.fn().mockResolvedValue("tx_hash_bump"), isLoading: false, }) + +// ── Yield allocation (vault) ──────────────────────────────────────────────── + +export const fetchYieldAllocationState = vi.fn().mockResolvedValue({ + vault: "C_MOCK_VAULT_ADDRESS", + shares: 1000000000n, + costBasis: 1000000000n, + bufferBps: 2000, + locked: false, + position: 1100000000n, + unrealised: 100000000n, + maxAllocatable: 500000000n, +}) + +export const useApplyYieldAllocation = vi.fn().mockReturnValue({ + applyAllocation: vi.fn().mockResolvedValue("tx_hash_allocate"), + isLoading: false, +}) + +export const useDeallocateYield = vi.fn().mockReturnValue({ + deallocate: vi.fn().mockResolvedValue("tx_hash_deallocate"), + isLoading: false, +}) + +export const useSyncYield = vi.fn().mockReturnValue({ + sync: vi.fn().mockResolvedValue("tx_hash_sync"), + isLoading: false, +}) + +export const useSetYieldBuffer = vi.fn().mockReturnValue({ + setBuffer: vi.fn().mockResolvedValue("tx_hash_set_buffer"), + isLoading: false, +}) + +export const useSetYieldLocked = vi.fn().mockReturnValue({ + setLocked: vi.fn().mockResolvedValue("tx_hash_set_locked"), + isLoading: false, +}) diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 4aa04f4e..118b6fbe 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -28,6 +28,14 @@ import { mapSorobanEvent } from "@/lib/soroban-event-mapping" import { isContractVersionUnknown } from "@/lib/contract-version" export { isContractVersionUnknown } +import type { + GovernanceProposal, + GovernanceProposalStatus, + GovernanceProposalType, +} from "@/lib/governance" + +export type { GovernanceProposal, GovernanceProposalStatus, GovernanceProposalType } + // ── Constants ───────────────────────────────────────────────────────────────── const FACTORY_ID = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID! @@ -1811,28 +1819,6 @@ export function useBumpPoolState(contractId: string) { // ── DAO governance (issue #207) ─────────────────────────────────────────────── -export type GovernanceProposalStatus = "Active" | "Passed" | "Executed" | "Expired" | "Rejected" - -export type GovernanceProposalType = - | "ChangeDepositAmount" - | "ExtendDeadline" - | "AddPenalty" - | "RemovePenalty" - | "ChangeQuorum" - | "Custom" - -export interface GovernanceProposal { - id: string - proposer: string - proposalType: GovernanceProposalType - description: string - votesFor: string[] - votesAgainst: string[] - status: GovernanceProposalStatus - createdAt: number - expiresAt: number -} - const PROPOSAL_STATUSES = new Set(["Active", "Passed", "Executed", "Expired", "Rejected"]) /** Build the scvBytes value for a BytesN<32> proposal id from hex. */ @@ -1873,6 +1859,19 @@ function parseProposal(val: xdr.ScVal): GovernanceProposal | null { const statusRaw = scValToText(field("status") ?? xdr.ScVal.scvVoid()) const typeRaw = scValToText(field("proposal_type") ?? xdr.ScVal.scvVoid()) + const paramsSc = field("parameters") + let parameters: Record | undefined + if (paramsSc && paramsSc.switch().name === "scvMap") { + parameters = {} + for (const entry of paramsSc.map() ?? []) { + const key = entry.key() + const val = entry.val() + if (key.switch().name === "scvString" && val.switch().name === "scvBytes") { + parameters[key.str().toString()] = (val.bytes() as Buffer).toString("hex") + } + } + } + return { id: idBuf.toString("hex"), proposer: scValToString(field("proposer") ?? xdr.ScVal.scvVoid()), @@ -1886,6 +1885,7 @@ function parseProposal(val: xdr.ScVal): GovernanceProposal | null { status: (PROPOSAL_STATUSES.has(statusRaw) ? statusRaw : "Active") as GovernanceProposalStatus, createdAt: Number(scValToBigInt(field("created_at") ?? xdr.ScVal.scvU64(0))), expiresAt: Number(scValToBigInt(field("expires_at") ?? xdr.ScVal.scvU64(0))), + parameters, } } catch { return null @@ -1994,6 +1994,201 @@ export function useGovernanceVote(govContractId: string) { return { vote, isLoading } } + +export async function fetchYieldAllocationState( + contractId: string, +): Promise { + const [vaultVal, sharesVal, costVal, bufVal, lockedVal, posVal, unrealVal, maxVal] = + await Promise.all([ + viewCall(contractId, "yield_vault"), + viewCall(contractId, "yield_shares"), + viewCall(contractId, "yield_cost_basis"), + viewCall(contractId, "yield_buffer_bps"), + viewCall(contractId, "yield_locked"), + viewCall(contractId, "yield_position"), + viewCall(contractId, "unrealised_yield"), + viewCall(contractId, "max_allocatable_amount"), + ]) + + const vault = vaultVal.switch().name === "scvAddress" ? vaultVal.address().toString() : null + const locked = lockedVal.switch().name === "scvBool" ? lockedVal.b() : false + const bufferBps = bufVal.switch().name === "scvU32" ? Number(bufVal.u32()) : 2000 + + return { + vault, + shares: scValToBigInt(sharesVal), + costBasis: scValToBigInt(costVal), + bufferBps, + locked, + position: scValToBigInt(posVal), + unrealised: scValToBigInt(unrealVal), + maxAllocatable: scValToBigInt(maxVal), + } +} + +export interface YieldAllocationState { + vault: string | null + shares: bigint + costBasis: bigint + bufferBps: number + locked: boolean + position: bigint + unrealised: bigint + maxAllocatable: bigint +} + +export function useApplyYieldAllocation(contractId: string) { + const { address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const applyAllocation = async (vaultId: string, amount: bigint): Promise => { + if (!address || !contractId || !vaultId) return + setIsLoading(true) + try { + return await submitTx(address, (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "apply_yield_allocation", + addressVal(address), + addressVal(vaultId), + i128Val(amount), + ), + ) + .setTimeout(TX_TIMEOUT) + .build(), + ) + } finally { + setIsLoading(false) + } + } + + return { applyAllocation, isLoading } +} + +export function useDeallocateYield(contractId: string) { + const { address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const deallocate = async (amount: bigint): Promise => { + if (!address || !contractId) return + setIsLoading(true) + try { + return await submitTx(address, (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "apply_yield_deallocation", + addressVal(address), + i128Val(amount), + ), + ) + .setTimeout(TX_TIMEOUT) + .build(), + ) + } finally { + setIsLoading(false) + } + } + + return { deallocate, isLoading } +} + +export function useSyncYield(contractId: string) { + const { address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const sync = async (): Promise => { + if (!address || !contractId) return + setIsLoading(true) + try { + return await submitTx(address, (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call("sync_yield", addressVal(address)), + ) + .setTimeout(TX_TIMEOUT) + .build(), + ) + } finally { + setIsLoading(false) + } + } + + return { sync, isLoading } +} + +export function useSetYieldBuffer(contractId: string) { + const { address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const setBuffer = async (bps: number): Promise => { + if (!address || !contractId) return + setIsLoading(true) + try { + return await submitTx(address, (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "set_yield_buffer", + addressVal(address), + xdr.ScVal.scvU32(bps), + ), + ) + .setTimeout(TX_TIMEOUT) + .build(), + ) + } finally { + setIsLoading(false) + } + } + + return { setBuffer, isLoading } +} + +export function useSetYieldLocked(contractId: string) { + const { address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const setLocked = async (locked: boolean): Promise => { + if (!address || !contractId) return + setIsLoading(true) + try { + return await submitTx(address, (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "set_yield_locked", + addressVal(address), + xdr.ScVal.scvBool(locked), + ), + ) + .setTimeout(TX_TIMEOUT) + .build(), + ) + } finally { + setIsLoading(false) + } + } + + return { setLocked, isLoading } +} + export function useExecuteProposal(govContractId: string) { const { address } = useStellar() const [isLoading, setIsLoading] = useState(false) diff --git a/frontend/hooks/useYieldAllocation.ts b/frontend/hooks/useYieldAllocation.ts new file mode 100644 index 00000000..61e53e72 --- /dev/null +++ b/frontend/hooks/useYieldAllocation.ts @@ -0,0 +1,126 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { + fetchYieldAllocationState, + useApplyYieldAllocation, + useDeallocateYield, + useSetYieldBuffer, + useSetYieldLocked, + useSyncYield, +} from "@/hooks/useJointSaveContracts" +import { type YieldAllocationState } from "@/lib/yield-allocation" + +export interface UseYieldAllocationResult { + state: YieldAllocationState | null + loading: boolean + error: string | null + refresh: () => Promise + maxAllocatable: bigint + applyAllocation: (vaultId: string, amount: bigint) => Promise + deallocate: (amount: bigint) => Promise + sync: () => Promise + setBuffer: (bps: number) => Promise + setLocked: (locked: boolean) => Promise +} + +/** + * Loads a flexible pool's on-chain yield position and exposes the actions that + * mutate it. Every mutation refetches so the UI reflects the new share balance + * and cost basis without a manual reload. + */ +export function useYieldAllocation(poolContractId: string | null): UseYieldAllocationResult { + const [state, setState] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const { applyAllocation: chainApply } = useApplyYieldAllocation(poolContractId ?? "") + const { deallocate: chainDeallocate } = useDeallocateYield(poolContractId ?? "") + const { sync: chainSync } = useSyncYield(poolContractId ?? "") + const { setBuffer: chainSetBuffer } = useSetYieldBuffer(poolContractId ?? "") + const { setLocked: chainSetLocked } = useSetYieldLocked(poolContractId ?? "") + + const refresh = useCallback(async () => { + if (!poolContractId) { + setState(null) + return + } + setLoading(true) + setError(null) + try { + const next = await fetchYieldAllocationState(poolContractId) + setState(next) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load yield state") + setState(null) + } finally { + setLoading(false) + } + }, [poolContractId]) + + useEffect(() => { + refresh() + }, [refresh]) + + const maxAllocatable = state?.maxAllocatable ?? 0n + + const applyAllocation = useCallback( + async (vaultId: string, amount: bigint): Promise => { + const hash = await chainApply(vaultId, amount) + if (!hash) return false + await refresh() + return true + }, + [chainApply, refresh] + ) + + const deallocate = useCallback( + async (amount: bigint): Promise => { + const hash = await chainDeallocate(amount) + if (!hash) return false + await refresh() + return true + }, + [chainDeallocate, refresh] + ) + + const sync = useCallback(async (): Promise => { + const hash = await chainSync() + if (!hash) return false + await refresh() + return true + }, [chainSync, refresh]) + + const setBuffer = useCallback( + async (bps: number): Promise => { + const hash = await chainSetBuffer(bps) + if (!hash) return false + await refresh() + return true + }, + [chainSetBuffer, refresh] + ) + + const setLocked = useCallback( + async (locked: boolean): Promise => { + const hash = await chainSetLocked(locked) + if (!hash) return false + await refresh() + return true + }, + [chainSetLocked, refresh] + ) + + return { + state, + loading, + error, + refresh, + maxAllocatable, + applyAllocation, + deallocate, + sync, + setBuffer, + setLocked, + } +} diff --git a/frontend/lib/governance.test.ts b/frontend/lib/governance.test.ts index cb6a9c06..c64185ef 100644 --- a/frontend/lib/governance.test.ts +++ b/frontend/lib/governance.test.ts @@ -4,6 +4,11 @@ import assert from "node:assert" import { decodeExecutionResult, encodeParamHex, + buildYieldAllocationParams, + isValidContractId, + parseAmountToBaseUnits, + decodeYieldAllocationParams, + formatBaseUnits, formatTimeRemaining, meetsQuorum, mergeProposals, @@ -91,3 +96,72 @@ test("mergeProposals - dedupes by id and orders active before terminal states", ["aaa", "ddd", "bbb", "ccc", "eee"] ) }) + +test("buildYieldAllocationParams - allocation encodes action, vault and amount", () => { + const vault = "C" + "A".repeat(55) + const params = buildYieldAllocationParams({ action: "alloc", vault, amountBaseUnits: 1_500_000n }) + assert.equal(Buffer.from(params.action, "hex").toString("utf8"), "alloc") + assert.equal(Buffer.from(params.vault, "hex").toString("utf8"), vault) + assert.equal(params.amount.length, 32) + assert.equal(BigInt("0x" + params.amount), 1_500_000n) +}) + +test("buildYieldAllocationParams - de-allocation omits vault", () => { + const params = buildYieldAllocationParams({ action: "dealloc", amountBaseUnits: 42n }) + assert.equal(Buffer.from(params.action, "hex").toString("utf8"), "dealloc") + assert.equal(params.vault, undefined) + assert.equal(BigInt("0x" + params.amount), 42n) +}) + +test("buildYieldAllocationParams - allocation requires a valid vault", () => { + assert.throws(() => buildYieldAllocationParams({ action: "alloc", amountBaseUnits: 1n }), /vault required/) + assert.throws( + () => buildYieldAllocationParams({ action: "alloc", vault: "nope", amountBaseUnits: 1n }), + /invalid vault/ + ) +}) + +test("isValidContractId", () => { + assert.equal(isValidContractId("C" + "A".repeat(55)), true) + assert.equal(isValidContractId("G" + "A".repeat(55)), false) + assert.equal(isValidContractId("Cshort"), false) +}) + +test("parseAmountToBaseUnits", () => { + assert.equal(parseAmountToBaseUnits("1", 7), 10_000_000n) + assert.equal(parseAmountToBaseUnits("1.5", 7), 15_000_000n) + assert.equal(parseAmountToBaseUnits("1,000.25", 7), 10_002_500_000n) + assert.equal(parseAmountToBaseUnits("0.0000001", 7), 1n) + assert.equal(parseAmountToBaseUnits("2.123456789", 7), 21_234_567n) + assert.throws(() => parseAmountToBaseUnits("abc", 7), /invalid amount/) +}) + +test("decodeYieldAllocationParams - round-trips an allocation", () => { + const vault = "C" + "B".repeat(55) + const encoded = buildYieldAllocationParams({ action: "alloc", vault, amountBaseUnits: 1_500_000n }) + const decoded = decodeYieldAllocationParams(encoded) + assert.deepStrictEqual(decoded, { action: "alloc", vault, amountBaseUnits: 1_500_000n }) +}) + +test("decodeYieldAllocationParams - round-trips a de-allocation without vault", () => { + const encoded = buildYieldAllocationParams({ action: "dealloc", amountBaseUnits: 7n }) + const decoded = decodeYieldAllocationParams(encoded) + assert.deepStrictEqual(decoded, { action: "dealloc", vault: undefined, amountBaseUnits: 7n }) +}) + +test("decodeYieldAllocationParams - rejects non-yield params", () => { + assert.equal(decodeYieldAllocationParams(undefined), null) + assert.equal(decodeYieldAllocationParams({}), null) + assert.equal(decodeYieldAllocationParams({ action: "616c6c6f63" }), null) + assert.equal(decodeYieldAllocationParams({ action: "6e6f7065", amount: "0".repeat(32) }), null) + assert.equal(decodeYieldAllocationParams({ action: "616c6c6f63", amount: "00" }), null) +}) + +test("formatBaseUnits", () => { + assert.equal(formatBaseUnits(15_000_000n, 7), "1.5") + assert.equal(formatBaseUnits(10_000_000n, 7), "1") + assert.equal(formatBaseUnits(1n, 7), "0.0000001") + assert.equal(formatBaseUnits(0n, 7), "0") + assert.equal(formatBaseUnits(-2_500_000n, 7), "-0.25") + assert.equal(formatBaseUnits(21_234_567n, 7), "2.1234567") +}) diff --git a/frontend/lib/governance.ts b/frontend/lib/governance.ts index 9f93ef03..ab45acc9 100644 --- a/frontend/lib/governance.ts +++ b/frontend/lib/governance.ts @@ -10,6 +10,7 @@ export type GovernanceProposalType = | "RemovePenalty" | "ChangeQuorum" | "Custom" + | "YieldAllocation" export interface GovernanceProposal { id: string @@ -21,6 +22,8 @@ export interface GovernanceProposal { status: GovernanceProposalStatus createdAt: number expiresAt: number + /** Raw parameter map (hex-encoded Bytes values), when present on-chain. */ + parameters?: Record } /** Metadata driving the create-proposal dialog's dynamic form fields. */ @@ -60,10 +63,108 @@ export const PROPOSAL_TYPES: Array<{ paramKey: "quorum", paramLabel: "New quorum percentage (1-100)", }, + { + value: "YieldAllocation", + label: "Allocate / De-allocate Yield", + paramKey: "", + paramLabel: "", + }, ] export const GOVERNANCE_DESCRIPTION_MAX = 500 +export type YieldAllocationAction = "alloc" | "dealloc" + +export interface YieldProposalInput { + action: YieldAllocationAction + /** Vault contract id β€” required for allocation, ignored for de-allocation. */ + vault?: string + amountBaseUnits: bigint +} + +/** + * Encode the parameter map the governance contract routes to the pool for a + * `Custom` yield-allocation proposal: + * * `action` β€” utf8 bytes of b"alloc" / b"dealloc", + * * `vault` β€” utf8 bytes of the vault strkey (allocation only), + * * `amount` β€” 16-byte big-endian i128. + * `buildYieldAllocationParams` is submitted under the on-chain `Custom` type. + */ +export function buildYieldAllocationParams( + input: YieldProposalInput +): Record { + const utf8Hex = (value: string) => Buffer.from(value, "utf8").toString("hex") + const params: Record = { + action: utf8Hex(input.action), + amount: encodeParamHex(input.amountBaseUnits), + } + if (input.action === "alloc") { + if (!input.vault) throw new Error("vault required for allocation") + if (!isValidContractId(input.vault)) throw new Error("invalid vault contract id") + params.vault = utf8Hex(input.vault) + } + return params +} + +/** A Stellar contract strkey: 56 chars, begins with `C`, base32 alphabet. */ +export function isValidContractId(id: string): boolean { + return /^C[A-Z2-7]{55}$/.test(id) +} + +/** Parse a human decimal amount (e.g. "12.50") into token base units. */ +export function parseAmountToBaseUnits(amount: string, decimals: number): bigint { + const trimmed = amount.trim().replace(/,/g, "") + if (!/^\d+(\.\d+)?$/.test(trimmed)) throw new Error("invalid amount") + const [whole, frac = ""] = trimmed.split(".") + const padded = (frac + "0".repeat(decimals)).slice(0, decimals) + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(padded || "0") +} + +interface DecodedYieldParams { + action: YieldAllocationAction + vault?: string + amountBaseUnits: bigint +} + +/** + * Decode a proposal's raw hex parameter map back into yield-allocation fields, + * or `null` when it is not a recognised yield-allocation proposal. Inverse of + * `buildYieldAllocationParams`. + */ +export function decodeYieldAllocationParams( + params: Record | undefined +): DecodedYieldParams | null { + if (!params || !params.action || !params.amount) return null + let action: string + try { + action = Buffer.from(params.action, "hex").toString("utf8") + } catch { + return null + } + if (action !== "alloc" && action !== "dealloc") return null + if (params.amount.length !== 32) return null + let vault: string | undefined + if (params.vault) { + vault = Buffer.from(params.vault, "hex").toString("utf8") + } + return { + action, + vault, + amountBaseUnits: BigInt("0x" + params.amount), + } +} + +/** Format token base units as a plain decimal string (e.g. 15000000 -> "1.5"). */ +export function formatBaseUnits(amount: bigint, decimals: number): string { + const negative = amount < 0n + const abs = negative ? -amount : amount + const base = 10n ** BigInt(decimals) + const whole = abs / base + const frac = (abs % base).toString().padStart(decimals, "0").replace(/0+$/, "") + const rendered = frac ? `${whole}.${frac}` : `${whole}` + return negative ? `-${rendered}` : rendered +} + /** * Minimum "for" votes required to meet the quorum percentage of the pool. * Mirrors the contract check: votes * 100 >= quorumPct * totalMembers. diff --git a/frontend/lib/yield-allocation.test.ts b/frontend/lib/yield-allocation.test.ts new file mode 100644 index 00000000..8460639c --- /dev/null +++ b/frontend/lib/yield-allocation.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + BPS_DENOMINATOR, + DEFAULT_BUFFER_BPS, + MAX_BUFFER_BPS, + allocatedSharePct, + clampBufferBps, + computeMaxAllocatable, + formatSharePrice, + isAllocated, + isIlliquid, + liquidBalance, + sharePrice, + unrealisedGain, + validateBufferBps, +} from "./yield-allocation" + +test("liquidBalance never goes negative", () => { + assert.equal(liquidBalance(200n, 50n), 150n) + assert.equal(liquidBalance(50n, 200n), 0n) + assert.equal(liquidBalance(0n, 0n), 0n) +}) + +test("computeMaxAllocatable leaves the configured buffer liquid", () => { + // 1000 total, 20% buffer (200), nothing allocated -> 800 allocatable. + assert.equal(computeMaxAllocatable(1000n, 0n, DEFAULT_BUFFER_BPS), 800n) + // 500 already allocated -> idle 500, buffer still 200 -> 300 allocatable. + assert.equal(computeMaxAllocatable(1000n, 500n, DEFAULT_BUFFER_BPS), 300n) + // Fully allocated -> nothing more to push. + assert.equal(computeMaxAllocatable(1000n, 1000n, DEFAULT_BUFFER_BPS), 0n) +}) + +test("computeMaxAllocatable rounds in favour of the buffer", () => { + // 333 * 20% = 66.6 -> floor 66; 333 - 66 = 267. + assert.equal(computeMaxAllocatable(333n, 0n, DEFAULT_BUFFER_BPS), 267n) +}) + +test("computeMaxAllocatable handles zero balance and zero buffer", () => { + assert.equal(computeMaxAllocatable(0n, 0n, DEFAULT_BUFFER_BPS), 0n) + assert.equal(computeMaxAllocatable(100n, 0n, 0), 100n) +}) + +test("clampBufferBps bounds into [0, 10000]", () => { + assert.equal(clampBufferBps(-5), 0) + assert.equal(clampBufferBps(20000), MAX_BUFFER_BPS) + assert.equal(clampBufferBps(2000), 2000) + assert.equal(clampBufferBps(Number.NaN), DEFAULT_BUFFER_BPS) +}) + +test("validateBufferBps rejects out-of-range and fractional values", () => { + assert.equal(validateBufferBps(2000), null) + assert.equal(validateBufferBps(0), null) + assert.equal(validateBufferBps(BPS_DENOMINATOR), null) + assert.match(validateBufferBps(-1) ?? "", /negative/) + assert.match(validateBufferBps(BPS_DENOMINATOR + 1) ?? "", /100%/) + assert.match(validateBufferBps(2000.5) ?? "", /whole number/) + assert.match(validateBufferBps(Number.NaN) ?? "", /number/) +}) + +test("sharePrice and unrealisedGain", () => { + // 1.1 tokens of value for 1 share -> 1.1 tokens per share (scaled by SHARE_SCALE). + assert.equal(sharePrice(1_100_000_000n, 1_000_000_000n), 1_100_000_000n) + assert.equal(sharePrice(0n, 0n), null) + assert.equal(unrealisedGain(1100n, 1000n), 100n) + assert.equal(unrealisedGain(900n, 1000n), 0n) +}) + +test("formatSharePrice renders a fixed-precision value", () => { + assert.equal(formatSharePrice(1_100_000_000n, 1_000_000_000n), "1.1000") + // Position worth 1.1x shares -> 1.1 tokens per share. + assert.equal(formatSharePrice(1_100_000_000n, 1_000_000_000n, 2), "1.10") + assert.equal(formatSharePrice(0n, 0n), null) +}) + +test("isAllocated and isIlliquid", () => { + assert.equal(isAllocated({ vault: "CABC", shares: 1n }), true) + assert.equal(isAllocated({ vault: null, shares: 1n }), false) + assert.equal(isAllocated({ vault: "CABC", shares: 0n }), false) + assert.equal(isIlliquid({ locked: true, shares: 5n }), true) + assert.equal(isIlliquid({ locked: true, shares: 0n }), false) + assert.equal(isIlliquid({ locked: false, shares: 5n }), false) +}) + +test("allocatedSharePct", () => { + assert.equal(allocatedSharePct(500n, 1000n), 50) + assert.equal(allocatedSharePct(0n, 0n), 0) + assert.equal(allocatedSharePct(2000n, 1000n), 100) +}) diff --git a/frontend/lib/yield-allocation.ts b/frontend/lib/yield-allocation.ts new file mode 100644 index 00000000..fd86f165 --- /dev/null +++ b/frontend/lib/yield-allocation.ts @@ -0,0 +1,120 @@ +// Pure yield-allocation domain helpers shared by hooks, components and tests. +// Kept framework-free so it can be unit-tested with the node test runner. +// +// Mirrors the on-chain invariants in `contracts/flexible`: +// * a configurable liquidity buffer (bps of total balance) must always stay +// liquid so withdrawals never depend on the vault, +// * only idle (unallocated) pot capital may be pushed into the vault, +// * a position's gain is `position - costBasis`, distributed pro-rata. + +export const SHARE_SCALE = 1_000_000_000 +export const BPS_DENOMINATOR = 10_000 +export const DEFAULT_BUFFER_BPS = 2000 +export const MAX_BUFFER_BPS = BPS_DENOMINATOR + +export interface YieldAllocationState { + vault: string | null + shares: bigint + costBasis: bigint + bufferBps: number + locked: boolean + position: bigint + unrealised: bigint + maxAllocatable: bigint +} + +/** Whether the pool has any capital allocated to a vault. */ +export function isAllocated(state: Pick): boolean { + return Boolean(state.vault) && state.shares > 0n +} + +/** + * Liquid (unallocated) pot capital given the total pot balance and the + * currently allocated cost basis. + */ +export function liquidBalance(totalBalance: bigint, allocated: bigint): bigint { + const liquid = totalBalance - allocated + return liquid > 0n ? liquid : 0n +} + +/** + * Largest amount that may be pushed into the vault: idle capital minus the + * buffer that must remain liquid. Mirrors the contract's floor in favour of + * the buffer (never over-allocate). + */ +export function computeMaxAllocatable( + totalBalance: bigint, + allocated: bigint, + bufferBps: number +): bigint { + if (totalBalance <= 0n) return 0n + const bps = clampBufferBps(bufferBps) + const buffered = (totalBalance * BigInt(bps)) / BigInt(BPS_DENOMINATOR) + const liquid = liquidBalance(totalBalance, allocated) + const headroom = liquid - buffered + return headroom > 0n ? headroom : 0n +} + +/** Clamp a buffer percentage (bps) into the accepted [0, 10000] range. */ +export function clampBufferBps(bps: number): number { + if (!Number.isFinite(bps)) return DEFAULT_BUFFER_BPS + const rounded = Math.trunc(bps) + if (rounded < 0) return 0 + if (rounded > MAX_BUFFER_BPS) return MAX_BUFFER_BPS + return rounded +} + +/** Validate a buffer percentage (bps) and return an error string when invalid. */ +export function validateBufferBps(bps: number): string | null { + if (!Number.isFinite(bps)) return "Buffer must be a number" + if (!Number.isInteger(bps)) return "Buffer must be a whole number of basis points" + if (bps < 0) return "Buffer cannot be negative" + if (bps > MAX_BUFFER_BPS) return "Buffer cannot exceed 100%" + return null +} + +/** + * The vault's share price in scaled units (tokens per SHARE_SCALE shares). + * Returns `null` when there are no shares to price. + */ +export function sharePrice(position: bigint, shares: bigint): bigint | null { + if (shares <= 0n) return null + return (position * BigInt(SHARE_SCALE)) / shares +} + +/** + * Unrealised gain still sitting in the vault (position value minus cost basis). + * Never negative β€” a loss leaves the pot with its principal basis intact. + */ +export function unrealisedGain(position: bigint, costBasis: bigint): bigint { + const gain = position - costBasis + return gain > 0n ? gain : 0n +} + +/** True when the pool holds an allocated position that cannot be withdrawn now. */ +export function isIlliquid(state: Pick): boolean { + return state.locked && state.shares > 0n +} + +/** Percentage of a member's principal that is currently earning yield (0-100). */ +export function allocatedSharePct(allocated: bigint, totalBalance: bigint): number { + if (totalBalance <= 0n) return 0 + const pct = (Number(allocated) / Number(totalBalance)) * 100 + if (!Number.isFinite(pct)) return 0 + return Math.min(100, Math.max(0, pct)) +} + +/** Format a share price (tokens per share) as a fixed-precision string. */ +export function formatSharePrice( + position: bigint, + shares: bigint, + precision = 4 +): string | null { + if (shares <= 0n) return null + const price = sharePrice(position, shares) + if (price === null) return null + const whole = price / BigInt(SHARE_SCALE) + const frac = price % BigInt(SHARE_SCALE) + const value = Number(whole) + Number(frac) / SHARE_SCALE + return value.toFixed(precision) +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f6922441..668d56cb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -421,21 +421,33 @@ "rotational": { "title": "Rotational Savings", "description": "Traditional savings circle onchain. Members take turns receiving payouts automatically.", - "features": ["Fixed contribution amount", "Scheduled payouts", "Turn-based system"], + "features": [ + "Fixed contribution amount", + "Scheduled payouts", + "Turn-based system" + ], "guideLabel": "Rotational:", "guideDescription": "Best for regular income groups who want predictable payouts" }, "target": { "title": "Target Pool", "description": "Save together toward a shared goal. Funds unlock when the target is reached.", - "features": ["Shared savings goal", "Locked until target", "Equal distribution"], + "features": [ + "Shared savings goal", + "Locked until target", + "Equal distribution" + ], "guideLabel": "Target:", "guideDescription": "Perfect for saving toward a specific goal like a wedding or business" }, "flexible": { "title": "Flexible Pool", "description": "Deposit and withdraw anytime with optional yield generation.", - "features": ["Flexible deposits", "Anytime withdrawal", "Optional yield"], + "features": [ + "Flexible deposits", + "Anytime withdrawal", + "Optional yield" + ], "guideLabel": "Flexible:", "guideDescription": "Ideal for emergency funds or groups with varying income" } @@ -944,7 +956,32 @@ "deploy": "Deploy", "processing": "Processing...", "harvestAndDistribute": "Harvest & Distribute Yield", - "harvestHint": "Harvests accumulated yield from the protocol and distributes proportionally to all members." + "harvestHint": "Harvests accumulated yield from the protocol and distributes proportionally to all members.", + "vaultTitle": "Vault Allocation", + "vaultEmpty": "No capital allocated to a vault yet.", + "vaultPosition": "Position value", + "vaultUnrealised": "Unrealised yield", + "vaultSharePrice": "Share price", + "vaultMaxAllocatable": "Available to allocate", + "vaultBuffer": "Liquidity buffer", + "vaultContract": "Vault:", + "vaultIlliquid": "Vault locked", + "allocateLabel": "Allocate to vault", + "vaultPlaceholder": "C... vault contract", + "deallocateLabel": "De-allocate from vault", + "deallocate": "De-allocate", + "vaultBufferPlaceholder": "Buffer (bps)", + "vaultSync": "Sync yield", + "vaultLock": "Lock vault", + "vaultUnlock": "Unlock vault", + "vaultHint": "Allocation and de-allocation are governed by member votes; syncing realises vault yield and distributes it pro-rata.", + "vaultAllocated": "Capital allocated to vault.", + "vaultDeallocated": "Capital recalled from vault.", + "vaultSynced": "Vault yield synced and distributed.", + "vaultBufferSet": "Liquidity buffer updated.", + "vaultLockedSuccess": "Vault locked.", + "vaultUnlockedSuccess": "Vault unlocked.", + "vaultError": "Transaction failed. Please try again." }, "chat": { "justNow": "just now", @@ -1055,7 +1092,9 @@ "admin_archived": "Archived by the pool admin.", "emergency_withdrawn": "Archived after an emergency withdrawal." } - } + }, + "flexibleWithdrawHint": "Withdraw anytime. Exit fee will be deducted. On-chain withdraws auto-deallocate from vaults when possible.", + "vaultIlliquidBanner": "Vault is locked with {amount} allocated shares β€” full withdrawal may be limited until de-allocated or unlocked." }, "explore": { "explorePools": "Explore Pools", @@ -1947,7 +1986,16 @@ "AddPenalty": "Add Penalty", "RemovePenalty": "Remove Penalty", "ChangeQuorum": "Change Quorum", - "Custom": "Custom" + "Custom": "Custom", + "YieldAllocation": "Allocate / De-allocate Yield" + }, + "yield": { + "alloc": "Allocate to vault", + "dealloc": "De-allocate from vault", + "actionLabel": "Action", + "vaultLabel": "Vault contract ID", + "amountLabel": "Amount", + "detailsLabel": "Yield allocation" } } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d805cdba..c7c186e7 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -421,7 +421,11 @@ "rotational": { "title": "Ahorro Rotativo", "description": "El cΓ­rculo de ahorro tradicional, en la blockchain. Los miembros se turnan para recibir los pagos automΓ‘ticamente.", - "features": ["Monto de aporte fijo", "Pagos programados", "Sistema por turnos"], + "features": [ + "Monto de aporte fijo", + "Pagos programados", + "Sistema por turnos" + ], "guideLabel": "Rotativo:", "guideDescription": "Ideal para grupos con ingresos regulares que buscan pagos predecibles" }, @@ -952,7 +956,32 @@ "deploy": "Colocar", "processing": "Procesando...", "harvestAndDistribute": "Cosechar y Distribuir Rendimiento", - "harvestHint": "Cosecha el rendimiento acumulado del protocolo y lo distribuye proporcionalmente entre todos los miembros." + "harvestHint": "Cosecha el rendimiento acumulado del protocolo y lo distribuye proporcionalmente entre todos los miembros.", + "vaultTitle": "AsignaciΓ³n a la BΓ³veda", + "vaultEmpty": "AΓΊn no hay capital asignado a una bΓ³veda.", + "vaultPosition": "Valor de la posiciΓ³n", + "vaultUnrealised": "Rendimiento no realizado", + "vaultSharePrice": "Precio de participaciΓ³n", + "vaultMaxAllocatable": "Disponible para asignar", + "vaultBuffer": "Reserva de liquidez", + "vaultContract": "BΓ³veda:", + "vaultIlliquid": "BΓ³veda bloqueada", + "allocateLabel": "Asignar a la bΓ³veda", + "vaultPlaceholder": "C... contrato de la bΓ³veda", + "deallocateLabel": "Retirar de la bΓ³veda", + "deallocate": "Retirar", + "vaultBufferPlaceholder": "Reserva (pbs)", + "vaultSync": "Sincronizar rendimiento", + "vaultLock": "Bloquear bΓ³veda", + "vaultUnlock": "Desbloquear bΓ³veda", + "vaultHint": "La asignaciΓ³n y el retiro se deciden por votos de los miembros; sincronizar realiza el rendimiento de la bΓ³veda y lo distribuye proporcionalmente.", + "vaultAllocated": "Capital asignado a la bΓ³veda.", + "vaultDeallocated": "Capital recuperado de la bΓ³veda.", + "vaultSynced": "Rendimiento de la bΓ³veda sincronizado y distribuido.", + "vaultBufferSet": "Reserva de liquidez actualizada.", + "vaultLockedSuccess": "BΓ³veda bloqueada.", + "vaultUnlockedSuccess": "BΓ³veda desbloqueada.", + "vaultError": "La transacciΓ³n fallΓ³. IntΓ©ntalo de nuevo." }, "chat": { "justNow": "justo ahora", @@ -1063,7 +1092,9 @@ "admin_archived": "Archivado por el administrador del grupo.", "emergency_withdrawn": "Archivado tras un retiro de emergencia." } - } + }, + "flexibleWithdrawHint": "Retira en cualquier momento. Se descontarΓ‘ la comisiΓ³n de salida. Los retiros en la cadena desasignan automΓ‘ticamente de las bΓ³vedas cuando es posible.", + "vaultIlliquidBanner": "La bΓ³veda estΓ‘ bloqueada con {amount} participaciones asignadas β€” el retiro completo puede estar limitado hasta que se desasigne o se desbloquee." }, "explore": { "explorePools": "Explorar Tandas", @@ -1955,7 +1986,16 @@ "AddPenalty": "Agregar penalizaciΓ³n", "RemovePenalty": "Quitar penalizaciΓ³n", "ChangeQuorum": "Cambiar quΓ³rum", - "Custom": "Personalizada" + "Custom": "Personalizada", + "YieldAllocation": "Asignar / Retirar Rendimiento" + }, + "yield": { + "alloc": "Asignar a la bΓ³veda", + "dealloc": "Retirar de la bΓ³veda", + "actionLabel": "AcciΓ³n", + "vaultLabel": "ID del contrato de la bΓ³veda", + "amountLabel": "Cantidad", + "detailsLabel": "AsignaciΓ³n de rendimiento" } } } diff --git a/smartcontract/Cargo.lock b/smartcontract/Cargo.lock index 1b83385b..16defa22 100644 --- a/smartcontract/Cargo.lock +++ b/smartcontract/Cargo.lock @@ -686,6 +686,7 @@ dependencies = [ name = "jointsave-flexible" version = "0.1.0" dependencies = [ + "jointsave-yield-vault", "proptest", "soroban-sdk", ] @@ -747,6 +748,14 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "jointsave-yield-vault" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", +] + [[package]] name = "js-sys" version = "0.3.95" diff --git a/smartcontract/Cargo.toml b/smartcontract/Cargo.toml index ba659f16..b5748018 100644 --- a/smartcontract/Cargo.toml +++ b/smartcontract/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/rotational", "contracts/target", "contracts/flexible", + "contracts/yield-vault", "contracts/yield-strategy", "contracts/reputation", "contracts/microloan", diff --git a/smartcontract/README.md b/smartcontract/README.md index e333d745..b10e2ed7 100644 --- a/smartcontract/README.md +++ b/smartcontract/README.md @@ -10,6 +10,7 @@ Soroban smart contracts powering JointSave on the Stellar network. | `rotational` | Rotational savings pool – members take turns receiving payouts | | `target` | Goal-based savings pool – funds unlock when target is reached | | `flexible` | Flexible deposit/withdraw pool with optional yield distribution | +| `yield-vault`| Vault adapter + mock vault for vault-backed yield allocation | ## Prerequisites @@ -39,10 +40,43 @@ Copy the output contract IDs into your frontend `.env`. ## API Reference -Full API reference for all four contracts β€” functions, events, storage keys, error conditions, and CLI examples: +Full API reference for the factory and pool contracts β€” functions, events, storage keys, error conditions, and CLI examples: **[docs/contract-api.md](../docs/contract-api.md)** +## Vault-Backed Yield Allocation + +`flexible` pools can opt in to moving **idle** capital into an external, +yield-bearing Soroban vault. The feature is deliberately constrained: + +- **Opt-in per group.** The vault address is proposed and passed through the + existing governance voting flow; a pool with no vault behaves exactly as before. +- **Vault-agnostic.** The pool talks to any contract implementing the + `VaultAdapter` interface (`deposit`, `withdraw`, `share_price`, `max_redeemable`). +- **Liquidity buffer.** A configurable share of the pot (default 20%) is kept + liquid so withdrawals always work. On withdrawal the pool auto-pulls from the + vault and reverts with a clear `"vault illiquid"` error if the vault cannot redeem. +- **Emergency lock.** `YieldLocked` stops new allocations and allows + de-allocation only. +- **Pro-rata accounting.** Realised yield is credited to members by principal + with integer-only, floor-rounded maths, so rounding always favours the pot. + +The `yield-vault` crate ships a **mock vault** behind the `mock` cargo feature +for tests and testnet demos. Production groups point at a real external vault. + +```bash +# Build the mock vault (testnet demo) +cargo build --release --target wasm32-unknown-unknown \ + --manifest-path=contracts/yield-vault/Cargo.toml --features mock +``` + +`scripts/deploy.sh` builds, uploads, deploys, and initializes the mock vault on +testnet, then records its wasm hash and contract id in +`deployments/stellar-testnet.json`. + +- **Threat model:** [docs/security/yield-allocation-threat-model.md](../docs/security/yield-allocation-threat-model.md) +- **Design spec:** [docs/plans/2026-10-16-yield-allocation.md](../docs/plans/2026-10-16-yield-allocation.md) + ## Network - **Testnet RPC:** `https://soroban-testnet.stellar.org` diff --git a/smartcontract/contracts/flexible/Cargo.toml b/smartcontract/contracts/flexible/Cargo.toml index b11cfc3b..d37a8736 100644 --- a/smartcontract/contracts/flexible/Cargo.toml +++ b/smartcontract/contracts/flexible/Cargo.toml @@ -8,7 +8,9 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { version = "21.0.0", features = ["alloc"] } +jointsave-yield-vault = { path = "../yield-vault" } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +jointsave-yield-vault = { path = "../yield-vault", features = ["mock"] } proptest = "1.4" diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index 9a852c5e..27acb815 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -5,6 +5,8 @@ use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol, Vec, }; +use jointsave_yield_vault::{VaultClient, BPS_DENOMINATOR, DEFAULT_BUFFER_BPS, SHARE_SCALE}; + const VERSION: u32 = 1; #[contracttype] @@ -33,6 +35,16 @@ pub enum DataKey { GovernanceContract, /// Late/missed-deposit penalty percentage governable by the DAO (0-100). PenaltyPercentage, + /// Address of the yield vault holding this pool's allocated capital. + YieldVault, + /// Shares the pool holds in the yield vault. + YieldShares, + /// Assets the pool has deposited into the vault (cost basis). + YieldCostBasis, + /// Minimum liquid share of the pot, in basis points (default 2000 = 20%). + YieldBufferBps, + /// When true, no new allocation is permitted (de-allocation only). + YieldLocked, } const LEDGER_THRESHOLD: u32 = 518400; @@ -145,6 +157,17 @@ impl FlexiblePool { let treasury: Address = storage.get(&DataKey::Treasury).unwrap(); let token_client = token::Client::new(&env, &token_addr); + // If capital is deployed to the vault, pull back just enough to cover + // this withdrawal. A partially-filled redemption still counts; only if + // the pot is left genuinely short do we revert, so funds are never + // trapped but members can always withdraw what the vault can return. + let liquid = token_client.balance(&env.current_contract_address()); + if liquid < net { + Self::deallocate_internal(&env, net - liquid); + let liquid_after = token_client.balance(&env.current_contract_address()); + assert!(liquid_after >= net, "vault illiquid"); + } + if fee > 0 { token_client.transfer(&env.current_contract_address(), &treasury, &fee); } @@ -530,6 +553,17 @@ impl FlexiblePool { if storage.has(&DataKey::PenaltyPercentage) { storage.extend_ttl(&DataKey::PenaltyPercentage, LEDGER_THRESHOLD, LEDGER_BUMP); } + if storage.has(&DataKey::YieldVault) { + storage.extend_ttl(&DataKey::YieldVault, LEDGER_THRESHOLD, LEDGER_BUMP); + storage.extend_ttl(&DataKey::YieldShares, LEDGER_THRESHOLD, LEDGER_BUMP); + storage.extend_ttl(&DataKey::YieldCostBasis, LEDGER_THRESHOLD, LEDGER_BUMP); + } + if storage.has(&DataKey::YieldBufferBps) { + storage.extend_ttl(&DataKey::YieldBufferBps, LEDGER_THRESHOLD, LEDGER_BUMP); + } + if storage.has(&DataKey::YieldLocked) { + storage.extend_ttl(&DataKey::YieldLocked, LEDGER_THRESHOLD, LEDGER_BUMP); + } } /// Point this pool at a deployed ReputationTracker contract so deposits @@ -544,6 +578,249 @@ impl FlexiblePool { Self::bump_config_state_internal(&env); } + // ── Yield vault allocation ──────────────────────────────────────────── + + /// Move idle pot capital into a vault. Callable by the pool admin or by the + /// registered governance contract (via an approved `Custom` proposal). + /// + /// A configurable share of the pot (`YieldBufferBps`, 20% by default) always + /// stays liquid, so ordinary member withdrawals keep working. The amount is + /// capped by that buffer; larger requests revert rather than illiquidating + /// the pool. + pub fn apply_yield_allocation(env: Env, caller: Address, vault: Address, amount: i128) { + caller.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + let gov: Option
= storage.get(&DataKey::GovernanceContract); + assert!( + caller == stored_admin || gov.is_some_and(|g| g == caller), + "not authorized" + ); + + let yield_enabled: bool = storage.get(&DataKey::YieldEnabled).unwrap_or(false); + assert!(yield_enabled, "yield disabled"); + + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let locked: bool = storage.get(&DataKey::YieldLocked).unwrap_or(false); + assert!(!locked, "yield locked"); + + assert!(amount > 0, "amount must be > 0"); + + let headroom = Self::max_allocatable(&env); + assert!(amount <= headroom, "buffer would be violated"); + + // Effects: record the vault and our share position before interacting. + storage.set(&DataKey::YieldVault, &vault); + + // Interactions: move the underlying asset in, then mint shares. + let token_addr: Address = storage.get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &vault, + &amount, + ); + let shares = + VaultClient::new(&env, &vault).deposit(&env.current_contract_address(), &amount); + assert!(shares > 0, "no shares minted"); + + let prev_shares: i128 = storage.get(&DataKey::YieldShares).unwrap_or(0); + storage.set(&DataKey::YieldShares, &(prev_shares + shares)); + let prev_cost: i128 = storage.get(&DataKey::YieldCostBasis).unwrap_or(0); + storage.set(&DataKey::YieldCostBasis, &(prev_cost + amount)); + + Self::bump_config_state_internal(&env); + + jointsave_yield_vault::allocated(&env, &vault, amount, shares); + } + + /// Pull capital back out of the vault. Callable by the pool admin, the + /// registered governance contract, or any pool member: increasing the pot's + /// liquidity can only help, so no vote is required. + /// + /// Returns the assets actually recovered. If the vault cannot honour the + /// full request it degrades to a partial redemption rather than reverting; + /// the caller can see how much came back. + pub fn apply_yield_deallocation(env: Env, caller: Address, amount: i128) -> i128 { + caller.require_auth(); + + let storage = env.storage().persistent(); + let admin: Address = storage.get(&DataKey::Admin).unwrap(); + let gov: Option
= storage.get(&DataKey::GovernanceContract); + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!( + caller == admin + || gov.is_some_and(|g| g == caller) + || Self::is_member(&members, &caller), + "not authorized" + ); + + assert!(amount > 0, "amount must be > 0"); + + let received = Self::deallocate_internal(&env, amount); + Self::bump_config_state_internal(&env); + received + } + + /// Emergency control: when locked, no new allocation is permitted (existing + /// capital can still be de-allocated). + pub fn set_yield_locked(env: Env, admin: Address, locked: bool) { + admin.require_auth(); + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + storage.set(&DataKey::YieldLocked, &locked); + Self::bump_config_state_internal(&env); + jointsave_yield_vault::lock_set(&env, locked); + } + + /// Realise any gain in the vault position and credit it to members pro-rata + /// by their principal balance. Callable by the admin, the governance + /// contract, or any member. + /// + /// Returns the amount distributed. Distributions use floor division, so any + /// rounding remainder stays in the pot (as still-unrealised yield) rather + /// than over-crediting a member. + pub fn sync_yield(env: Env, caller: Address) -> i128 { + caller.require_auth(); + + let storage = env.storage().persistent(); + let admin: Address = storage.get(&DataKey::Admin).unwrap(); + let gov: Option
= storage.get(&DataKey::GovernanceContract); + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!( + caller == admin + || gov.is_some_and(|g| g == caller) + || Self::is_member(&members, &caller), + "not authorized" + ); + + let vault: Option
= storage.get(&DataKey::YieldVault); + let Some(vault) = vault else { + return 0; + }; + let shares: i128 = storage.get(&DataKey::YieldShares).unwrap_or(0); + let cost_basis: i128 = storage.get(&DataKey::YieldCostBasis).unwrap_or(0); + if shares <= 0 { + return 0; + } + + let price = VaultClient::new(&env, &vault).share_price(); + let position = shares * price / SHARE_SCALE; + if position <= cost_basis { + // No gain to realise (flat or underwater). + return 0; + } + let realised = position - cost_basis; + + let total: i128 = storage.get(&DataKey::TotalBalance).unwrap_or(0); + if total <= 0 { + return 0; + } + + let mut distributed: i128 = 0; + for m in members.iter() { + let bal: i128 = storage.get(&DataKey::Balance(m.clone())).unwrap_or(0); + if bal > 0 { + let share = realised * bal / total; + if share > 0 { + let bal_key = DataKey::Balance(m.clone()); + storage.set(&bal_key, &(bal + share)); + storage.extend_ttl(&bal_key, LEDGER_THRESHOLD, LEDGER_BUMP); + distributed += share; + } + } + } + + if distributed > 0 { + storage.set(&DataKey::TotalBalance, &(total + distributed)); + // Only the cost of what we actually credited is removed from the + // cost basis, leaving the rounding remainder to be swept next time. + storage.set(&DataKey::YieldCostBasis, &(cost_basis + distributed)); + } + + Self::bump_config_state_internal(&env); + jointsave_yield_vault::synced(&env, distributed); + distributed + } + + /// Redeem up to `amount` assets from the vault. Returns the assets actually + /// received (which may be less than `amount` if the vault is illiquid or the + /// pool holds too few shares). Never reverts on a shortfall. + fn deallocate_internal(env: &Env, amount: i128) -> i128 { + let storage = env.storage().persistent(); + let vault: Option
= storage.get(&DataKey::YieldVault); + let shares: i128 = storage.get(&DataKey::YieldShares).unwrap_or(0); + let Some(vault) = vault else { + return 0; + }; + if shares <= 0 || amount <= 0 { + return 0; + } + + let client = VaultClient::new(env, &vault); + let price = client.share_price(); + if price <= 0 { + return 0; + } + + // Shares needed to raise `amount`, rounded up so we never under-pull. + let needed = mul_div_ceil(amount, SHARE_SCALE, price); + let mut to_redeem = if needed < shares { needed } else { shares }; + let redeemable = client.max_redeemable(); + if to_redeem > redeemable { + to_redeem = redeemable; + } + if to_redeem <= 0 { + // Fully illiquid right now: recover nothing rather than reverting. + return 0; + } + + let received = client.withdraw(&env.current_contract_address(), &to_redeem); + + // Burn redeemed shares and remove their proportional cost basis. + let cost_basis: i128 = storage.get(&DataKey::YieldCostBasis).unwrap_or(0); + let removed_cost = cost_basis * to_redeem / shares; + storage.set(&DataKey::YieldShares, &(shares - to_redeem)); + storage.set(&DataKey::YieldCostBasis, &(cost_basis - removed_cost)); + + jointsave_yield_vault::deallocated(env, amount, received, to_redeem); + received + } + + /// Set the minimum liquid share of the pot, in basis points (0-10000). + pub fn set_yield_buffer(env: Env, admin: Address, bps: u32) { + admin.require_auth(); + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + assert!(bps <= BPS_DENOMINATOR, "buffer must be <= 10000"); + storage.set(&DataKey::YieldBufferBps, &bps); + Self::bump_config_state_internal(&env); + jointsave_yield_vault::buffer_set(&env, bps); + } + + /// How much idle capital can currently be allocated without breaching the + /// liquidity buffer. + fn max_allocatable(env: &Env) -> i128 { + let storage = env.storage().persistent(); + let total: i128 = storage.get(&DataKey::TotalBalance).unwrap_or(0); + let buffer_bps: u32 = storage + .get(&DataKey::YieldBufferBps) + .unwrap_or(DEFAULT_BUFFER_BPS); + let token_addr: Address = storage.get(&DataKey::Token).unwrap(); + let liquid = token::Client::new(env, &token_addr).balance(&env.current_contract_address()); + + let target_liquid = mul_div_ceil(total, buffer_bps as i128, BPS_DENOMINATOR as i128); + if liquid > target_liquid { + liquid - target_liquid + } else { + 0 + } + } + // ── DAO governance ──────────────────────────────────────────────────── /// Register the DAO governance contract allowed to apply proposals. @@ -705,6 +982,79 @@ impl FlexiblePool { .unwrap_or(0) } + /// Address of the vault holding the pool's allocated capital, if any. + pub fn yield_vault(env: Env) -> Option
{ + env.storage().persistent().get(&DataKey::YieldVault) + } + + /// Shares the pool holds in the yield vault. + pub fn yield_shares(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::YieldShares) + .unwrap_or(0) + } + + /// Assets the pool has deposited into the vault (cost basis). + pub fn yield_cost_basis(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::YieldCostBasis) + .unwrap_or(0) + } + + /// Minimum liquid share of the pot, in basis points. + pub fn yield_buffer_bps(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::YieldBufferBps) + .unwrap_or(DEFAULT_BUFFER_BPS) + } + + /// Whether new allocations are currently blocked. + pub fn yield_locked(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::YieldLocked) + .unwrap_or(false) + } + + /// Idle capital that can be allocated right now without breaching the buffer. + pub fn max_allocatable_amount(env: Env) -> i128 { + Self::max_allocatable(&env) + } + + /// Current value of the pool's vault position, in assets. Reads the vault's + /// live share price; returns 0 when nothing is allocated. + pub fn yield_position(env: Env) -> i128 { + let storage = env.storage().persistent(); + let vault: Option
= storage.get(&DataKey::YieldVault); + let Some(vault) = vault else { + return 0; + }; + let shares: i128 = storage.get(&DataKey::YieldShares).unwrap_or(0); + if shares <= 0 { + return 0; + } + let price = VaultClient::new(&env, &vault).share_price(); + shares * price / SHARE_SCALE + } + + /// Unrealised gain in the vault position (position value minus cost basis). + pub fn unrealised_yield(env: Env) -> i128 { + let position = Self::yield_position(env.clone()); + let cost_basis: i128 = env + .storage() + .persistent() + .get(&DataKey::YieldCostBasis) + .unwrap_or(0); + if position > cost_basis { + position - cost_basis + } else { + 0 + } + } + // ── Helpers ─────────────────────────────────────────────────────────── /// If a supported-token allowlist has been configured, require `token` to @@ -784,3 +1134,10 @@ mod tests; #[cfg(test)] mod fuzz_tests; + +/// `ceil(a * b / d)` with checked integer math. Used for the liquidity buffer +/// so the required liquid floor is never under-estimated. +fn mul_div_ceil(a: i128, b: i128, d: i128) -> i128 { + let product = a.checked_mul(b).expect("math overflow"); + product.checked_add(d - 1).expect("math overflow") / d +} diff --git a/smartcontract/contracts/flexible/src/tests.rs b/smartcontract/contracts/flexible/src/tests.rs index 8d6073b8..a95337bc 100644 --- a/smartcontract/contracts/flexible/src/tests.rs +++ b/smartcontract/contracts/flexible/src/tests.rs @@ -1,4 +1,5 @@ use super::{FlexiblePool, FlexiblePoolClient}; +use jointsave_yield_vault::mock::{MockVault, MockVaultClient}; use soroban_sdk::{ testutils::{storage::Persistent, Address as _, Ledger as _}, token, Address, Env, Symbol, Vec, @@ -761,3 +762,409 @@ fn non_admin_cannot_register_governance_contract() { client.set_governance_contract(&member_a, &Address::generate(&env)); } + +// ── Yield vault allocation ─────────────────────────────────────────────────── + +mod yield_fixture { + use super::setup_pool; + use jointsave_yield_vault::mock::MockVault; + use jointsave_yield_vault::mock::MockVaultClient; + use soroban_sdk::{token, Address, Env}; + + pub struct YieldCtx { + pub pool: super::FlexiblePoolClient<'static>, + pub vault_id: Address, + pub token_addr: Address, + pub admin: Address, + pub member_a: Address, + } + + /// A yield-enabled pool with 100 units deposited by member_a and a fresh + /// mock vault wired as the allocation target. + pub fn setup(env: &Env) -> YieldCtx { + let (pool, token_addr, admin, _treasury, member_a, _member_b) = setup_pool(env, true); + + let vault_id = env.register_contract(None, MockVault); + MockVaultClient::new(env, &vault_id).initialize(&admin, &token_addr); + + let token_admin = token::StellarAssetClient::new(env, &token_addr); + token_admin.mint(&member_a, &100i128); + pool.deposit(&member_a, &100i128); + + YieldCtx { + pool, + vault_id, + token_addr, + admin, + member_a, + } + } +} + +use yield_fixture::{setup, YieldCtx}; + +#[test] +fn allocation_moves_idle_capital_into_vault() { + let env = Env::default(); + env.mock_all_auths(); + let c: YieldCtx = setup(&env); + + assert_eq!(c.pool.yield_buffer_bps(), 2000u32); + // 100 pot, 20% buffer -> 80 allocatable. + assert_eq!(c.pool.max_allocatable_amount(), 80i128); + + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + + assert_eq!(c.pool.yield_vault(), Some(c.vault_id.clone())); + assert_eq!(c.pool.yield_shares(), 80i128); + assert_eq!(c.pool.yield_cost_basis(), 80i128); + + // Pot total is unchanged; only the liquid/illiquid split moved. + assert_eq!(c.pool.total_balance(), 100i128); + let vault_balance = token::Client::new(&env, &c.token_addr).balance(&c.vault_id); + assert_eq!(vault_balance, 80i128); + + // Nothing more is allocatable while the pot total is unchanged. + assert_eq!(c.pool.max_allocatable_amount(), 0i128); +} + +#[test] +#[should_panic(expected = "buffer would be violated")] +fn allocation_respects_liquidity_buffer() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &81i128); +} + +#[test] +#[should_panic(expected = "yield disabled")] +fn allocation_requires_yield_enabled() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, token_addr, admin, _treasury, member_a, _b) = setup_pool(&env, false); + let vault_id = env.register_contract(None, MockVault); + MockVaultClient::new(&env, &vault_id).initialize(&admin, &token_addr); + token::StellarAssetClient::new(&env, &token_addr).mint(&member_a, &100i128); + pool.deposit(&member_a, &100i128); + + pool.apply_yield_allocation(&admin, &vault_id, &10i128); +} + +#[test] +#[should_panic(expected = "not authorized")] +fn stranger_cannot_allocate() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + let stranger = Address::generate(&env); + c.pool + .apply_yield_allocation(&stranger, &c.vault_id, &10i128); +} + +#[test] +fn governance_contract_can_allocate() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + let gov = Address::generate(&env); + c.pool.set_governance_contract(&c.admin, &gov); + + c.pool.apply_yield_allocation(&gov, &c.vault_id, &80i128); + assert_eq!(c.pool.yield_shares(), 80i128); +} + +#[test] +fn set_yield_buffer_changes_headroom() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + + c.pool.set_yield_buffer(&c.admin, &5000u32); + assert_eq!(c.pool.yield_buffer_bps(), 5000u32); + // 50% of 100 stays liquid -> 50 allocatable. + assert_eq!(c.pool.max_allocatable_amount(), 50i128); +} + +#[test] +#[should_panic(expected = "not admin")] +fn non_admin_cannot_set_yield_buffer() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool.set_yield_buffer(&c.member_a, &5000u32); +} + +#[test] +#[should_panic(expected = "buffer must be <= 10000")] +fn yield_buffer_above_100_percent_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool.set_yield_buffer(&c.admin, &10_001u32); +} + +#[test] +fn deallocation_recovers_capital() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + + let received = c.pool.apply_yield_deallocation(&c.admin, &30i128); + assert_eq!(received, 30i128); + assert_eq!(c.pool.yield_shares(), 50i128); + assert_eq!(c.pool.yield_cost_basis(), 50i128); + + // 50 remains in the vault. + let vault_balance = token::Client::new(&env, &c.token_addr).balance(&c.vault_id); + assert_eq!(vault_balance, 50i128); +} + +#[test] +fn member_can_deallocate_without_vote() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + + let received = c.pool.apply_yield_deallocation(&c.member_a, &20i128); + assert_eq!(received, 20i128); + assert_eq!(c.pool.yield_shares(), 60i128); +} + +#[test] +#[should_panic(expected = "not authorized")] +fn stranger_cannot_deallocate() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + let stranger = Address::generate(&env); + c.pool.apply_yield_deallocation(&stranger, &10i128); +} + +#[test] +fn withdraw_auto_deallocates_when_liquid_short() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + // Deploy everything above the buffer: 20 stays liquid, 80 in the vault. + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + + // Withdrawing 50 needs 30 more than the 20 liquid. + c.pool.withdraw(&c.member_a, &50i128); + assert_eq!(c.pool.balance_of(&c.member_a), 50i128); + // 30 shares were redeemed to cover the shortfall. + assert_eq!(c.pool.yield_shares(), 50i128); +} + +#[test] +#[should_panic(expected = "vault illiquid")] +fn withdraw_reverts_when_vault_cannot_cover() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + + // Simulate the vault's assets being fully locked up. + MockVaultClient::new(&env, &c.vault_id).set_locked_assets(&c.admin, &80i128); + + c.pool.withdraw(&c.member_a, &50i128); +} + +#[test] +fn deallocation_returns_partial_when_illiquid() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + MockVaultClient::new(&env, &c.vault_id).set_locked_assets(&c.admin, &40i128); + + // Only 40 of the 80 is liquid, so a 100 request returns 40 (no revert). + let received = c.pool.apply_yield_deallocation(&c.admin, &100i128); + assert_eq!(received, 40i128); + assert_eq!(c.pool.yield_shares(), 40i128); +} + +#[test] +#[should_panic(expected = "yield locked")] +fn locked_blocks_new_allocation() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool.set_yield_locked(&c.admin, &true); + assert!(c.pool.yield_locked()); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &10i128); +} + +#[test] +fn locked_still_allows_deallocation() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool + .apply_yield_allocation(&c.admin, &c.vault_id, &80i128); + c.pool.set_yield_locked(&c.admin, &true); + + let received = c.pool.apply_yield_deallocation(&c.admin, &80i128); + assert_eq!(received, 80i128); + assert_eq!(c.pool.yield_shares(), 0i128); +} + +#[test] +#[should_panic(expected = "not admin")] +fn non_admin_cannot_lock() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool.set_yield_locked(&c.member_a, &true); +} + +/// Pool with two members (a: 100, b: 100), a mock vault, and 80% allocated. +fn setup_two_members_allocated( + env: &Env, +) -> ( + FlexiblePoolClient<'static>, + Address, + Address, + Address, + Address, + Address, +) { + let (pool, token_addr, admin, _treasury, a, b) = setup_pool(env, true); + let vault_id = env.register_contract(None, MockVault); + MockVaultClient::new(env, &vault_id).initialize(&admin, &token_addr); + + let asset = token::StellarAssetClient::new(env, &token_addr); + asset.mint(&a, &100i128); + asset.mint(&b, &100i128); + pool.deposit(&a, &100i128); + pool.deposit(&b, &100i128); + + // 200 pot, 20% buffer -> 160 allocatable. + pool.apply_yield_allocation(&admin, &vault_id, &160i128); + (pool, token_addr, vault_id, admin, a, b) +} + +#[test] +fn sync_yield_distributes_pro_rata() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, token_addr, vault_id, _admin, a, b) = setup_two_members_allocated(&env); + + // 10% gain on the deployed 160. + token::StellarAssetClient::new(&env, &token_addr).mint(&vault_id, &16i128); + + assert_eq!(pool.yield_position(), 176i128); + assert_eq!(pool.unrealised_yield(), 16i128); + + let distributed = pool.sync_yield(&a); + assert_eq!(distributed, 16i128); + assert_eq!(pool.balance_of(&a), 108i128); + assert_eq!(pool.balance_of(&b), 108i128); + assert_eq!(pool.total_balance(), 216i128); + assert_eq!(pool.yield_cost_basis(), 176i128); + assert_eq!(pool.unrealised_yield(), 0i128); +} + +#[test] +fn sync_yield_rounding_remainder_stays_in_pot() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, token_addr, vault_id, _admin, a, _b) = setup_two_members_allocated(&env); + + // 1 unit of yield across two equal members floors to 0 each. + token::StellarAssetClient::new(&env, &token_addr).mint(&vault_id, &1i128); + assert_eq!(pool.unrealised_yield(), 1i128); + + let distributed = pool.sync_yield(&a); + assert_eq!(distributed, 0i128); + assert_eq!(pool.total_balance(), 200i128); + // The remainder is still unrealised and can be swept later. + assert_eq!(pool.unrealised_yield(), 1i128); +} + +#[test] +fn sync_yield_with_no_gain_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, _token, _vault, _admin, a, _b) = setup_two_members_allocated(&env); + assert_eq!(pool.sync_yield(&a), 0i128); + assert_eq!(pool.total_balance(), 200i128); +} + +#[test] +#[should_panic(expected = "not authorized")] +fn stranger_cannot_sync_yield() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, _token, _vault, _admin, _a, _b) = setup_two_members_allocated(&env); + let stranger = Address::generate(&env); + pool.sync_yield(&stranger); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn allocation_respects_paused_state() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, token_addr, admin, _treasury, member_a, _b) = setup_pool(&env, true); + let vault_id = env.register_contract(None, MockVault); + MockVaultClient::new(&env, &vault_id).initialize(&admin, &token_addr); + token::StellarAssetClient::new(&env, &token_addr).mint(&member_a, &100i128); + pool.deposit(&member_a, &100i128); + pool.pause(&admin); + pool.apply_yield_allocation(&admin, &vault_id, &10i128); +} + +#[test] +#[should_panic(expected = "amount must be > 0")] +fn allocation_rejects_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); + let c = setup(&env); + c.pool.apply_yield_allocation(&c.admin, &c.vault_id, &0i128); +} + +#[test] +#[should_panic(expected = "amount must be > 0")] +fn deallocation_rejects_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, _token, _vault, _admin, a, _b) = setup_two_members_allocated(&env); + pool.apply_yield_deallocation(&a, &0i128); +} + +#[test] +fn deallocation_on_fully_illiquid_returns_zero() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, _token_addr, vault_id, admin, a, _b) = setup_two_members_allocated(&env); + MockVaultClient::new(&env, &vault_id).set_locked_assets(&admin, &160i128); + let received = pool.apply_yield_deallocation(&a, &100i128); + assert_eq!(received, 0i128); +} + +#[test] +fn set_yield_buffer_zero_forces_full_liquidity() { + let env = Env::default(); + env.mock_all_auths(); + let (pool, _token, _vault_id, admin, a, _b) = setup_two_members_allocated(&env); + pool.set_yield_buffer(&admin, &0u32); + assert_eq!(pool.yield_buffer_bps(), 0u32); + // 200 pot, 0% buffer -> 200 allocatable, but headroom is 200 (liquid at start 200). + let head = pool.max_allocatable_amount(); + assert!(head > 0); + pool.apply_yield_deallocation(&a, &160i128); + assert!(pool.yield_shares() <= 0); +} diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json index 0b382c4c..b7075600 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json @@ -1085,7 +1085,7 @@ "data": { "vec": [ { - "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#217)'" + "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#229)'" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json index b469ae93..50ac5fe4 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json @@ -1858,6 +1858,58 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "balance" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "balance" + } + ], + "data": { + "i128": { + "hi": 0, + "lo": 1000 + } + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/governance/src/lib.rs b/smartcontract/contracts/governance/src/lib.rs index b1fc238f..24b01076 100644 --- a/smartcontract/contracts/governance/src/lib.rs +++ b/smartcontract/contracts/governance/src/lib.rs @@ -546,10 +546,7 @@ impl Governance { ); format_result(env, "penalty", 0) } - ProposalType::Custom => { - let len = proposal.parameters.len(); - format_result(env, "custom", len as i128) - } + ProposalType::Custom => Self::dispatch_custom(env, proposal), _ => { let value = Self::require_i128(env, &raw_value); let type_symbol = match proposal.proposal_type { @@ -579,6 +576,64 @@ impl Governance { } } + /// Route a `Custom` proposal. Yield-allocation actions are recognised by an + /// `action` parameter and are forwarded to the pool; any other `Custom` + /// proposal keeps the original "record and report" behaviour so existing + /// groups are unaffected. + fn dispatch_custom(env: &Env, proposal: &Proposal) -> Bytes { + let action_key = String::from_str(env, "action"); + let action_bytes = match proposal.parameters.get(action_key) { + Some(bytes) => bytes, + // No `action` parameter: not a yield-allocation proposal. + None => return format_result(env, "custom", proposal.parameters.len() as i128), + }; + + let gov = env.current_contract_address(); + let pool: Address = env + .storage() + .persistent() + .get(&DataKey::PoolContract) + .unwrap(); + + if action_bytes == Bytes::from_slice(env, b"alloc") { + let amount = Self::require_i128( + env, + &proposal.parameters.get(String::from_str(env, "amount")), + ); + let vault_bytes = proposal + .parameters + .get(String::from_str(env, "vault")) + .expect("missing vault"); + let vault = Address::from_string_bytes(&vault_bytes); + + let _: () = env.invoke_contract( + &pool, + &Symbol::new(env, "apply_yield_allocation"), + soroban_sdk::vec![ + env, + gov.into_val(env), + vault.into_val(env), + amount.into_val(env), + ], + ); + format_result(env, "yield_alloc", amount) + } else if action_bytes == Bytes::from_slice(env, b"dealloc") { + let amount = Self::require_i128( + env, + &proposal.parameters.get(String::from_str(env, "amount")), + ); + + let _: () = env.invoke_contract( + &pool, + &Symbol::new(env, "apply_yield_deallocation"), + soroban_sdk::vec![env, gov.into_val(env), amount.into_val(env)], + ); + format_result(env, "yield_dealc", amount) + } else { + format_result(env, "custom", proposal.parameters.len() as i128) + } + } + fn require_i128(env: &Env, raw: &Option) -> i128 { let bytes = raw.as_ref().expect("missing parameter"); assert!(bytes.len() == 16, "invalid parameter encoding"); diff --git a/smartcontract/contracts/governance/src/tests.rs b/smartcontract/contracts/governance/src/tests.rs index e2c1b87a..906d8fae 100644 --- a/smartcontract/contracts/governance/src/tests.rs +++ b/smartcontract/contracts/governance/src/tests.rs @@ -46,12 +46,50 @@ impl TestPool { .get(&DataKeyTest::LastType(proposal_type)) .unwrap_or(0) } + + pub fn apply_yield_allocation(env: Env, caller: Address, vault: Address, amount: i128) { + caller.require_auth(); + let storage = env.storage().persistent(); + storage.set(&DataKeyTest::AllocAmount, &amount); + storage.set(&DataKeyTest::AllocVault, &vault); + } + + pub fn apply_yield_deallocation(env: Env, caller: Address, amount: i128) { + caller.require_auth(); + env.storage() + .persistent() + .set(&DataKeyTest::DeallocAmount, &amount); + } + + pub fn alloc_amount(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKeyTest::AllocAmount) + .unwrap_or(0) + } + + pub fn alloc_vault(env: Env) -> Address { + env.storage() + .persistent() + .get(&DataKeyTest::AllocVault) + .expect("no vault") + } + + pub fn dealloc_amount(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKeyTest::DeallocAmount) + .unwrap_or(0) + } } #[contracttype] pub enum DataKeyTest { Members, LastType(Symbol), + AllocAmount, + AllocVault, + DeallocAmount, } struct Ctx { @@ -76,6 +114,30 @@ fn empty_params(env: &Env) -> Map { Map::new(env) } +/// Build the parameters for a yield-allocation `Custom` proposal. +fn yield_params( + env: &Env, + action: &str, + amount: i128, + vault: Option<&Address>, +) -> Map { + let mut m = Map::new(env); + m.set( + String::from_str(env, "action"), + Bytes::from_slice(env, action.as_bytes()), + ); + m.set( + String::from_str(env, "amount"), + Bytes::from_array(env, &amount.to_be_bytes()), + ); + if let Some(v) = vault { + let mut arr = [0u8; 56]; + v.to_string().copy_into_slice(&mut arr); + m.set(String::from_str(env, "vault"), Bytes::from_array(env, &arr)); + } + m +} + fn setup() -> Ctx { let env = Env::default(); env.mock_all_auths(); @@ -526,3 +588,70 @@ fn active_proposals_query_rejects_wrong_pool() { let gov = GovernanceClient::new(&ctx.env, &ctx.gov_id); gov.get_active_proposals(&Address::generate(&ctx.env)); } + +#[test] +fn custom_yield_alloc_proposal_forwards_to_pool() { + let ctx = setup(); + let gov = GovernanceClient::new(&ctx.env, &ctx.gov_id); + let vault = Address::generate(&ctx.env); + + let id = create_and_pass( + &ctx, + ProposalType::Custom, + yield_params(&ctx.env, "alloc", 750, Some(&vault)), + ); + gov.execute_proposal(&ctx.voters[0], &id); + + let pool = TestPoolClient::new(&ctx.env, &ctx.pool_id); + assert_eq!(pool.alloc_amount(), 750i128); + assert_eq!(pool.alloc_vault(), vault); + + let p = gov.get_proposal(&id); + assert_eq!(p.status, ProposalStatus::Executed); +} + +#[test] +fn custom_yield_dealloc_proposal_forwards_to_pool() { + let ctx = setup(); + let gov = GovernanceClient::new(&ctx.env, &ctx.gov_id); + + let id = create_and_pass( + &ctx, + ProposalType::Custom, + yield_params(&ctx.env, "dealloc", 400, None), + ); + gov.execute_proposal(&ctx.voters[0], &id); + + assert_eq!( + TestPoolClient::new(&ctx.env, &ctx.pool_id).dealloc_amount(), + 400i128 + ); +} + +#[test] +fn custom_without_action_keeps_legacy_behaviour() { + let ctx = setup(); + let gov = GovernanceClient::new(&ctx.env, &ctx.gov_id); + + let id = create_and_pass(&ctx, ProposalType::Custom, params_i128(&ctx.env, "note", 7)); + gov.execute_proposal(&ctx.voters[0], &id); + + // Legacy path: nothing is forwarded to the pool. + let pool = TestPoolClient::new(&ctx.env, &ctx.pool_id); + assert_eq!(pool.alloc_amount(), 0i128); + assert_eq!(pool.dealloc_amount(), 0i128); + assert_eq!(gov.get_proposal(&id).status, ProposalStatus::Executed); +} + +#[test] +#[should_panic(expected = "missing vault")] +fn custom_alloc_without_vault_fails() { + let ctx = setup(); + let gov = GovernanceClient::new(&ctx.env, &ctx.gov_id); + let id = create_and_pass( + &ctx, + ProposalType::Custom, + yield_params(&ctx.env, "alloc", 100, None), + ); + gov.execute_proposal(&ctx.voters[0], &id); +} diff --git a/smartcontract/contracts/yield-vault/Cargo.toml b/smartcontract/contracts/yield-vault/Cargo.toml new file mode 100644 index 00000000..ff7e539e --- /dev/null +++ b/smartcontract/contracts/yield-vault/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "jointsave-yield-vault" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +# The mock vault is only needed for tests and testnet demos; keeping it behind +# a feature stops it from being linked into consumers' wasm. +mock = [] + +[dependencies] +soroban-sdk = { version = "21.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } +proptest = "1.4" diff --git a/smartcontract/contracts/yield-vault/src/adapter.rs b/smartcontract/contracts/yield-vault/src/adapter.rs new file mode 100644 index 00000000..71972e06 --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/adapter.rs @@ -0,0 +1,31 @@ +use soroban_sdk::{contractclient, Address, Env}; + +/// Interface every yield vault adapter must implement. +/// +/// The savings pool depends only on this trait, never on a concrete vault, so +/// any contract that implements it (a mock, a lending market, a tokenised +/// fund) can be plugged in without changing the pool. The pool is responsible +/// for moving the underlying SEP-41 tokens to the vault *before* calling +/// `deposit`; the vault is responsible for reporting `max_redeemable` honestly +/// so callers never request more shares than it can pay for. +#[contractclient(name = "VaultClient")] +pub trait VaultAdapter { + /// Deposit `amount` of the vault's underlying asset on behalf of `from`. + /// Returns the number of shares minted. + fn deposit(env: Env, from: Address, amount: i128) -> i128; + + /// Redeem `shares` and send the proceeds to `to`. Returns the assets paid. + fn withdraw(env: Env, to: Address, shares: i128) -> i128; + + /// Assets per `SHARE_SCALE` shares, rounded down. + fn share_price(env: Env) -> i128; + + /// Maximum shares that can be redeemed right now (liquidity-limited). + fn max_redeemable(env: Env) -> i128; + + /// Total shares outstanding. + fn total_shares(env: Env) -> i128; + + /// Total assets under management. + fn total_assets(env: Env) -> i128; +} diff --git a/smartcontract/contracts/yield-vault/src/errors.rs b/smartcontract/contracts/yield-vault/src/errors.rs new file mode 100644 index 00000000..94feb0dc --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/errors.rs @@ -0,0 +1,17 @@ +use soroban_sdk::contracterror; + +/// Shared error vocabulary for the yield-allocation feature. Adapters return +/// the vault-side variants; the pool returns `BufferViolation` and `Unauthorized`. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum VaultError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidAmount = 4, + InsufficientShares = 5, + Illiquid = 6, + MathOverflow = 7, + BufferViolation = 8, +} diff --git a/smartcontract/contracts/yield-vault/src/events.rs b/smartcontract/contracts/yield-vault/src/events.rs new file mode 100644 index 00000000..f1ca78fa --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/events.rs @@ -0,0 +1,55 @@ +use soroban_sdk::{symbol_short, Address, Env}; + +/// Emitted when the pool's capital is moved into a vault. +pub fn allocated(env: &Env, vault: &Address, amount: i128, shares: i128) { + env.events().publish( + (symbol_short!("ya_alloc"),), + (vault.clone(), amount, shares), + ); +} + +/// Emitted when the pool's capital is pulled back out of a vault. +pub fn deallocated(env: &Env, requested: i128, received: i128, shares_burned: i128) { + env.events().publish( + (symbol_short!("ya_deall"),), + (requested, received, shares_burned), + ); +} + +/// Emitted when realised yield is credited to the pot. +pub fn synced(env: &Env, yield_amount: i128) { + env.events() + .publish((symbol_short!("ya_sync"),), yield_amount); +} + +/// Emitted when the liquidity buffer is changed. +pub fn buffer_set(env: &Env, bps: u32) { + env.events().publish((symbol_short!("ya_bufset"),), bps); +} + +/// Emitted when the pool enters or leaves de-allocation-only mode. +pub fn lock_set(env: &Env, locked: bool) { + env.events().publish((symbol_short!("ya_locked"),), locked); +} + +/// Emitted by a vault when it mints shares for a deposit. +#[cfg(any(test, feature = "mock"))] +pub fn deposited(env: &Env, from: &Address, amount: i128, shares: i128) { + env.events().publish( + (symbol_short!("deposited"),), + (from.clone(), amount, shares), + ); +} + +/// Emitted by a vault when it burns shares and pays out assets. +#[cfg(any(test, feature = "mock"))] +pub fn withdrew(env: &Env, to: &Address, shares: i128, assets: i128) { + env.events() + .publish((symbol_short!("withdrew"),), (to.clone(), shares, assets)); +} + +/// Emitted by a vault when its locked (illiquid) assets change. +#[cfg(any(test, feature = "mock"))] +pub fn vault_locked(env: &Env, amount: i128) { + env.events().publish((symbol_short!("v_locked"),), amount); +} diff --git a/smartcontract/contracts/yield-vault/src/lib.rs b/smartcontract/contracts/yield-vault/src/lib.rs new file mode 100644 index 00000000..04b75b82 --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/lib.rs @@ -0,0 +1,16 @@ +#![no_std] + +pub mod adapter; +mod errors; +mod events; +#[cfg(any(test, feature = "mock"))] +pub mod mock; +mod types; + +pub use adapter::{VaultAdapter, VaultClient}; +pub use errors::VaultError; +pub use events::{allocated, buffer_set, deallocated, lock_set, synced}; +pub use types::{BPS_DENOMINATOR, DEFAULT_BUFFER_BPS, SHARE_SCALE}; + +#[cfg(test)] +mod tests; diff --git a/smartcontract/contracts/yield-vault/src/mock.rs b/smartcontract/contracts/yield-vault/src/mock.rs new file mode 100644 index 00000000..58ad4e2b --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/mock.rs @@ -0,0 +1,166 @@ +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; + +use crate::events; +use crate::types::SHARE_SCALE; + +/// Storage keys for the mock vault. Kept private to this contract. +#[contracttype] +pub enum MockVaultKey { + Admin, + Token, + TotalShares, + Locked, + Shares(Address), +} + +/// A deliberately simple, self-contained vault that satisfies `VaultAdapter`. +/// +/// It exists for tests and local development only: it holds a single SEP-41 +/// asset, mints shares 1:1 on the first deposit, and reports a live share price +/// from its token balance. Tests can simulate yield by minting the underlying +/// token to the vault, and simulate illiquidity with `set_locked_assets`. +/// It is **not** a production vault. +#[contract] +pub struct MockVault; + +#[contractimpl] +impl MockVault { + pub fn initialize(env: Env, admin: Address, token: Address) { + admin.require_auth(); + let storage = env.storage().persistent(); + assert!(!storage.has(&MockVaultKey::Admin), "already initialized"); + storage.set(&MockVaultKey::Admin, &admin); + storage.set(&MockVaultKey::Token, &token); + storage.set(&MockVaultKey::TotalShares, &0i128); + storage.set(&MockVaultKey::Locked, &0i128); + } + + /// Deposit `amount` of the underlying asset held on behalf of `from`. + /// The caller must have transferred `amount` tokens to this contract first. + pub fn deposit(env: Env, from: Address, amount: i128) -> i128 { + from.require_auth(); + assert!(amount > 0, "amount must be > 0"); + + let storage = env.storage().persistent(); + let token_addr: Address = storage.get(&MockVaultKey::Token).expect("not initialized"); + let total_shares: i128 = storage.get(&MockVaultKey::TotalShares).unwrap_or(0); + let total_assets = + token::Client::new(&env, &token_addr).balance(&env.current_contract_address()); + + // Assets already held *before* this deposit (the pool transfers first). + let pre_assets = total_assets - amount; + let shares = if total_shares == 0 || pre_assets <= 0 { + amount + } else { + amount * total_shares / pre_assets + }; + + let holder_key = MockVaultKey::Shares(from.clone()); + let held: i128 = storage.get(&holder_key).unwrap_or(0); + storage.set(&holder_key, &(held + shares)); + storage.set(&MockVaultKey::TotalShares, &(total_shares + shares)); + + events::deposited(&env, &from, amount, shares); + shares + } + + /// Redeem `shares` owned by `to` and send the proceeds to `to`. + pub fn withdraw(env: Env, to: Address, shares: i128) -> i128 { + to.require_auth(); + assert!(shares > 0, "shares must be > 0"); + + let storage = env.storage().persistent(); + let token_addr: Address = storage.get(&MockVaultKey::Token).expect("not initialized"); + let total_shares: i128 = storage.get(&MockVaultKey::TotalShares).unwrap_or(0); + assert!(total_shares > 0, "no shares"); + + let holder_key = MockVaultKey::Shares(to.clone()); + let held: i128 = storage.get(&holder_key).unwrap_or(0); + assert!(held >= shares, "insufficient shares"); + + let total_assets = + token::Client::new(&env, &token_addr).balance(&env.current_contract_address()); + // Floor division favours the vault, never the withdrawing member. + let assets = shares * total_assets / total_shares; + + storage.set(&holder_key, &(held - shares)); + storage.set(&MockVaultKey::TotalShares, &(total_shares - shares)); + + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &to, + &assets, + ); + + events::withdrew(&env, &to, shares, assets); + assets + } + + /// Simulate illiquidity: `amount` assets are excluded from redemptions. + pub fn set_locked_assets(env: Env, admin: Address, amount: i128) { + admin.require_auth(); + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&MockVaultKey::Admin).expect("not initialized"); + assert!(admin == stored_admin, "not admin"); + assert!(amount >= 0, "amount must be >= 0"); + storage.set(&MockVaultKey::Locked, &amount); + events::vault_locked(&env, amount); + } + + // ── VaultAdapter views ──────────────────────────────────────────────── + + pub fn total_assets(env: Env) -> i128 { + let storage = env.storage().persistent(); + let token_addr: Address = storage.get(&MockVaultKey::Token).expect("not initialized"); + token::Client::new(&env, &token_addr).balance(&env.current_contract_address()) + } + + pub fn total_shares(env: Env) -> i128 { + env.storage() + .persistent() + .get(&MockVaultKey::TotalShares) + .unwrap_or(0) + } + + pub fn share_price(env: Env) -> i128 { + let total_shares = Self::total_shares(env.clone()); + if total_shares == 0 { + return SHARE_SCALE; + } + Self::total_assets(env) * SHARE_SCALE / total_shares + } + + pub fn max_redeemable(env: Env) -> i128 { + let total_shares = Self::total_shares(env.clone()); + if total_shares == 0 { + return 0; + } + let total_assets = Self::total_assets(env.clone()); + if total_assets <= 0 { + return 0; + } + let locked: i128 = env + .storage() + .persistent() + .get(&MockVaultKey::Locked) + .unwrap_or(0); + let available = if total_assets > locked { + total_assets - locked + } else { + 0 + }; + let shares = available * total_shares / total_assets; + if shares > total_shares { + total_shares + } else { + shares + } + } + + pub fn balance_of(env: Env, who: Address) -> i128 { + env.storage() + .persistent() + .get(&MockVaultKey::Shares(who)) + .unwrap_or(0) + } +} diff --git a/smartcontract/contracts/yield-vault/src/tests.rs b/smartcontract/contracts/yield-vault/src/tests.rs new file mode 100644 index 00000000..b277d9b5 --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/tests.rs @@ -0,0 +1,107 @@ +use crate::mock::{MockVault, MockVaultClient}; +use crate::types::SHARE_SCALE; +use soroban_sdk::{testutils::Address as _, token, Address, Env}; + +struct Fixture { + contract_id: Address, + client: MockVaultClient<'static>, + token: token::Client<'static>, + token_admin: token::StellarAssetClient<'static>, + admin: Address, + depositor: Address, +} + +fn setup(env: &Env) -> Fixture { + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MockVault); + let client = MockVaultClient::new(env, &contract_id); + + let token_admin_addr = Address::generate(env); + let sac = env.register_stellar_asset_contract_v2(token_admin_addr.clone()); + let token_addr = sac.address(); + let token = token::Client::new(env, &token_addr); + let token_admin = token::StellarAssetClient::new(env, &token_addr); + + let admin = Address::generate(env); + client.initialize(&admin, &token_addr); + + let depositor = Address::generate(env); + + Fixture { + contract_id, + client, + token, + token_admin, + admin, + depositor, + } +} + +/// Fund `who` and move the tokens into the vault, then call `deposit`. +fn deposit(f: &Fixture, who: &Address, amount: i128) -> i128 { + f.token_admin.mint(who, &amount); + f.token.transfer(who, &f.contract_id, &amount); + f.client.deposit(who, &amount) +} + +#[test] +fn first_deposit_mints_one_to_one() { + let env = Env::default(); + let f = setup(&env); + let shares = deposit(&f, &f.depositor, 1_000); + assert_eq!(shares, 1_000); + assert_eq!(f.client.total_shares(), 1_000); + assert_eq!(f.client.total_assets(), 1_000); + assert_eq!(f.client.share_price(), SHARE_SCALE); + assert_eq!(f.client.balance_of(&f.depositor), 1_000); +} + +#[test] +fn yield_raises_share_price_and_redemption_value() { + let env = Env::default(); + let f = setup(&env); + deposit(&f, &f.depositor, 1_000); + + // Simulate 10% yield by minting the asset straight into the vault. + f.token_admin.mint(&f.contract_id, &100); + + // 1100 assets / 1000 shares -> 1.1 * SHARE_SCALE + assert_eq!(f.client.share_price(), SHARE_SCALE * 11 / 10); + + let before = f.token.balance(&f.depositor); + let assets = f.client.withdraw(&f.depositor, &1_000); + // Floor division: 1000 * 1100 / 1000 == 1100. + assert_eq!(assets, 1_100); + assert_eq!(f.token.balance(&f.depositor), before + 1_100); + assert_eq!(f.client.total_shares(), 0); +} + +#[test] +fn withdrawal_rounds_down_in_the_vaults_favour() { + let env = Env::default(); + let f = setup(&env); + deposit(&f, &f.depositor, 3); // 3 shares + f.token_admin.mint(&f.contract_id, &1); // 4 assets / 3 shares + + let assets = f.client.withdraw(&f.depositor, &1); + // 1 * 4 / 3 == 1 (floored, remainder stays in the vault). + assert_eq!(assets, 1); + assert_eq!(f.client.total_shares(), 2); + assert_eq!(f.client.total_assets(), 3); +} + +#[test] +fn max_redeemable_respects_locked_assets() { + let env = Env::default(); + let f = setup(&env); + deposit(&f, &f.depositor, 1_000); + assert_eq!(f.client.max_redeemable(), 1_000); + + f.client.set_locked_assets(&f.admin, &400); + // 600 of 1000 assets are liquid -> 600 shares are redeemable. + assert_eq!(f.client.max_redeemable(), 600); + + f.client.set_locked_assets(&f.admin, &10_000); + assert_eq!(f.client.max_redeemable(), 0); +} diff --git a/smartcontract/contracts/yield-vault/src/types.rs b/smartcontract/contracts/yield-vault/src/types.rs new file mode 100644 index 00000000..de53cc9b --- /dev/null +++ b/smartcontract/contracts/yield-vault/src/types.rs @@ -0,0 +1,10 @@ +/// Fixed-point scale for share prices. A `share_price` of `SHARE_SCALE` means +/// one underlying-asset unit per share. All vault math stays in integers. +pub const SHARE_SCALE: i128 = 1_000_000_000; + +/// Default liquidity buffer: 20% of the pot (2000 basis points) always stays +/// liquid so ordinary member withdrawals keep working. +pub const DEFAULT_BUFFER_BPS: u32 = 2000; + +/// Basis-point denominator. +pub const BPS_DENOMINATOR: u32 = 10_000; diff --git a/smartcontract/deployments/stellar-testnet.json b/smartcontract/deployments/stellar-testnet.json index 38ee1f0e..147e82b1 100644 --- a/smartcontract/deployments/stellar-testnet.json +++ b/smartcontract/deployments/stellar-testnet.json @@ -10,6 +10,11 @@ }, "RotationalWasmHash": "d350a325d8734263a3d7150c875555d8956e13a527fb3497d5141b8b3f3d2c74", "TargetWasmHash": "133a62226501fc5443e70007d79deeeb0b33fdf8c85c7fcd3cf16293bb5c7292", - "FlexibleWasmHash": "df6ff088fd79f13d8d03e72160434517fdb4a83b8c7bfdd887be4369805e0d6b" + "FlexibleWasmHash": "df6ff088fd79f13d8d03e72160434517fdb4a83b8c7bfdd887be4369805e0d6b", + "YieldVaultWasmHash": null, + "YieldVaultContractId": null + }, + "notes": { + "YieldVault": "Mock VaultAdapter implementation, built with the `mock` cargo feature. Both fields are populated by smartcontract/scripts/deploy.sh; null until a mock vault is deployed to testnet." } } diff --git a/smartcontract/scripts/deploy.sh b/smartcontract/scripts/deploy.sh index 81bfb00c..c818a0ae 100755 --- a/smartcontract/scripts/deploy.sh +++ b/smartcontract/scripts/deploy.sh @@ -50,6 +50,29 @@ FLEXIBLE_WASM_HASH=$(stellar contract upload \ --network "$NETWORK") echo "Flexible wasm hash: $FLEXIBLE_WASM_HASH" +echo "" +echo "Building Yield Vault (mock feature)..." +# The mock vault implements the VaultAdapter interface and is only intended for +# testnet demos. Production groups point at a real external vault instead. +cargo build --release --target wasm32v1-none \ + --manifest-path=contracts/yield-vault/Cargo.toml --features mock + +echo "" +echo "Uploading Yield Vault (mock) wasm..." +YIELD_VAULT_WASM_HASH=$(stellar contract upload \ + --wasm target/wasm32v1-none/release/jointsave_yield_vault.wasm \ + --source "$SOURCE" \ + --network "$NETWORK") +echo "Yield vault wasm hash: $YIELD_VAULT_WASM_HASH" + +echo "" +echo "Deploying Yield Vault (mock)..." +YIELD_VAULT_ID=$(stellar contract deploy \ + --wasm target/wasm32v1-none/release/jointsave_yield_vault.wasm \ + --source "$SOURCE" \ + --network "$NETWORK") +echo "Yield vault contract ID: $YIELD_VAULT_ID" + echo "" echo "Initializing factory..." # XLM native token contract on Stellar testnet @@ -65,6 +88,17 @@ stellar contract invoke \ --treasury "$DEPLOYER_ADDRESS" echo "Factory initialized." +echo "" +echo "Initializing Yield Vault (mock)..." +stellar contract invoke \ + --id "$YIELD_VAULT_ID" \ + --source "$SOURCE" \ + --network "$NETWORK" \ + -- initialize \ + --admin "$DEPLOYER_ADDRESS" \ + --token "$NATIVE_TOKEN" +echo "Yield vault initialized." + echo "" echo "Deployment complete. Update your .env with:" echo "NEXT_PUBLIC_FACTORY_CONTRACT_ID=$FACTORY_ID" @@ -72,3 +106,5 @@ echo "NEXT_PUBLIC_REPUTATION_CONTRACT_ID=$REPUTATION_ID" echo "NEXT_PUBLIC_ROTATIONAL_WASM_HASH=$ROTATIONAL_WASM_HASH" echo "NEXT_PUBLIC_TARGET_WASM_HASH=$TARGET_WASM_HASH" echo "NEXT_PUBLIC_FLEXIBLE_WASM_HASH=$FLEXIBLE_WASM_HASH" +echo "NEXT_PUBLIC_YIELD_VAULT_WASM_HASH=$YIELD_VAULT_WASM_HASH" +echo "NEXT_PUBLIC_YIELD_VAULT_ID=$YIELD_VAULT_ID" diff --git a/subgraph/abis/Factory.json b/subgraph/abis/Factory.json new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/abis/Pool.json b/subgraph/abis/Pool.json new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql new file mode 100644 index 00000000..30e9e723 --- /dev/null +++ b/subgraph/schema.graphql @@ -0,0 +1,33 @@ +type Protocol @entity { + id: ID! # Single instance "1" + totalValueLockedUSD: BigDecimal! + totalVolumeUSD: BigDecimal! + activePoolsCount: BigInt! + totalMembers: BigInt! +} + +type ProtocolDayData @entity { + id: ID! # Timestamp / 86400 + date: Int! + tvlUSD: BigDecimal! + dailyVolumeUSD: BigDecimal! + totalMembers: BigInt! +} + +type Pool @entity { + id: ID! # Pool contract address + token0: Bytes! + token1: Bytes! + reserve0: BigInt! + reserve1: BigInt! + totalValueLockedUSD: BigDecimal! + volumeUSD: BigDecimal! + isActive: Boolean! + createdAtTimestamp: BigInt! +} + +type Member @entity { + id: ID! # Wallet address + joinedTimestamp: BigInt! + txCount: BigInt! +} \ No newline at end of file diff --git a/subgraph/src/factory.ts b/subgraph/src/factory.ts new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/src/pool.ts b/subgraph/src/pool.ts new file mode 100644 index 00000000..f9ca6a53 --- /dev/null +++ b/subgraph/src/pool.ts @@ -0,0 +1,60 @@ +import { BigInt, BigDecimal } from "@graphprotocol/graph-ts"; +import { Swap, MemberJoined } from "../generated/templates/Pool/Pool"; +import { Protocol, ProtocolDayData, Pool, Member } from "../generated/schema"; + +const FACTORY_ADDRESS = "1"; + +export function handleSwap(event: Swap): void { + let pool = Pool.load(event.address.toHexString()); + if (!pool) return; + + // Mock USD pricing calculation for volume (e.g., token0 amount * price) + let volumeUSD = event.params.amount0In.toBigDecimal().div(BigDecimal.fromString("1e18")); + + // Update Pool stats + pool.volumeUSD = pool.volumeUSD.plus(volumeUSD); + pool.save(); + + // Update Protocol global stats + let protocol = Protocol.load(FACTORY_ADDRESS); + if (protocol) { + protocol.totalVolumeUSD = protocol.totalVolumeUSD.plus(volumeUSD); + protocol.save(); + } + + // Update Daily Aggregates + let dayID = event.block.timestamp.toI32() / 86400; + let dayData = ProtocolDayData.load(dayID.toString()); + if (!dayData) { + dayData = new ProtocolDayData(dayID.toString()); + dayData.date = dayID * 86400; + dayData.tvlUSD = protocol ? protocol.totalValueLockedUSD : BigDecimal.fromString("0"); + dayData.dailyVolumeUSD = BigDecimal.fromString("0"); + dayData.totalMembers = protocol ? protocol.totalMembers : BigInt.fromI32(0); + } + + dayData.dailyVolumeUSD = dayData.dailyVolumeUSD.plus(volumeUSD); + dayData.tvlUSD = protocol ? protocol.totalValueLockedUSD : BigDecimal.fromString("0"); + dayData.save(); +} + +export function handleMemberJoined(event: MemberJoined): void { + let memberId = event.params.member.toHexString(); + let member = Member.load(memberId); + + if (!member) { + member = new Member(memberId); + member.joinedTimestamp = event.block.timestamp; + member.txCount = BigInt.fromI32(1); + member.save(); + + let protocol = Protocol.load(FACTORY_ADDRESS); + if (protocol) { + protocol.totalMembers = protocol.totalMembers.plus(BigInt.fromI32(1)); + protocol.save(); + } + } else { + member.txCount = member.txCount.plus(BigInt.fromI32(1)); + member.save(); + } +} \ No newline at end of file diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml new file mode 100644 index 00000000..79387e88 --- /dev/null +++ b/subgraph/subgraph.yaml @@ -0,0 +1,48 @@ +specVersion: 0.0.5 +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum + name: Factory + network: mainnet + source: + address: "0x1111111111111111111111111111111111111111" + abi: Factory + startBlock: 17000000 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Protocol + - Pool + abis: + - name: Factory + file: ./abis/Factory.json + eventHandlers: + - event: PoolCreated(indexed address,indexed address,address,uint256) + handler: handlePoolCreated + file: ./src/factory.ts +templates: + - kind: ethereum + name: Pool + network: mainnet + source: + abi: Pool + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Pool + - ProtocolDayData + - Member + abis: + - name: Pool + file: ./abis/Pool.json + eventHandlers: + - event: Swap(indexed address,uint256,uint256,uint256,uint256,indexed address) + handler: handleSwap + - event: MemberJoined(indexed address) + handler: handleMemberJoined + file: ./src/pool.ts \ No newline at end of file diff --git a/subgraph/tests/pool.test.ts b/subgraph/tests/pool.test.ts new file mode 100644 index 00000000..c956140d --- /dev/null +++ b/subgraph/tests/pool.test.ts @@ -0,0 +1,44 @@ +import { assert, describe, test, newMockEvent, clearStore } from "matchstick-as/assembly/index"; +import { Address, BigInt, ethereum } from "@graphprotocol/graph-ts"; +import { MemberJoined } from "../generated/templates/Pool/Pool"; +import { handleMemberJoined } from "../src/pool"; +import { Protocol } from "../generated/schema"; + +describe("Pool Unit Tests", () => { + test("Should increment unique total members on MemberJoined event", () => { + // Setup initial protocol state + const protocol = new Protocol("1"); + protocol.totalValueLockedUSD = BigInt.fromI32(0).toBigDecimal(); + protocol.totalVolumeUSD = BigInt.fromI32(0).toBigDecimal(); + protocol.activePoolsCount = BigInt.fromI32(0); + protocol.totalMembers = BigInt.fromI32(0); + protocol.save(); + + // Create mock MemberJoined event + const mockEvent = newMockEvent(); + const memberAddress = Address.fromString("0x0000000000000000000000000000000000000001"); + + const memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress)); + mockEvent.parameters = [memberParam]; + + const memberJoinedEvent = new MemberJoined( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt + ); + + // Run Handler + handleMemberJoined(memberJoinedEvent); + + // Assertions + assert.fieldEquals("Protocol", "1", "totalMembers", "1"); + assert.fieldEquals("Member", memberAddress.toHexString(), "txCount", "1"); + + clearStore(); + }); +}); \ No newline at end of file