A constant-product Automated Market Maker (AMM) built as a Soroban smart contract on the Stellar blockchain. It implements the classic x * y = k bonding curve model — the same design used by Uniswap v2 — providing decentralized token swaps and liquidity provisioning.
- Overview
- Architecture
- Contracts
- Math & Formulas
- Getting Started
- Usage
- Contributing
- Changelog
- Security
- License
This AMM lets users:
- Provide liquidity — deposit two tokens into a pool and receive LP (Liquidity Provider) tokens representing their share.
- Swap tokens — exchange one pool token for the other at a price determined by the constant-product formula.
- Redeem liquidity — burn LP tokens to withdraw a proportional share of the pool's reserves.
All operations include slippage protection parameters. Fees are configurable in basis points at deployment and are distributed to liquidity providers by growing the pool reserves.
The project is a Cargo workspace with four contracts:
soroban-amm/
├── Cargo.toml # Workspace root
└── contracts/
├── amm/ # Core AMM pool contract
│ └── src/lib.rs
├── token/ # SEP-41 LP token contract
│ └── src/lib.rs
├── factory/ # Pool factory contract
│ └── src/lib.rs
└── twap_consumer/ # Example TWAP consumer integration contract
└── src/lib.rs
The AMM contract depends on the token contract. When liquidity is added or removed, the AMM calls the LP token contract to mint or burn shares on behalf of the provider. The factory contract depends on both AMM and token: it deploys and initialises them as a pair when a new pool is created.
| Key | Storage Tier | Type | Description |
|---|---|---|---|
TokenA |
Instance | Address |
First pool asset |
TokenB |
Instance | Address |
Second pool asset |
LpToken |
Instance | Address |
LP token contract |
ReserveA |
Instance | i128 |
Current TokenA reserves |
ReserveB |
Instance | i128 |
Current TokenB reserves |
TotalShares |
Instance | i128 |
Total LP shares issued |
FeeBps |
Instance | i128 |
Swap fee in basis points |
| Key | Storage Tier | Type | Description |
|---|---|---|---|
Admin |
Instance | Address |
Contract administrator (the AMM pool) |
Name |
Instance | String |
Token name |
Symbol |
Instance | String |
Token symbol |
Decimals |
Instance | u32 |
Token decimal places |
TotalSupply |
Instance | i128 |
Total shares in circulation |
Balance(Address) |
Persistent | i128 |
Individual user share balance |
Allowance(Address, Address) |
Persistent | i128 |
Third-party spending allowance |
- Storage Immutability: Critical setup parameters (e.g.,
TokenA,TokenB,LpToken) are immutable afterinitialize. - Breaking Changes: Modifying
DataKeyvariants or data types constitutes a breaking change. Since Soroban storage is keyed by the enum's binary representation, any restructuring requires a new deployment or a careful migration strategy. - State Migration: Upgrading logic while preserving state is possible via contract code upgrades, but changing storage tiers (e.g., Instance to Persistent) requires manual data relocation.
| Key | Type | Description |
|---|---|---|
TokenA |
Address |
First pool asset |
TokenB |
Address |
Second pool asset |
LpToken |
Address |
LP token contract |
ReserveA |
i128 |
Pool's current balance of TokenA |
ReserveB |
i128 |
Pool's current balance of TokenB |
TotalShares |
i128 |
Total LP shares outstanding |
Shares(Address) |
i128 |
LP shares held by a specific provider |
FeeBps |
i128 |
Swap fee in basis points (e.g. 30 = 0.30%) |
Paused |
bool |
Emergency circuit breaker state |
FlashLoanFeeBps |
i128 |
Flash-loan fee in basis points; defaults to FeeBps |
Located in contracts/amm/src/lib.rs.
| Function | Description |
|---|---|
initialize(token_a, token_b, lp_token, fee_bps) |
One-time pool setup |
pause(admin) |
Pause state-changing pool operations; requires admin auth |
unpause(admin) |
Resume state-changing pool operations; requires admin auth |
is_paused() → bool |
Read the current pause state |
initialize_with_flash_loan_fee(token_a, token_b, lp_token, fee_bps, flash_loan_fee_bps) |
One-time pool setup with a distinct flash-loan fee |
flash_loan(receiver, token, amount, data) -> fee |
Borrow pool reserves and repay within the receiver callback |
add_liquidity(provider, amount_a, amount_b, min_shares) → shares |
Deposit tokens, receive LP shares |
remove_liquidity(provider, shares, min_a, min_b) → (a, b) |
Burn LP shares, withdraw tokens |
swap(trader, token_in, amount_in, min_out) → amount_out |
Exchange tokens |
get_amount_out(token_in, amount_in) → amount_out |
Quote a swap without executing it |
get_info() → PoolInfo |
Read pool state (reserves, fee, shares) |
shares_of(provider) → shares |
Read an LP's share balance |
Located in contracts/factory/src/lib.rs.
A single-entry-point contract for creating and discovering AMM pools. The factory deploys a new AMM pool and its paired LP token in one transaction, enforces uniqueness per token pair, and maintains a registry of all pools it has deployed.
| Key | Type | Description |
|---|---|---|
Admin |
Address |
Factory administrator; set as AMM fee recipient |
AmmWasmHash |
BytesN<32> |
WASM hash of the AMM pool contract |
TokenWasmHash |
BytesN<32> |
WASM hash of the LP token contract |
Pool(Address, Address) |
Address |
Normalised token pair → pool address |
AllPools |
Vec<Address> |
Ordered list of all deployed pool addresses |
PoolCount |
u64 |
Monotonic counter used to derive deploy salts |
| Function | Description |
|---|---|
initialize(admin, amm_wasm_hash, token_wasm_hash) |
One-time factory setup |
create_pool(token_a, token_b, fee_bps) → Address |
Deploy a new AMM + LP token pair; panics on duplicate |
get_pool(token_a, token_b) → Option<Address> |
Look up an existing pool (order-independent) |
all_pools() → Vec<Address> |
List every pool deployed by this factory |
- Token pair order is normalised at creation time (smaller address stored first).
get_poolaccepts either order. create_poolpanics with"pool already exists"if a pool for the pair is already registered.- The factory admin is set as the AMM's
fee_recipient; protocol fees start at 0 bps and can be enabled later.
Located in contracts/twap_consumer/src/lib.rs.
An example integration contract that reads the AMM cumulative oracle and computes a windowed TWAP for token A.
| Function | Description |
|---|---|
save_snapshot(pool) |
Stores (cum_a, cum_b, pool_ts) under Snapshot(pool, pool_ts) |
get_twap_price(pool, window_seconds) -> i128 |
Returns (cum_a_now - cum_a_then) / window_seconds, where cum_a_then comes from the snapshot at now_ts - window_seconds |
This contract is intentionally simple and intended as integration documentation for downstream builders.
Borrowers must implement a callback contract with this interface:
pub trait FlashLoanReceiver {
fn on_flash_loan(env: Env, token: Address, amount: i128, fee: i128, data: Bytes) -> bool;
}During flash_loan, the AMM transfers amount of token to receiver, invokes on_flash_loan, and then checks that the pool's token balance increased by at least fee. If the receiver does not return amount + fee before the callback finishes, the transaction reverts.
Located in contracts/token/src/lib.rs.
| Function | Description |
|---|---|
initialize(admin, name, symbol, decimals) |
One-time token setup |
mint(to, amount) |
Mint tokens — admin only |
burn(from, amount) |
Burn tokens — admin only |
transfer(from, to, amount) |
Transfer between accounts |
transfer_from(spender, from, to, amount) |
Spend an approved allowance |
approve(from, spender, amount) |
Approve a spender |
balance(id) → i128 |
Read account balance |
allowance(from, spender) → i128 |
Read spending allowance |
total_supply() → i128 |
Read total tokens minted |
Every swap must satisfy:
reserve_a * reserve_b = k (constant)
Fees are deducted from the input before applying the formula:
amount_in_with_fee = amount_in * (10_000 - fee_bps)
amount_out = (amount_in_with_fee * reserve_out)
/ (reserve_in * 10_000 + amount_in_with_fee)
Uses the geometric mean of the deposited amounts:
shares = sqrt(amount_a * amount_b)
Uses the lesser of the two proportional contributions to prevent imbalanced deposits:
shares = min(
amount_a * total_shares / reserve_a,
amount_b * total_shares / reserve_b
)
Proportional to pool ownership at the time of withdrawal:
out_a = shares * reserve_a / total_shares
out_b = shares * reserve_b / total_shares
- Rust (stable toolchain)
wasm32-unknown-unknowncompilation target:rustup target add wasm32-unknown-unknown
- Stellar CLI (
stellar) for deployment:cargo install --locked stellar-cli --features opt
-
Clone the repository:
git clone https://github.com/your-org/soroban-amm.git cd soroban-amm -
Verify the toolchain and target are installed:
rustup show # confirm stable toolchain is active rustup target list --installed # should include wasm32-unknown-unknown
If the WASM target is missing:
rustup target add wasm32-unknown-unknown
-
Configure the Stellar CLI for your target network (testnet shown):
stellar network add testnet \ --rpc-url https://soroban-testnet.stellar.org \ --network-passphrase "Test SDF Network ; September 2015" -
Create or import an account identity:
# Generate a new keypair and fund it via Friendbot stellar keys generate --default-seed mykey stellar keys fund mykey --network testnetOr import an existing secret key:
stellar keys add mykey --secret-key # paste your secret key when prompted -
Confirm everything is wired up:
stellar keys address mykey # should print your public key
You are now ready to build, test, and deploy.
Build all contracts as optimised WASM binaries:
cargo wasmwasm is a Cargo alias defined in .cargo/config.toml that expands to:
cargo build --release --target wasm32-unknown-unknownOutput files:
target/wasm32-unknown-unknown/release/amm.wasm
target/wasm32-unknown-unknown/release/token.wasm
The AMM and token contract tests run without pre-built WASM:
cargo test -p amm -p tokenThe factory tests embed compiled WASM at compile time, so build WASM first:
cargo build --release --target wasm32-unknown-unknown
cargo test -p factoryOr run the full suite in one go:
cargo build --release --target wasm32-unknown-unknown && cargo testFor a real-network smoke test on Stellar testnet, run the end-to-end script:
scripts/e2e.shThe script deploys fresh contracts, funds a test account, adds liquidity, swaps, removes liquidity, and exits non-zero on any failed assertion.
The fastest way to deploy a full AMM environment (Token A, Token B, LP Token, and AMM Pool) to testnet is using the provided deployment script:
./scripts/deploy.sh [network]- network: Optional target network (defaults to
testnet). - The script builds contracts, generates/funds a deployer account, deploys all contracts, and initialises them.
- Deployed contract IDs are printed to the console and saved to
.soroban-amm.deploy.env.
A machine-readable JSON schema of all public contract functions, parameters, and events is available at docs/abi.json.
The project includes a Makefile to simplify common development tasks:
make build: Build contracts for production (wasm32-unknown-unknown)make test: Run all contract unit testsmake fmt: Format code usingcargo fmtmake lint: Runclippywith warnings treated as errorsmake check: Run formatting, linting, and tests in sequencemake deploy: Deploy contracts to testnet viascripts/deploy.shmake e2e: Run full end-to-end integration testsmake clean: Remove build artifacts
To ensure identical WASM binaries across different environments, you can use the provided Docker configuration:
# Build using Docker Compose
docker compose run --rm build
# Alternatively, using raw Docker
docker build -t soroban-amm-build .
docker run --rm -v $(pwd):/app soroban-amm-build- Base Image:
rust:1.93.0-slim - Stellar CLI:
25.1.0
The factory is the recommended way to create pools. It deploys and initialises the AMM pool and its LP token in a single transaction, and registers the pool in its on-chain registry.
1. Upload the contract WASM blobs:
stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/amm.wasm \
--network testnet --source <YOUR_KEY>
# → prints AMM_WASM_HASH
stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/token.wasm \
--network testnet --source <YOUR_KEY>
# → prints TOKEN_WASM_HASH2. Deploy the factory:
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/factory.wasm \
--network testnet --source <YOUR_KEY>
# → prints FACTORY_CONTRACT_ID3. Initialise the factory:
stellar contract invoke \
--id <FACTORY_CONTRACT_ID> \
--network testnet --source <YOUR_KEY> \
-- initialize \
--admin <YOUR_ADDRESS> \
--amm_wasm_hash <AMM_WASM_HASH> \
--token_wasm_hash <TOKEN_WASM_HASH>4. Create a pool (deploys AMM + LP token, registers the pair):
stellar contract invoke \
--id <FACTORY_CONTRACT_ID> \
--network testnet --source <YOUR_KEY> \
-- create_pool \
--token_a <TOKEN_A_CONTRACT_ID> \
--token_b <TOKEN_B_CONTRACT_ID> \
--fee_bps 30
# → prints the new POOL_CONTRACT_ID5. Look up an existing pool:
stellar contract invoke \
--id <FACTORY_CONTRACT_ID> \
-- get_pool \
--token_a <TOKEN_A_CONTRACT_ID> \
--token_b <TOKEN_B_CONTRACT_ID>
stellar contract invoke --id <FACTORY_CONTRACT_ID> -- all_poolsDeploy the LP token contract first, then the AMM pool. The AMM contract address becomes the LP token's admin.
# Deploy the LP token
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/token.wasm \
--network testnet \
--source <YOUR_KEY>
# Deploy the AMM pool
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/amm.wasm \
--network testnet \
--source <YOUR_KEY>Initialize the LP token (admin = AMM contract address):
stellar contract invoke \
--id <LP_TOKEN_CONTRACT_ID> \
--network testnet \
--source <YOUR_KEY> \
-- initialize \
--admin <AMM_CONTRACT_ID> \
--name "Pool LP Token" \
--symbol "AMMLP" \
--decimals 7Initialize the AMM pool (fee of 30 bps = 0.30%):
stellar contract invoke \
--id <AMM_CONTRACT_ID> \
--network testnet \
--source <YOUR_KEY> \
-- initialize \
--token_a <TOKEN_A_CONTRACT_ID> \
--token_b <TOKEN_B_CONTRACT_ID> \
--lp_token <LP_TOKEN_CONTRACT_ID> \
--fee_bps 30 \
--fee_recipient <FEE_RECIPIENT_ADDRESS> \
--protocol_fee_bps 0stellar contract invoke \
--id <AMM_CONTRACT_ID> \
--network testnet \
--source <YOUR_KEY> \
-- add_liquidity \
--provider <PROVIDER_ADDRESS> \
--amount_a 1000000 \
--amount_b 2000000 \
--min_shares 0min_shares is the minimum LP tokens you are willing to accept. Set to 0 to skip slippage protection during initial seeding.
stellar contract invoke \
--id <AMM_CONTRACT_ID> \
--network testnet \
--source <YOUR_KEY> \
-- swap \
--trader <TRADER_ADDRESS> \
--token_in <TOKEN_A_CONTRACT_ID> \
--amount_in 100000 \
--min_out 0Use get_amount_out first to compute an appropriate min_out.
stellar contract invoke \
--id <AMM_CONTRACT_ID> \
--network testnet \
--source <YOUR_KEY> \
-- remove_liquidity \
--provider <PROVIDER_ADDRESS> \
--shares <LP_SHARE_AMOUNT> \
--min_a 0 \
--min_b 0# Full pool info
stellar contract invoke --id <AMM_CONTRACT_ID> -- get_info
# Quote a swap
stellar contract invoke --id <AMM_CONTRACT_ID> \
-- get_amount_out \
--token_in <TOKEN_A_CONTRACT_ID> \
--amount_in 100000
# LP share balance
stellar contract invoke --id <AMM_CONTRACT_ID> \
-- shares_of --provider <PROVIDER_ADDRESS>The AMM exposes cumulative price state with get_price_cumulative(). The example consumer contract shows one way to turn that into a fixed-window TWAP.
- Deploy
twap_consumer.wasm. - Save a snapshot (for example every minute):
stellar contract invoke \
--id <TWAP_CONSUMER_CONTRACT_ID> \
--network testnet --source <YOUR_KEY> \
-- save_snapshot \
--pool <AMM_CONTRACT_ID>- After
window_secondshas elapsed, read TWAP:
stellar contract invoke \
--id <TWAP_CONSUMER_CONTRACT_ID> \
--network testnet --source <YOUR_KEY> \
-- get_twap_price \
--pool <AMM_CONTRACT_ID> \
--window_seconds 60Notes:
window_secondsmust be greater than 0.save_snapshotmust have been called exactly atnow_ts - window_seconds(matching the pool timestamp used byget_price_cumulative).- Returned TWAP is scaled the same way as AMM spot price (
1_000_000scale factor).
A standalone TypeScript client is available in examples/client. It demonstrates connecting to Stellar testnet RPC, reading get_info(), quoting with get_amount_out(), executing swap(), and reading LP shares with shares_of().
cd examples/client
npm install
npm run build
npm startA standalone Python client is available in examples/python. It demonstrates the same flow using py-stellar-base (stellar-sdk): connect to Stellar testnet RPC, read get_info(), quote with get_amount_out(), execute swap(), and read LP shares with shares_of().
cd examples/python
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
python client.pyContributions are welcome. Please follow the guidelines below to keep the codebase consistent and review cycles short.
- Search existing issues before opening a new one.
- Include the Rust /
soroban-sdkversion, the steps to reproduce, and the expected vs. actual behavior. - For security vulnerabilities, do not open a public issue — see SECURITY.md for the responsible disclosure process.
-
Fork the repository and create a branch from
main:git checkout -b feat/my-feature
Branch naming conventions:
Prefix Use for feat/New features fix/Bug fixes refactor/Code restructuring without behavior change test/Adding or improving tests docs/Documentation only chore/Build scripts, tooling, dependencies -
Make your changes, then ensure the build and tests pass:
cargo build --release --target wasm32-unknown-unknown cargo test -
Write tests for any new behavior. All public functions should have at least one test. Tests live alongside the implementation in
src/lib.rsunder a#[cfg(test)]module. -
Keep commits focused. One logical change per commit. Use the Conventional Commits format:
feat: add time-weighted average price accumulator fix: prevent zero-share mint on initial deposit test: cover swap with maximum fee setting -
Open a Pull Request against
main. In the PR description:- Explain what changed and why.
- Reference any related issues with
Closes #<issue>orRelated to #<issue>. - If the change affects contract behavior, include before/after output or test coverage evidence.
- An
.editorconfigat the workspace root defines shared formatting rules (UTF-8, LF line endings, 4-space indentation, trailing-whitespace trimming). Most editors apply it automatically; install the EditorConfig plugin if yours does not. - A
rustfmt.tomlat the workspace root defines Rust formatting rules. It enforces:- Edition: 2021
- Max width: 100 columns
- Indentation: 4 spaces
- Line endings: Unix (LF)
- Import grouping: Standard library, external crates, then crate-local modules
- Run
cargo fmtbefore committing to automatically apply these rules. - Run
cargo clippy -- -D warningsand resolve any warnings before opening a PR. - Prefer explicit arithmetic with overflow checks over silent wrapping. The release profile already enables
overflow-checks = true. - Avoid unsafe code. There is no reason to use
unsafein a Soroban contract. - Do not add dependencies without discussion. The contract binary size and attack surface matter.
Before requesting review, confirm:
-
cargo fmthas been run -
cargo clippy -- -D warningspasses -
cargo testpasses - New behavior is covered by tests
- Public interface changes are reflected in this README
-
CHANGELOG.mdhas been updated with any notable changes - Commit messages follow the Conventional Commits format
This project follows Semantic Versioning. Breaking changes to the on-chain interface (function signatures, storage layout, error codes) constitute a major version bump.
See CHANGELOG.md for a history of notable changes to this project.
Please do not open public issues for security vulnerabilities. See SECURITY.md for the full vulnerability disclosure policy, supported versions, and how to reach the maintainers privately.
This project is licensed under the MIT License.