Skip to content

Repository files navigation

rwa — Trade Tokenized Stocks on Solana

CLI for buying & selling tokenized stocks and ETFs (Ondo Global Markets) on Solana via Jupiter. 438 tokens — TSLA, AAPL, NVDA, SPY, QQQ, and more. Flagship tokens trade 24/7.

⚠️ Beta (pre-v1). Breaking changes possible. Not financial advice — use at your own risk, and always preview with --dry-run before trading real funds.

Install

curl -fsSL https://raw.githubusercontent.com/outputlayer/rwa_cli/main/install.sh | sh

Downloads a pre-built binary for Linux/macOS/Windows from Releases, falling back to a source build. Update later with rwa update.

The installer verifies every download against the release's SHA-256 checksums and fails closed (refuses to install) if that verification is impossible — no manifest, no checksum entry, or no sha256sum/shasum on the system. RWA_INSTALL_INSECURE=1 is an explicit, unrecommended bypass.

Quick start

rwa keys generate                # 1. Create a wallet (encrypted; shows a recovery phrase)
rwa keys show                    # 2. Your address — fund it with USDC
rwa gm buy TSLA 10 --dry-run     # 3. Preview a $10 TSLA buy
rwa gm buy TSLA 10 -y            # 4. Execute it
rwa gm portfolio                 # 5. See your holdings

USDC is enough. If the wallet has no SOL, the CLI buys a small amount automatically for network fees (dynamic 5–25 USDC, sized to current fees; disable with RWA_NO_AUTO_GAS=1) and tops it up when it runs low. Swaps themselves are usually gasless.

Commands

Command Description
rwa gm buy <SYM> <AMT> [-y] Buy with USDC (min 5 USDC)
rwa gm sell <SYM> <AMT> [-y] Sell for USDC (exact, 50%, or all)
rwa gm buy/sell ... --limit-price <P> [share|token] Conditional trade: fills only at ≤ P (buy) / ≥ P (sell); e.g. --limit-price 748 share (bare = token)
rwa gm close-all [<PCT>] [-y] Sell every position (or a % of each)
rwa gm buy-basket <SYM AMT ...> [-y] Buy multiple tokens at once
rwa gm buy-basket <SYM PCT% ...> --total <USDC> [-y] Split a USDC total across percent weights
rwa gm buy-basket --total N --equal <SYM...> Even-weight basket / bulk buy
rwa gm sell-basket <SYM AMT ...> [-y] Sell multiple tokens at once
rwa gm portfolio [WALLET] Holdings + allocation + 24h change
rwa gm portfolio --view <term> Slice holdings: split by category (sector/region/class/type/factor) or filter by tag/ticker
rwa gm pnl Avg entry price + realized/unrealized P&L (from your CLI trades)
rwa gm hours [--tradable] Current session (+ what trades right now; 24/7 = flagships only)
rwa gm list / search / tradable Browse & filter (sector, --tag asia/dividend/...)
rwa gm history <SYM> [-r 1D..ALL] Price history
rwa gm send <TOKEN> <AMT> <TO> [-y] Transfer SOL/USDC/tokens to another wallet
rwa gm reclaim Close empty token accounts, reclaim SOL rent
rwa keys generate/import/export/show Wallet: create, import, back up
rwa keys encrypt/decrypt Toggle passphrase encryption of the key file
rwa keys store-passphrase/forget-passphrase Save (TTY-only) / remove (headless OK) the passphrase in the OS keychain
rwa keys add/list/use/remove Multiple named wallets
rwa keys policy show/allow/remove Manage the send allowed-recipient list (encrypted wallets only)
rwa update [-y] Self-update

Every trading command takes --dry-run (preview), -y (skip confirmation), --slippage <BPS>, --max-bps <N> (cost ceiling), and --json.

rwa gm portfolio --view sector      # split the portfolio into blocks by sector
rwa gm portfolio --view Dividend    # only positions tagged Dividend
rwa gm portfolio --view MRNA,CAPR   # only these tickers
rwa gm portfolio --view Healthcare,factor   # filter, then split by factor

