Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
build:
name: Build Soroban Contract
name: Build Soroban Contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -17,23 +17,47 @@ 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)
runs-on: ubuntu-latest
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
Expand All @@ -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
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["contracts/pool"]
members = ["contracts/pool", "contracts/lp-token"]

[workspace.dependencies]
soroban-sdk = "26.1.0"
Expand Down
23 changes: 13 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
142 changes: 104 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -47,17 +64,28 @@ 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
errors.rs Stable #[contracterror] enum
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
```

---
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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.

---

Expand Down
19 changes: 19 additions & 0 deletions contracts/lp-token/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
14 changes: 14 additions & 0 deletions contracts/lp-token/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading