diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b21a2..1e45a8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: build: - name: Build Soroban Contract + name: Build Soroban Contracts runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,13 +17,31 @@ jobs: targets: wasm32v1-none components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - - name: Build contract WASM - run: cargo build --release --target wasm32v1-none - - name: Upload WASM artifact + # The pool contract imports the LP token contract's compiled WASM + # via contractimport! (see contracts/pool/src/lib.rs), so that WASM + # must exist before the pool builds. This must be a separate `cargo + # build` invocation, not folded into the --workspace build below: + # Cargo has no dependency-graph edge between the two crates (that's + # the whole point of contractimport! over a regular path dependency + # -- see the comment in lib.rs), so within a single `--workspace` + # invocation Cargo is free to compile them in parallel, and + # sometimes does, racing the pool's build against a LP token WASM + # file that doesn't exist yet. Confirmed by hitting that exact race + # locally before splitting this into two steps. + - name: Build LP token WASM (must finish before the pool) + run: cargo build --release --target wasm32v1-none -p nodus-protocol-lp-token + - name: Build contract WASM (all workspace members) + run: cargo build --release --target wasm32v1-none --workspace + - name: Upload pool WASM artifact uses: actions/upload-artifact@v4 with: name: nodus-protocol-amm-wasm path: target/wasm32v1-none/release/nodus_protocol_amm.wasm + - name: Upload LP token WASM artifact + uses: actions/upload-artifact@v4 + with: + name: nodus-protocol-lp-token-wasm + path: target/wasm32v1-none/release/nodus_protocol_lp_token.wasm test: name: Test (unit + integration) @@ -31,9 +49,15 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none - uses: Swatinem/rust-cache@v2 + # See the build job's comment: required before anything touches + # the pool crate, including plain `cargo test`. + - name: Build LP token WASM (must finish before the pool) + run: cargo build --release --target wasm32v1-none -p nodus-protocol-lp-token - name: Run tests - run: cargo test --features testutils + run: cargo test --workspace --features testutils lint: name: Lint @@ -42,9 +66,14 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: + targets: wasm32v1-none components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + # See the build job's comment: required before anything touches + # the pool crate, including clippy. + - name: Build LP token WASM (must finish before the pool) + run: cargo build --release --target wasm32v1-none -p nodus-protocol-lp-token - name: Clippy - run: cargo clippy --all-targets --features testutils -- -D warnings + run: cargo clippy --workspace --all-targets --features testutils -- -D warnings - name: Format check run: cargo fmt --all -- --check diff --git a/Cargo.lock b/Cargo.lock index 4e4a08a..9bc723b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,14 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nodus-protocol-amm" version = "0.1.0" +dependencies = [ + "nodus-protocol-lp-token", + "soroban-sdk", +] + +[[package]] +name = "nodus-protocol-lp-token" +version = "0.1.0" dependencies = [ "soroban-sdk", ] diff --git a/Cargo.toml b/Cargo.toml index a171f73..515667c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["contracts/pool"] +members = ["contracts/pool", "contracts/lp-token"] [workspace.dependencies] soroban-sdk = "26.1.0" diff --git a/Makefile b/Makefile index 7d8d61c..d4292c2 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,25 @@ -.PHONY: build test lint format clean deploy-testnet deploy-mainnet help +.PHONY: build build-lp-token test test-math lint format clean deploy-testnet deploy-mainnet help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' -build: ## Build optimised contract WASM via Stellar CLI - stellar contract build +build-lp-token: ## Build the LP token contract WASM (must finish before the pool -- it imports this WASM via contractimport!) + cargo build --release --target wasm32v1-none -p nodus-protocol-lp-token -test: ## Run all tests (unit + integration; requires testutils feature) - cargo test --features testutils +build: build-lp-token ## Build all contract WASMs (LP token first, then everything else) + cargo build --release --target wasm32v1-none --workspace + +test: build-lp-token ## Run all tests (unit + integration; requires testutils feature) + cargo test --workspace --features testutils test-math: ## Run math-only unit tests (no Soroban env needed) - cargo test math_tests - cargo test liquidity_pool_tests - cargo test fuzz_math + cargo test -p nodus-protocol-amm math_tests + cargo test -p nodus-protocol-amm liquidity_pool_tests + cargo test -p nodus-protocol-amm fuzz_math -lint: ## Run clippy and check formatting - cargo clippy --all-targets --features testutils -- -D warnings +lint: build-lp-token ## Run clippy and check formatting + cargo clippy --workspace --all-targets --features testutils -- -D warnings cargo fmt --all --check format: ## Format all source files diff --git a/README.md b/README.md index d7d71d5..13f6a82 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,46 @@ Constant-product Automated Market Maker (AMM) smart contract written in **Rust** ## Overview -This contract implements a Uniswap V2-style AMM on Stellar Soroban. It holds reserves for two SEP-41 Stellar tokens, executes atomic swaps, and issues LP tokens representing each provider's proportional share. +This is a Uniswap V2-style AMM on Stellar Soroban, split across multiple +contracts rather than one monolithic one. It holds reserves for two SEP-41 +Stellar tokens, executes atomic swaps, and issues LP tokens representing +each provider's proportional share via a standalone LP token contract. ## Architecture ``` -┌─────────────────────────────────────┐ -│ NodusAmm │ -│ │ -│ reserve_0 ──── reserve_1 │ -│ \ / │ -│ k = x * y (invariant) │ -│ │ -│ add_liquidity() → mint LP tokens │ -│ remove_liquidity() → burn LP tokens│ -│ swap() │ -│ sync() (drift correction) │ -│ │ +┌─────────────────────────────────────┐ ┌──────────────────────────┐ +│ NodusAmm (pool) │ │ nodus-protocol-lp-token │ +│ │ │ │ +│ reserve_0 ──── reserve_1 │ mint/ │ Standalone SEP-41- │ +│ \ / │ burn │ compatible token. │ +│ k = x * y (invariant) │──────►│ mint/burn are pool- │ +│ │ │ gated; transfer/ │ +│ add_liquidity() → mint LP tokens │ │ approve/allowance are │ +│ remove_liquidity() → burn LP tokens│ │ standard and open to │ +│ swap() │ │ any holder. │ +│ sync() (drift correction) │ └──────────────────────────┘ +│ │ │ TWAP price accumulators │ │ (price_0_cumulative_last, …) │ -└─────────────────────────────────────┘ +└──────────────────────────────────────┘ ``` -LP tokens are tracked internally in the pool's persistent storage — no separate token contract is required. +The pool talks to its LP token contract via `contractimport!` (see +`contracts/pool/src/lib.rs`) rather than a regular Cargo dependency on the +`nodus-protocol-lp-token` crate — depending on the crate directly links +its own `#[contractimpl]`-generated WASM exports into the pool's binary +too (confirmed empirically: both crates export an `initialize` function, +which fails the link with a duplicate-symbol error). `contractimport!` +reads the LP token's *compiled* WASM instead, so **the LP token contract +must be built before the pool** — `make build`/`make test`/`make lint` +all handle this ordering; see [Build](#build) below if you're running +`cargo` directly. + +A factory contract (deploying and tracking a pool + LP token pair per +token combination, since today's pool still only supports one hard-coded +pair per deployed instance) and a router contract (multi-hop swaps once +more than one pool exists) are planned as follow-up PRs. --- @@ -47,8 +64,7 @@ contracts/ pool/ src/ lib.rs Contract entry point — all public functions - liquidity_pool.rs Pool math: optimal amounts, K-invariant, LP mint/burn - lp_token.rs Internal LP ledger: mint, burn, transfer, approve, allowance + liquidity_pool.rs Pool math: optimal amounts, K-invariant math.rs AMM formulas: get_amount_out, get_amount_in, sqrt storage.rs DataKey enum for all instance + persistent storage keys events.rs Soroban event wrappers: Mint, Burn, Swap, Sync @@ -56,8 +72,20 @@ contracts/ traits.rs IAmmPool interface definition tests/ unit_tests.rs Pure math + liquidity-pool unit tests (no Soroban env) - integration_tests.rs Soroban testenv contract interaction tests + integration_tests.rs Soroban testenv contract interaction tests, including + a full add_liquidity/remove_liquidity round trip + through a real LP token contract instance fuzz_tests.rs Property tests: k-invariant, sqrt floor, fee monotonicity + lp-token/ + src/ + lib.rs Contract entry point: mint (pool-gated), plus the + standard transfer/transfer_from/approve/allowance/ + burn/burn_from/balance/decimals/name/symbol interface + storage.rs DataKey enum + errors.rs Stable #[contracterror] enum + events.rs Mint, Burn, Transfer, Approve event wrappers + tests/ + integration_tests.rs Soroban testenv contract interaction tests ``` --- @@ -68,7 +96,7 @@ contracts/ | Function | Auth | Description | |----------|------|-------------| -| `initialize(token_0, token_1)` | — | One-time setup. Stores token addresses. | +| `initialize(token_0, token_1, fee_to_setter, lp_token)` | — | One-time setup. `lp_token` must already be a deployed, uninitialized `nodus-protocol-lp-token` instance — this contract never deploys or initializes it itself; that's the factory's job (planned). | | `sync()` | — | Reconcile reserves with actual contract token balances. | ### Liquidity @@ -86,16 +114,11 @@ contracts/ | `get_amount_out(amount_in, reserve_in, reserve_out)` | — | Quote output for a given input (0.3% fee). | | `get_amount_in(amount_out, reserve_in, reserve_out)` | — | Quote input required to receive a given output. | -### LP token interface +### LP token -| Function | Auth | Description | -|----------|------|-------------| -| `lp_balance_of(owner)` | — | Return LP token balance. | -| `lp_total_supply()` | — | Return total LP tokens in circulation. | -| `transfer_lp(from, to, amount)` | `from` | Transfer LP tokens directly. | -| `approve_lp(owner, spender, amount)` | `owner` | Approve `spender` to transfer up to `amount` LP tokens. | -| `lp_allowance(owner, spender)` | — | Return remaining approved LP amount. | -| `transfer_lp_from(spender, from, to, amount)` | `spender` | Transfer LP tokens using an existing allowance. | +| Function | Description | +|----------|-------------| +| `lp_token()` | Returns the address of this pool's LP token contract. Balance, transfer, approve, and supply queries all live there now — interact with it directly rather than through the pool; see [LP Token Contract](#lp-token-contract) below. | ### View @@ -107,18 +130,42 @@ contracts/ --- +## LP Token Contract + +`nodus-protocol-lp-token` is a standalone contract, one instance per pool. +`mint` is pool-gated (see [Pool lifecycle](#pool-lifecycle)); everything +else is the standard SEP-41 token interface (`soroban_sdk::token::Client` +can call it like any other token), open to any holder. + +| Function | Auth | Description | +|----------|------|-------------| +| `initialize(pool, name, symbol, decimals)` | — | One-time setup. `pool` becomes the only address `mint` will ever accept. | +| `pool()` | — | Returns the authorized pool address. | +| `mint(caller, to, amount)` | `caller` (must be `pool`) | Mints new LP tokens. Not part of SEP-41 — minting is issuer-specific by design in that standard. | +| `balance(id)` | — | Return `id`'s LP token balance. | +| `total_supply()` | — | Return total LP tokens in circulation. | +| `transfer(from, to, amount)` | `from` | Standard transfer. `to` is a `MuxedAddress` per SEP-41, so a payment can carry a muxed id for the recipient's own bookkeeping. | +| `approve(from, spender, amount, expiration_ledger)` | `from` | Approve `spender` to move up to `amount`, expiring at `expiration_ledger`. `amount: 0` revokes regardless of `expiration_ledger`. | +| `allowance(from, spender)` | — | Return the remaining approved amount. | +| `transfer_from(spender, from, to, amount)` | `spender` | Transfer using an existing allowance. | +| `burn(from, amount)` | `from` | Burns `from`'s own tokens. When the pool calls this during `remove_liquidity`, `from`'s authorization for that top-level call covers this nested one too. A holder can also call it directly, bypassing the pool — that forfeits their claim on the underlying reserves with no payout, which only benefits every other LP holder proportionally. Unusual, not unsafe. | +| `burn_from(spender, from, amount)` | `spender` | Burns using an existing allowance. | +| `name()` / `symbol()` / `decimals()` | — | Standard metadata. | + +--- + ## Build ```bash # Install Stellar CLI cargo install --locked stellar-cli --features opt -# Build (produces optimised WASM) +# Build (produces optimised WASM for every contract) make build -# or: stellar contract build -# Build output used by the deploy scripts +# Build output target/wasm32v1-none/release/nodus_protocol_amm.wasm +target/wasm32v1-none/release/nodus_protocol_lp_token.wasm # Run tests make test @@ -127,24 +174,43 @@ make test make lint ``` +`make build`/`make test`/`make lint` all build the LP token contract's +WASM before touching the pool crate — required because the pool imports +it via `contractimport!` at compile time (see [Architecture](#architecture)). +If you're running `cargo` directly instead of through `make`, build +`nodus-protocol-lp-token` first as its own step: + +```bash +cargo build --release --target wasm32v1-none -p nodus-protocol-lp-token +cargo build --release --target wasm32v1-none --workspace # or test/clippy/fmt +``` + +A single `cargo build --workspace` from a clean `target/` **will not** +reliably do this for you — Cargo has no dependency-graph edge between the +two crates (that's the point of `contractimport!` over a regular +dependency), so it's free to compile them in parallel and sometimes does, +racing the pool's build against an LP token WASM that doesn't exist yet. + --- ## Deploy ```bash # Testnet -STELLAR_SECRET_KEY=S... TOKEN_0=C... TOKEN_1=C... make deploy-testnet +STELLAR_SECRET_KEY=S... TOKEN_0=C... TOKEN_1=C... FEE_TO_SETTER=G... make deploy-testnet # Mainnet -STELLAR_SECRET_KEY=S... TOKEN_0=C... TOKEN_1=C... make deploy-mainnet +STELLAR_SECRET_KEY=S... TOKEN_0=C... TOKEN_1=C... FEE_TO_SETTER=G... make deploy-mainnet ``` -The deploy script uploads the WASM, deploys a new contract instance, and calls `initialize`. +The deploy script uploads and deploys both contracts, initializes the LP +token first (it needs to know its pool's address before the pool can be +initialized with it), then initializes the pool. `LP_TOKEN_NAME` / +`LP_TOKEN_SYMBOL` / `LP_TOKEN_DECIMALS` are optional overrides. -The pool crate is named `nodus-protocol-amm`, so the generated WASM artifact -uses the underscore form `nodus_protocol_amm.wasm`. Keep deploy scripts and -manual commands pointed at that filename unless the crate name is -intentionally changed. +This is manual, one-pair-at-a-time tooling. The planned factory contract +will do this deployment + wiring on-chain, for any token pair, without a +human running a script per pool. --- diff --git a/contracts/lp-token/Cargo.toml b/contracts/lp-token/Cargo.toml new file mode 100644 index 0000000..e3d1eef --- /dev/null +++ b/contracts/lp-token/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nodus-protocol-lp-token" +version = "0.1.0" +authors = ["Nodus Protocol Team"] +edition = "2021" +license = "MIT" +description = "Standalone SEP-41 LP token contract for a Nodus Protocol pool" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/lp-token/src/errors.rs b/contracts/lp-token/src/errors.rs new file mode 100644 index 0000000..98ba27d --- /dev/null +++ b/contracts/lp-token/src/errors.rs @@ -0,0 +1,14 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + ZeroAmount = 4, + InsufficientBalance = 5, + Overflow = 6, + ApprovalExpired = 7, +} diff --git a/contracts/lp-token/src/events.rs b/contracts/lp-token/src/events.rs new file mode 100644 index 0000000..ed4bc53 --- /dev/null +++ b/contracts/lp-token/src/events.rs @@ -0,0 +1,174 @@ +#![allow(deprecated)] +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +#[contracttype] +pub struct MintEvent { + pub to: Address, + pub amount: i128, +} + +#[contracttype] +pub struct BurnEvent { + pub from: Address, + pub amount: i128, +} + +#[contracttype] +pub struct TransferEvent { + pub from: Address, + pub to: Address, + pub amount: i128, +} + +#[contracttype] +pub struct ApproveEvent { + pub from: Address, + pub spender: Address, + pub amount: i128, + pub expiration_ledger: u32, +} + +/// Topics include the emitting contract's address so an indexer watching +/// every LP token instance across every pool can attribute each event to +/// the right one, matching the convention already used by the pool +/// contract's own events. +pub fn emit_mint(env: &Env, to: Address, amount: i128) { + env.events().publish( + ( + symbol_short!("v1_mint"), + env.current_contract_address(), + to.clone(), + ), + MintEvent { to, amount }, + ); +} + +pub fn emit_burn(env: &Env, from: Address, amount: i128) { + env.events().publish( + ( + symbol_short!("v1_burn"), + env.current_contract_address(), + from.clone(), + ), + BurnEvent { from, amount }, + ); +} + +pub fn emit_transfer(env: &Env, from: Address, to: Address, amount: i128) { + env.events().publish( + ( + symbol_short!("v1_xfer"), + env.current_contract_address(), + from.clone(), + ), + TransferEvent { from, to, amount }, + ); +} + +pub fn emit_approve( + env: &Env, + from: Address, + spender: Address, + amount: i128, + expiration_ledger: u32, +) { + env.events().publish( + ( + symbol_short!("v1_appr"), + env.current_contract_address(), + from.clone(), + ), + ApproveEvent { + from, + spender, + amount, + expiration_ledger, + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Events}; + + fn setup() -> (Env, Address) { + let env = Env::default(); + let contract_id = env.register(crate::NodusLpToken, ()); + (env, contract_id) + } + + #[test] + fn mint_event_is_attributable_to_contract() { + let (env, contract_id) = setup(); + let to = Address::generate(&env); + + env.as_contract(&contract_id, || { + emit_mint(&env, to, 100); + }); + + let filtered = env.events().all().filter_by_contract(&contract_id); + assert_eq!(filtered.events().len(), 1); + } + + #[test] + fn burn_event_is_attributable_to_contract() { + let (env, contract_id) = setup(); + let from = Address::generate(&env); + + env.as_contract(&contract_id, || { + emit_burn(&env, from, 100); + }); + + let filtered = env.events().all().filter_by_contract(&contract_id); + assert_eq!(filtered.events().len(), 1); + } + + #[test] + fn transfer_event_is_attributable_to_contract() { + let (env, contract_id) = setup(); + let from = Address::generate(&env); + let to = Address::generate(&env); + + env.as_contract(&contract_id, || { + emit_transfer(&env, from, to, 50); + }); + + let filtered = env.events().all().filter_by_contract(&contract_id); + assert_eq!(filtered.events().len(), 1); + } + + #[test] + fn approve_event_is_attributable_to_contract() { + let (env, contract_id) = setup(); + let from = Address::generate(&env); + let spender = Address::generate(&env); + + env.as_contract(&contract_id, || { + emit_approve(&env, from, spender, 25, 1000); + }); + + let filtered = env.events().all().filter_by_contract(&contract_id); + assert_eq!(filtered.events().len(), 1); + } + + #[test] + fn two_lp_tokens_emit_independently_attributable_events() { + let env = Env::default(); + let token_a = env.register(crate::NodusLpToken, ()); + let token_b = env.register(crate::NodusLpToken, ()); + let holder = Address::generate(&env); + + env.as_contract(&token_a, || { + emit_mint(&env, holder.clone(), 10); + }); + let token_a_events = env.events().all().filter_by_contract(&token_a); + assert_eq!(token_a_events.events().len(), 1); + + env.as_contract(&token_b, || { + emit_mint(&env, holder, 20); + }); + let token_b_events = env.events().all().filter_by_contract(&token_b); + assert_eq!(token_b_events.events().len(), 1); + } +} diff --git a/contracts/lp-token/src/lib.rs b/contracts/lp-token/src/lib.rs new file mode 100644 index 0000000..2ef890a --- /dev/null +++ b/contracts/lp-token/src/lib.rs @@ -0,0 +1,324 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; + +pub mod errors; +pub mod events; +pub mod storage; + +pub use errors::Error; +use storage::DataKey; + +const INSTANCE_TTL_THRESHOLD: u32 = 100; +const INSTANCE_TTL_BUMP: u32 = 500; +const BALANCE_TTL_THRESHOLD: u32 = 100; +const BALANCE_TTL_BUMP: u32 = 500; + +fn require_initialized(env: &Env) -> Result<(), Error> { + if !env + .storage() + .instance() + .get::(&DataKey::Initialized) + .unwrap_or(false) + { + return Err(Error::NotInitialized); + } + Ok(()) +} + +fn read_balance(env: &Env, addr: &Address) -> i128 { + let key = DataKey::Balance(addr.clone()); + let balance = env.storage().persistent().get(&key).unwrap_or(0i128); + if balance > 0 { + env.storage() + .persistent() + .extend_ttl(&key, BALANCE_TTL_THRESHOLD, BALANCE_TTL_BUMP); + } + balance +} + +fn write_balance(env: &Env, addr: &Address, amount: i128) { + let key = DataKey::Balance(addr.clone()); + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, BALANCE_TTL_THRESHOLD, BALANCE_TTL_BUMP); +} + +fn read_allowance(env: &Env, owner: &Address, spender: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Allowance(owner.clone(), spender.clone())) + .unwrap_or(0i128) +} + +fn write_allowance(env: &Env, owner: &Address, spender: &Address, amount: i128, live_for: u32) { + let key = DataKey::Allowance(owner.clone(), spender.clone()); + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, live_for, live_for); +} + +fn read_total_supply(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0i128) +} + +fn write_total_supply(env: &Env, amount: i128) { + env.storage().instance().set(&DataKey::TotalSupply, &amount); +} + +fn spend_allowance( + env: &Env, + owner: &Address, + spender: &Address, + amount: i128, +) -> Result<(), Error> { + let allowed = read_allowance(env, owner, spender); + if allowed < amount { + return Err(Error::Unauthorized); + } + // Deliberately doesn't extend the allowance's TTL on spend (only on a + // fresh approve()) -- consuming less than the full amount shouldn't + // resurrect an entry the owner otherwise let expire. + let key = DataKey::Allowance(owner.clone(), spender.clone()); + env.storage().persistent().set(&key, &(allowed - amount)); + Ok(()) +} + +#[contract] +pub struct NodusLpToken; + +#[contractimpl] +impl NodusLpToken { + /// One-time setup, called by the factory (or pool) right after + /// deployment. `pool` is the only address ever authorized to [`mint`]. + pub fn initialize( + env: Env, + pool: Address, + name: String, + symbol: String, + decimals: u32, + ) -> Result<(), Error> { + if env + .storage() + .instance() + .get::(&DataKey::Initialized) + .unwrap_or(false) + { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Pool, &pool); + env.storage().instance().set(&DataKey::Name, &name); + env.storage().instance().set(&DataKey::Symbol, &symbol); + env.storage().instance().set(&DataKey::Decimals, &decimals); + env.storage().instance().set(&DataKey::Initialized, &true); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_BUMP); + Ok(()) + } + + pub fn pool(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Pool) + .ok_or(Error::NotInitialized) + } + + // ── Pool-gated mint (not part of the standard token interface -- SEP-41 + // deliberately leaves minting out, since it's issuer-specific) ───────── + + /// Mints new LP tokens to `to`. `caller` must be the pool this token + /// was initialized with; there is no other admin. Takes `caller` + /// explicitly (rather than always requiring the stored pool's auth + /// unconditionally) so the rejection is a plain identity comparison, + /// matching how the pool contract itself gates set_fee_to/pause -- + /// and, unlike a bare require_auth() on the stored address, testable + /// under mock_all_auths() without exotic per-address auth mocking. + pub fn mint(env: Env, caller: Address, to: Address, amount: i128) -> Result<(), Error> { + require_initialized(&env)?; + if amount <= 0 { + return Err(Error::ZeroAmount); + } + caller.require_auth(); + let pool: Address = env.storage().instance().get(&DataKey::Pool).unwrap(); + if caller != pool { + return Err(Error::Unauthorized); + } + + let new_balance = read_balance(&env, &to) + .checked_add(amount) + .ok_or(Error::Overflow)?; + let new_supply = read_total_supply(&env) + .checked_add(amount) + .ok_or(Error::Overflow)?; + write_balance(&env, &to, new_balance); + write_total_supply(&env, new_supply); + events::emit_mint(&env, to, amount); + Ok(()) + } + + // ── SEP-41 token interface ─────────────────────────────────────────── + + pub fn allowance(env: Env, from: Address, spender: Address) -> i128 { + read_allowance(&env, &from, &spender) + } + + /// `expiration_ledger` becomes the allowance entry's live-until ledger + /// via `extend_ttl`; a lower value than the current ledger is only + /// accepted when `amount` is 0 (revoking an approval never needs to + /// extend anything). + pub fn approve( + env: Env, + from: Address, + spender: Address, + amount: i128, + expiration_ledger: u32, + ) -> Result<(), Error> { + require_initialized(&env)?; + if amount < 0 { + return Err(Error::ZeroAmount); + } + from.require_auth(); + + let live_for = expiration_ledger.saturating_sub(env.ledger().sequence()); + if amount > 0 && live_for == 0 { + return Err(Error::ApprovalExpired); + } + write_allowance(&env, &from, &spender, amount, live_for); + events::emit_approve(&env, from, spender, amount, expiration_ledger); + Ok(()) + } + + pub fn balance(env: Env, id: Address) -> i128 { + read_balance(&env, &id) + } + + /// `to` is a [`MuxedAddress`] per the standard token interface, so a + /// payment can carry a muxed id for the recipient's own bookkeeping; + /// the balance itself is always credited to the underlying `Address`. + pub fn transfer(env: Env, from: Address, to: MuxedAddress, amount: i128) -> Result<(), Error> { + require_initialized(&env)?; + if amount <= 0 { + return Err(Error::ZeroAmount); + } + from.require_auth(); + + let to_address = to.address(); + let from_balance = read_balance(&env, &from); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + let to_new = read_balance(&env, &to_address) + .checked_add(amount) + .ok_or(Error::Overflow)?; + write_balance(&env, &from, from_balance - amount); + write_balance(&env, &to_address, to_new); + events::emit_transfer(&env, from, to_address, amount); + Ok(()) + } + + pub fn transfer_from( + env: Env, + spender: Address, + from: Address, + to: Address, + amount: i128, + ) -> Result<(), Error> { + require_initialized(&env)?; + if amount <= 0 { + return Err(Error::ZeroAmount); + } + spender.require_auth(); + spend_allowance(&env, &from, &spender, amount)?; + + let from_balance = read_balance(&env, &from); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + let to_new = read_balance(&env, &to) + .checked_add(amount) + .ok_or(Error::Overflow)?; + write_balance(&env, &from, from_balance - amount); + write_balance(&env, &to, to_new); + events::emit_transfer(&env, from, to, amount); + Ok(()) + } + + /// Burns `from`'s own tokens. Authorized by `from` alone -- when the + /// pool calls this as part of remove_liquidity, `from`'s own + /// authorization for that top-level call covers this nested one too, + /// the same way it already covers the pool's own token transfers. + /// A holder can also call this directly, bypassing the pool; that + /// forfeits their claim on the underlying reserves with no payout, + /// which only benefits every other LP holder proportionally -- an + /// unusual thing to do, not an unsafe one. + pub fn burn(env: Env, from: Address, amount: i128) -> Result<(), Error> { + require_initialized(&env)?; + if amount <= 0 { + return Err(Error::ZeroAmount); + } + from.require_auth(); + + let balance = read_balance(&env, &from); + if balance < amount { + return Err(Error::InsufficientBalance); + } + let new_supply = read_total_supply(&env) + .checked_sub(amount) + .ok_or(Error::Overflow)?; + write_balance(&env, &from, balance - amount); + write_total_supply(&env, new_supply); + events::emit_burn(&env, from, amount); + Ok(()) + } + + pub fn burn_from(env: Env, spender: Address, from: Address, amount: i128) -> Result<(), Error> { + require_initialized(&env)?; + if amount <= 0 { + return Err(Error::ZeroAmount); + } + spender.require_auth(); + spend_allowance(&env, &from, &spender, amount)?; + + let balance = read_balance(&env, &from); + if balance < amount { + return Err(Error::InsufficientBalance); + } + let new_supply = read_total_supply(&env) + .checked_sub(amount) + .ok_or(Error::Overflow)?; + write_balance(&env, &from, balance - amount); + write_total_supply(&env, new_supply); + events::emit_burn(&env, from, amount); + Ok(()) + } + + pub fn decimals(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Decimals) + .ok_or(Error::NotInitialized) + } + + pub fn name(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Name) + .ok_or(Error::NotInitialized) + } + + pub fn symbol(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Symbol) + .ok_or(Error::NotInitialized) + } + + pub fn total_supply(env: Env) -> i128 { + read_total_supply(&env) + } +} diff --git a/contracts/lp-token/src/storage.rs b/contracts/lp-token/src/storage.rs new file mode 100644 index 0000000..9b5da66 --- /dev/null +++ b/contracts/lp-token/src/storage.rs @@ -0,0 +1,14 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + Pool, + Initialized, + Name, + Symbol, + Decimals, + TotalSupply, + Balance(Address), + /// (owner, spender) -> approved amount. + Allowance(Address, Address), +} diff --git a/contracts/lp-token/tests/integration_tests.rs b/contracts/lp-token/tests/integration_tests.rs new file mode 100644 index 0000000..e38cc1f --- /dev/null +++ b/contracts/lp-token/tests/integration_tests.rs @@ -0,0 +1,241 @@ +#[cfg(test)] +#[cfg(feature = "testutils")] +mod integration { + use nodus_protocol_lp_token::{Error, NodusLpToken, NodusLpTokenClient}; + use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, MuxedAddress, String, + }; + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract = env.register(NodusLpToken, ()); + let client = NodusLpTokenClient::new(&env, &contract); + let pool = Address::generate(&env); + client.initialize( + &pool, + &String::from_str(&env, "Nodus LP XLM/USDC"), + &String::from_str(&env, "NODUS-LP"), + &7, + ); + (env, contract, pool) + } + + #[test] + fn initialize_sets_metadata() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + assert_eq!(client.pool(), pool); + assert_eq!(client.name(), String::from_str(&env, "Nodus LP XLM/USDC")); + assert_eq!(client.symbol(), String::from_str(&env, "NODUS-LP")); + assert_eq!(client.decimals(), 7); + assert_eq!(client.total_supply(), 0); + } + + #[test] + fn double_initialize_rejected() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + assert_eq!( + client.try_initialize( + &pool, + &String::from_str(&env, "x"), + &String::from_str(&env, "x"), + &7, + ), + Err(Ok(Error::AlreadyInitialized)), + ); + } + + #[test] + fn metadata_queries_fail_before_initialize() { + let env = Env::default(); + let contract = env.register(NodusLpToken, ()); + let client = NodusLpTokenClient::new(&env, &contract); + assert!(client.try_name().is_err()); + assert!(client.try_symbol().is_err()); + assert!(client.try_decimals().is_err()); + assert!(client.try_pool().is_err()); + } + + #[test] + fn pool_can_mint() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let holder = Address::generate(&env); + + client.mint(&pool, &holder, &1_000); + + assert_eq!(client.balance(&holder), 1_000); + assert_eq!(client.total_supply(), 1_000); + } + + #[test] + fn non_pool_cannot_mint() { + let (env, contract, _pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let intruder = Address::generate(&env); + let holder = Address::generate(&env); + + assert_eq!( + client.try_mint(&intruder, &holder, &1_000), + Err(Ok(Error::Unauthorized)), + ); + assert_eq!(client.total_supply(), 0); + } + + #[test] + fn mint_rejects_non_positive_amount() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let holder = Address::generate(&env); + + assert_eq!( + client.try_mint(&pool, &holder, &0), + Err(Ok(Error::ZeroAmount)), + ); + assert_eq!( + client.try_mint(&pool, &holder, &-5), + Err(Ok(Error::ZeroAmount)), + ); + } + + #[test] + fn holder_can_burn_own_tokens() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let holder = Address::generate(&env); + client.mint(&pool, &holder, &1_000); + + client.burn(&holder, &400); + + assert_eq!(client.balance(&holder), 600); + assert_eq!(client.total_supply(), 600); + } + + #[test] + fn burn_rejects_insufficient_balance() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let holder = Address::generate(&env); + client.mint(&pool, &holder, &100); + + assert_eq!( + client.try_burn(&holder, &200), + Err(Ok(Error::InsufficientBalance)), + ); + } + + #[test] + fn transfer_moves_balance() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint(&pool, &alice, &1_000); + + client.transfer(&alice, MuxedAddress::from(bob.clone()), &300); + + assert_eq!(client.balance(&alice), 700); + assert_eq!(client.balance(&bob), 300); + } + + #[test] + fn transfer_rejects_insufficient_balance() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint(&pool, &alice, &100); + + assert_eq!( + client.try_transfer(&alice, MuxedAddress::from(bob), &200), + Err(Ok(Error::InsufficientBalance)), + ); + } + + #[test] + fn approve_and_transfer_from() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let recipient = Address::generate(&env); + client.mint(&pool, &owner, &1_000); + env.ledger().set_sequence_number(100); + + client.approve(&owner, &spender, &500, &1_100); + assert_eq!(client.allowance(&owner, &spender), 500); + + client.transfer_from(&spender, &owner, &recipient, &300); + + assert_eq!(client.balance(&owner), 700); + assert_eq!(client.balance(&recipient), 300); + assert_eq!(client.allowance(&owner, &spender), 200); + } + + #[test] + fn transfer_from_rejects_over_allowance() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let recipient = Address::generate(&env); + client.mint(&pool, &owner, &1_000); + env.ledger().set_sequence_number(100); + client.approve(&owner, &spender, &100, &1_100); + + assert_eq!( + client.try_transfer_from(&spender, &owner, &recipient, &200), + Err(Ok(Error::Unauthorized)), + ); + } + + #[test] + fn approve_rejects_an_already_expired_ledger_for_a_positive_amount() { + let (env, contract, _pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + env.ledger().set_sequence_number(1_000); + + assert_eq!( + client.try_approve(&owner, &spender, &100, &500), + Err(Ok(Error::ApprovalExpired)), + ); + } + + #[test] + fn approve_with_zero_amount_revokes_regardless_of_expiration() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + client.mint(&pool, &owner, &1_000); + env.ledger().set_sequence_number(100); + client.approve(&owner, &spender, &500, &1_100); + + // Revoking with an already-past expiration_ledger must still work. + client.approve(&owner, &spender, &0, &1); + + assert_eq!(client.allowance(&owner, &spender), 0); + } + + #[test] + fn burn_from_spends_allowance_and_reduces_supply() { + let (env, contract, pool) = setup(); + let client = NodusLpTokenClient::new(&env, &contract); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + client.mint(&pool, &owner, &1_000); + env.ledger().set_sequence_number(100); + client.approve(&owner, &spender, &400, &1_100); + + client.burn_from(&spender, &owner, &400); + + assert_eq!(client.balance(&owner), 600); + assert_eq!(client.total_supply(), 600); + assert_eq!(client.allowance(&owner, &spender), 0); + } +} diff --git a/contracts/pool/Cargo.toml b/contracts/pool/Cargo.toml index 5d7f568..2193630 100644 --- a/contracts/pool/Cargo.toml +++ b/contracts/pool/Cargo.toml @@ -17,3 +17,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +nodus-protocol-lp-token = { path = "../lp-token", features = ["testutils"] } diff --git a/contracts/pool/src/lib.rs b/contracts/pool/src/lib.rs index ab0753f..59c25b3 100644 --- a/contracts/pool/src/lib.rs +++ b/contracts/pool/src/lib.rs @@ -5,7 +5,6 @@ use soroban_sdk::{contract, contractimpl, token::Client as TokenClient, Address, pub mod errors; pub mod events; pub mod liquidity_pool; -pub mod lp_token; pub mod math; pub mod storage; pub mod traits; @@ -13,6 +12,25 @@ pub mod traits; pub use errors::Error; use storage::DataKey; +/// Imports the LP token contract's interface from its own compiled WASM +/// (built separately -- see contracts/lp-token) rather than depending on +/// its crate directly. A regular Cargo dependency would link that +/// crate's own #[contractimpl]-generated WASM exports into this +/// contract's binary too: confirmed empirically, since both crates +/// export an `initialize` function, which fails the link with a +/// duplicate-symbol error. This only pulls in the client type and call +/// signatures, not the LP token's own contract code. +/// +/// Build order requirement: contracts/lp-token must be built to WASM +/// before this crate, since contractimport! reads the file at compile +/// time. `make build` / CI handle this; see the workspace README. +mod lp_token_contract { + soroban_sdk::contractimport!( + file = "../../target/wasm32v1-none/release/nodus_protocol_lp_token.wasm" + ); +} +use lp_token_contract::Client as LpTokenClient; + const INSTANCE_TTL_THRESHOLD: u32 = 100; const INSTANCE_TTL_BUMP: u32 = 500; @@ -151,16 +169,26 @@ fn dead_address(env: &Env) -> Address { env.current_contract_address() } +fn lp_token_client(env: &Env) -> LpTokenClient<'_> { + let lp_token: Address = env.storage().instance().get(&DataKey::LpToken).unwrap(); + LpTokenClient::new(env, &lp_token) +} + #[contract] pub struct NodusAmm; #[contractimpl] impl NodusAmm { + /// `lp_token` must already be a deployed, uninitialized + /// nodus-protocol-lp-token instance; the factory is responsible for + /// deploying it and handing its address here. This contract never + /// deploys or initializes the LP token itself. pub fn initialize( env: Env, token_0: Address, token_1: Address, fee_to_setter: Address, + lp_token: Address, ) -> Result<(), Error> { if env .storage() @@ -175,6 +203,7 @@ impl NodusAmm { } env.storage().instance().set(&DataKey::Token0, &token_0); env.storage().instance().set(&DataKey::Token1, &token_1); + env.storage().instance().set(&DataKey::LpToken, &lp_token); env.storage() .instance() .set(&DataKey::FeeToSetter, &fee_to_setter); @@ -230,13 +259,18 @@ impl NodusAmm { token_pull(&env, &token_0, &from, amount_0); token_pull(&env, &token_1, &from, amount_1); - let total_supply = lp_token::total_supply(&env); + let lp_client = lp_token_client(&env); + let this_contract = env.current_contract_address(); + let total_supply = lp_client.total_supply(); let liquidity = if total_supply == 0 { let initial = liquidity_pool::calculate_initial_liquidity(amount_0, amount_1) .inspect_err(|_| unlock(&env))?; - lp_token::mint(&env, &dead_address(&env), math::MINIMUM_LIQUIDITY) - .inspect_err(|_| unlock(&env))?; + lp_client.mint( + &this_contract, + &dead_address(&env), + &math::MINIMUM_LIQUIDITY, + ); initial } else { liquidity_pool::calculate_liquidity_to_mint( @@ -254,7 +288,7 @@ impl NodusAmm { return Err(Error::InsufficientLiquidityMinted); } - lp_token::mint(&env, &to, liquidity).inspect_err(|_| unlock(&env))?; + lp_client.mint(&this_contract, &to, &liquidity); let b0 = token_balance(&env, &token_0); let b1 = token_balance(&env, &token_1); @@ -289,7 +323,8 @@ impl NodusAmm { let token_0: Address = env.storage().instance().get(&DataKey::Token0).unwrap(); let token_1: Address = env.storage().instance().get(&DataKey::Token1).unwrap(); - let total_supply = lp_token::total_supply(&env); + let lp_client = lp_token_client(&env); + let total_supply = lp_client.total_supply(); let reserve_0 = get_reserve_0(&env); let reserve_1 = get_reserve_1(&env); @@ -306,7 +341,13 @@ impl NodusAmm { return Err(Error::InsufficientLiquidityBurned); } - lp_token::burn(&env, &from, liquidity).inspect_err(|_| unlock(&env))?; + // Burns from's own LP tokens; from already authorized this whole + // call above, and that same authorization covers this nested + // require_auth() on the LP token contract. Panics (reverting the + // whole transaction, same as everywhere else in this contract + // that unwraps an internal invariant) if from's real on-chain LP + // balance is less than the amount they asked to redeem. + lp_client.burn(&from, &liquidity); token_push(&env, &token_0, &to, amount_0); token_push(&env, &token_1, &to, amount_1); @@ -666,44 +707,16 @@ impl NodusAmm { is_paused(&env) } - // ── LP token interface ────────────────────────────────────────────────── - - pub fn lp_balance_of(env: Env, owner: Address) -> i128 { - lp_token::balance_of(&env, &owner) - } - - pub fn lp_total_supply(env: Env) -> i128 { - lp_token::total_supply(&env) - } - - pub fn transfer_lp(env: Env, from: Address, to: Address, amount: i128) -> Result<(), Error> { - from.require_auth(); - lp_token::transfer(&env, &from, &to, amount) - } - - pub fn approve_lp( - env: Env, - owner: Address, - spender: Address, - amount: i128, - ) -> Result<(), Error> { - owner.require_auth(); - lp_token::approve(&env, &owner, &spender, amount) - } + // ── LP token ───────────────────────────────────────────────────────────── - pub fn lp_allowance(env: Env, owner: Address, spender: Address) -> i128 { - lp_token::allowance(&env, &owner, &spender) - } - - pub fn transfer_lp_from( - env: Env, - spender: Address, - from: Address, - to: Address, - amount: i128, - ) -> Result<(), Error> { - spender.require_auth(); - lp_token::transfer_from(&env, &spender, &from, &to, amount) + /// The standalone SEP-41 LP token contract for this pool. Balance, + /// transfer, approve, and supply queries all live there now -- + /// interact with it directly rather than through this contract. + pub fn lp_token(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::LpToken) + .ok_or(Error::NotInitialized) } pub fn token_0(env: Env) -> Result { diff --git a/contracts/pool/src/lp_token.rs b/contracts/pool/src/lp_token.rs deleted file mode 100644 index 52241c2..0000000 --- a/contracts/pool/src/lp_token.rs +++ /dev/null @@ -1,130 +0,0 @@ -use crate::{errors::Error, storage::DataKey}; -use soroban_sdk::{Address, Env}; - -const TTL_THRESHOLD: u32 = 100; -const TTL_BUMP: u32 = 500; - -fn bump_balance(env: &Env, key: &DataKey) { - env.storage() - .persistent() - .extend_ttl(key, TTL_THRESHOLD, TTL_BUMP); -} - -pub fn total_supply(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::LpTotalSupply) - .unwrap_or(0i128) -} - -pub fn balance_of(env: &Env, owner: &Address) -> i128 { - let key = DataKey::LpBalance(owner.clone()); - let bal = env.storage().persistent().get(&key).unwrap_or(0i128); - if bal > 0 { - bump_balance(env, &key); - } - bal -} - -pub fn allowance(env: &Env, owner: &Address, spender: &Address) -> i128 { - let key = DataKey::LpAllowance(owner.clone(), spender.clone()); - env.storage().persistent().get(&key).unwrap_or(0i128) -} - -pub fn approve(env: &Env, owner: &Address, spender: &Address, amount: i128) -> Result<(), Error> { - if amount < 0 { - return Err(Error::ZeroAmount); - } - let key = DataKey::LpAllowance(owner.clone(), spender.clone()); - env.storage().persistent().set(&key, &amount); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); - Ok(()) -} - -pub fn mint(env: &Env, to: &Address, amount: i128) -> Result<(), Error> { - if amount <= 0 { - return Err(Error::ZeroAmount); - } - let key = DataKey::LpBalance(to.clone()); - let new_bal = balance_of(env, to) - .checked_add(amount) - .ok_or(Error::Overflow)?; - let new_supply = total_supply(env) - .checked_add(amount) - .ok_or(Error::Overflow)?; - env.storage().persistent().set(&key, &new_bal); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); - env.storage() - .instance() - .set(&DataKey::LpTotalSupply, &new_supply); - Ok(()) -} - -pub fn burn(env: &Env, from: &Address, amount: i128) -> Result<(), Error> { - if amount <= 0 { - return Err(Error::ZeroAmount); - } - let key = DataKey::LpBalance(from.clone()); - let bal = balance_of(env, from); - if bal < amount { - return Err(Error::InsufficientLiquidityBurned); - } - let supply = total_supply(env); - env.storage().persistent().set(&key, &(bal - amount)); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); - env.storage() - .instance() - .set(&DataKey::LpTotalSupply, &(supply - amount)); - Ok(()) -} - -pub fn transfer(env: &Env, from: &Address, to: &Address, amount: i128) -> Result<(), Error> { - if amount <= 0 { - return Err(Error::ZeroAmount); - } - let from_bal = balance_of(env, from); - if from_bal < amount { - return Err(Error::InsufficientLiquidityBurned); - } - let to_bal = balance_of(env, to); - let to_new = to_bal.checked_add(amount).ok_or(Error::Overflow)?; - let from_key = DataKey::LpBalance(from.clone()); - let to_key = DataKey::LpBalance(to.clone()); - env.storage() - .persistent() - .set(&from_key, &(from_bal - amount)); - env.storage() - .persistent() - .extend_ttl(&from_key, TTL_THRESHOLD, TTL_BUMP); - env.storage().persistent().set(&to_key, &to_new); - env.storage() - .persistent() - .extend_ttl(&to_key, TTL_THRESHOLD, TTL_BUMP); - Ok(()) -} - -/// Transfer on behalf of `from` using a pre-approved allowance. -pub fn transfer_from( - env: &Env, - spender: &Address, - from: &Address, - to: &Address, - amount: i128, -) -> Result<(), Error> { - if amount <= 0 { - return Err(Error::ZeroAmount); - } - let allowed = allowance(env, from, spender); - if allowed < amount { - return Err(Error::InsufficientLiquidity); - } - // Deduct allowance first (checks-effects-interactions) - approve(env, from, spender, allowed - amount)?; - transfer(env, from, to, amount) -} diff --git a/contracts/pool/src/storage.rs b/contracts/pool/src/storage.rs index 1657ed4..596950e 100644 --- a/contracts/pool/src/storage.rs +++ b/contracts/pool/src/storage.rs @@ -1,19 +1,18 @@ -use soroban_sdk::{contracttype, Address}; +use soroban_sdk::contracttype; #[contracttype] pub enum DataKey { Token0, Token1, + /// The standalone SEP-41 LP token contract for this pool. LP token + /// balances/allowances/supply all live over there now, not here. + LpToken, Reserve0, Reserve1, TimestampLast, Price0CumulativeLast, Price1CumulativeLast, KLast, - LpTotalSupply, - LpBalance(Address), - /// Approved LP-token spending allowance: (owner, spender) → amount. - LpAllowance(Address, Address), Locked, Initialized, FeeTo, diff --git a/contracts/pool/tests/integration_tests.rs b/contracts/pool/tests/integration_tests.rs index cac49c3..d187fd4 100644 --- a/contracts/pool/tests/integration_tests.rs +++ b/contracts/pool/tests/integration_tests.rs @@ -2,9 +2,10 @@ #[cfg(feature = "testutils")] mod integration { use nodus_protocol_amm::{NodusAmm, NodusAmmClient}; + use nodus_protocol_lp_token::{NodusLpToken, NodusLpTokenClient}; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, - Address, Env, + Address, Env, String, }; fn setup_initialized() -> (Env, Address, Address, Address) { @@ -15,7 +16,8 @@ mod integration { let t0 = Address::generate(&env); let t1 = Address::generate(&env); let admin = Address::generate(&env); - client.initialize(&t0, &t1, &admin); + let lp_token = Address::generate(&env); + client.initialize(&t0, &t1, &admin, &lp_token); (env, contract, t0, t1) } @@ -35,20 +37,6 @@ mod integration { assert!(client.try_swap(&to, &0, &0).is_err()); } - #[test] - fn lp_balance_starts_zero() { - let (env, contract, _, _) = setup_initialized(); - let client = NodusAmmClient::new(&env, &contract); - assert_eq!(client.lp_balance_of(&Address::generate(&env)), 0); - } - - #[test] - fn lp_total_supply_starts_zero() { - let (env, contract, _, _) = setup_initialized(); - let client = NodusAmmClient::new(&env, &contract); - assert_eq!(client.lp_total_supply(), 0); - } - #[test] fn token_0_and_1_readable_after_init() { let (env, contract, t0, t1) = setup_initialized(); @@ -87,7 +75,8 @@ mod integration { let t0 = Address::generate(&env); let t1 = Address::generate(&env); let admin = Address::generate(&env); - client.initialize(&t0, &t1, &admin); + let lp_token = Address::generate(&env); + client.initialize(&t0, &t1, &admin, &lp_token); (env, contract, admin) } @@ -227,4 +216,91 @@ mod integration { Err(Ok(nodus_protocol_amm::Error::ContractPaused)) ); } + + /// Exercises the real cross-contract wiring end to end: a genuine + /// NodusLpToken instance (not a bare placeholder address) as the + /// pool's LP token, and two more NodusLpToken instances standing in + /// for token_0/token_1 -- close enough to a real SEP-41 token + /// (mint/balance/transfer_from) to prove add_liquidity/ + /// remove_liquidity actually move real balances through real + /// cross-contract calls, not just internal bookkeeping. + #[test] + fn add_liquidity_then_remove_liquidity_round_trips_through_real_lp_token() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_sequence_number(100); + + let pool = env.register(NodusAmm, ()); + let lp_token = env.register(NodusLpToken, ()); + let token_0 = env.register(NodusLpToken, ()); + let token_1 = env.register(NodusLpToken, ()); + + let mint_authority = Address::generate(&env); + let provider = Address::generate(&env); + let admin = Address::generate(&env); + + let lp_client = NodusLpTokenClient::new(&env, &lp_token); + lp_client.initialize( + &pool, + &String::from_str(&env, "Nodus LP"), + &String::from_str(&env, "NODUS-LP"), + &7, + ); + + let t0_client = NodusLpTokenClient::new(&env, &token_0); + t0_client.initialize( + &mint_authority, + &String::from_str(&env, "Token0"), + &String::from_str(&env, "TOK0"), + &7, + ); + let t1_client = NodusLpTokenClient::new(&env, &token_1); + t1_client.initialize( + &mint_authority, + &String::from_str(&env, "Token1"), + &String::from_str(&env, "TOK1"), + &7, + ); + + // Give the liquidity provider tokens to deposit, and have them + // approve the pool to pull them (add_liquidity uses + // transfer_from, matching how it already worked against real + // Stellar Asset Contract tokens before this refactor). + t0_client.mint(&mint_authority, &provider, &1_000_000); + t1_client.mint(&mint_authority, &provider, &1_000_000); + t0_client.approve(&provider, &pool, &1_000_000, &10_000); + t1_client.approve(&provider, &pool, &1_000_000, &10_000); + + let pool_client = NodusAmmClient::new(&env, &pool); + pool_client.initialize(&token_0, &token_1, &admin, &lp_token); + + let liquidity = + pool_client.add_liquidity(&provider, &provider, &100_000, &100_000, &0, &0, &u64::MAX); + + // sqrt(100_000 * 100_000) - MINIMUM_LIQUIDITY(1_000) = 99_000; + // the other 1_000 is permanently locked at the dead address. + assert_eq!(liquidity, 99_000); + assert_eq!(lp_client.balance(&provider), 99_000); + assert_eq!(lp_client.total_supply(), 100_000); + assert_eq!(t0_client.balance(&provider), 900_000); + assert_eq!(t1_client.balance(&provider), 900_000); + assert_eq!(t0_client.balance(&pool), 100_000); + assert_eq!(t1_client.balance(&pool), 100_000); + let (r0, r1, _) = pool_client.get_reserves(); + assert_eq!(r0, 100_000); + assert_eq!(r1, 100_000); + + let (amount_0, amount_1) = + pool_client.remove_liquidity(&provider, &provider, &liquidity, &0, &0, &u64::MAX); + + // Proportional to the 99_000 of 100_000 total supply redeemed. + assert_eq!(amount_0, 99_000); + assert_eq!(amount_1, 99_000); + assert_eq!(lp_client.balance(&provider), 0); + assert_eq!(lp_client.total_supply(), 1_000); + // Net down 1_000 of each token versus the starting 1_000_000 -- + // permanently locked in the pool via the dead-address LP shares. + assert_eq!(t0_client.balance(&provider), 999_000); + assert_eq!(t1_client.balance(&provider), 999_000); + } } diff --git a/contracts/pool/tests/unit_tests.rs b/contracts/pool/tests/unit_tests.rs index 0c3c339..f648efa 100644 --- a/contracts/pool/tests/unit_tests.rs +++ b/contracts/pool/tests/unit_tests.rs @@ -143,7 +143,8 @@ mod soroban_contract_tests { let t0 = Address::generate(&env); let t1 = Address::generate(&env); let admin = Address::generate(&env); - assert!(client.try_initialize(&t0, &t1, &admin).is_ok()); + let lp_token = Address::generate(&env); + assert!(client.try_initialize(&t0, &t1, &admin, &lp_token).is_ok()); } #[test] @@ -152,7 +153,8 @@ mod soroban_contract_tests { let client = NodusAmmClient::new(&env, &contract); let t = Address::generate(&env); let admin = Address::generate(&env); - assert!(client.try_initialize(&t, &t, &admin).is_err()); + let lp_token = Address::generate(&env); + assert!(client.try_initialize(&t, &t, &admin, &lp_token).is_err()); } #[test] @@ -162,8 +164,9 @@ mod soroban_contract_tests { let t0 = Address::generate(&env); let t1 = Address::generate(&env); let admin = Address::generate(&env); - client.initialize(&t0, &t1, &admin); - assert!(client.try_initialize(&t0, &t1, &admin).is_err()); + let lp_token = Address::generate(&env); + client.initialize(&t0, &t1, &admin, &lp_token); + assert!(client.try_initialize(&t0, &t1, &admin, &lp_token).is_err()); } #[test] @@ -172,7 +175,7 @@ mod soroban_contract_tests { let client = NodusAmmClient::new(&env, &contract); let t0 = Address::generate(&env); let t1 = Address::generate(&env); - client.initialize(&t0, &t1, &Address::generate(&env)); + client.initialize(&t0, &t1, &Address::generate(&env), &Address::generate(&env)); let (r0, r1, _) = client.get_reserves(); assert_eq!(r0, 0); assert_eq!(r1, 0); @@ -184,7 +187,7 @@ mod soroban_contract_tests { let client = NodusAmmClient::new(&env, &contract); let t0 = Address::generate(&env); let t1 = Address::generate(&env); - client.initialize(&t0, &t1, &Address::generate(&env)); + client.initialize(&t0, &t1, &Address::generate(&env), &Address::generate(&env)); env.ledger().set_timestamp(2_000); let from = Address::generate(&env); let to = Address::generate(&env); @@ -202,13 +205,14 @@ mod soroban_contract_tests { } #[test] - fn lp_balance_starts_zero() { + fn lp_token_readable_after_init() { let (env, contract) = setup(); let client = NodusAmmClient::new(&env, &contract); let t0 = Address::generate(&env); let t1 = Address::generate(&env); - client.initialize(&t0, &t1, &Address::generate(&env)); - assert_eq!(client.lp_balance_of(&Address::generate(&env)), 0); + let lp_token = Address::generate(&env); + client.initialize(&t0, &t1, &Address::generate(&env), &lp_token); + assert_eq!(client.lp_token(), lp_token); } #[test] @@ -217,7 +221,7 @@ mod soroban_contract_tests { let client = NodusAmmClient::new(&env, &contract); let t0 = Address::generate(&env); let t1 = Address::generate(&env); - client.initialize(&t0, &t1, &Address::generate(&env)); + client.initialize(&t0, &t1, &Address::generate(&env), &Address::generate(&env)); let (p0, p1) = client.get_price_cumulative(); assert_eq!(p0, 0u128); assert_eq!(p1, 0u128); diff --git a/scripts/build.sh b/scripts/build.sh index 03e1106..917ec2f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,7 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -echo "Building Nodus AMM contract for Stellar Soroban..." +echo "Building Nodus Protocol contracts for Stellar Soroban..." +echo "Prefer 'make build' -- it builds the LP token contract before the" +echo "pool, which the pool's build requires (see contracts/pool/src/lib.rs)." +echo "'stellar contract build' below builds the whole workspace and its" +echo "own internal ordering hasn't been verified against that requirement." if ! command -v stellar &>/dev/null; then echo "Stellar CLI not found. Install: cargo install --locked stellar-cli --features opt" @@ -10,6 +14,7 @@ fi stellar contract build -WASM="target/wasm32-unknown-unknown/release/nodus_protocol_amm.wasm" -echo "Build complete: $WASM" -ls -lh "$WASM" +POOL_WASM="target/wasm32v1-none/release/nodus_protocol_amm.wasm" +LP_TOKEN_WASM="target/wasm32v1-none/release/nodus_protocol_lp_token.wasm" +echo "Build complete:" +ls -lh "$POOL_WASM" "$LP_TOKEN_WASM" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cf511f6..a9d77ca 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -24,40 +24,77 @@ if ! command -v stellar &>/dev/null; then exit 1 fi -WASM="target/wasm32-unknown-unknown/release/nodus_protocol_amm.wasm" -if [ ! -f "$WASM" ]; then +POOL_WASM="target/wasm32v1-none/release/nodus_protocol_amm.wasm" +LP_TOKEN_WASM="target/wasm32v1-none/release/nodus_protocol_lp_token.wasm" +if [ ! -f "$POOL_WASM" ] || [ ! -f "$LP_TOKEN_WASM" ]; then echo "WASM not found. Run: make build" exit 1 fi -echo "Uploading contract to $NETWORK..." -CONTRACT_HASH=$(stellar contract upload \ - --wasm "$WASM" \ +: "${TOKEN_0:?Set TOKEN_0 to the first token contract address}" +: "${TOKEN_1:?Set TOKEN_1 to the second token contract address}" +: "${FEE_TO_SETTER:?Set FEE_TO_SETTER to the address allowed to set the protocol fee and pause the pool}" +LP_TOKEN_NAME="${LP_TOKEN_NAME:-Nodus LP Token}" +LP_TOKEN_SYMBOL="${LP_TOKEN_SYMBOL:-NODUS-LP}" +LP_TOKEN_DECIMALS="${LP_TOKEN_DECIMALS:-7}" + +invoke() { + stellar contract invoke \ + --source "$STELLAR_SECRET_KEY" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + "$@" +} + +echo "Uploading pool contract to $NETWORK..." +POOL_HASH=$(stellar contract upload \ + --wasm "$POOL_WASM" \ --source "$STELLAR_SECRET_KEY" \ --rpc-url "$RPC_URL" \ --network-passphrase "$NETWORK_PASSPHRASE") +echo "Pool contract hash: $POOL_HASH" -echo "Contract hash: $CONTRACT_HASH" - -: "${TOKEN_0:?Set TOKEN_0 to the first token contract address}" -: "${TOKEN_1:?Set TOKEN_1 to the second token contract address}" - -echo "Deploying NodusAmm pool..." -CONTRACT_ID=$(stellar contract deploy \ - --wasm-hash "$CONTRACT_HASH" \ +echo "Uploading LP token contract to $NETWORK..." +LP_TOKEN_HASH=$(stellar contract upload \ + --wasm "$LP_TOKEN_WASM" \ --source "$STELLAR_SECRET_KEY" \ --rpc-url "$RPC_URL" \ --network-passphrase "$NETWORK_PASSPHRASE") +echo "LP token contract hash: $LP_TOKEN_HASH" -echo "Contract deployed: $CONTRACT_ID" +echo "Deploying pool..." +POOL_ID=$(stellar contract deploy \ + --wasm-hash "$POOL_HASH" \ + --source "$STELLAR_SECRET_KEY" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE") +echo "Pool deployed: $POOL_ID" -stellar contract invoke \ - --id "$CONTRACT_ID" \ +echo "Deploying LP token..." +LP_TOKEN_ID=$(stellar contract deploy \ + --wasm-hash "$LP_TOKEN_HASH" \ --source "$STELLAR_SECRET_KEY" \ --rpc-url "$RPC_URL" \ - --network-passphrase "$NETWORK_PASSPHRASE" \ + --network-passphrase "$NETWORK_PASSPHRASE") +echo "LP token deployed: $LP_TOKEN_ID" + +# The LP token must know its pool's address (the only address it will +# ever accept mint() calls from) before the pool is initialized, so this +# order matters: LP token first, then the pool. +echo "Initializing LP token..." +invoke --id "$LP_TOKEN_ID" \ + -- initialize \ + --pool "$POOL_ID" \ + --name "$LP_TOKEN_NAME" \ + --symbol "$LP_TOKEN_SYMBOL" \ + --decimals "$LP_TOKEN_DECIMALS" + +echo "Initializing pool..." +invoke --id "$POOL_ID" \ -- initialize \ --token_0 "$TOKEN_0" \ - --token_1 "$TOKEN_1" + --token_1 "$TOKEN_1" \ + --fee_to_setter "$FEE_TO_SETTER" \ + --lp_token "$LP_TOKEN_ID" -echo "Pool initialized. Contract ID: $CONTRACT_ID" +echo "Done. Pool: $POOL_ID LP token: $LP_TOKEN_ID"