Polymarket CLI 技術深度剖析:為什麼用 Rust 寫一個預測市場的命令列工具


2026 年 2 月底,Polymarket 開源了一個 Rust CLI 工具,讓使用者可以從終端機瀏覽預測市場、下單交易、管理鏈上資產。兩天內拿到 1,000+ stars。

為什麼一個預測市場平台要寫 CLI?為什麼用 Rust?這個專案的架構有什麼值得學習的地方?

本文從原始碼層面逐一拆解。


專案概覽

維度數據
語言Rust (Edition 2024, MSRV 1.88.0)
授權MIT
原始碼行數~8,700 行(不含 target)
最大檔案output/clob.rs(1,467 行)
依賴數12 個直接依賴
測試488 行整合測試 + 各模組單元測試
發布GitHub Releases + Homebrew + Shell 安裝腳本
跨平台macOS (x86/ARM), Linux (x86/ARM)

核心是一個圍繞 polymarket-client-sdk 的 CLI 包裝層。SDK 提供 API 通訊和鏈上交互,CLI 負責命令解析、認證管理、輸出格式化。


架構設計

目錄結構

src/
  main.rs        -- 進入點,clap 命令解析,錯誤處理
  auth.rs        -- 錢包解析、RPC provider、CLOB 認證
  config.rs      -- 設定檔管理 (~/.config/polymarket/config.json)
  shell.rs       -- 互動式 REPL
  commands/      -- 每個命令群組一個模組(14 個子模組)
  output/        -- 每個命令群組的 table/JSON 渲染(14 個子模組)

這個目錄結構體現了一個清晰的關注點分離:

  • commands/ 負責「做什麼」— 解析參數、呼叫 SDK、傳遞結果
  • output/ 負責「怎麼顯示」— 將 SDK 回傳的資料結構渲染為 table 或 JSON
  • auth.rs / config.rs 負責「誰在用」— 私鑰解析、認證狀態管理

命令分發:乾淨的 enum + match

main.rs 定義了一個 Commands enum,每個 variant 對應一個頂層命令:

#[derive(Subcommand)]
enum Commands {
    Setup,
    Shell,
    Markets(commands::markets::MarketsArgs),
    Events(commands::events::EventsArgs),
    Clob(commands::clob::ClobArgs),
    Ctf(commands::ctf::CtfArgs),
    Data(commands::data::DataArgs),
    // ... 共 16 個 variant
}

run() 函式用一個巨大的 match 將每個命令分發到對應的 commands::*::execute() 函式。這種模式在 Rust CLI 中非常常見 — 用型別系統保證所有命令都被處理,新增命令時編譯器會提醒你。

認證分層:讀取免費,交易要簽名

CLOB 模組(最核心的交易模組)內部再做了一層精細的分發:

pub async fn execute(args: ClobArgs, output: OutputFormat,
                     private_key: Option<&str>, signature_type: Option<&str>) -> Result<()> {
    match args.command {
        // 不需要錢包的讀取命令
        ClobCommand::Ok | ClobCommand::Price { .. } | ClobCommand::Book { .. } | ...
            => execute_read(args.command, &output).await,

        // 需要認證的交易命令
        ClobCommand::CreateOrder { .. } | ClobCommand::Cancel { .. } | ...
            => execute_trade(args.command, &output, private_key, signature_type).await,

        // 需要認證的獎勵查詢
        ClobCommand::Rewards { .. } | ...
            => execute_rewards(args.command, &output, private_key, signature_type).await,

        // 帳戶管理
        ClobCommand::ApiKeys | ...
            => execute_account(args.command, &output, private_key, signature_type).await,
    }
}

這個設計意味著:你不需要設定錢包就能瀏覽市場。polymarket markets listpolymarket clob book <token> 完全不需要私鑰。只有當你要下單、查餘額、管理 API key 時才需要認證。

這對 Agent 使用場景特別友好 — AI Agent 可以先無認證地探索所有市場數據,只在需要交易時才提供私鑰。


為什麼用 Rust

技術選型的考量

Cargo.toml 可以看到這個專案的依賴選擇:

[dependencies]
polymarket-client-sdk = { version = "0.4", features = ["gamma", "data", "bridge", "clob", "ctf"] }
alloy = { version = "1.6.3", features = ["providers", "sol-types", "contract", ...] }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tabled = "0.17"
serde_json = "1"
anyhow = "1"

