Skip to content

013Meta/safe-tui

Repository files navigation

safe-tui

CI

Terminal UI for Gnosis Safe multisig management with offline signature passing.

Built with Rust, Ratatui, and Alloy. Connects directly to any EVM chain — no backend, no browser, no extensions.

Install

cargo build --release
cp target/release/safe-tui /usr/local/bin/

Usage

# First run — startup wizard walks you through Safe address,
# chain, optional custom RPC, and signing mode.
safe-tui

# Returning user — wizard re-opens pre-filled with last values;
# Enter through to reconnect, or edit any field to switch.
safe-tui

# Skip the wizard for scripted / known-good runs
safe-tui --safe 0xYourSafeAddress
safe-tui --no-wizard

# Read-only mode (no signing or execution)
safe-tui --readonly

# Ephemeral session — no read or write to disk
safe-tui --ephemeral

# Custom config path
safe-tui --config /path/to/config.toml

CLI Flags

Flag Description
-s, --safe <ADDR> Safe address. Supplying this skips the startup wizard.
-c, --config <PATH> Config file path (default: ~/.safe-tui/config.toml).
--readonly Disable all signing and execution.
--no-wizard Skip the startup wizard and connect using saved config.
--ephemeral Don't read or write the config file. Wizard opens empty; nothing persists on exit.

The wizard is the default landing — it always opens unless --safe or --no-wizard is supplied. Chain and custom RPC are picked from inside the wizard (steps 2 and 3) rather than via CLI flags.

Supported Chains

ethereum, sepolia, polygon, arbitrum, optimism, gnosis, base

Each chain has a primary and fallback public RPC. Step 3 of the wizard accepts an optional custom RPC URL that overrides both.

Config

Config lives at ~/.safe-tui/config.toml. Created automatically on first wizard completion. Conceptually, it's remembered state for the wizard's pre-fill, not a "configuration" the user hand-edits — though the file format is editable if you want to.

safe_address = "0x..."
chain = "ethereum"
# rpc_url = "https://custom-rpc.com"    # optional override
refresh_interval_secs = 15
sigfile_dir = "~/.safe-tui/sigfiles"

# Quick-switch Safes with 1-9 keys
[[saved_safes]]
address = "0xABC..."
chain = "ethereum"
label = "Treasury"

[[saved_safes]]
address = "0xDEF..."
chain = "polygon"
label = "Ops"

Hotkeys

Press ? at any time to open the in-app help overlay.

Normal Mode

Key Action
j / k / Up / Down Navigate transaction queue
s Open sign modal for selected tx
e Execute selected tx (if threshold met)
n New transaction (opens builder modal)
K Open :load_key prompt
r Refresh all on-chain data
Tab / o Cycle panel focus
1-9 Switch to saved Safe at that slot
: Enter command mode
? Open help overlay
q Quit

Sign Modal

When you press s, the sign modal opens showing full transaction details (to, value, data, nonce, operation) for review. Nothing is signed until you confirm.

Key Action
s Confirm and sign with all loaded keys (HW signers route through the hash-confirm modal first)
e Execute (if enough signatures)
Esc Cancel and close modal

Hardware Wallet Hash-Confirmation Modal

Pops automatically before any blind-signing device (Ledger today) is asked to sign. Shows the locally-derived domainHash and safeTxHash — you must visually compare them to what the device displays before proceeding.

Key Action
y Hashes match the device — proceed with the sign request
anything else Cancel — abort the sign

This gate exists because Ledger does not yet clear-sign Gnosis Safe SafeTx fields. Until ERC-7730 metadata for Safe singletons lands upstream, the device shows only those two 32-byte hashes; this modal lets you compare them character-for-character.

Transaction Builder Modal

Opened with n. Tab between fields, Enter to submit.

Key Action
Tab Next field (To / Value / Data)
Enter Submit transaction
Backspace Delete character
Esc Cancel

Wizard Modal

The wizard appears on every launch (unless --safe or --no-wizard is supplied). 4 steps: Safe address → chain → optional custom RPC → load-key vs read-only.

Key Action
Enter / Tab Next step (or finalize on the summary step)
Shift-Tab Previous step
1-9 Pick chain (step 2) or signing mode (step 4)
Esc Cancel — falls back to "no Safe connected" status

Command Mode

Press : to enter command mode (vim-style). Type a command and press Enter.

Key Action
Enter Execute command
Backspace Delete character
Esc Exit command mode

Private keys are masked in the command bar — typing :load_key 0xabc... displays :load_key ****.

Commands

Key Management

Command Description
:load_key <hex> Load a private key as a Safe owner. Key is masked in the UI.
:load_key_env <VAR> Load a private key from an environment variable. The env var is wiped from the process after loading.
:load_ledger [live|legacy] [n] Connect a Ledger and load account n (default 0) on the chosen path style. live = Ledger Live (m/44'/60'/{n}'/0/0), legacy = m/44'/60'/0'/{n}.
:list_ledger Probe the Ledger and show the first 5 accounts on each path style. Useful before :load_ledger if you can't remember which derivation holds your owner key.
:unload <address> Unload a previously loaded key.

Creating Transactions

All transaction commands create a pending Safe tx in the local queue. Nothing goes on-chain until you sign and execute.

Command Description
:send_ether <to> <amount_eth> Send native token (ETH/POL/xDAI). Amount in human units (e.g. 1.5).
:send_erc20 <token> <to> <amount> ERC-20 transfer. Encodes transfer(to, amount) calldata. Amount in token units (18 decimals).
:send_custom <to> <value_wei> <hex_data> Raw transaction with arbitrary calldata. Value in wei.

Safe Management

These create Safe self-transactions (the Safe calls itself to modify its own state).

Command Description
:add_owner <address> [--threshold N] Propose adding a new owner. Optionally set new threshold.
:remove_owner <address> [--threshold N] Propose removing an owner. Automatically resolves prevOwner from the linked list. Threshold capped at owners - 1.
:change_threshold <N> Propose a new signature threshold. Rejected client-side if N == 0 or N > owner_count.
:modules List enabled modules.
:enable_module <address> Propose enabling a module.
:disable_module <address> Propose disabling a module. Auto-resolves prevModule from the linked list.
:batch <nonce1> <nonce2> [...] Combine existing pending txs at the given nonces into one MultiSend Safe tx (DelegateCall to MultiSendCallOnly). Originals stay in queue; only one can execute.

AI (Natural Language)

Requires ANTHROPIC_API_KEY in env. Optional SAFE_CLI_MODEL overrides the default claude-sonnet-4-6.

Command Description
:ask <prompt> Translate a natural-language request into one Safe command. Example: :ask send 0.25 eth to 0xabc.... The model sees the live Safe context (owners, threshold, nonce, loaded signer addresses — never private keys) and picks the right tool, or asks a clarifying question if ambiguous. The output is a pending tx — signing and execution still require s and e. Rolling conversation memory: up to 6 sequential turns share context; the 7th turn starts fresh.
:clear_ask Wipe the rolling :ask memory manually.

Signing & Execution

Command Description
:simulate <nonce> Dry-run a pending tx via eth_call. Shows whether it would succeed or revert.

Or use hotkeys: s to sign, e to execute.

Signature Files (Offline Signing)

Sigfiles let you pass signatures between air-gapped machines or between co-signers who aren't online at the same time.

Command Description
:export_sig <nonce> Export a pending tx and its collected signatures to ~/.safe-tui/sigfiles/tx_<nonce>.sigfile. Auto-suffixes -2, -3, … if a file already exists at that path so prior exports are never clobbered.
:import_sig <path> Import a sigfile. Path must canonicalize under the configured sigfile_dir (or be an absolute path the user explicitly typed). Verifies hash, chain, Safe address, and re-recovers every signature against the local owner set.
:export_link <nonce> Encode a pending tx + its signatures into a safe-tx://v2/... URL. The URL goes in the status bar and is also written to tx_<nonce>.link (auto-suffixed on collision).
:import_link <url> Same defenses as :import_sig plus a 16 KB decompressed / 8 KB encoded size cap to defeat zip-bomb URLs.
:verify <url> Inspect a safe-tx:// link without merging into the queue — opens the verify modal showing what the link claims to sign.

TX Service Integration

Interact with the Safe Transaction Service (safe.global) to share transactions with the Safe web UI and other signers.

Command Description
:propose <nonce> Push a signed pending tx to the TX Service. Requires at least one signature.
:pull Fetch all pending (unexecuted) transactions from the TX Service and merge into local state. Each fetched tx has its safeTxHash re-derived locally and any mismatch causes the tx to be rejected; each confirmation is recovered against the local hash and the recovered signer must be a current owner.

Navigation & Info

Command Description
:wizard / :setup Re-open the startup wizard (pre-filled with current config).
:switch <address> Switch to a different Safe address on the current chain.
:chain <name> Switch chain (reconnects with the current Safe address). Run without args to list available chains.
:info Show Safe details: address, chain, threshold, owner count, nonce.
:help Open the help overlay.
:quit / :q Quit the application.

Signing Flow

Local key, single-machine

  1. Load a key: :load_key 0x... or :load_key_env PRIVATE_KEY
  2. Create a transaction: :send_ether 0xRecipient 1.0 (or any tx command)
  3. Review and sign: Press s to open the sign modal. Review to/value/data/nonce. Press s again to confirm.
  4. Collect threshold: Repeat with other signers, or import their signatures via :import_sig / :import_link.
  5. Execute: When the tx has enough signatures (status shows READY), press e to broadcast on-chain.

Hardware wallet (Ledger)

  1. Plug in the Ledger, unlock, open the Ethereum app (≥ v1.6.0).
  2. :list_ledger to see the first 5 accounts on each path style; identify the one that matches your owner address.
  3. :load_ledger live 0 (or whichever style/index has your owner).
  4. Build a tx as usual.
  5. Press s → review modal → s to confirm. The hash-confirmation modal pops with domainHash and safeTxHash.
  6. Compare those two 32-byte values to what the device shows. If they match, press y. If not, press anything else to abort.
  7. The device prompts. Approve.

Air-gapped co-signing

  1. Each owner runs safe-tui on their own machine and loads their key (local hex, env, or Ledger).
  2. The first signer creates the tx and runs :export_link <nonce>.
  3. The resulting safe-tx://v2/... URL is shared via any channel (Slack, email, USB stick).
  4. Each co-signer runs :import_link <url> (verifies the hash + chain + Safe + every signature locally), s to add their signature, then :export_link <nonce> to forward the now-richer link.
  5. Once threshold is met, an executor (any address with gas — doesn't have to be an owner) imports the fully-signed link, loads a local hot key with :load_key, and presses e to broadcast.

The signing machines never need internet.

Architecture

src/
  main.rs              CLI entry point (clap)
  app.rs               Event loop, state machine, command dispatch
  config.rs            TOML config persistence
  hotkeys.rs           Context-aware key mapping
  safe/
    client.rs          Alloy provider, on-chain reads, signing, execution
    contracts.rs       ABI definitions (Safe v1.3.0, ERC-20, ERC-721, MultiSend)
    signers/
      mod.rs           SafeSigner trait + SignerCapability
      local.rs         Hex-key signer (PrivateKeySigner wrapper)
      ledger.rs        Ledger HW signer (alloy-signer-ledger)
    signing.rs         EIP-712 typed data signing for SafeTx
    sigfile.rs         Offline signature import/export (.sigfile + safe-tx:// URL)
    networks.rs        Chain configs (RPC, TX Service, contract addresses)
    tx_service.rs      Safe Transaction Service REST client
    types.rs           Core types (SafeTxData, PendingTx, SafeSignature, etc.)
    abi.rs             ABI encoding helpers
    multisend.rs       MultiSend batching
    util.rs            EIP-55 strict address parser, unique-path resolver
    decoder.rs         Calldata → human-readable summary (best-effort)
  ui/
    layout.rs          Main TUI layout
    header.rs          Safe address + chain header
    info.rs            Safe info panel (threshold, owners, nonce)
    commands.rs        Always-visible commands cheatsheet
    queue.rs           Pending transaction queue panel
    owners.rs          Owner list panel
    balances.rs        Token balances panel
    history.rs         Executed tx history panel
    command.rs         Vim-style command bar (with key masking)
    help.rs            Help overlay (press ?)
    tx_modal.rs        Transaction builder modal
    sign_modal.rs      Sign review + confirm modal
    verify_modal.rs    Read-only inspector for safe-tx:// links and probe results
    exec_picker_modal.rs  Multi-key executor picker
    wizard_modal.rs    First-run / re-run setup wizard
    hw_confirm_modal.rs   Mandatory hash-comparison modal for blind-signing HW

Security Notes

  • Private keys live in memory only. Never written to disk. Zeroized on :unload, on command-bar submission, and (env path) the live env var is also wiped.
  • EIP-55 strict on every user-supplied address. A typo'd lowercase address is rejected, not silently accepted as a different valid address.
  • HW blind-sign gate. Ledger is asked to sign only after the user visually confirms the local domainHash and safeTxHash against the device screen. Ledger Trezor / Keystone — see HARDWARE_WALLET_PLAN.md.
  • TX-service hash gate. Every pending tx fetched from the Safe Transaction Service has its safeTxHash re-derived locally; mismatched txs are dropped, and confirmations whose recovered signer doesn't match the claimed owner (or isn't a current owner) are dropped.
  • Sigfile / link transport. Re-derives hash + checks chain + checks Safe address + recovers every signature against the local owner set. 16 KB decompressed / 8 KB encoded cap; zip-bomb defended.
  • Read-only mode (--readonly) disables all tx-creation commands (:send_*, owner / threshold / module ops, the tx modal) and signing/execution.
  • Strict v-byte validation. Signature recovery rejects any v outside {0, 1, 27, 28} instead of normalizing arbitrary bytes.
  • Pre-flight chain validation. eth_chainId + eth_getCode checks before every connect — surfaces "wrong chain" / "no contract here" with a precise message instead of a generic RPC error.
  • :ask / LLM isolation. The AskContext sent to Anthropic contains only the Safe address, chain, threshold, owner addresses, nonce, and the addresses of loaded signers (via HashMap::keys()PrivateKeySigner values are never iterated). The ai module does not import PrivateKeySigner, so the type system rejects any attempt to include key material. A separate defensive check refuses prompts containing a 64-hex-char string. Unit tests in src/ai.rs pin both invariants.
  • --ephemeral mode runs entirely in memory: no config read, no config write, no on-disk trace.

License

MIT

About

Rust TUI for Gnosis Safe multisig — offline signing, Ledger support, air-gapped sigfile/safe-tx:// transport, natural-language command agent

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors