# Aspens SDK
[](https://crates.io/crates/aspens)
[](https://docs.rs/aspens)
A comprehensive SDK and CLI tools for interacting with an [Aspens Markets Stack](https://docs.aspens.xyz).
> Looking for the TypeScript SDK? It is published on npm as [`@aspensprotocol/sdk-ts`](https://www.npmjs.com/package/@aspensprotocol/sdk-ts).
The core library is published on crates.io as [`aspens`](https://crates.io/crates/aspens).
The `aspens-cli`, `aspens-repl`, and `aspens-admin` binaries live in this
workspace; `aspens-cli` and `aspens-repl` ship as prebuilt release binaries
(see below), and all three can be built from source.
## Install
Install the latest `aspens-cli` and `aspens-repl` (Linux x86_64 / aarch64, macOS Apple Silicon):
```sh
Overrides: `INSTALL_DIR=<dir>` picks the location (default `/usr/local/bin` on
Linux, `~/.local/bin` on macOS); `ASPENS_VERSION=vX.Y.Z` pins a specific release.
Binaries are attached to each [GitHub release](https://github.com/aspensprotocol/sdk/releases)
alongside a `SHA256SUMS` file.
Or build from source (also the path for `aspens-admin`):
```sh
cargo install --locked --git https://github.com/aspensprotocol/sdk aspens-cli aspens-repl
```
## Available Commands
### Trader commands (`aspens-cli` / `aspens-repl`)
| `config [--output-file <path>]` | Fetch and display the configuration from the server (saves to `.json` / `.toml` if `--output-file` is set) |
| `deposit <network> <token> <amount>` | Deposit tokens to make them available for trading |
| `withdraw <network> <token> <amount>` | Withdraw tokens to a local wallet |
| `buy-market <market> <amount> --quote-budget <quote>` | Send a market BUY order (executes at best available price). `--quote-budget` is **required**: a market buy gives quote and has no price to size that with, so the budget — not `<amount>` — is what bounds the spend and what gets collateralised. |
| `buy-limit <market> <amount> <price> [--post-only]` | Send a limit BUY order (executes at specified price or better). With `--post-only`, the order is rejected if it would cross at submission — guarantees maker-side execution. |
| `sell-market <market> <amount>` | Send a market SELL order (executes at best available price). No budget needed: a sell gives base, so `<amount>` IS its budget. |
| `sell-limit <market> <amount> <price> [--post-only]` | Send a limit SELL order (executes at specified price or better). See `--post-only` above. |
| `buy-marketable <market> <amount> [--slippage-bps <bps>]` | **CLI only.** Snapshot the resting book, cap slippage above best ask (default 50 bps = 0.5%), submit as a buy-limit. Turns "take the top of book with a slippage cap" into the equivalent priced order — useful when you want an explicit price ceiling rather than a quote budget. |
| `sell-marketable <market> <amount> [--slippage-bps <bps>]` | **CLI only.** Same as `buy-marketable`, but capping slippage below best bid. |
| `cancel-order <market> <side> <order_id>` | Cancel an existing order by its ID |
| `stream-orderbook <market> [--historical] [--trader <addr>]` | Stream orderbook entries in real-time |
| `stream-trades <market> [--historical] [--trader <addr>]` | Stream executed trades in real-time |
| `balance` | Fetch the current balances for all supported tokens across all chains |
| `status` | Show current configuration and connection status |
| `trader-public-key` | Get the public key and address for the trader wallet |
| `signer-public-key [--chain-network <network>]` | Get the signer public key(s) for the trading instance (filtered to a chain network if provided) |
| `get-attestation [--nonce <hex>] [--save-quote <file>] [-o text\|json]` | Fetch the signer's TD Quote; the caller-chosen nonce is bound into the quote's REPORTDATA (challenge-response); `--save-quote` writes the raw quote for offline `verify-attestation --quote` |
All commands above are available in both `aspens-cli` and `aspens-repl`, except `buy-marketable` / `sell-marketable` which are CLI-only. The REPL also adds a `quit` command to exit the session.
### Admin commands (`aspens-admin`)
Most commands below require a JWT (set via `--jwt`, `ASPENS_JWT` in `.env`, or the `aspens-admin login` flow).
| `init-admin --address <eth-address>` | Initialize the first admin on a fresh stack (no JWT required) |
| `login [--chain-id <id>]` | Authenticate via EIP-712 signature using `ADMIN_PRIVKEY` and obtain a JWT |
| `update-admin <eth-address>` | Update the admin address |
| `set-chain --architecture … --canonical-name … --network … --chain-id … --rpc-url … --factory-address … [--explorer-url …] [--instance-signer-address …]` | Add or update a chain entry |
| `delete-chain <network>` | Remove a chain from the configuration |
| `rpc list <network>` | List a chain's current RPC endpoint set (masked; unauthenticated) |
| `rpc set <network> --endpoint <endpoint> [--endpoint <endpoint> …]` | Replace a chain's complete RPC endpoint set (full replace, priority = flag order) |
| `rpc probe <network> <url> [--scheme none\|header\|basic\|bearer] [--key <key>] [--secret <secret>]` | Probe a candidate RPC endpoint before committing it with `rpc set`; never stored |
| `set-token --network … --name … --symbol … --address … --decimals …` | Add or update a token on a chain |
| `delete-token --network <network> --symbol <symbol>` | Remove a token from a chain |
| `set-market --base-network … --quote-network … --base-symbol … --quote-symbol … --base-address … --quote-address … --pair-decimals …` | Add or update a market (register both tokens with `set-token` first — the market takes its token decimals from them) |
| `delete-market <market_id>` | Remove a market |
| `deploy-contract <network> [--fees <bps>]` | Deploy a trade contract on a chain (fee in basis points, default 0) |
| `set-trade-contract --address <addr> --chain-network <network>` | Register an existing trade contract address on a chain |
| `set-operator-fee --chain-network <network> --recipient <addr> --bps <bps>` | Set an instance's operator fee (recipient + bps). Arborter-submitted, so it fails on-chain unless the instance's `operator_admin` is the arborter signer — which current contracts refuse |
| `rotate-operator-admin --chain-network <network> --new-admin <addr>` | Rotate an instance's `operator_admin` key. Same limitation as `set-operator-fee` |
| `delete-trade-contract <chain_network>` | Remove the trade contract association from a chain |
| `version` | Show server version information |
| `status` | Show current configuration and connection status |
| `admin-public-key` | Get the public key and address for the admin wallet (from `ADMIN_PRIVKEY`) |
| `balances` | Show balances for owner, signers, and contracts across all chains |
## Project Structure
This is a Cargo workspace with four main components:
- **`aspens/`** - Core Rust library crate with trading logic and gRPC client
- **`aspens-cli/`** - Command-line interface binary for scripted operations
- **`aspens-repl/`** - Interactive REPL binary for manual trading
- **`aspens-admin/`** - Administrative CLI for stack configuration (chains, tokens, markets)
## Prerequisites
1. **Install Rust:**
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
2. **Install Just (Optional but Recommended):**
[Just](https://github.com/casey/just) is a command runner that simplifies common development tasks.
```bash
brew install just # macOS
cargo install just # Any platform
```
3. **Configure environment:**
```bash
cp .env.sample .env ```
## Building
```bash
just build # Build entire workspace
just release # Build release version
just build-lib # Build core library only
just build-cli # Build CLI only
just build-repl # Build REPL only
just build-admin # Build Admin CLI only
```
## Usage
### 1. As a Rust Library
Install from crates.io:
```bash
cargo add aspens
```
Or add it manually to your `Cargo.toml`:
```toml
[dependencies]
aspens = "0.8"
```
Full client (gRPC + trading commands + RPC submission):
```rust
use aspens::{AspensClient, DirectExecutor};
#[tokio::main]
async fn main() -> eyre::Result<()> {
let client = AspensClient::builder()
.with_url("http://localhost:50051")?
.build()?;
// Use trading operations...
Ok(())
}
```
Stateless signing only (no gRPC, no tokio, no RPC client — e.g. browser
via `wasm-bindgen`, edge workers, or a service that submits orders over
its own transport):
```bash
cargo add aspens --no-default-features --features evm,solana
```
```toml
[dependencies]
aspens = { version = "0.8", default-features = false, features = ["evm", "solana"] }
```
```rust
use aspens::orders::derive_order_id;
use aspens::evm::envelope_signing_digest;
use aspens::solana::withdrawal_voucher_signing_message;
// The canonical order id, derived from the intent fields. The server derives
// the same id from the signed order, so it is never sent alongside it.
let order_id = derive_order_id(
&user_pubkey_bytes, nonce, origin_chain, dest_chain,
&input_token_bytes, &output_token_bytes, input_amount, output_amount,
);
// EVM: the EIP-191 digest recovered against for the outer order envelope.
let digest = envelope_signing_digest(&encoded_send_order_request);
// Solana: the exact bytes the arborter signed for a withdrawal voucher, which
// go in the paired Ed25519 verify instruction.
let msg = withdrawal_voucher_signing_message(
&instance, &account, &mint, amount, nonce, deadline,
)?;
```
Under the optimistic shadow ledger, orders never lock on-chain, so there is no
per-order EIP-712 lock signature: a client signs the outer envelope over the
encoded request, and that is all.
The pure modules:
- **`aspens::orders`** — chain-agnostic `derive_order_id`, destination-token parsing.
- **`aspens::evm`** — MidribV3 `sol!` bindings, the EIP-191 envelope digest,
native-token sentinel helpers.
- **`aspens::solana`** — PDA derivations, instruction builders, borsh payload
encoder, Ed25519 precompile ix, well-known program ids.
### 2. Interactive Mode (REPL)
```bash
cargo run --bin aspens-repl
# Inside the REPL
aspens> help
aspens> balance
aspens> deposit base-sepolia USDC 1000
aspens> buy-market USDC/USDT 100 --quote-budget 250
aspens> quit
```
### 3. Scripted Mode (CLI)
```bash
cargo run --bin aspens-cli -- balance
cargo run --bin aspens-cli -- deposit base-sepolia USDC 1000
cargo run --bin aspens-cli -- buy-market USDC/USDT 100 --quote-budget 250
```
### Post-only orders
Pass `--post-only` to `buy-limit` / `sell-limit` to guarantee your order
adds liquidity rather than taking it. If the price would cross the
opposing side of the book at submission, arborter returns
`FAILED_PRECONDITION` and the order is never booked, so no collateral is
committed and you can resubmit at a different price.
```bash
# Post a maker-only bid at 100. If the best ask is ≤ 100, the order
# is rejected and you can retry at 99 (or below).
aspens-cli buy-limit USDC/USDT 1.5 100 --post-only
# Same on the sell side: rejected if there's a resting bid at ≥ 200.
aspens-cli sell-limit USDC/USDT 1.5 200 --post-only
```
In Rust:
```rust
use aspens::commands::trading::send_order;
let response = send_order::send_order_with_wallet(
stack_url,
market_id,
1, // 1 = BUY
"1.5".into(), // quantity
Some("100".into()), // limit price (required for post-only)
&wallet,
config,
true, // post_only
false, // hidden
None, // quote_budget: only a market BUY states one
).await?;
```
Post-only is incompatible with market orders (the SDK pre-rejects
`post_only=true` with `price=None` before signing) and with the
`buy-marketable` / `sell-marketable` CLI variants (which are designed
to cross — the CLI hard-codes `post_only=false` for them).
### 4. Admin CLI
```bash
# Initialize admin (first time only)
cargo run --bin aspens-admin -- init-admin --address 0xYourAddress
# Login to get JWT
cargo run --bin aspens-admin -- login
# Admin commands (JWT set in .env or via --jwt flag)
cargo run --bin aspens-admin -- set-chain --network base-sepolia ...
cargo run --bin aspens-admin -- set-token --network base-sepolia --symbol USDC ...
cargo run --bin aspens-admin -- status
```
## Cargo Feature Flags
The `aspens` crate exposes eight feature flags. `evm`, `solana`, `client`,
and `trader` are on by default; `admin`, `fce`, `dcap`, and `dcap-fetch`
are opt-in. Consumers can trim down to just what they need:
| `evm` | `aspens::evm` (sol! bindings, EIP-712 hasher, envelope signer) + `aspens::orders`. Tiny — `alloy-primitives`/`alloy-sol-types`/`alloy-signer-local`. | Keep if you build or sign EVM orders. |
| `solana` | `aspens::solana` (PDA derivations, instruction builders, borsh payload encoder, Ed25519 precompile ix). Pulls `solana-sdk`, `borsh`, `bs58`. | Keep if you build or sign Solana orders. |
| `client` | Full runtime: `AspensClient`, trading commands, gRPC (`tonic`/`prost`), async runtime (`tokio`), RPC submission (`solana-client`, `alloy`). | Keep for the CLI/REPL/admin experience or anything that talks to the Aspens stack. Drop it for browser / embedded / offline-signing. |
| `trader` | `commands::trading` (deposit/withdraw/order/balance/streams) + table formatting (`comfy-table`). | Keep for the trading command surface (needs `client`). |
| `admin` | `commands::trading`, `commands::admin`, `commands::auth` (chain/token/market registration, JWT login). No extra deps. | Keep for the admin command surface (needs `client`). |
| `fce` | The FCE direct-action transport (`aspens::fce`): POST `/direct` + poll. Pulls `reqwest`, `tokio`. | Keep only when talking to a stack behind the FCE proxy — parked deployment mode. |
| `dcap` | The relying-party DCAP quote verifier (`tdx_verify::dcap`), pure-Rust `dcap-qvl`. | Keep if you verify TDX attestations locally. |
| `dcap-fetch` | `dcap` + collateral fetching over the PCS/PCCS REST API (`tdx_verify::collateral`): `reqwest`, `asn1_der`, `pem`, `urlencoding`. | Keep for the `verify-attestation` CLI command. |
Common configurations:
- **Default** (everything): `aspens = "0.8"`
- **Lean EVM signing**: `aspens = { version = "0.8", default-features = false, features = ["evm"] }`
- **Lean Solana signing**: `aspens = { version = "0.8", default-features = false, features = ["solana"] }`
- **Both chains, no client runtime**: `aspens = { version = "0.8", default-features = false, features = ["evm", "solana"] }`
The `aspens-cli`, `aspens-repl`, and `aspens-admin` binaries all depend
on the default feature set.
## Just Commands
```bash
just # List all available commands
just build # Build the project
just test # Run all tests
just test-lib # Run library tests only
just fmt # Format code
just check # Check code style
just lint # Run linter
just clean # Clean build artifacts
```
## Architecture
### Core Library (`aspens/`)
- **AspensClient** - Main client with builder pattern for configuration
- **Trading operations** - Deposit, withdraw, buy, sell, balance queries across EVM and Solana chains
- **Curve-agnostic wallet** - `Wallet::Evm` (secp256k1) and `Wallet::Solana` (Ed25519) behind one signing interface
- **Chain dispatch** - `ChainClient` routes RPC calls to Alloy (EVM) or `solana-client` based on chain architecture
- **Executor pattern** - Async/sync execution strategies
- **gRPC client** - Protocol buffer communication with an Aspens Market Stack
- **Client-side order helpers** (`aspens::orders` / `aspens::evm` / `aspens::solana`) — stateless
builders for the gRPC order payload: `derive_order_id`, the EIP-191 envelope digest (EVM),
PDA derivations, borsh voucher payloads, Ed25519 precompile ix (Solana).
Available without the `client` feature for browser / embedded callers.
- **EVM integration** - MidribV3 ABI bindings (shared JSON artifacts with arborter), Alloy signer
- **Solana integration** - Midrib Anchor program: Anchor discriminators, PDA seeds, SPL token flow
### CLI Binary (`aspens-cli/`)
Command-line interface for scripted trading operations.
It also carries the two **operator** commands that must NOT go through the
arborter, `set-withdraw-epoch-cap` and `set-settle-epoch-cap`:
```bash
OPERATOR_ADMIN_PRIVKEY_SOLANA=<base58 or id.json contents> \
aspens-cli set-withdraw-epoch-cap <network> <token> <cap>
OPERATOR_ADMIN_PRIVKEY_SOLANA=<base58 or id.json contents> \
aspens-cli set-settle-epoch-cap <network> <token> <cap>
```
This arms the Solana midrib program's per-`(instance, mint)` per-epoch
withdrawal ceiling. `cap` is in human units (scaled by the token's `decimals`),
and **`0` means unlimited** — the shipped default for every mint, matching
`MidribV3.setWithdrawEpochCap` on EVM. The epoch is 9,000 slots (~1 hour) and
the window is **tumbling, not sliding**, so up to `2 × cap` can leave across a
boundary; for at most X per hour, set `cap = X/2`.
`set-settle-epoch-cap` arms the matching ceiling on settlement: the most
`settle_batch` may credit to accounts (the sum of its positive deltas) per
`(instance, mint)` per epoch, across all batches — `MidribV3.setSettleEpochCap`
on EVM. Same units, epoch and `0` = unlimited. Size it above the venue's
legitimate hourly settled volume: a batch that would exceed it is refused on
chain, and the arborter holds that token's settlement until it fits (the
epoch rolls over or the cap is raised).
The authority for both is the instance's on-chain `operator_admin`, so the
commands build, sign and submit the transaction locally with an offline key
(`OPERATOR_ADMIN_PRIVKEY_SOLANA`, base58 or JSON keypair) — deliberately not
the trader or admin key, and never the arborter's. The program refuses an
`operator_admin` equal to the TEE signer, so the TEE cannot raise the caps that
bound it; an instance created before that check may still carry one, and the
commands warn when they see it. `--help` on each command restates all of this.
### REPL Binary (`aspens-repl/`)
Interactive Read-Eval-Print Loop for manual trading with command history and session state.
### Admin Binary (`aspens-admin/`)
Administrative CLI for managing stack configuration with EIP-712 signature authentication.
## Decimal Handling
Aspens handles tokens with different decimal places across chains. The SDK works in "pair decimals" format internally. See `decimals.md` for detailed conversion examples.
## Token Assumptions
**Important:** Aspens only supports tokens with **standard ERC-20 / SPL semantics**. Adding a non-compliant token to a market — via `aspens-admin set-token` or the admin-console — will produce incorrect balances, fee leakage, or stuck funds. The contracts do **not** detect non-compliant tokens; gating happens here, in market configuration.
A token is safe to add **only if all of the following hold**:
- **Standard transfer semantics.** A `transfer(to, amount)` reduces the sender's balance by exactly `amount`. No transfer hooks that re-enter or opportunistically revert.
- **No fee-on-transfer.** Tokens that charge a fee on transfer (reflection tokens, deflationary tokens) silently shift cost onto the user's existing `tradeBalance` during `_depositAndLock`, cause the Aspens vault to under-collect fees, and under-deliver `SETTLE_AND_WITHDRAW` payouts.
- **No rebasing.** Tokens whose balances change between two reads of `balanceOf` (AMPL-style, aTokens in rebase mode) break the `balanceBefore`/`balanceAfter` reconciliation used throughout the contract. Use the wrapped, non-rebasing variant (e.g. wstETH, not stETH).
- **No supply-pause that strands open orders.** Pausable tokens are tolerable as long as pauses are short-lived; pauses of a duration longer than the cancel-and-unlock window can strand `lockedTradeBalance` until the pause lifts.
- **Blocklist tokens (USDC-style) are accepted with caveats.** Funds remain correctly accounted for, but a blocklisted address cannot withdraw or settle out until removed from the list.
Quick checklist before running `aspens-admin set-token`:
1. Read the token's `transfer` implementation — confirm `_balances[from] -= amount` is the only debit.
2. Run a probe transfer (any amount) and check `balanceAfter == balanceBefore - amount` on both sides.
3. Confirm `balanceOf` is a pure function of stored state, not a function of total supply.
If any of these checks fails, **do not add the token**. Common safe examples: USDC, USDT (on chains where USDT does not enable fee-on-transfer), WBTC, WETH, DAI, most stablecoins.
### Solana-specific notes
The on-chain `midrib` program uses the **legacy SPL Token program**, not Token-2022. Mints owned by the Token-2022 program (`TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb`) will fail deserialization at every entry point — by design, since Token-2022's transfer-fee, interest-bearing, and confidential-transfer extensions would all break the program's `deposited += amount` accounting.
Withdrawal vouchers carry an `args.deadline` slot. The on-chain tombstone PDA makes a voucher single-use regardless of deadline, but keeping the deadline tight (`current_slot + 600`, roughly 4 minutes at 400ms slots) limits the window between the arborter signing and the holder submitting.
## Documentation
- [Decimal Conversion Guide](decimals.md) - Understanding decimal handling
- [CHANGELOG.md](CHANGELOG.md) - Release notes per version
- [AGENTS.md](AGENTS.md) - Architecture guide for development
## Versioning
The `aspens` crate follows [Semantic Versioning](https://semver.org/). The
workspace is pre-1.0, so the conventions in effect today are:
- **Patch releases (`0.4.x` → `0.4.y`)** — bug fixes, performance work,
internal refactors. No source-breaking changes to public items in
`aspens::{client, wallet, orders, evm, solana, decimals}` or to the
re-exports at the crate root.
- **Minor releases (`0.4.x` → `0.5.0`)** — may include breaking changes
to the public API surface (renames, signature changes, removals).
Notable changes are recorded in [`CHANGELOG.md`](CHANGELOG.md).
- **Internal modules** — `aspens::grpc` (and any module marked
`#[doc(hidden)]` or `pub(crate)`) are implementation details and may
change in any release. Generated proto bindings — `aspens::attestation::*`
and the `*_pb` modules beside each command group
(`commands::config::config_pb`, `commands::trading::arborter_pb`,
`commands::auth::auth_pb`) — track the upstream `protos/` repo and follow
its compatibility, not the SDK's. Each generated file is compiled exactly
once; a second `include!` of one would mint a parallel, incompatible set of
types rather than an alias.
- **CLI / REPL / Admin binaries** — version-bumped together with the
library. Flag and command renames are called out in `CHANGELOG.md`.
When in doubt about whether a change is breaking, check the changelog
entry for the target version.
## License
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.