關鍵依賴:

  1. alloy — Rust 生態中最主流的 Ethereum 工具庫(取代了 ethers-rs),處理鏈上交互、交易簽名
  2. clap — Rust 生態中的標準 CLI 框架,derive macro 自動生成參數解析
  3. tokio — 非同步運行時,處理所有 HTTP / RPC 呼叫
  4. tabled — 終端表格渲染
  5. polymarket-client-sdk — Polymarket 自己的 Rust SDK

Rust 的優勢

  1. 單一二進制,零依賴安裝
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
panic = "abort"

這個 release profile 的每一行都在壓縮二進制大小:

  • lto = "thin" — 跨 crate 連結時間優化,消除未使用程式碼
  • codegen-units = 1 — 單一編譯單元,更好的全域優化
  • strip = true — 去除 debug symbol
  • panic = "abort" — panic 時直接 abort 而非 unwind,省去 unwind 表

最終產物是一個自包含的二進制檔,curl | sh 就能安裝。不需要 Node.js runtime、不需要 Python 虛擬環境、不需要 Docker。

  1. 型別安全的命令解析

clap 的 derive macro 讓命令定義和型別檢查合為一體:

#[derive(Subcommand)]
pub enum ClobCommand {
    CreateOrder {
        #[arg(long)]
        token: String,
        #[arg(long)]
        side: CliSide,      // enum, 只接受 buy/sell
        #[arg(long)]
        price: String,
        #[arg(long)]
        size: String,
        #[arg(long, default_value = "GTC")]
        order_type: CliOrderType,  // enum, 只接受 GTC/FOK/GTD/FAK
        #[arg(long)]
        post_only: bool,
    },
    // ...
}

所有參數在進入業務邏輯之前就已經被驗證過。CliSide 只能是 BuySellCliOrderType 只能是 GTC/FOK/GTD/FAK。這些是編譯時的保證,不是運行時的字串比對。

  1. 完整的 Ethereum 工具鏈

Rust 的 alloy 庫提供了完整的 Ethereum 原生操作能力:

pub async fn create_provider(private_key: Option<&str>) -> Result<impl Provider + Clone> {
    let signer = LocalSigner::from_str(&key)?
        .with_chain_id(Some(POLYGON));
    ProviderBuilder::new()
        .wallet(signer)
        .connect(RPC_URL)
        .await
}

直接在 Polygon 上建立 provider、簽名交易、呼叫合約。不需要透過 JavaScript bridge,不需要 Web3 wrapper。

  1. 跨平台編譯

release workflow 展示了 Rust 的交叉編譯能力:

matrix:
  include:
    - target: x86_64-apple-darwin
    - target: aarch64-apple-darwin
    - target: x86_64-unknown-linux-gnu
    - target: aarch64-unknown-linux-gnu
      cross: true

四個平台,一套程式碼。ARM Linux 使用 cross 工具做交叉編譯,其餘平台原生編譯。

Rust 的代價

  1. 編譯速度

12 個直接依賴看起來不多,但 alloytokiopolymarket-client-sdk 各自帶來大量傳遞依賴。從 Cargo.lock 的存在可以推斷完整依賴樹相當龐大。首次 cargo build 可能需要數分鐘。

  1. 字串處理的冗餘

CLI 工具不可避免地要大量處理字串。Rust 的所有權系統讓簡單的字串操作變得囉嗦:

fn normalize_key(key: &str) -> String {
    if key.starts_with("0x") || key.starts_with("0X") {
        key.to_string()
    } else {
        format!("0x{key}")
    }
}

在 Python 或 Go 中,這是一行的事。在 Rust 中需要處理 &str vs String 的轉換。

  1. 非同步的傳染性

幾乎每個 execute() 函式都是 async fn,因為底層的 HTTP 呼叫都是非同步的。即使是簡單的 status 查詢也需要 tokio runtime。shell.rs 中的 Box::pin(shell::run_shell()).await 暗示了非同步遞迴的複雜性。

  1. 錯誤處理的模板程式碼

整個專案使用 anyhow::Result 做錯誤處理,幾乎每個可能失敗的操作都需要 .context("...")

let signer = LocalSigner::from_str(&key)
    .context("Invalid private key")?
    .with_chain_id(Some(POLYGON));
ProviderBuilder::new()
    .wallet(signer)
    .connect(RPC_URL)
    .await
    .context("Failed to connect to Polygon RPC with wallet")

這在 Go 中是 if err != nil { return fmt.Errorf("...: %w", err) },冗餘程度差不多。但 Rust 的 ? 運算子至少比 Go 的三行式優雅一些。


安全模型

私鑰管理的三層優先級

/// Priority: CLI flag > env var > config file.
pub fn resolve_key(cli_flag: Option<&str>) -> (Option<String>, KeySource) {
    if let Some(key) = cli_flag {
        return (Some(key.to_string()), KeySource::Flag);
    }
    if let Ok(key) = std::env::var(ENV_VAR) && !key.is_empty() {
        return (Some(key), KeySource::EnvVar);
    }
    if let Some(config) = load_config() {
        return (Some(config.private_key), KeySource::ConfigFile);
    }
    (None, KeySource::None)
}

三種方式提供私鑰,優先級明確:

  1. --private-key flag — 一次性使用,不落磁碟
  2. POLYMARKET_PRIVATE_KEY 環境變數 — 適合 CI/CD 和腳本
  3. ~/.config/polymarket/config.json — 互動式使用

設定檔的權限保護

pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> {
    let dir = config_dir()?;
    fs::create_dir_all(&dir)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
    }

    // ...

    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = fs::OpenOptions::new()
            .write(true).create(true).truncate(true)
            .mode(0o600)
            .open(&path)?;
        file.write_all(json.as_bytes())?;
    }
}

在 Unix 系統上:

  • 設定目錄權限設為 0o700(只有擁有者可讀寫執行)
  • 設定檔權限設為 0o600(只有擁有者可讀寫)

這是存放私鑰的基本安全措施。#[cfg(unix)] 條件編譯確保這些 Unix-specific 的權限設定不會在 Windows 上編譯失敗。

簽名類型

支援三種簽名方式:

  • proxy(預設)— 使用 Polymarket 的代理錢包系統
  • eoa — 直接用私鑰簽名
  • gnosis-safe — 多簽錢包

代理錢包是 Polymarket 的特色設計 — 使用者的 EOA 授權一個代理地址來執行交易,降低了每次交易都需要直接暴露主私鑰的風險。

下載驗證

安裝腳本和自動更新都實作了 SHA-256 checksum 驗證:

fn verify_checksum(file_path: &str, checksums_file: &str, expected_name: &str) -> Result<()> {
    // 下載 checksums.txt
    // 比對 SHA-256
    if actual_hash != expected_hash {
        bail!("Checksum mismatch! The downloaded binary may have been tampered with.");
    }
}

polymarket upgrade 命令在替換二進制時還做了原子性保護 — 先備份舊版本,新版本安裝成功後才刪除備份:

fs::rename(exe_path, &backup)?;
if let Err(e) = fs::rename(&new_binary, exe_path) {
    let _ = fs::rename(&backup, exe_path); // 失敗時恢復
    return Err(e);
}
let _ = fs::remove_file(&backup);

雙輸出格式:為人類和機器而設計

這可能是整個專案最值得學習的設計決策。

每個命令都支援兩種輸出格式:

# 人類友好的表格(預設)
polymarket markets list --limit 2

# 機器友好的 JSON
polymarket -o json markets list --limit 2

錯誤處理也遵循同樣的雙軌制:

if let Err(e) = run(cli).await {
    match output {
        OutputFormat::Json => {
            println!("{}", serde_json::json!({"error": e.to_string()}));
        }
        OutputFormat::Table => {
            eprintln!("Error: {e}");
        }
    }
    return ExitCode::FAILURE;
}
  • Table 模式:錯誤訊息寫入 stderr
  • JSON 模式:錯誤以 {"error": "..."} 格式寫入 stdout

無論哪種模式,失敗都回傳非零 exit code。

這個設計讓同一個工具可以同時服務兩種使用場景:

# 人類瀏覽
polymarket markets search "bitcoin"

# 腳本處理
polymarket -o json markets list --limit 100 | jq '.[].question'

# Agent 整合
result=$(polymarket -o json clob midpoint $TOKEN_ID)

output/ 目錄下 14 個模組、超過 3,000 行程式碼,全部在做同一件事:為每種資料結構提供 table 和 JSON 兩種渲染路徑。這是大量的重複工作,但也是讓 CLI 真正可用的關鍵。


互動式 Shell

pub async fn run_shell() {
    let mut rl = rustyline::DefaultEditor::new()?;

    loop {
        match rl.readline("polymarket> ") {
            Ok(line) => {
                let args = split_args(line.trim());
                let mut full_args = vec!["polymarket".to_string()];
                full_args.extend(args);

                match crate::Cli::try_parse_from(&full_args) {
                    Ok(cli) => {
                        if let Err(e) = crate::run(cli).await { /* ... */ }
                    }
                    Err(e) => { let _ = e.print(); }
                }
            }
            // ...
        }
    }
}

shell 命令提供了一個互動式 REPL。實作非常巧妙 — 它不重新實作命令邏輯,而是把使用者輸入拼接成完整的 CLI 參數,然後用 Cli::try_parse_from() 重新走一遍 clap 的解析流程。

這意味著 shell 模式和 CLI 模式的行為保證完全一致 — 因為它們共用同一套解析和執行邏輯。


CTF 操作:鏈上合約交互

CTF (Conditional Token Framework) 模組展示了 CLI 如何直接和智能合約互動:

// 將 $10 USDC 拆分為 YES/NO 代幣
let req = SplitPositionRequest::builder()
    .collateral_token(collateral_addr)   // USDC 合約地址
    .parent_collection_id(parent)
    .condition_id(condition_id)          // 市場的 condition ID
    .partition(partition)                // [1, 2] 代表二元結果
    .amount(usdc_amount)                 // 10_000_000 (6 位小數)
    .build();

let resp = client.split_position(&req).await?;

USDC 金額轉換有精確的精度檢查:

fn usdc_to_raw(val: Decimal) -> Result<U256> {
    let raw = val * USDC_DECIMALS; // USDC_DECIMALS = 1_000_000
    anyhow::ensure!(
        raw.fract().is_zero(),
        "Amount {val} exceeds USDC precision (max 6 decimal places)"
    );
    Ok(U256::from(raw_u64))
}

USDC 有 6 位小數精度。如果你輸入 0.0000001(7 位小數),CLI 會在本地就拒絕你,而不是把錯誤的數字送到鏈上。這種「提前失敗」的設計在金融應用中至關重要。


測試策略

整合測試

tests/cli_integration.rs 有 488 行、超過 30 個測試案例,覆蓋:

// 所有頂層命令都在 --help 中出現
fn help_lists_all_top_level_commands() { ... }

// 所有子命令群組的 --help 都列出子命令
fn markets_help_lists_subcommands() { ... }
fn clob_help_lists_subcommands() { ... }

// 缺少必要參數時正確失敗
fn markets_get_requires_id() { ... }
fn clob_book_requires_token() { ... }

// JSON 模式的錯誤輸出是有效的 JSON
fn json_mode_error_is_valid_json_with_error_key() { ... }

// Table 模式的錯誤走 stderr
fn table_mode_error_goes_to_stderr() { ... }

特別值得注意的是,測試會清除環境變數以避免汙染:

fn polymarket() -> Command {
    let mut cmd = Command::cargo_bin("polymarket").unwrap();
    cmd.env_remove("POLYMARKET_PRIVATE_KEY");
    cmd.env_remove("POLYMARKET_SIGNATURE_TYPE");
    cmd
}

單元測試

每個模組都有自己的 #[cfg(test)] mod tests,涵蓋邊界條件:

  • parse_usdc_amount("0.000001") — USDC 最小單位
  • parse_usdc_amount("1.0000001") — 超出精度要拒絕
  • truncate("cafe!", 3) — Unicode 字元計數而非位元組計數
  • format_decimal(dec!(999_999)) — 百萬邊界值

為什麼寫 CLI

這是最核心的問題,也是這個專案真正有趣的地方。

表面的答案:開發者工具

CLI 是開發者和 power user 的自然界面。你可以:

  • 在終端快速查價
  • 寫 shell 腳本做自動化交易
  • cron 定期監控持倉
  • jq 過濾和轉換數據

真正的答案:Agent Interface

這是原文引用點出的核心觀點:

CLIs are super exciting precisely because they are a "legacy" technology, which means AI agents can natively and easily use them, combine them, interact with them via the entire terminal toolkit.

CLI 是 Agent 的原生界面。原因:

  1. 無需瀏覽器自動化

GUI 需要 Selenium、Playwright 這類工具來模擬人類操作。CLI 只需要 subprocess.run()

  1. 結構化輸入輸出

-o json 讓每個命令的輸出都是機器可解析的。Agent 不需要「理解」表格排版,直接解析 JSON。

  1. 可組合性
# Agent 可以自由組合這些命令
polymarket -o json markets search "AI" | \
  jq -r '.[].slug' | \
  xargs -I{} polymarket -o json markets get {} | \
  jq '[.[] | {question, price: .outcomePrices[0]}]'

Unix 管道讓 CLI 天然具備可組合性。Agent 可以把多個命令串成 pipeline,不需要寫額外的膠水代碼。

  1. 零設定成本

安裝就是下載一個二進制檔。Agent 可以用 curl | sh 在幾秒內完成安裝。不需要瀏覽器 extension、不需要 OAuth flow、不需要 API key(大部分讀取操作)。

  1. 完整的操作覆蓋

這個 CLI 不是閹割版。它覆蓋了 Polymarket 的完整功能:

  • 市場瀏覽與搜尋
  • 訂單簿查詢
  • 限價單 / 市價單交易
  • 批量下單
  • CTF 操作(拆分、合併、贖回)
  • 跨鏈 Bridge
  • 排行榜和數據查詢

Agent 用這個 CLI,能做到跟人類用 Web UI 一樣的所有事情。

2026 年的產品設計啟示

這個專案暗示了一個產品設計趨勢:

If you have any kind of product or service think: can agents access and use them?

核心問題不再只是「使用者體驗好不好」,而是「Agent 能不能用你的產品」。檢查清單:

  • 文件是否有 Markdown 格式? Agent 能讀 Markdown,不能讀 Figma 設計稿
  • 是否有 CLI 界面? CLI 是 Agent 的原生操作界面
  • 是否有 MCP Server? MCP 是 AI Agent 的標準工具協議
  • API 輸出是否結構化? JSON > HTML > 截圖
  • 是否需要瀏覽器? 能不用就不用

Polymarket CLI 選擇了最直接的路線:一個輸出 JSON 的 CLI 工具。不需要 MCP adapter、不需要 API wrapper。任何能執行 shell 命令的 Agent 都能立即使用。


技術細節觀察

值得學習的

乾淨的 commands/output 分離

命令邏輯和渲染邏輯完全解耦。如果未來要加 CSV 輸出格式,只需要在 output/ 中加一個分支,不需要動 commands/ 的任何程式碼。

Builder pattern 的一致使用

SDK 的 request 物件全部使用 builder pattern:

let request = PriceHistoryRequest::builder()
    .market(parse_token_id(&token_id)?)
    .time_range(TimeRange::from_interval(Interval::from(interval)))
    .maybe_fidelity(fidelity)
    .build();

.maybe_*() 方法優雅地處理了 Option 類型的參數 — 有值就設定,None 就跳過。

Self-update 機制

polymarket upgrade 命令實作了完整的自我更新:查詢最新版本、下載、驗證 checksum、原子替換二進制。使用者不需要重新跑 brew installcurl | sh

互動式 Shell 重用解析邏輯

Shell 模式不是另一套實作,而是把使用者輸入餵回 Cli::try_parse_from()。一份程式碼,兩種界面。

可以改進的

output/clob.rs 過大

1,467 行的單一檔案,包含超過 30 個 print 函式。每個函式的結構都是 match output { Table => ..., Json => ... }。可以用 macro 或 trait 減少重複。

沒有 --quiet / --verbose 層級

目前只有 table 和 JSON 兩種輸出模式。缺少靜默模式(只輸出 exit code)和詳細模式(加 debug 資訊)。

缺少 shell 模式的 tab 補全

rustyline 支援自定義補全器(completer),但目前的 shell 模式只有歷史記錄,沒有 tab 補全。加上命令補全會讓互動體驗更好。


小結

Polymarket CLI 是一個教科書級的 Rust CLI 專案:

  1. 用 Rust 寫 CLI 的理由充分 — 單一二進制、跨平台、Ethereum 生態的原生支援、型別安全的命令解析
  2. 架構設計清晰 — commands/output 分離、認證分層、雙輸出格式
  3. 安全意識到位 — 私鑰權限保護、下載 checksum 驗證、原子更新
  4. 真正的啟示在於 CLI 作為 Agent Interface — 這不是復古,而是前瞻

2026 年,你的產品能被 Agent 使用嗎?如果答案是否定的,那你可能已經落後了。一個好的 CLI + JSON 輸出,可能比花三個月做的 MCP Server 更實用。

Build. For. Agents.