Things to know

  • Preview first. --dry-run validates and quotes without executing; --quote-only (buy) quotes any size even without funds.
  • Conditional orders. --limit-price <P> (buy/sell) only fills if the quoted price is ≤ P (buy) / ≥ P (sell), else condition_not_met (exit 1) — in --dry-run too; worst-case fill is limit ± slippage. Add share to gate per underlying share instead of per raw token. For a synthetic limit order, run it on a schedule until it fills: rwa gm buy TSLA 100 --limit-price 400 --slippage 20 -y --json.
  • Amounts are exact (100), percent (50%), or all — never silently rounded. Minimum buy: 5 USDC. Token sell amounts are raw; for dividend-accruing tokens wallets display slightly more (shares_per_token multiplier) — if an exact sell overshoots, the error shows both raw and wallet-displayed numbers; prefer all/50%.
  • --view slices portfolio. A category name (sector, region, class, type, factor) splits the output into blocks; a tag label (Dividend, Healthcare, …) or ticker (MRNA) filters it instead — comma-separated and repeatable, same-category terms OR together, different categories AND (at most one category per call). gm_alloc_pct/GM TOTAL always reflect the whole portfolio, never the filtered slice; JSON's gm_positions.positions[] IS narrowed to the matched subset, though — use view.matched_value_usd for the slice's total. --view factor groups overlap (tokens carry 2+ factor tags) and won't sum to the total — JSON marks it overlapping: true. An unrecognized term, or all terms blank, fails closed with invalid_view (exit 1) rather than silently showing everything. Every position also carries sector/asset_class/region/type/tags, --view or not.
  • P&L is tracked automatically. Every CLI trade lands in a local per-wallet ledger with a tamper-evident hash chain; rwa gm pnl shows average entry price, realized and unrealized P&L (built from your trades only) plus ledger_integrity (ok/legacy/broken@line N) so a corrupted ledger is visible instead of silently mispricing.
  • sell swaps to USDC; send transfers out. send USDC all sends your entire USDC balance.
  • Multi-token commands run in parallel by default (internally bounded); --sequential is the rate-limit fallback. close-all is the canonical exit — it skips dust and paused/off-session positions and reports what it skipped; a skip that a retry would clear makes the run "partial" and exit 75 instead of a plain success.
  • buy-basket --from-file - reads the token list from stdin instead of a file, so it composes with search/jq: pipe a filtered symbol list straight into a bulk buy.
  • Slippage: 1% default, hard-blocked above 3%; unfillable routes are retried through another market maker automatically.
  • Liquidity is market-maker-driven. These tokens trade only against USDC, filled by Ondo's RFQ makers (who mint just-in-time). Thin single-name tokens depend on a maker being able to fill right now — a rare route_unfillable (exit 75, transient) means none could; retry shortly. Under a keyless wallet, Jupiter's per-wallet rate limit also bounds throughput (set RWA_JUPITER_API_KEY to raise it).
  • Back up your wallet. keys generate derives from a BIP39 phrase (works in Phantom/Solflare) and shows it once; encrypted wallets keep it — reveal anytime with keys export --reveal.
  • Sessions are ET-based — Pre-Market 4:00, Regular 9:30, Post-Market 16:00, Overnight 20:00. Weekends/holidays are off-hours: only flagship tokens (TSLAon, NVDAon, SPYon, QQQon, GOOGLon, …) trade then. rwa gm hours --tradable shows what's available right now.

For agents

npx skills add outputlayer/rwa_skills -g -y

Then talk in plain language: "Buy $100 of TSLA · Show my portfolio · Send all USDC to a friend's address".

Every command supports --json — a stable contract with typed error_kind values and exit code 75 for retry-worthy transient failures (1 otherwise). Rules: prefer --json, use -y only for real execution, never run wallet-changing commands as parallel shell processes (multi-token commands parallelize internally). --json without -y never executes the six gated money-moving commands (buy, sell, send, buy-basket, sell-basket, close-all) — it fails closed instead of running non-interactively (an otherwise-valid trade returns error_kind: confirmation_required (exit 1); a precondition failure like insufficient_funds/no_position surfaces its own kind first); add -y to execute or --dry-run to preview. reclaim reclaims rent to your own wallet and runs without confirmation. Baskets and close-all add a third envelope status, "partial" — some items landed, some are in failed[] — alongside "success" (all landed, or nothing to do) and the all-failed "error" (exit non-zero); always read per-item failed[] for the reason, even on "partial". A failed[]-only "partial" exits 0, but for close-all a "partial" caused by a retryable skipped[] entry (trading_paused, not tradable in current session, market data unavailable) also carries incomplete_reason and exits 75 — the portfolio isn't empty, so a close-all && reclaim && send chain must stop there; dust (below $1.50 minimum) never does this and keeps "success"/exit 0. Manual skill install: outputlayer/rwa_skills.

RPC endpoints

Public Solana endpoints are rate-limited. The CLI retries with backoff and falls back to Jupiter's holdings API for portfolio, but for regular use get a free dedicated endpoint (Helius, Alchemy, QuickNode, …):

export RWA_RPC_URL="https://<your-endpoint>"       # Solana RPC (or --rpc-url)
export RWA_JUPITER_API_KEY="<your-jupiter-key>"    # optional: higher Jupiter limits
Performance (live-measured)
Command Time
keys show, --help ~10 ms
gm hours (cached), list, tradable 30–200 ms
gm portfolio, pnl 0.2–1 s
single quote (buy/sell --dry-run) ~0.6 s
gm buy / sell (single, executed) 1–6 s
baskets / close-all (parallel, default) ~1.3 s for 3 tokens on a healthy wallet¹
baskets / close-all (--sequential) ~7.4 s for 3 tokens (3 s spacing)
gm reclaim ~1.5 s

¹ Parallel quotes are staggered (RWA_QUOTE_STAGGER_MS, default 350 ms) to dodge Jupiter's per-wallet 429 burst; best case is ~0.6 s + (N−1) × stagger. Without an API key the per-wallet rate limit still bounds throughput — under heavy use latency degrades to multi-second retries. Setting RWA_JUPITER_API_KEY auto-selects the keyed profile (stagger 0, higher quote concurrency) — no manual tuning needed (RWA_QUOTE_STAGGER_MS still overrides). Numbers are network-bound (Jupiter / Solana RPC) and vary with connectivity and rate-limit state.

How it works
  • Ondo GM tokens are total-return trackers (dividends reinvested, so 1 token ≠ 1 share): token price = share price × multiplier, and portfolio shows shares_per_token once it drifts from 1. TSLA and TSLAon are both accepted.
  • Swaps route through Jupiter (swap/v2 → Ultra fallbacks → Metis). Quotes with bad slippage are refreshed across market makers; a route that would fail on-chain is excluded and requoted. Quote fetching backs off up to 3.2s between retries within a ~20s budget per quote-fetch pass, printing a still fetching quote (...) heartbeat to stderr so a slow quote stays visible instead of hanging silently.
  • Every swap is verified before signing: the transaction is simulated on-chain and the CLI refuses to sign unless the balance deltas match the quote (debit ≤ expected, credit ≥ quote − slippage). Wallet keys never leave the machine; encrypted storage is age with your passphrase.
  • Optional send allowlist: keys policy allow <ADDR> (encrypted wallets only, typed passphrase) locks send to a whitelist stored inside the encrypted wallet — once set, a non-interactive send to an unlisted address fails closed with recipient_not_allowed instead of moving funds.
  • portfolio JSON: cash.{sol,usdc} + gm_positions.{value_usd, change_24h_usd, positions[]}. Surfaced error_kind values: market_closed, not_tradable, slippage_too_high, cost_too_high, condition_not_met, trading_paused, route_unfillable, rpc_unavailable, insufficient_funds, amount_below_minimum, no_position, confirmation_timeout, on_chain_failure, execute_unavailable, confirmation_required (--json without -y), plus input kinds unknown_token, invalid_amount, invalid_address, unknown_wallet, self_send, reveal_required, interactive_required, recipient_not_allowed, lock_contention (transient, exit 75), and invalid_view (gm portfolio --view term matched no category, ticker, or Ondo tag label; exit 1, not transient). Transaction-layer kinds (TransactionErrorKind): missing_blockhash / invalid_blockhash (RPC blockhash fetch failed or was undecodable — transient, exit 75, retry gets a fresh blockhash) and simulation_failure (pre-send simulation proved the tx would fail on-chain, nothing was submitted — permanent, exit 1).
  • Jupiter /execute failure kinds (ExecuteFailureKind, mapped from Jupiter's numeric error codes): failed_to_land, rfq_failed_to_land, quote_expired, swap_rejected, internal_error, missing_cached_order, and route_unfillable are transient (exit 75) — each carries a RefreshOrder/RetrySameOrder retry action and gets an internal retry before surfacing, so a fresh command invocation may still succeed (same treatment as execute_unavailable, already exit 75). New in 0.7.9: swap_rejected, missing_cached_order, and route_unfillable become exit 75 here (were exit 1) — their retry_action is RefreshOrder. invalid_signed_transaction, invalid_message_bytes, unknown_aggregator_error, unknown_rfq_error, invalid_payload, unknown are permanent (exit 1).
Architecture
bin/rwa/        → binary entry point
crates/cli/     → clap parsing, human/JSON output, process lock
crates/ondo/    → protocol layer
  usecases/     → trade orchestration (prepare, execute, retry, gas refuel)
  solana/       → RPC retry/race, balances, fees, transfers, swap simulation
  jupiter/      → swap backends (order, execute, holdings)
  api/          → Ondo sessions, prices, history
  wallet/       → Ed25519 + SLIP-10/BIP39, sign-time tx verification
  spl.rs        → shared SPL primitives (program ids, ATA derivation)

~19k lines of pure Rust (no C deps, no unsafe), 500+ tests including adversarial sign-time verification and spawned-binary JSON-contract tests.

Development

make ci                # run before pushing — exact mirror of the GitHub CI (RUSTFLAGS=-Dwarnings)
make install-hooks     # optional: pre-push hook that runs `make ci` automatically

CI treats warnings as errors (-Dwarnings), so make ci — not a plain cargo test/clippy — is what keeps main green.

Contributing

See CONTRIBUTING.md — in short: make ci before every push, and money-path changes need live validation.

License

MIT

About

Rust CLI for trading tokenized stocks & ETFs (Ondo Global Markets) on Solana via Jupiter

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages