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.
cargo build --release
cp target/release/safe-tui /usr/local/bin/# 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| 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.
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 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"Press ? at any time to open the in-app help overlay.
| 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 |
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 |
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.
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 |
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 |
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 ****.
| 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. |
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. |
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. |
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. |
| 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.
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. |
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. |
| 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. |
- Load a key:
:load_key 0x...or:load_key_env PRIVATE_KEY - Create a transaction:
:send_ether 0xRecipient 1.0(or any tx command) - Review and sign: Press
sto open the sign modal. Review to/value/data/nonce. Presssagain to confirm. - Collect threshold: Repeat with other signers, or import their signatures via
:import_sig/:import_link. - Execute: When the tx has enough signatures (status shows
READY), presseto broadcast on-chain.
- Plug in the Ledger, unlock, open the Ethereum app (≥ v1.6.0).
:list_ledgerto see the first 5 accounts on each path style; identify the one that matches your owner address.:load_ledger live 0(or whichever style/index has your owner).- Build a tx as usual.
- Press
s→ review modal →sto confirm. The hash-confirmation modal pops withdomainHashandsafeTxHash. - Compare those two 32-byte values to what the device shows. If they match, press
y. If not, press anything else to abort. - The device prompts. Approve.
- Each owner runs
safe-tuion their own machine and loads their key (local hex, env, or Ledger). - The first signer creates the tx and runs
:export_link <nonce>. - The resulting
safe-tx://v2/...URL is shared via any channel (Slack, email, USB stick). - Each co-signer runs
:import_link <url>(verifies the hash + chain + Safe + every signature locally),sto add their signature, then:export_link <nonce>to forward the now-richer link. - 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 presseseto broadcast.
The signing machines never need internet.
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
- 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
domainHashandsafeTxHashagainst the device screen. Ledger Trezor / Keystone — seeHARDWARE_WALLET_PLAN.md. - TX-service hash gate. Every pending tx fetched from the Safe Transaction Service has its
safeTxHashre-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_getCodechecks before every connect — surfaces "wrong chain" / "no contract here" with a precise message instead of a generic RPC error. :ask/ LLM isolation. TheAskContextsent to Anthropic contains only the Safe address, chain, threshold, owner addresses, nonce, and the addresses of loaded signers (viaHashMap::keys()—PrivateKeySignervalues are never iterated). Theaimodule does not importPrivateKeySigner, 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 insrc/ai.rspin both invariants.--ephemeralmode runs entirely in memory: no config read, no config write, no on-disk trace.
MIT