Overview
Paypunk Project
This is experimental software and should not be used with real funds
The Goal
Privacy is under threat. The tools people use to transact privately are fragmented, hard to integrate, and locked behind walled gardens. Every privacy coin has its own wallet, its own architecture, its own signing model — and none of them talk to each other.
Paypunk is building toward a different future: one wallet framework, every privacy protocol, fully extensible.
Imagine a wallet where:
- Your Zcash, Monero, Ethereum, Railgun, Bitcoin live side by side, managed from a single interface
- The same backend powers a terminal UI, a desktop app, a mobile app, and an agent SDK — because the architecture is frontend-agnostic from day one
- Your keys never touch an internet-connected device - due to architecture air-gapped signing via QR codes is a first-class flow
- Agents can transact on your behalf via a scriptable CLI and IPC API, with human approval for sensitive operations
- Swapping between ZEC and ETH happens in-wallet, routed through decentralized protocols, without surrendering custody
- Host your wallet on the server approve on your phone
- New privacy protocols — Aleo, Aztec, Railgun — plug in by implementing a couple of traits, not by forking the wallet
- As new decentralized cross chain swap mechanisms come up they are integrated (eg near-intents / thorchain etc.)
The architecture is functional. The trait system works. Zcash and Ethereum are proven. The foundation is solid. What’s missing is the work to harden it, polish it, and extend it to the protocols that matter.
We need help
This is an ambitious project and it’s early. If any of this resonates, there’s meaningful work for you here:
- Rust developers — implement new chain protocols, harden existing ones, improve the IPC layer
- Security researchers — review the threat model, audit the crypto, find the gaps before they’re exploited
- Tauri/frontend developers — build the desktop and mobile UIs that will replace the throwaway TUI
- Zcash protocol experts — help with Sapling/transparent support, ZSAs, and PCZT edge cases
- Privacy advocates — help shape the product, test the flows, and make sure the UX serves real people
Every issue is tracked at github.com/blockhackersio/paypunk/issues — architecture improvements, security hardening, new chain integrations, and UI work are all written up and ready to be picked up.
Read the architecture docs, the contributing guide, and the add-a-protocol guide. Pick an issue. Open a PR.
Privacy shouldn’t be hard. Let’s build the tools that make it easy.
Warning: This project is a work in progress. The architecture is designed for extensibility, but only simple transfers (send/receive) are currently supported. Several features — DB encryption, interactive password prompts, environment-variable passphrase input — are planned but not yet implemented. Expect breaking changes.
What is Paypunk?
Paypunk began as an entry for the Zcash Hackathon — an opportunity to build the privacy wallet I’d been wanting to make for years. But the goal was never just a wallet. It’s an extensible framework for building privacy-preserving crypto wallets, designed so that adding new chains (Monero, Bitcoin, and beyond) is a lighter lift than starting from scratch.
Two architectural decisions drive everything else:
- Signing/wallet separation — Keys live in a separate process (
keypunkd) or on an entirely air-gapped device (the mobile signer app). The wallet daemon never holds key material. This makes offline signing, hardware wallets, and multi-signature flows natural extensions rather than bolted-on features. - Multi-token by design — Chain-specific logic is isolated behind
ProtocolandSignerProtocoltraits. Zcash and Ethereum are the first two implementations; adding a new chain means implementing those traits, not rearchitecting the wallet.
The IPC layer (Unix sockets + tactix actors) means frontends can be built in any technology — the current TUI is a throwaway first draft. The same backend serves a CLI for scripting, a TUI for interactive use, a web bridge for QR-based signing, and future desktop/mobile apps. The transport is designed to be swappable — the UnixSocketTransport encapsulates all I/O behind a framed read/write interface, so a TCP or TLS transport could be added for remote/web deployment without changing the actor code. Sensitive payloads (passwords, mnemonics) are end-to-end encrypted at the application layer using X25519 + AES-256-GCM before being sent over IPC, and every message is authenticated with a per-message Blake2b MAC derived from an X25519 key exchange.
Think of this not as “a Zcash wallet with a TUI and an offline signer” but as a scriptable, privacy-first wallet framework ready to extend to whatever protocols matter next.
Architecture
Layered, multi-process design:
types— Chain-agnostic domain types (Address,Amount,Balance,Transfer,Intent,Protocol/SignerProtocoltraits, etc.). No chain-specific logic.config— TOML-based configuration with environment variable overrides (socket paths, data directory, RPC endpoints, network selection).api— Chain-agnostic library. Dispatches to the appropriate chain backend byProtocolId(Zcash, Ethereum). Hides IPC and actor details from consumers.paypunkd— App daemon (library crate, launched viapaypunk paypunkd). Hosts thePaypunkdactor, usecases, service orchestration, chain backend injection.keypunkd— Key daemon (library crate, launched viapaypunk keypunkd). Hosts theKeypunkdactor. Seed generation, signing, proving. Designed to run as a separate system user (deployment concern, not enforced by code).ipc— Tactix actor sender for interprocess communication. Transport-agnostic framing (currently Unix sockets; TCP/TLS swappable). Per-message X25519 + Blake2b MAC authentication. Sensitive payloads encrypted at the application layer (X25519 + AES-256-GCM). Carries opaque byte payloads; serialization (postcard) is done by callers.protocols/{zcash,ethereum}— Chain-specific implementations of theProtocolandSignerProtocoltraits frompaypunk-types.cli— Command-line interface binary (paypunk). Usesapifor scripting and automation. Also launches daemons and the TUI.tui— Terminal-based interactive UI (ratatui). Library crate consumed by the CLI, also builds as a standalone binary.bridge— WebSocket/HTTP relay between a local IPC client and a browser, for air-gapped QR-based signing.signer— Tauri v2 mobile app for offline air-gapped signing (separate build, excluded from workspace).ping/pong— Diagnostic IPC round-trip test pair.
Process Model
Three processes with a strict security boundary:
- paypunk — CLI/TUI binary. Connects to paypunkd via the
apilibrary. Never touches key material directly. - paypunkd — Manages addresses, chain sync, balance tracking, and transfer construction. Delegates signing to keypunkd. Never holds key material.
- keypunkd — Holds decrypted keys in protected memory. Accepts sign/prove requests from any process that completes the X25519 IPC handshake, never exposes raw key material.
Privacy
- Zcash Orchard shielded pool and Ethereum support
- Seed encrypted at rest with Argon2id-derived key (AES-256-GCM)
- Wallet state database is currently plaintext (
paypunkd.db); encryption at rest is planned
Installation
From GitHub
cargo install --locked --git https://github.com/blockhackersio/paypunk
From source
git clone https://github.com/blockhackersio/paypunk.git
cd paypunk
cargo install --locked --path cli
The paypunk binary is installed to ~/.cargo/bin/paypunk.
Getting started
The paypunk binary auto-launches both daemons and the TUI when run with no subcommand. Individual subcommands are also available:
paypunk # auto-launch keypunkd + paypunkd + TUI
paypunk keypunkd # launch key daemon only
paypunk paypunkd # launch app daemon only
paypunk tui # launch TUI only (daemons must be running)
paypunk generate-seed -p <password> # CLI: generate a new wallet
paypunk get-balance --protocol zcash # CLI: check balance
Running the TUI
The simplest way — auto-launches both daemons and opens the TUI:
paypunk
To run the TUI against already-running daemons (e.g. daemons started separately or on another machine):
paypunk tui
The TUI can also connect to an offline signer instead of a local keypunkd:
paypunk tui --signer
Keybindings within the TUI:
| Key | Action |
|---|---|
? | Help overlay (context-sensitive) |
Enter | Select / confirm |
Esc | Back / cancel |
q | Quit |
s | Send |
o | Receive |
a | Add account |
r | Refresh |
c | Copy to clipboard |
Networks
Paypunk supports Zcash regtest, testnet, and mainnet. The network is selected via the --zcash-network flag or the PAYPUNK_ZCASH_NETWORK env var. Each network uses its own data directory and default lightwalletd endpoint:
| Network | Lightwalletd default | Data directory |
|---|---|---|
regtest | http://127.0.0.1:9067 (local) | ~/.local/share/paypunk/regtest/ |
testnet | https://testnet.zec.rocks:443 | ~/.local/share/paypunk/testnet/ |
mainnet | https://zec.rocks:443 | ~/.local/share/paypunk/mainnet/ |
Regtest (local development)
Requires a local zcashd + lightwalletd running on port 9067. See support/zcash/README.md for a Docker-based regtest setup.
# Start the regtest stack
cd support/zcash && make up
# Run paypunk against regtest (default)
paypunk --zcash-network regtest
# Or via env var
PAYPUNK_ZCASH_NETWORK=regtest paypunk
To fund your wallet in regtest, mine blocks and shield coinbase to your wallet’s address:
cd support/zcash
make fund UA=<your-orchard-ua>
Mainnet
Connects to a public lightwalletd endpoint by default. Use a custom endpoint for better privacy or reliability:
# Using the default public endpoint (https://zec.rocks:443)
paypunk --zcash-network mainnet
# Using a custom lightwalletd
paypunk --zcash-network mainnet --lightwalletd-host https://my-lwd.example.com:443
# Or via env vars
PAYPUNK_ZCASH_NETWORK=mainnet PAYPUNK_LIGHTWALLETD_HOST=https://my-lwd.example.com:443 paypunk
Birthday block
When restoring a mainnet wallet that has prior activity, provide a birthday block height so the initial sync starts from that block instead of scanning from genesis (or auto-fetching the chain tip, which skips historical blocks entirely):
paypunk restore-seed -m "word1 ... word12" -p <password> --zcash-network mainnet --birthday-height 1234567
If omitted on mainnet, the wallet auto-fetches the current chain tip as the birthday — fine for fresh wallets, but existing funds below that height will not be detected. The stored birthday is consulted by create_account, register_signer, and bulk_derive_accounts before falling back to the tip. The background sync loop also falls back to the minimum account birthday when the wallet DB has no scanned blocks yet, so historical scanning will eventually occur even if the initial SyncNewAccount is missed.
When using the devenv setup script with a .mnemonic file (mainnet mode), pass the birthday block as the first argument:
devenv shell -- run setup 1234567
The script will warn and prompt you if a mainnet wallet is restored without a birthday block.
Ethereum
Ethereum uses an RPC URL (JSON-RPC over HTTP). The default points to a local node (http://127.0.0.1:8545); override it for mainnet or testnet:
# Local anvil/hardhat node (see support/ethereum/README.md)
paypunk --ethereum-rpc-url http://127.0.0.1:8545
# Mainnet (via your own node or provider)
PAYPUNK_ETHEREUM_RPC_URL=https://mainnet.infura.io/v3/<key> paypunk
# Sepolia testnet
PAYPUNK_ETHEREUM_RPC_URL=https://sepolia.infura.io/v3/<key> paypunk
Configuration file
All defaults can be overridden in ~/.config/paypunk/config.toml. Generate a template with:
paypunk # creates the config file on first run if it doesn't exist
See config/src/lib.rs for all available fields and env var overrides.
Offline signer
Paypunk supports air-gapped signing via two mechanisms: a QR bridge (desktop-to-mobile) and a Tauri mobile app.
QR bridge (desktop)
In offline signer mode, paypunk spawns a WebSocket/HTTP bridge instead of keypunkd. The bridge relays signing requests to a browser (or the mobile signer app) via QR codes, keeping key material on the air-gapped device.
# Run the TUI in signer mode — spawns bridge + paypunkd, then launches TUI
paypunk --signer
# Or via env var / config
PAYPUNK_OFFLINE_SIGNER=true paypunk
# Or set it in config.toml:
# offline_signer = true
To run the bridge manually (e.g. on a separate machine):
# Start the bridge on a custom port and socket
paypunk bridge --port 12345 --socket-path /tmp/keypunkd.sock
# Then start paypunkd pointing at the bridge socket
paypunk paypunkd --keypunkd-socket /tmp/keypunkd.sock
# Then launch the TUI
paypunk tui --signer
The bridge serves an HTML page at http://0.0.0.0:12345/ and a WebSocket endpoint at ws://0.0.0.0:12345/ws. Open the page in a browser on the signing device to scan/display QR codes.
Mobile signer app (Tauri v2)
The signer/ directory contains a Tauri v2 mobile app for Android that handles QR-based signing on a phone. It wraps the same Keypunk signing logic as keypunkd but runs entirely on-device — keys never leave the phone.
See signer/README.md for build and installation instructions.
Signing flow:
- Wallet (desktop) constructs a transaction and encodes it as QR codes
- User scans the QR with the signer app (or the bridge web page)
- Signer app previews the transaction (recipient, amount, fee)
- User approves — signer signs with the on-device seed
- Signed artifact is displayed as QR codes
- User scans the result back into the wallet, which broadcasts it
Roadmap
- DB encryption from signer password entropy — encrypt
paypunkd.dbat rest using key material derived from the signer password, with separate compartmentalization from seed encryption - Tauri Desktop UI — replace the throwaway TUI with a proper desktop application using the same IPC backend
- Tauri Mobile UI — full mobile wallet experience (the signer app is the first step; a full mobile wallet follows)
- Transparent / Sapling addresses — extend Zcash support beyond Orchard to include Sapling and transparent pools
- Zcash / Ethereum hardening — production-grade error handling, edge cases, replay protection, and test coverage for existing chains
- Cross-chain asset swaps — near-intent-level swaps between assets (e.g. ZEC ↔ ETH) using the
Intentenum pattern - ZSAs — Zcash Shielded Assets support
- Further privacy token integrations — Monero, Railgun, Aleo, Aztec tokens, and other privacy-preserving protocols
Architecture
Note: This is a work in progress. The architecture is designed for extensibility, but only simple transfers (send/receive) are currently implemented. Some features described here (DB encryption, fee estimation) are planned but not yet built.
Design Principles
- Signing/wallet separation — Keys live in a separate process (
keypunkd) or on an air-gapped device. The wallet daemon never holds key material. - Multi-token by design — Chain-specific logic is isolated behind
ProtocolandSignerProtocoltraits. Adding a chain means implementing traits, not rearchitecting. - Frontend agnostic — The IPC layer means any frontend technology can connect. The TUI is a first draft; future frontends (Tauri, web, agent SDKs) use the same backend.
- Referential transparency — Local and remote actors share the same
Recipient<IpcMessage>type, so in-process tests and cross-process production use identical code paths.
Process Model
┌────────────────────────────────────────────────────────────────┐
│ paypunk (CLI/TUI) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────────┐ │
│ │ TUI │ │ CLI │ │ API Client (paypunk-api) │ │
│ │ (ratatui)│ │ (clap) │ │ Client::connect(socket) │ │
│ └────┬─────┘ └────┬─────┘ └──────────┬───────────────────┘ │
│ │ │ │ │
│ └─────────────┴───────────────────┘ │
│ │ IpcSender │
└─────────────────────┼──────────────────────────────────────────┘
│ Unix socket
│ (X25519 handshake + Blake2b MAC)
▼
┌────────────────────────────────────────────────────────────────┐
│ paypunkd (app daemon) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ Paypunkd │ │ Protocol │ │ SQLite DB │ │
│ │ actor │──│ Service │ │ (paypunkd.db) │ │
│ │ (tactix) │ │ (HashMap) │ │ │ │
│ └──────┬──────┘ └──┬───┬───┬──┘ └──────────────────────┘ │
│ │ │ │ │ │
│ │ ┌──────┘ │ └──────┐ │
│ │ ▼ ▼ ▼ │
│ ┌──────┴─────────────────────────────────────────────────┐ │
│ │ ZcashProtocol EthereumProtocol (future chains) │ │
│ │ (Protocol trait) (Protocol trait) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ IpcSender (to keypunkd) │
└─────────┼──────────────────────────────────────────────────────┘
│ Unix socket
│ (X25519 handshake + Blake2b MAC)
▼
┌────────────────────────────────────────────────────────────────┐
│ keypunkd (key daemon) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Keypunkd │ │ Signer │ │ Seed Store │ │
│ │ actor │──│ Protocol │ │ (seed.enc) │ │
│ │ (tactix) │ │ Service │ │ Argon2id + AES-GCM │ │
│ └─────────────┘ └──┬───┬───────┘ └──────────────────────┘ │
│ │ │ │
│ ┌───────┘ └───────┐ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ZcashSigner │ │ EthereumSigner │ │
│ │ Protocol │ │ Protocol │ │
│ │ (SignerProtocol) │ │ (SignerProtocol) │ │
│ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Crate Dependency Graph
paypunk-types
/ \
paypunk-ipc paypunk-config
| |
paypunk-api |
/ \ |
paypunkd paypunk-tui |
/ \ | |
/ \ paypunk (CLI) ──/
protocols keypunkd
/ \ |
zcash eth |
|
paypunk-bridge
Actor Message Flow
Two-phase signing (submit + approve)
User CLI/TUI paypunkd keypunkd
│ │ │ │
│ submit_intent │ │ │
├────────────────────>│ SubmitIntent │ │
│ ├────────────────────>│ │
│ │ │ build(intent) │
│ │ │ → raw_artifact │
│ │ │ preview_artifact │
│ │ ├────────────────────>│
│ │ │ │ parse_artifact
│ │ │ │ → summary
│ │ │ ArtifactPreview │
│ │ │<────────────────────┤
│ │ SignablePreview │ │
│ │<────────────────────┤ │
│ show preview │ │ │
│ enter password │ │ │
├────────────────────>│ approve_signature │ │
│ ├────────────────────>│ │
│ │ │ authorize_artifact │
│ │ ├────────────────────>│
│ │ │ │ decrypt seed
│ │ │ │ sign artifact
│ │ │ SignatureApproved │
│ │ │<────────────────────┤
│ │ │ finalize + broadcast│
│ │ tx_hash │ │
│ │<────────────────────┤ │
│ show result │ │ │
│<────────────────────┤ │ │
Unlock flow (first time)
User CLI/TUI paypunkd keypunkd
│ │ │ │
│ unlock │ │ │
├────────────>│ Unlock │ │
│ ├──────────────>│ │
│ │ │ ensure DB exists │
│ │ │ run migrations │
│ │ │ SELECT accounts │
│ │ │ (none found) │
│ │ │ bulk_export_keys │
│ │ ├────────────────────>│
│ │ │ │ decrypt seed
│ │ │ │ derive 30 keys/protocol
│ │ │ viewing keys │
│ │ │<────────────────────┤
│ │ │ INSERT pre_derived │
│ │ │ derive addresses │
│ │ │ INSERT accounts │
│ │ │ sync_account │
│ │ UnlockSuccess│ │
│ │<──────────────┤ │
│ show home │ │ │
│<────────────┤ │ │
IPC Layer
The paypunk-ipc crate provides:
IpcSender— tactix actor that connects to a Unix socket, performs the X25519 handshake, and sends authenticated messagesIpcReceiver— accepts connections, performs the server-side handshake, verifies MACs, dispatches to a handler actorUnixSocketTransport— 4-byte LE length-prefixed framing overUnixStreamIpcMessage— tactix message carrying opaqueVec<u8>payload + sender public key
The crate carries raw bytes — serialization (postcard) is done by callers. This keeps the IPC layer chain-agnostic.
Wire protocol
| Byte | Message type | Payload |
|---|---|---|
0x00 | MSG_GET_PUBLIC_KEY | none |
0x01 | MSG_PUBLIC_KEY | 32-byte X25519 public key |
0x02 | MSG_REGISTER_CLIENT | 32-byte client public key |
0x03 | MSG_REGISTER_CLIENT_ACK | none |
0x04 | MSG_APPLICATION | [postcard bytes][32-byte Blake2b MAC] |
See ADR-001 for the authentication design.
Trait System
Protocol (wallet side, in paypunkd)
15 required methods + 9 optional with defaults. Required: build, finalize, broadcast, get_balance, validate_address, metadata getters, derive_address_from_viewing_key. Optional: sync, history, fee estimation.
SignerProtocol (signer side, in keypunkd)
3 required methods, no defaults: export_viewing, parse_artifact, sign.
Registration
paypunkd—ProtocolService::register(Box<dyn Protocol>)inrun.rskeypunkd—ProtocolService::register(ProtocolId, Box<dyn SignerProtocol>)inrun.rs
Both registries are HashMap-based and require no changes when adding a chain — just call register.
Data Storage
keypunkd
| File | Format | Encryption |
|---|---|---|
{data_dir}/seed.enc | [salt(16B)][nonce(12B)][AES-256-GCM ciphertext] | Argon2id key derivation + AES-256-GCM |
{data_dir}/seed.mnemonic.enc | Same format | Same encryption |
Atomic writes via .tmp + rename.
paypunkd
| File | Format | Encryption |
|---|---|---|
{data_dir}/paypunkd.db | SQLite (rusqlite, bundled) | Plaintext (encryption planned) |
{data_dir}/.wallet_initialized | Marker file | None |
Database tables: accounts, pre_derived_keys, address_book, settings, signer_state, _migrations.
Background Sync
paypunkd spawns a background sync loop on startup that sends Sync messages to the Zcash ScanActor every 10 seconds. The ScanActor fetches compact blocks from lightwalletd via gRPC and feeds them to the WalletDbActor in 20-block chunks. When the wallet DB has no scanned blocks yet (chain_tip == 0), the Sync handler falls back to the minimum account birthday (via GetMinBirthday) so historical scanning starts from the account birthday rather than silently no-oping.
Ethereum has no background sync — it queries the RPC node on demand.
Testing
The tests/ workspace crate wires up the full actor chain (keypunkd + paypunkd) in-memory using IpcSender::with_recipient, which allows testing the complete IPC message flow without Unix sockets. See tests/tests/integration_test.rs and tests/tests/pczt_test.rs.
See also
- CONTEXT.md — domain glossary and terminology
- ADD_PROTOCOL.md — guide for adding new chains
- SECURITY.md — threat model and security boundaries
- ADR-001 — IPC authentication design
CLI Reference
Note: This is a work in progress. Only simple transfers (send/receive) are currently supported. Some subcommands may be incomplete or have placeholder behavior. Passwords are accepted via
--passwordflag only (no interactive prompt yet).
The paypunk binary is the single entry point for all wallet operations. It auto-launches daemons when needed and tears them down on exit.
Usage
paypunk [OPTIONS] [COMMAND]
Run with no subcommand to auto-launch daemons + TUI.
Global options
| Flag | Short | Default | Description |
|---|---|---|---|
--socket-path | -s | /tmp/paypunkd.sock | paypunkd IPC socket path |
--signer | false | Enable offline signer mode (spawns bridge instead of keypunkd) | |
--zcash-network | regtest | Zcash network: regtest, testnet, or mainnet | |
--lightwalletd-host | Network default | Override lightwalletd endpoint | |
--data-dir | ~/.local/share/paypunk/<network>/ | Override data directory |
Environment variables
All config fields can be overridden via environment variables:
| Env var | Config field |
|---|---|
PAYPUNK_SOCKET_PATH | paypunkd_socket_path |
KEYPUNKD_SOCKET_PATH | keypunkd_socket_path |
PAYPUNK_DATA_DIR | data_dir |
PAYPUNK_CONFIG_DIR | config_dir |
PAYPUNK_ETHEREUM_RPC_URL | ethereum_rpc_url |
PAYPUNK_LIGHTWALLETD_HOST | lightwalletd_host |
PAYPUNK_ZCASH_NETWORK | zcash_network |
PAYPUNK_BRIDGE_SOCKET_PATH | bridge_socket_path |
PAYPUNK_OFFLINE_SIGNER | offline_signer ("true" or "1") |
Configuration file
Located at ~/.config/paypunk/config.toml. Generated on first run if missing.
paypunkd_socket_path = "/tmp/paypunkd.sock"
keypunkd_socket_path = "/tmp/keypunkd.sock"
bridge_socket_path = "/tmp/paypunk-bridge.sock"
data_dir = "~/.local/share/paypunk/"
config_dir = "~/.config/paypunk/"
ethereum_rpc_url = "http://127.0.0.1:8545"
zcash_network = "regtest"
offline_signer = false
Subcommands
generate-seed
Generate a new wallet seed.
paypunk generate-seed -p <password>
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--password | -p | String | Yes | — |
Prints the 12-word BIP39 mnemonic. Save it offline.
restore-seed
Restore a wallet from an existing seed phrase.
paypunk restore-seed -m "word1 word2 ... word12" -p <password> --birthday-height 1234567
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--mnemonic | -m | String | Yes | — |
--password | -p | String | Yes | — |
--birthday-height | u64 | No | None |
Birthday height speeds up initial sync by avoiding scanning from genesis. Recommended for mainnet.
On mainnet, if --birthday-height is omitted the wallet auto-fetches the current chain tip as the birthday. This is fine for fresh wallets, but existing funds below that height will not be detected. Always provide a birthday block when restoring a mainnet wallet with prior activity. The stored birthday is consulted by create_account, register_signer, and bulk_derive_accounts before falling back to the tip, and the background sync loop falls back to the minimum account birthday when no blocks have been scanned yet.
unlock
Unlock the wallet and derive accounts from pre-derived viewing keys.
paypunk unlock -p <password>
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--password | -p | String | Yes | — |
On first unlock, bulk-derives 30 viewing keys per protocol from keypunkd, stores them in pre_derived_keys, and creates the first account per protocol.
submit-transfer
Submit a transfer intent for preview (two-phase signing, step 1).
paypunk submit-transfer \
--to "u1..." --amount "0.001" --from "u1..." \
--protocol zcash --memo "payment" --account 0
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--to | -t | String | Yes | — |
--amount | -a | String | Yes | — |
--from | -f | String | Yes | — |
--asset | String | No | eip155:1/slip44:60 | |
--protocol | -p | String | No | Inferred from asset |
--data | -d | String | No | None |
--memo | -m | String | No | None |
--account | u32 | No | 0 |
Protocol is inferred from --asset if not provided: eip155 → Ethereum, zcash → Zcash, else defaults to Ethereum.
Saves a pending intent to <data_dir>/pending.intent. Run approve-signature next.
approve-signature
Approve a previously submitted intent (two-phase signing, step 2).
paypunk approve-signature -p <password>
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--password | -p | String | Yes | — |
--account | u32 | No | 0 |
Reads <data_dir>/pending.intent, signs the artifact via keypunkd, broadcasts the transaction, and removes the pending file.
get-balance
Query balance for a protocol and account.
paypunk get-balance --protocol zcash --account 0
paypunk get-balance --protocol ethereum --address 0xf39Fd6...
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--protocol | -p | String | No | zcash |
--account | -a | u32 | No | 0 |
--address | String | No | None (uses account) |
If --address is omitted, looks up the account by protocol + derivation path.
list-accounts
List all accounts in the wallet.
paypunk list-accounts
No flags. Prints id | protocol | derivation_path | name | address for each account.
create-account
Create a new account from a pre-derived viewing key.
paypunk create-account --protocol zcash --account-index 1 --name "Savings"
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--protocol | -p | String | No | zcash |
--account-index | u32 | No | 0 | |
--name | -n | String | No | "{Protocol} Account {index}" |
--birthday-height | u64 | No | None |
tui
Launch the terminal UI. Does NOT start daemons — they must be running.
paypunk tui
paypunk tui --signer # connect to bridge instead of keypunkd
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--signer | bool | No | false |
keypunkd
Launch the key daemon as a foreground process.
paypunk keypunkd --zcash-network regtest
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--socket-path | -s | String | No | /tmp/keypunkd.sock |
--data-dir | -d | String | No | ~/.local/share/paypunk/<network>/ |
--zcash-network | -z | String | No | regtest |
paypunkd
Launch the app daemon as a foreground process.
paypunk paypunkd --keypunkd-socket /tmp/keypunkd.sock --lightwalletd-host http://127.0.0.1:9067
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--socket-path | -s | String | No | /tmp/paypunkd.sock |
--keypunkd-socket | -k | String | No | /tmp/keypunkd.sock |
--ethereum-rpc-url | -e | String | No | http://127.0.0.1:8545 |
--data-dir | -d | String | No | ~/.local/share/paypunk/<network>/ |
--lightwalletd-host | -l | String | No | Network default |
--zcash-network | -z | String | No | regtest |
bridge
Run the QR bridge web server for air-gapped signing.
paypunk bridge --port 12345 --socket-path /tmp/keypunkd.sock
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--port | u16 | No | 12345 | |
--socket-path | String | No | /tmp/keypunkd.sock |
Serves an HTML page at http://0.0.0.0:{port}/ and a WebSocket at ws://0.0.0.0:{port}/ws.
reset
Remove all wallet data (seed, database, accounts). Resets to clean state.
paypunk reset
No flags. Removes the entire data directory (all networks).
uninstall
Permanently remove all wallet + config data.
paypunk uninstall # prompts for confirmation
paypunk uninstall --force # skip confirmation
| Flag | Short | Type | Required | Default |
|---|---|---|---|---|
--force | -f | bool | No | false |
Removes both data_dir and config_dir.
Auto-launch behavior
When run with no subcommand (or with a client command like generate-seed), paypunk automatically:
- Checks if
paypunkdis already running on the socket (500ms connect probe) - If not, spawns
keypunkd(orbridgein signer mode) +paypunkdas child processes - Waits up to 30 seconds for sockets to appear
- Runs the command/TUI
- Kills spawned daemons on exit
If daemons are already running, they are reused — no new processes are spawned.
Two-phase signing (CLI)
The CLI uses a two-phase signing flow for transfers:
# Phase 1: build + preview
paypunk submit-transfer --to "u1..." --amount "0.001" --from "u1..." --protocol zcash
# → prints fee, saves pending.intent
# Phase 2: approve + broadcast
paypunk approve-signature -p <password>
# → signs with keypunkd, broadcasts, prints tx hash
The pending intent is stored at <data_dir>/pending.intent (base data dir, not network-specific).
Contributing
Thanks for your interest in contributing to paypunk! This is an experimental privacy wallet framework, and we welcome contributions of all kinds.
Prerequisites
- Rust stable (no
rust-toolchain.toml— stable is implied by CI) - Docker + Compose v2 (for local chain stacks)
- jq (used by the Zcash regtest funding script)
- devenv + direnv (recommended but optional)
Getting set up
With devenv (recommended)
git clone https://github.com/blockhackersio/paypunk.git
cd paypunk
devenv shell # enters the dev environment
With direnv installed, .envrc auto-activates the environment on cd.
Without devenv
git clone https://github.com/blockhackersio/paypunk.git
cd paypunk
cargo build
Development commands
| Command | Purpose |
|---|---|
cargo build | Build the workspace |
cargo test | Run all tests (unit + integration) |
cargo fmt --all --check | Check formatting (CI enforces this) |
cargo clippy --all-targets | Run lints (recommended, not yet CI-gated) |
cargo run | Auto-launch daemons + TUI |
cargo run -- tui | Launch TUI only (daemons must be running) |
devenv scripts
If using devenv, these shortcuts are available:
| Script | Command | Purpose |
|---|---|---|
cb | cargo build | Build |
ct | cargo test | Test |
setup | scripts/setup-test-wallet.sh | Reset + restore test wallet (pass a block height as the first arg for mainnet birthday) |
ethereum | scripts/start-ethereum.sh | Start anvil Docker stack |
zcash | scripts/start-zcash.sh | Start zcashd + lightwalletd regtest |
kp | scripts/key-daemon.sh | Run keypunkd |
pp | scripts/wallet-daemon.sh | Run paypunkd |
tui | scripts/ui.sh | Run TUI |
bal | scripts/get-balance.sh | Query ETH balance |
Local chain stacks
Zcash regtest
cd support/zcash && make up # start zcashd + lightwalletd
make fund UA=<your-orchard-ua> # mine + shield funds to your wallet
make info # check height + Orchard pool value
See support/zcash/README.md for details.
Ethereum (anvil)
cd support/ethereum && docker compose up
10 pre-funded accounts at http://127.0.0.1:8545. See support/ethereum/README.md.
Testing
Unit tests
Inline #[cfg(test)] modules in each crate. Run with cargo test.
Integration tests
In the tests/ workspace crate:
tests/tests/integration_test.rs— 14 async tests covering seed generation, address derivation, balance queries, the full ETH send flow, and account CRUD. UsesTestBuilderto wire up the full actor chain in-memory.tests/tests/pczt_test.rs— Zcash Orchard PCZT pipeline tests.
Test wallet setup
scripts/setup-test-wallet.sh # reset + restore + unlock with test mnemonic
Uses the standard BIP39 test mnemonic (test test ... junk) with password test.
For mainnet (when a .mnemonic file exists), pass a birthday block height as the first argument so the wallet scans from that block instead of auto-fetching the chain tip as a fallback:
scripts/setup-test-wallet.sh 1234567
# or via devenv:
devenv shell -- run setup 1234567
Without a birthday block on mainnet, the script will warn you and continue after 5 seconds.
Code style
- Formatting:
cargo fmtdefaults (norustfmt.toml). CI checkscargo fmt --all --check. - No comments unless necessary — the codebase favors self-documenting code. Don’t add comments unless asked.
- Crate naming: Libraries use
paypunk-prefix (paypunk-types,paypunk-ipc). Daemons use short names (paypunk,paypunkd,keypunkd). - Dependencies: Centralized in
[workspace.dependencies]in the rootCargo.toml. Reference asfoo.workspace = truein crate manifests. - Serialization:
postcardfor IPC payloads,tomlfor config,serdefor derive macros. - Actor framework:
tactixthroughout. Actors implementActor+Handler<Message>. - Edition: All crates use
edition = "2021". - License:
AGPL-3.0-only.
Project structure
types/ Chain-agnostic domain types + traits
config/ TOML configuration with env var overrides
ipc/ Tactix actor IPC over Unix sockets
keypunkd/ Key daemon (seed storage, signing)
paypunkd/ App daemon (wallet DB, protocol orchestration)
api/ Public-facing client library
cli/ CLI/TUI binary (paypunk)
tui/ Terminal UI (ratatui)
protocols/
zcash/ Zcash Protocol + SignerProtocol
ethereum/ Ethereum Protocol + SignerProtocol
bridge/ WebSocket/HTTP relay for air-gapped signing
signer/ Tauri v2 mobile signer app (separate build)
ping/ pong/ IPC diagnostic test pair
tests/ Integration tests
support/ Docker stacks for local chains
docs/ Documentation
adr/ Architecture decision records
Adding a new chain
See docs/ADD_PROTOCOL.md for a step-by-step guide.
Architecture decisions
See adr/ for architecture decision records. Current:
Submitting changes
- Fork the repo and create a branch
cargo fmt --all— format your codecargo test— make sure tests passcargo clippy --all-targets— fix any warnings (recommended)- Open a pull request against
master
CI runs cargo fmt --all --check and cargo test on all PRs to master.
The signer subproject
The signer/ directory is a separate Tauri v2 mobile app with its own devenv.nix, Cargo.lock, and build tooling (Android SDK, NDK, Node, JDK). It is excluded from the Cargo workspace. See signer/README.md for build instructions.
Paypunk
Multi-chain wallet infrastructure for privacy-preserving commerce on desktop and agentic workflows.
Language
Wallet:
A key manager capable of generating Addresses, checking Balance, building outgoing Transfers, and scanning the chain for incoming funds. Split across three processes: Keypunkd actor in keypunkd, Paypunkd actor in paypunkd, IPC routing via the ipc crate.
Avoid: Vault, safe
Keypunkd actor:
An actor (tactix) that holds the decrypted spending key in protected memory. Lives inside keypunkd. The security boundary — only accepts GetEncryptionKey, GenerateSeed, RestoreSeed, PreviewArtifact, AuthorizeArtifact, ExportViewingKey, VerifyPassword, BulkExportViewingKeys, and ExportMnemonic messages (defined as KeypunkdRequest in paypunk-types). Never exposes raw key material. Uses SignerProtocol implementations to perform chain-specific signing. Stateless — the seed is derived from the encrypted store on each AuthorizeArtifact or ExportViewingKey call using the password provided in the request.
Avoid: KeyActor, Key Daemon, signer
Paypunkd actor:
An actor (tactix) managing non-secret operations: address derivation, LSP sync, balance tracking, transfer construction. Lives inside paypunkd. Owns the SQLite wallet state database and Protocol implementations. Delegates signing to the Keypunkd actor (in keypunkd) via IPC when a transfer needs finalization.
Avoid: WalletActor, Wallet Daemon
Seed:
A 12-word BIP39 mnemonic phrase from which all wallet keys are deterministically derived. Stored at rest in a dedicated file (seed.enc), encrypted with an Argon2id-derived key from the user’s password (AES-256-GCM). The mnemonic is stored separately in seed.mnemonic.enc with the same encryption scheme. The seed file is eventually owned by a different system user than the wallet process for security compartmentalization. The seed is decrypted on-demand for each AuthorizeArtifact or ExportViewingKey call and held on the stack for the duration of the signing operation.
Address: A unique receiving address derived for each incoming payment. One address per payment — never reused (post-v1 goal; address reuse is acceptable for initial build). Avoid: Reuse
Intent:
A strongly-typed enum representing what the user wants to do. One variant per protocol (e.g., Intent::Zcash(ZcashIntent::Transfer { ... }), Intent::Ethereum(EthereumIntent::Transfer { ... })). Consumed by Protocol::build(). All amounts are human-readable strings; addresses are raw protocol-level strings. Constructed by the caller (CLI/TUI), not by the API layer.
Avoid: Transaction intent, message
Transfer:
An outbound payment from the wallet to a recipient’s Address, including an Amount and an optional Memo (Zcash). Initiated by the user or an agent acting on their behalf via an Intent::Zcash(ZcashIntent::Transfer) or Intent::Ethereum(EthereumIntent::Transfer).
Incoming Payment: Funds received into the wallet detected via LSP chain scanning of the current Address. Avoid: Receipt
keypunkd:
Long-running daemon hosting the Keypunkd actor. Responsible for key generation and signing via SignerProtocol. Runs as a separate system user for defense-in-depth (file/memory isolation — deployment concern, not enforced by code). IPC auth is per-message Blake2b keyed-hash MAC using X25519 shared secret — any process can connect, but only a client holding the registered keypair can send valid messages. Password is additionally required for sensitive operations (AuthorizeArtifact, ExportViewingKey, ExportMnemonic). See ADR-001.
Avoid: Key daemon, KeyActor
paypunkd:
Long-running daemon hosting the Paypunkd actor, usecases, and service orchestration. Holds the wallet database (paypunkd.db, currently plaintext) and Protocol implementations for transaction building, proving, and finalizing. Exposes IPC over Unix socket. Runs as the user’s login UID. Never holds key material — delegates signing to keypunkd via IPC.
Avoid: App daemon, WalletActor
ipc: Library crate providing a tactix actor that carries opaque byte payloads over Unix domain sockets with X25519-based per-message authentication. The communication sender between all processes. api, paypunkd, and keypunkd all use it. Serialization (postcard) is done by the callers, not by the ipc crate. Avoid: Transport, wire
api:
Public-facing library that CLI and TUI depend on. Provides high-level functions (get_balance, submit_intent, approve_signature, etc.) that communicate with paypunkd via IPC. Hides IPC/tactix details from consumers when using Client::connect. Internally communicates with paypunkd via the ipc crate. Callers construct Intent values; the API submits them.
Avoid: SDK
protocols:
Directory of chain-specific implementation crates (e.g., protocols/zcash, protocols/ethereum). Each implements Protocol and SignerProtocol traits from paypunk-types.
Avoid: adapters
Architecture
- Single context repo. No CONTEXT-MAP.md needed.
- Three-process architecture:
keypunkd(key daemon),paypunkd(app daemon), andpaypunk(CLI/TUI). Both daemons are library crates launched via thepaypunkCLI binary. - Layers: paypunk (CLI/TUI) → api → ipc → paypunkd → ipc → keypunkd
- IPC: Unix domain socket, postcard serialization (by callers), tactix actor wrapping each connection, X25519 + Blake2b MAC per-message authentication
Product Layers
api: Chain-agnostic library providing the public API. Dispatches to the correct chain backend by ProtocolId. Hides IPC and actor details from consumers.
keypunkd: Key daemon — hosts Keypunkd actor. Seed generation, signing via SignerProtocol. Designed to run as a separate system user.
paypunkd: App daemon — hosts Paypunkd actor, usecases, service orchestration, chain backend injection (Protocol).
paypunk: CLI binary. Connects to paypunkd via api. Includes TUI mode (ratatui) for interactive use. Also launches both daemons via subcommands.
TUI (future Tauri): Terminal-based user interface, ships inside the CLI binary as a library crate. Planned migration to Tauri later.
Data Model
All entity types are chain-agnostic primitives (strings, numbers, enums). No generics or trait objects on the data types (except Page<T>). Chain-specific logic lives inside protocol implementation crates (protocols/zcash, protocols/ethereum).
Types: Address(String), Amount(u128), TransferId(String), BlockHeight(u64), Balance { spendable: Amount, pending: Amount, total: Amount }, TransactionStatus { Pending, Confirmed(BlockHeight), Failed(String) }, TxStatus { Pending, Confirmed { confirmations: u64 }, Failed { reason: String }, NotFound }, Transfer { id: TransferId, from: Address, to: Address, amount: Amount, fee: Amount, memo: Option<String>, status: TransactionStatus, created_at: u64 }, HistoryEntry { hash: String, direction: TxDirection, counterparty: Address, amount: Amount, status: TxStatus, timestamp: Option<u64> }, Account { id, protocol, derivation_path, name, address, viewing_key: Vec<u8>, created_at: u64, birthday_height: Option<u64> }, ProtocolMetadata { id: ProtocolId, chain_id, native_asset, ticker, decimals: u8, block_explorer_template }, SyncStatus { is_syncing: bool, current_height: u64, target_height: u64 }, Page<T> { items: Vec<T>, next_cursor: Option<String>, has_more: bool }
PRD
Problem Statement
Businesses and individuals want to accept and pay with privacy-preserving cryptocurrencies (Zcash) and other digital assets (Ethereum), but the current focus is on mobile applications and terminal based tooling is mainly linked to running a full node, requires expertise in blockchain infrastructure, and offers poor integration for desktop applications and agentic workflows. There needs to be a simple, secure, non-custodial wallet that works for both human users and autonomous agents, enabling privacy-preserving commerce that is a delight to use without requiring deep protocol knowledge.
Solution
Build Paypunk Wallet — a multi-chain wallet tool with layered interfaces, targeting Zcash Orchard shielded pool and Ethereum (with Sapling and transparent planned for later):
- api — Chain-agnostic Rust library providing high-level wallet operations. Dispatches to the appropriate chain backend by
ProtocolId. Hides IPC, actor, and chain-specific details from consumers. - CLI — Command-line interface using api for scripting and automation. Integrates the TUI as a library for interactive use.
- TUI — Terminal-based user interface (ratatui) for interactive human use. Ships alongside the CLI as a reusable library crate.
The architecture is designed to eventually support a Tauri desktop interface, agent-to-agent commerce flows, and FROST multi-signature workflows where an agent proposes transactions that require human approval.
Target User (v1)
Individual privacy-conscious users, including developers and agent operators running their own wallet. Businesses raising and paying invoices is the long-term goal (via a sister project) but not the v1 target.
User Stories
- As a privacy-conscious wallet user, I want to generate a new wallet with a 12-word BIP39 seed phrase, so that I can back up my keys offline
- As a privacy-conscious wallet user, I want to restore my wallet from a saved seed phrase and password, so that I can recover my funds on a different device
- As a privacy-conscious wallet user, I want to check my available ZEC balance at any time, so that I know how much I can spend
- As a privacy-conscious wallet user, I want to initiate a Transfer to another Zcash address with an amount and optional memo, so that I can pay for goods and services privately
- As a privacy-conscious wallet user, I want to view my transaction history with confirmation status (pending/confirmed), so that I can track my payments
- As a privacy-conscious wallet user, I want my seed to be encrypted at rest with Argon2id derived from my password, so that my keys are protected against unauthorized access
- As a privacy-conscious wallet user, I want my wallet state database to be encrypted separately from my seed encryption, so that there is security compartmentalization
- As a privacy-conscious wallet user, I want the wallet to scan for Incoming Payments without exposing my view keys to external servers, so that my financial data remains private
- As a privacy-conscious wallet user, I want the wallet to connect to public lightwalletd endpoints by default, so that I can start using it without running infrastructure
- As a CLI user, I want to unlock my wallet with a password prompt, environment variable, or mounted secrets file, so that I can use the wallet interactively or non-interactively
- As a CLI user, I want to create a new wallet from the command line, so that I can script wallet provisioning
- As a CLI user, I want to generate addresses from the command line, so that I can get payment destinations programmatically
- As a CLI user, I want to check balance from the command line, so that I can monitor funds via scripts
- As a CLI user, I want to send Transfers from the command line, so that I can automate payments
- As a CLI user, I want to view transaction history from the command line, so that I can audit my wallet activity programmatically
- As a CLI user, I want to sync the wallet chain state on demand, so that I can see recent activity without running a background process
- As an agent operator, I want to provide my wallet password via a mounted secrets file with restricted permissions, so that my agent can sign Transfers without interactive prompts
- As an agent, I want to call the wallet API over IPC, so that I can integrate Zcash payments into my workflows
- As a TUI user, I want to interactively view my balance, addresses, and transaction history in a terminal interface, so that I can manage my wallet without a web browser
- As a TUI user, I want the wallet to stay synced in the background while I use the interface, so that I always see up-to-date information
- As a developer, I want the wallet API to be a separate library crate from my CLI and TUI, so that it can be consumed by third-party integrations
Implementation Decisions
Architecture
- Three-process model —
keypunkd(key daemon),paypunkd(app daemon),paypunk(CLI/TUI). Both daemons are library crates launched via thepaypunkCLI binary. Process separation from v1 enforces the security boundary — neither the CLI nor the application daemon ever hold key material. - Key isolation — The
Keypunkdactor (in keypunkd) must never expose raw private keys. It accepts sign/prove requests and returns only results (signatures, protocol proofs). The password is required on eachAuthorizeArtifactandExportViewingKeycall — there is no long-lived unlocked session. keypunkd is designed to run as a separate system user (deployment concern, not enforced by the code). - IPC — A tactix actor wrapping Unix domain sockets. The ipc crate carries opaque byte payloads with X25519-based per-message authentication (see ADR-001). Postcard serialization is performed by the callers (paypunkd, keypunkd, api), not by the ipc crate itself.
- Structured logging —
tracingcrate with env-filter support. Info-level for operations, debug for scan details, warn/error for failures.
Crate Layout
types(library) — Chain-agnostic domain types (Address,Amount,Balance,Transfer,Intent,Protocol/SignerProtocoltraits, etc.). No chain-specific logic.api(library) — Chain-agnostic public API. Dispatches to the correct chain backend byProtocolId. Hides IPC/tactix details. CLI and TUI depend on this.paypunkd(library, launched via CLI) — App daemon. HostsPaypunkdactor, usecases, service orchestration, chain backend injection.keypunkd(library, launched via CLI) — Key daemon. HostsKeypunkdactor. Seed generation, signing, proving. Designed to run as separate system user.ipc(library) — Tactix actor sender for interprocess communication. Carries opaque byte payloads with X25519 per-message auth. Used by api, paypunkd, and keypunkd.protocols/{zcash,ethereum}— Chain-specific implementations. Each implements theProtocolandSignerProtocoltraits frompaypunk-types.tui— Ratatui screens and widgets. Library crate consumed by CLI, also builds as standalone binary. Reusable by future Tauri desktop app.cli(binary) — Linksapiandtui. Runs in CLI mode (single command) or TUI mode (interactive session). Also launches daemons via subcommands.
Passphrase Input
- Currently supports
--passwordCLI flag only. Interactive prompt, environment variable, and secrets file support are planned but not yet implemented.
Configuration
- Data directory (default
~/.local/share/paypunk/) - LSP endpoint / lightwalletd host (with defaults per network)
- Secrets file path (for agent mode) — planned, not yet implemented
Out of Scope
- Invoice generation and payment request processing (planned for sister project)
- Subscription/recurring payments
- FROST multi-signature / agent approval workflows (post-v1)
- n8n integration and merchant invoicing tools (separate product)
- Tauri desktop interface (future migration target)
- OS keyring integration (post-v1 enhancement)
Security
This is experimental software and should not be used with real funds.
Warning: This is a work in progress. Only simple transfers are supported. The wallet database is not yet encrypted, IPC provides integrity but not confidentiality, and several security features (DB encryption, interactive password prompts, secrets file support) are planned but not implemented. Review this document carefully before relying on any security property.
Security Boundaries
┌──────────────────────────────────────────────────────────┐
│ Untrusted zone │ Trusted zone │
│ │ │
│ CLI / TUI process │ keypunkd process │
│ (never holds keys) │ (holds decrypted keys) │
│ │ │
│ paypunkd process │ seed.enc (encrypted at rest) │
│ (never holds keys) │ │
└──────────────────────────┴────────────────────────────────┘
▲ ▲
│ X25519 + Blake2b MAC │
│ (IPC auth) │
└──────────────────────────────┘
The core security boundary is between keypunkd (which holds keys) and everything else. paypunkd and the CLI/TUI never touch key material — they delegate signing to keypunkd via IPC.
What’s Protected
Seed at rest
- File:
{data_dir}/seed.enc - Encryption: Argon2id key derivation from password → AES-256-GCM
- Format:
[salt(16B)][nonce(12B)][ciphertext] - Atomic writes:
.tmp+ rename to prevent corruption - Mnemonic: Stored separately in
seed.mnemonic.encwith the same encryption
Seed in memory
- Decrypted on-demand for each
AuthorizeArtifactorExportViewingKeycall - Held on the stack for the duration of the signing operation only
- No long-lived unlocked session
Zeroizingwrappers used in the API layer for passwords and mnemonics
IPC authentication
- X25519 ECDH key exchange on connection
- Per-message Blake2b-256 MAC (keyed-hash:
Blake2b(hmac_key || payload)) - MAC mismatch drops the connection immediately
- Keypairs are ephemeral — regenerated on each daemon restart
- See ADR-001 for the full design
Password sealing
- Passwords are never sent in plaintext over IPC
- The API layer creates an ephemeral X25519 keypair per operation
- Password is Argon2id-hashed with a domain separator (
"keypunkd-seed-key") - Hashed password is encrypted to keypunkd’s public key before transmission
- keypunkd decrypts with its private key, uses the hash to decrypt the seed
Air-gapped signing
- The mobile signer app (Tauri v2) holds keys on a separate device
- Communication via QR codes (BC-UR fountain coding) — no network connection needed
- The bridge relay uses the same IPC authentication on the socket side
- See BRIDGE.md for the bridge protocol
What’s Not Protected
Wallet database is plaintext
paypunkd.dbis an unencrypted SQLite fileis_locked()always returnsfalse- Encryption at rest is planned but not yet implemented
- Risk: Anyone with filesystem access can read account data, viewing keys, and transaction history
IPC provides integrity, not confidentiality
- The Blake2b MAC authenticates messages but does not encrypt them
- Postcard-serialized payloads ride in cleartext over the Unix socket
- Sensitive fields (passwords, mnemonics) are encrypted at the application layer before serialization
- Risk: A local attacker sniffing the socket could see unsigned transaction details (recipient, amount)
No client identity verification
- Any process that can reach the socket path can complete the X25519 handshake
- The handshake establishes a shared MAC key but never verifies who the client is
- Access control is entirely via filesystem permissions on the socket path
- Risk: On a shared host, any user can connect to the socket. Mitigate by restricting the socket directory permissions or running daemons as separate users.
Separate system user not enforced
keypunkdis designed to run as a separate system user for defense-in-depth- This is a deployment concern, not enforced by code
- No
setuid,setgid, or user-switching logic exists in the codebase - Mitigation: Use systemd unit files with
User=directives to enforce process isolation
Bridge binds to all interfaces
- The bridge HTTP/WebSocket server binds to
0.0.0.0:{port}, not localhost - No TLS, no WebSocket authentication
- Any network client can connect and intercept signing requests
- Risk: On a networked machine, a remote attacker could connect to the bridge WebSocket and inject forged signing responses
- Mitigation: Only use the bridge on a trusted network or behind a firewall. The camera-based QR flow requires
localhostor HTTPS forgetUserMediaaccess.
Passwords on the command line
- The CLI accepts passwords via
--passwordflag only - No interactive prompt, environment variable, or secrets file support yet
- Risk: Passwords appear in process arguments and shell history
- Mitigation: Use the TUI for interactive use. CLI password input improvements are planned.
Threat Model
Attacker with filesystem access (no keypunkd password)
| Asset | Exposed? | Notes |
|---|---|---|
| Seed | No | Encrypted with Argon2id + AES-256-GCM |
| Mnemonic | No | Encrypted with same scheme |
| Viewing keys | Yes | Stored in paypunkd.db (plaintext) |
| Transaction history | Yes | Stored in paypunkd.db (plaintext) |
| Address book | Yes | Stored in paypunkd.db (plaintext) |
| Settings | Yes | Stored in paypunkd.db (plaintext) |
Attacker with filesystem access + keypunkd password
| Asset | Exposed? | Notes |
|---|---|---|
| Seed | Yes | Password decrypts the seed |
| Mnemonic | Yes | Password decrypts the mnemonic |
| All funds | Yes | Seed allows signing transactions |
Attacker with process memory access to keypunkd
| Asset | Exposed? | Notes |
|---|---|---|
| Decrypted seed | Yes | On the stack during signing operations |
| IPC keypair | Yes | Ephemeral, regenerated on restart |
| All funds | Yes | Seed allows signing transactions |
| Mitigation | Run keypunkd as a separate system user; limit memory access |
Attacker with process memory access to paypunkd
| Asset | Exposed? | Notes |
|---|---|---|
| Seed | No | Never held by paypunkd |
| Viewing keys | Yes | Stored in DB, loaded into memory |
| IPC keypair | Yes | Ephemeral, regenerated on restart |
| Mitigation | Viewing keys are non-spending; risk is metadata exposure |
Attacker on the network (bridge mode)
| Asset | Exposed? | Notes |
|---|---|---|
| Unsigned transactions | Yes | Bridge WS has no auth, binds to 0.0.0.0 |
| Forged signatures | Yes | Attacker can inject WS responses |
| Mitigation | Use bridge only on trusted networks; prefer the mobile signer app |
Recommendations
- Run keypunkd as a separate system user — Use systemd
User=andGroup=directives. This provides file/memory isolation even though the code doesn’t enforce it. - Restrict socket permissions — Place Unix sockets in a directory with restricted permissions (e.g.,
/run/paypunk/with0700). - Use the mobile signer for high-value wallets — Air-gapped signing via QR codes eliminates network attack surface.
- Don’t use
--passwordon shared systems — Passwords in process arguments are visible to other users viaps. - Encrypt the wallet database — Until DB encryption is implemented, consider full-disk encryption (LUKS) for the data directory.
- Keep keypunkd off the network — keypunkd only needs a Unix socket, not network access. Firewall it if possible.
Reporting vulnerabilities
Please report security issues privately by opening a draft GitHub issue at https://github.com/blockhackersio/paypunk/issues.
Adding a New Chain
Note: This is a work in progress. The trait system is stable in design but only Zcash and Ethereum are implemented. Only simple transfers are supported — more complex intent variants (swaps, staking, etc.) will require extending the
Intentenum pattern described below.
This guide walks through implementing a new cryptocurrency protocol in paypunk. The architecture is designed so that adding a chain means implementing two traits and registering them — not rearchitecting the wallet.
Overview
Each chain requires:
Protocoltrait (wallet side) — builds unsigned transactions, finalizes/broadcasts signed ones, queries balances, exposes metadata. Lives inpaypunkd.SignerProtocoltrait (signer side) — derives viewing keys, parses artifacts for preview, signs with the seed. Lives inkeypunkd.- Intent variants — a new
ProtocolId,Intentvariant, andArtifactSummaryvariant inpaypunk-types.
The two existing implementations serve as templates:
- Ethereum (
protocols/ethereum/) — the minimal template. Uses trait defaults for sync/history, no wallet DB, separate signer unit struct. Start here if your chain doesn’t need chain scanning. - Zcash (
protocols/zcash/) — the full-featured template. Overrides every method, has acreate_protocolfactory that spawns actor tasks, supports multi-network, uses awalletcargo feature to keep the signer side light.
Step 1: Add types to paypunk-types
Edit types/src/lib.rs:
Add a ProtocolId variant
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum ProtocolId {
Zcash,
Ethereum,
Monero, // your new chain
}
}
Add an Intent variant and intent enum
#![allow(unused)]
fn main() {
pub enum Intent {
Zcash(ZcashIntent),
Ethereum(EthereumIntent),
Monero(MoneroIntent), // your new variant
}
pub enum MoneroIntent {
Transfer {
to: String,
amount: String,
from: String,
asset: String,
memo: Option<String>,
},
}
}
Add an ArtifactSummary variant
#![allow(unused)]
fn main() {
pub enum ArtifactSummary {
Zcash(ZcashArtifactSummary),
Ethereum(EthereumArtifactSummary),
Monero(MoneroArtifactSummary), // your new variant
}
pub struct MoneroArtifactSummary {
pub to: String,
pub amount: String,
pub fee: String,
}
}
Step 2: Create the protocol crate
Create protocols/monero/Cargo.toml:
[package]
name = "paypunk-chains-monero"
version = "0.1.0"
edition = "2021"
[dependencies]
paypunk-types.workspace = true
async-trait.workspace = true
postcard = { workspace = true, features = ["alloc"] }
# Add your chain's crypto crates here
Add to the workspace Cargo.toml:
# In [workspace] members:
"protocols/monero",
# In [workspace.dependencies]:
paypunk-chains-monero = { path = "protocols/monero" }
Optional: wallet feature gate
If your chain has heavy wallet dependencies (like Zcash’s zcash_client_sqlite), use a feature gate so keypunkd stays light:
[features]
default = ["wallet"]
wallet = ["dep:monero-wallet", "dep:rusqlite"]
Step 3: Implement SignerProtocol
The signer protocol runs in keypunkd where the seed lives. It has three required methods:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait SignerProtocol: Send + Sync {
/// Derive and export a viewing key from a seed at the given BIP32 path.
/// Returns protocol-specific viewing-key bytes.
fn export_viewing(&self, seed: &[u8; 64], path: &str) -> Result<Vec<u8>, String>;
/// Parse an unsigned artifact into a serialized ArtifactSummary (postcard).
fn parse_artifact(&self, artifact: &[u8]) -> Result<Vec<u8>, String>;
/// Sign an unsigned artifact with the key derived from seed at path.
fn sign(&self, seed: &[u8; 64], path: &str, artifact: &[u8]) -> Result<Vec<u8>, String>;
}
}
Create protocols/monero/src/signer.rs:
#![allow(unused)]
fn main() {
use paypunk_types::SignerProtocol;
pub struct MoneroSignerProtocol;
impl MoneroSignerProtocol {
pub fn new() -> Self { Self }
}
#[async_trait::async_trait]
impl SignerProtocol for MoneroSignerProtocol {
fn export_viewing(&self, seed: &[u8; 64], path: &str) -> Result<Vec<u8>, String> {
// Derive viewing key from seed at path
// Return protocol-specific bytes (must be parseable by your Protocol::derive_address_from_viewing_key)
todo!()
}
fn parse_artifact(&self, artifact: &[u8]) -> Result<Vec<u8>, String> {
// Parse the unsigned transaction and serialize an ArtifactSummary
// The TUI displays this to the user before signing
let summary = ArtifactSummary::Monero(MoneroArtifactSummary { /* ... */ });
postcard::to_allocvec(&summary).map_err(|e| e.to_string())
}
fn sign(&self, seed: &[u8; 64], path: &str, artifact: &[u8]) -> Result<Vec<u8>, String> {
// Derive spending key from seed at path, sign the artifact
// Return signed artifact bytes
todo!()
}
}
}
Reference: protocols/ethereum/src/signer.rs is the cleanest minimal example.
Step 4: Implement Protocol
The protocol runs in paypunkd and handles all non-secret operations. Required methods (no defaults):
| Method | Purpose |
|---|---|
protocol_id() | Return your ProtocolId |
build(intent) | Construct an unsigned artifact from an Intent |
store_and_finalize(signed) | Store and finalize a signed artifact into a broadcastable transaction |
finalize(signed) | Finalize a signed artifact (synchronous) |
broadcast(tx) | Submit a finalized transaction to the network |
validate_address(addr) | Check if an address is valid for your chain |
get_balance(addr, asset) | Query balance for an address and CAIP-19 asset |
chain_id() | Return CAIP-2 chain ID (e.g. monero:mainnet) |
native_asset() | Return CAIP-19 native asset (e.g. monero:mainnet/slip44:128) |
ticker() | Return ticker symbol (e.g. "XMR") |
decimals() | Return decimal places (e.g. 12 for XMR) |
block_explorer_url(tx_hash) | Return block explorer URL template |
default_derivation_path(account) | Return BIP32/ZIP32 derivation path |
default_account_name(index) | Return default account name |
derive_address_from_viewing_key(vk, index) | Derive an address from viewing key bytes |
Optional methods with defaults (override if your chain supports them):
| Method | Default |
|---|---|
get_sync_status() | Returns error |
create_transfer(...) | Returns error |
estimate_fee(...) | Returns error |
get_history(...) | Returns empty page |
get_transaction_status(txid) | Returns error |
get_current_block_height(host) | Returns error |
lightwalletd_host() | Returns None |
sync_account(...) | No-op |
sync_incremental() | No-op |
Create protocols/monero/src/protocol.rs:
#![allow(unused)]
fn main() {
use paypunk_types::{Protocol, ProtocolId, Intent, Balance, ChainId, /* ... */ };
pub struct MoneroProtocol {
// RPC client, network params, etc.
}
#[async_trait::async_trait]
impl Protocol for MoneroProtocol {
fn protocol_id(&self) -> ProtocolId { ProtocolId::Monero }
async fn build(&self, intent: &Intent) -> Result<Vec<u8>, String> {
match intent {
Intent::Monero(MoneroIntent::Transfer { to, amount, .. }) => {
// Build unsigned transaction
}
_ => return Err("unexpected intent variant for Monero protocol".into()),
}
}
// ... implement all required methods
}
}
Reference: protocols/ethereum/src/protocol.rs shows the minimal set; protocols/zcash/src/protocol.rs shows full sync support.
Step 5: Add derivation path helper
Create protocols/monero/src/lib.rs:
#![allow(unused)]
fn main() {
pub fn derivation_path(account: u32) -> String {
format!("m/44'/128'/{account}'") // Monero SLIP-44 coin type 128
}
}
The path must be consistent between Protocol::default_derivation_path (paypunkd) and SignerProtocol::export_viewing (keypunkd), since the same path string is passed over IPC and stored in the database.
Step 6: Register in paypunkd
Edit paypunkd/src/run.rs:
#![allow(unused)]
fn main() {
let monero = paypunk_chains_monero::MoneroProtocol::new(/* config */);
protocols.register(Box::new(monero));
}
Add a match arm in paypunkd/src/usecases.rs (submit_intent function):
#![allow(unused)]
fn main() {
Intent::Monero(_) => ProtocolId::Monero,
}
Step 7: Register in keypunkd
Edit keypunkd/src/run.rs:
#![allow(unused)]
fn main() {
protocols.register(
ProtocolId::Monero,
Box::new(paypunk_chains_monero::MoneroSignerProtocol::new()),
);
}
Step 8: Add config fields (if needed)
If your chain needs configuration (RPC URL, network type, etc.):
- Add fields to
paypunkd/src/run.rs::Config - Add corresponding fields to
config/src/lib.rs::PaypunkConfigwith defaults and env var overrides - Plumb them through the CLI in
cli/src/main.rsif needed
Step 9: Write tests
Add tests in protocols/monero/src/ and integration tests in tests/tests/. The existing integration test pattern (tests/tests/integration_test.rs) wires up the full actor chain in-memory.
Checklist
-
types/src/lib.rs—ProtocolId,Intent,ArtifactSummaryvariants added -
protocols/monero/crate created and added to workspace -
SignerProtocolimplemented (3 methods) -
Protocolimplemented (15 required + optional overrides) - Derivation path helper added
- Registered in
paypunkd/src/run.rs - Intent dispatch added in
paypunkd/src/usecases.rs - Registered in
keypunkd/src/run.rs - Config fields added (if needed)
- Tests written
-
cargo buildpasses -
cargo testpasses -
cargo fmt --all --checkpasses
Bridge — Air-Gapped Signing Relay
Warning: This is a work in progress. The bridge binds to all interfaces with no TLS and no WebSocket authentication. Only use it on trusted networks. Only simple transfer signing is supported.
The bridge is a WebSocket/HTTP relay between a local IPC client (paypunkd) and a browser or mobile signer app. It enables air-gapped QR-based signing: the wallet constructs transactions on an online machine, the bridge relays them via QR codes to an offline device for signing, and the signed result is scanned back.
Architecture
paypunkd Bridge Browser / Mobile Signer
│ │ │
│ Unix socket │ HTTP + WebSocket │ Camera + QR
│ (X25519 + MAC) │ │
├─────────────────────>│ │
│ KeypunkdRequest │ WS binary frame │
│ (postcard) ├───────────────────────────────────>│
│ │ │ display animated QR
│ │ │ signer scans QR
│ │ │ signer signs offline
│ │ │ signer displays response QR
│ │ │ bridge scans response QR
│ │<───────────────────────────────────┤
│ KeypunkdResponse │ WS binary frame │
│ (postcard) │ │
│<─────────────────────┤ │
The bridge is a transparent relay — it never inspects or serializes KeypunkdRequest/KeypunkdResponse payloads. It only handles IPC framing + MAC verification on the socket side and raw binary WebSocket frames on the browser side.
Launching
Auto-spawn (signer mode)
paypunk --signer # spawns bridge + paypunkd, then launches TUI
Manual
# Start the bridge
paypunk bridge --port 12345 --socket-path /tmp/keypunkd.sock
# Start paypunkd pointing at the bridge socket
paypunk paypunkd --keypunkd-socket /tmp/keypunkd.sock
# Launch TUI
paypunk tui --signer
Configuration
#![allow(unused)]
fn main() {
pub struct BridgeConfig {
pub port: u16, // default: 12345
pub socket_path: String, // default: /tmp/keypunkd.sock (CLI) or /tmp/paypunk-bridge.sock (config)
}
}
HTTP routes
| Method | Path | Description |
|---|---|---|
GET | / | Serves index.html — the WebSocket + animated QR page |
GET | /ws | WebSocket upgrade endpoint |
The server binds to 0.0.0.0:{port} (all interfaces). The page uses ws:// (no TLS).
WebSocket protocol
- Transport: binary WebSocket frames only (no text, no JSON)
- No authentication: any WS client that connects becomes “the browser”
- Single session: only one browser connection is tracked at a time; new connections silently replace old ones
- Single in-flight request: one request at a time; a second request while the first is awaiting a response cancels the first
Bridge → Browser
The raw msg_payload from the IPC MSG_APPLICATION message (MAC stripped). This is a postcard-serialized KeypunkdRequest.
Browser → Bridge
Response bytes sent as a binary WS frame. Convention: [0x00][postcard KeypunkdResponse] for success, [0x01][UTF-8 error message] for failure.
IPC handshake (socket side)
The bridge acts as an IPC server on a Unix domain socket. The handshake uses the same protocol as keypunkd:
| Step | Message | Direction | Payload |
|---|---|---|---|
| 1 | MSG_GET_PUBLIC_KEY (0x00) | Client → Bridge | none |
| 2 | MSG_PUBLIC_KEY (0x01) | Bridge → Client | 32-byte X25519 public key |
| 3 | MSG_REGISTER_CLIENT (0x02) | Client → Bridge | 32-byte client public key |
| 4 | MSG_REGISTER_CLIENT_ACK (0x03) | Bridge → Client | none |
Both sides derive: shared = X25519(my_secret, their_public), then hmac_key = Blake2b256(shared || "paypunk-ipc-hmac").
Application messages (MSG_APPLICATION, 0x04): [postcard payload][32-byte Blake2b256(hmac_key || payload)]. The bridge verifies the MAC, strips it, and forwards the payload to the browser.
Transport framing: 4-byte little-endian length prefix + payload.
QR transport
The bridge web page uses BC-UR animated QR codes (fountain-coded fragments) for air-gapped transfer:
- Display:
UREncoderwithMAX_FRAGMENT_LEN=200, rendered at 8 fps viaQRCode.toCanvaswitherrorCorrectionLevel: 'L' - Scan:
getUserMedia({ facingMode: 'environment' })+jsQRfor decoding +URDecoderfor fragment reassembly - Libraries:
@ngraveio/bc-ur,qrcode,jsqr,buffer(polyfill)
Camera access requires a secure context (HTTPS or localhost). Loading the page from a LAN IP over plain HTTP will block camera access in modern browsers.
Application payload format
The payloads inside the WS frames are postcard-serialized types from paypunk-types:
- Request:
KeypunkdRequest— variants includePreviewArtifact,AuthorizeArtifact,ExportViewingKey,RegisterViewingKeys,VerifySignerSession, etc. - Response:
KeypunkdResponse— variants includeArtifactPreview,ArtifactAuthorized,ViewingKey,ViewingKeysRegistered,SessionVerified,Error, etc.
See types/src/lib.rs for the full enum definitions.
Writing a custom signer client
Option A: WebSocket client (replace the browser)
Connect to the bridge’s WebSocket and handle signing requests programmatically:
const ws = new WebSocket('ws://127.0.0.1:12345/ws');
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
const bytes = new Uint8Array(event.data);
// bytes is a postcard-serialized KeypunkdRequest
// Deserialize, handle, build KeypunkdResponse
const response = new Uint8Array([0x00, /* postcard KeypunkdResponse */]);
ws.send(response);
};
Or in Rust using a WebSocket client library:
#![allow(unused)]
fn main() {
// Connect to ws://127.0.0.1:12345/ws
// Receive binary frames → postcard::from_bytes::<KeypunkdRequest>(&frame)
// Build KeypunkdResponse → [0x00, postcard::to_allocvec(&resp)?]
// Send as binary WS frame
}
Option B: IPC client (replace paypunkd’s connection)
Use the paypunk-ipc crate to connect directly to the bridge’s Unix socket:
#![allow(unused)]
fn main() {
use paypunk_ipc::{IpcMessage, IpcSender};
use paypunk_types::{KeypunkdRequest, KeypunkdResponse};
let addr = IpcSender::connect("/tmp/paypunk-bridge.sock").await?;
let payload = postcard::to_allocvec(&request)?;
let response: Vec<u8> = addr.ask(IpcMessage::new(payload)).await?;
// response[0] == 0x00 → success, postcard::from_bytes::<KeypunkdResponse>(&response[1..])
// response[0] == 0x01 → error, UTF-8 string
}
IpcSender::connect handles the full X25519 handshake + MAC key derivation automatically.
Security considerations
- Binds to
0.0.0.0— the bridge is reachable from the network, not just localhost. Use only on trusted networks. - No TLS / no WSS — all traffic is plaintext. Sensitive fields (passwords, mnemonics) are encrypted at the application layer before reaching the bridge.
- No WebSocket authentication — any WS client can receive signing requests and inject responses. A malicious actor on the network could forge signing responses.
- No client identity verification on IPC — any process that can reach the socket path can complete the handshake. Access control is via filesystem permissions.
- Single-browser, last-writer-wins — no session tokens, no multiplexing, no concurrency control.
See SECURITY.md for the full threat model.
See also
- SECURITY.md — threat model and security boundaries
- signer/README.md — Tauri v2 mobile signer app
- ADR-001 — IPC authentication design
TUI Wallet Usecases
| # | Usecase | File | Description | Persistence |
|---|---|---|---|---|
| 1 | SetupScreen | tui/src/screens/setup.rs:32 | Wallet creation/import wizard (7 sub-steps) | Writes seed.enc (keypunkd), writes pre_derived_keys + accounts tables (paypunkd) |
| 2 | GreetingScreen | tui/src/screens/greeting.rs:16 | Initial unlock prompt for existing wallet | Reads seed.enc (keypunkd), writes pre_derived_keys + accounts tables (paypunkd) |
| 3 | LockScreen | tui/src/screens/lock.rs:17 | Re-authentication after auto-lock | None — no-op implementation |
| 4 | HomeScreen | tui/src/screens/home.rs:19 | Account list and main navigation | Reads accounts table, writes accounts on add |
| 5 | AssetsScreen | tui/src/screens/assets.rs:27 | Asset balance view with Send/Receive/History buttons | Reads accounts table; balance from chain RPC |
| 6 | SendScreen | tui/src/screens/send.rs:78 | Multi-step send flow (Form → Review → Sending → Confirm) | Reads accounts table; address book persisted to SQLite; signing reads seed.enc |
| 7 | ReceiveScreen | tui/src/screens/receive.rs:15 | Display receiving address + QR code | Reads accounts table |
| 8 | SettingsScreen | tui/src/screens/settings.rs:21 | Auto-lock, fiat currency, reveal recovery phrase | Reads/writes settings table; reveal reads seed.enc via keypunkd |
| 9 | HelpScreen | tui/src/screens/help.rs:11 | Context-sensitive keybinding overlay | None — pure UI overlay |
Persistence Layer Summary
keypunkd — Seed Store
- File:
{data_dir}/seed.enc - Format:
[salt(16B) | nonce(12B) | AES-256-GCM ciphertext] - Encryption: Argon2id key derivation + AES-256-GCM
- Access: Atomic write via
seed.enc.tmp+ rename; read viastd::fs::read
paypunkd — SQLite Database
- File:
{data_dir}/paypunkd.db(plaintext — encryption at rest is planned but not yet implemented) - Tables:
accounts—id TEXT PK, protocol TEXT, derivation_path TEXT, name TEXT, address TEXT, viewing_key BLOB, created_at INTEGER, birthday_height INTEGERpre_derived_keys—protocol TEXT, account_index INTEGER, viewing_key BLOB, created_at INTEGER DEFAULT (strftime('%s','now')), PRIMARY KEY (protocol, account_index)address_book—id INTEGER PK AUTOINCREMENT, name TEXT, address TEXT UNIQUE, protocol TEXT, created_at INTEGERsettings—key TEXT PK, value TEXTsigner_state—session_public_key BLOB, created_at INTEGER DEFAULT (strftime('%s','now'))_migrations—version INTEGER PK, applied_at TEXT DEFAULT (datetime('now'))
- Lock/unlock:
is_locked()always returnsfalse— no DB encryption currently implemented. A.wallet_initializedmarker file tracks wallet existence.
Address Book
- Persisted in paypunkd SQLite
address_booktable viaAddAddressBookEntry/GetAddressBookIPC messages
Architecture Layers
TUI Screen → RealWalletApi → paypunk-api Client → IPC (Unix socket)
↓
paypunkd
↙ ↘
SQLite DB keypunkd (IPC)
↓
seed.enc (disk)
- TUI Screen: Ratatui widget with
Screentrait — renders UI and handles keyboard input - RealWalletApi:
tui/src/api/real.rs— implementsWalletApitrait, communicates viapaypunk-api::Client - paypunk-api Client:
api/src/client.rs— high-level client wrappingPaypunkServiceIPC calls - paypunkd:
paypunkd/src/— wallet daemon (DB, protocol implementations, orchestration) - keypunkd:
keypunkd/src/— key daemon (seed storage, signing, viewing key derivation)
SetupScreen — Wallet Creation / Import
File: tui/src/screens/setup.rs:32
Two paths: Create New Wallet and Import Existing Wallet. Both end with Nav::Replace(HomeScreen) on success.
Create New Wallet Flow
sequenceDiagram
participant U as User
participant TUI as SetupScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant SeedFile as seed.enc (disk)
participant SQLite as paypunkd.db (SQLite)
Note over TUI: init() called
TUI->>API: get_setup()
API->>Client: generate_mnemonic()
Client-->>API: Zeroizing<String> (12-word phrase)
API-->>TUI: SetupData { new_mnemonic: [12 words], ... }
Note over TUI: User sees mnemonic (ShowMnemonic step)
U->>TUI: Enter (confirm saved)
Note over TUI: VerifyMnemonic step — user types words #4, #8, #12
U->>TUI: Enter (verification submitted)
TUI->>TUI: Validate words against stored mnemonic
Note over TUI: SetPassword step — user enters + confirms password
U->>TUI: Enter (password submitted)
Note over TUI: Creating step — spinner shown
TUI->>API: submit_setup_create(SetupCreateInput { password })
API->>Client: restore_seed(mnemonic, password)
Client->>paypunkd: IpcMessage(RestoreSeed { encrypted_mnemonic, encrypted_password, client_pk })
paypunkd->>keypunkd: forward RestoreSeed
keypunkd->>keypunkd: decrypt password, validate mnemonic, derive seed
keypunkd->>keypunkd: encrypt_seed(seed, password) — Argon2id + AES-256-GCM
keypunkd->>SeedFile: write(encrypted_blob) — atomic write via seed.enc.tmp + rename
keypunkd-->>paypunkd: SeedRestored
API->>Client: unlock(password)
Client->>paypunkd: IpcMessage(Unlock { encrypted_keypunkd_password, keypunkd_client_pk, paths })
paypunkd->>paypunkd: ensure database file exists, run migrations
paypunkd->>keypunkd: bulk_export_viewing_keys(encrypted_password, keypunkd_client_pk, paths)
keypunkd->>keypunkd: decrypt seed, derive viewing keys for 30 paths per protocol
keypunkd-->>paypunkd: viewing keys per (protocol, path)
paypunkd->>SQLite: INSERT OR REPLACE INTO pre_derived_keys (protocol, account_index, viewing_key) — for each key
paypunkd->>SQLite: SELECT viewing_key FROM pre_derived_keys WHERE protocol=?1 AND account_index=?2 — for account 0
paypunkd->>paypunkd: derive address from viewing key via protocol
paypunkd->>SQLite: INSERT INTO accounts (id, protocol, derivation_path, name, address, viewing_key, created_at) — first account per protocol
paypunkd-->>Client: UnlockSuccess { accounts_count }
Client-->>API: Ok(accounts_count)
API-->>TUI: Ok(())
TUI->>TUI: Nav::Replace(HomeScreen)
Import Existing Wallet Flow
sequenceDiagram
participant U as User
participant TUI as SetupScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant SeedFile as seed.enc (disk)
participant SQLite as paypunkd.db (SQLite)
Note over TUI: init() called
TUI->>API: get_setup()
API-->>TUI: SetupData { import_methods: ["mnemonic"], ... }
U->>TUI: Select "Import Existing Wallet"
Note over TUI: ImportMnemonic step — 12-field grid
U->>TUI: Enter (phrase entered)
Note over TUI: ImportPassword step — set + confirm password
U->>TUI: Enter (password submitted)
TUI->>API: submit_setup_import(SetupImportInput { method: "mnemonic", secret: phrase, password })
API->>Client: restore_seed(mnemonic, password)
Client->>paypunkd: IpcMessage(RestoreSeed { encrypted_mnemonic, encrypted_password, client_pk })
paypunkd->>keypunkd: forward RestoreSeed
keypunkd->>keypunkd: decrypt password, validate mnemonic, derive seed
keypunkd->>keypunkd: encrypt_seed(seed, password) — Argon2id + AES-256-GCM
keypunkd->>SeedFile: write(encrypted_blob) — atomic write
keypunkd-->>paypunkd: SeedRestored
API->>Client: unlock(password)
Client->>paypunkd: IpcMessage(Unlock { ... })
paypunkd->>SQLite: ensure file exists, run migrations
paypunkd->>keypunkd: bulk_export_viewing_keys
keypunkd->>keypunkd: derive viewing keys
keypunkd-->>paypunkd: keys
paypunkd->>SQLite: INSERT pre_derived_keys (30 per protocol)
paypunkd->>SQLite: INSERT accounts (first account per protocol)
paypunkd-->>Client: UnlockSuccess
Client-->>API: Ok(())
API-->>TUI: Ok(())
TUI->>TUI: Nav::Replace(HomeScreen)
GreetingScreen — Initial Unlock
File: tui/src/screens/greeting.rs:16
Shown when check_wallet_exists() returns true (existing wallet found). Prompts for password, then navigates to HomeScreen.
Persistence involved:
- keypunkd reads
seed.encfrom disk and decrypts with password to derive viewing keys - paypunkd opens
paypunkd.db(plaintext SQLite), runs migrations - paypunkd writes pre-derived viewing keys to
pre_derived_keystable - paypunkd writes first account to
accountstable
sequenceDiagram
participant U as User
participant lib as run_tui()
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant SeedFile as seed.enc (disk)
participant SQLite as paypunkd.db (disk)
lib->>API: check_wallet_exists()
API->>Client: check_wallet_exists()
Client->>paypunkd: IpcMessage(HasSeed)
paypunkd->>paypunkd: check .wallet_initialized marker file
paypunkd-->>Client: HasSeed { exists: true }
Client-->>API: true
API-->>lib: true
lib->>GreetingScreen: init() — no-op
lib->>lib: Push GreetingScreen onto screen stack
lib->>GreetingScreen: render() — shows password field
U->>GreetingScreen: Type password + Enter
GreetingScreen->>API: unlock(password)
API->>Client: unlock(password)
Note over Client: Creates ephemeral X25519 Keypair
Client->>paypunkd: IpcMessage(GetKeypunkEncryptionKey)
paypunkd-->>Client: keypunkd public key
Client->>paypunkd: IpcMessage(GetPaypunkdEncryptionKey)
paypunkd-->>Client: paypunkd public key
Note over Client: Argon2id-hashes password for domain:
Note over Client: "keypunkd-seed-key" → encrypted to keypunkd's key
Client->>paypunkd: IpcMessage(GetSupportedProtocols)
paypunkd-->>Client: [ProtocolId::Ethereum, ProtocolId::Zcash, ...]
Note over Client: Builds derivation paths: 30 per protocol (0..29)
Client->>paypunkd: IpcMessage(Unlock { encrypted_keypunkd_password, keypunkd_client_pk, paths })
paypunkd->>SQLite: ensure paypunkd.db exists, run pending migrations
paypunkd->>SQLite: SELECT * FROM accounts
Note over paypunkd: No accounts found (first unlock)
paypunkd->>keypunkd: bulk_export_viewing_keys(encrypted_keypunkd_password, keypunkd_client_pk, paths)
keypunkd->>SeedFile: read() — load encrypted seed blob
keypunkd->>keypunkd: decrypt seed with password
keypunkd->>keypunkd: for each (protocol, path): derive viewing key from seed
keypunkd-->>paypunkd: [(protocol, path, viewing_key), ...] — 60 keys (30 eth + 30 zcash)
paypunkd->>SQLite: INSERT OR REPLACE INTO pre_derived_keys (protocol, account_index, viewing_key) — 60 rows
paypunkd->>SQLite: SELECT viewing_key FROM pre_derived_keys WHERE protocol='Ethereum' AND account_index=0
paypunkd->>paypunkd: derive address from viewing key via protocol
paypunkd->>SQLite: INSERT INTO accounts (id, protocol, derivation_path, name, address, viewing_key, created_at) — Ethereum account 0
paypunkd->>SQLite: SELECT viewing_key FROM pre_derived_keys WHERE protocol='Zcash' AND account_index=0
paypunkd->>paypunkd: derive address from viewing key
paypunkd->>SQLite: INSERT INTO accounts — Zcash account 0
paypunkd-->>Client: UnlockSuccess { accounts_count: 2 }
Client-->>API: Ok(2)
API-->>GreetingScreen: Ok(UnlockData { accounts_count: 2 })
GreetingScreen->>GreetingScreen: Nav::Replace(Box::new(HomeScreen))
LockScreen — Re-authentication
File: tui/src/screens/lock.rs:17
Shown after auto-lock timeout. User authenticates with password to return to HomeScreen.
Persistence: get_lock() reads lock state (password set, failed attempts) from paypunkd via IPC. submit_lock() verifies the password against keypunkd via IPC. Failed attempts are tracked server-side.
sequenceDiagram
participant U as User
participant TUI as LockScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
Note over TUI: init() called
TUI->>API: get_lock()
API->>Client: get_lock_state()
Client->>paypunkd: IpcMessage(GetLockState)
paypunkd->>paypunkd: check .wallet_initialized marker (HasSeed)
paypunkd-->>Client: LockState { password_set, failed_attempts }
Client-->>API: (password_set, failed_attempts)
API-->>TUI: LockData { auth_methods: { password_set }, failed_attempts }
Note over TUI: Renders password field + failed attempts counter
U->>TUI: Type password + Enter
TUI->>API: submit_lock(LockInput { credential: { type: "password", value } })
API->>Client: verify_password(password)
Client->>paypunkd: IpcMessage(VerifyPassword)
paypunkd->>keypunkd: verify_password
keypunkd->>keypunkd: attempt seed decryption with password
alt Correct password
keypunkd-->>paypunkd: PasswordVerified
paypunkd-->>Client: Ok
API-->>TUI: Ok(())
TUI->>TUI: Nav::Replace(Box::new(HomeScreen))
else Wrong password
keypunkd-->>paypunkd: Error
paypunkd-->>Client: Err
API-->>TUI: Err(ApiError)
Note over TUI: Shows error, stays on LockScreen
end
On Esc, it returns Nav::Pop.
HomeScreen — Account List / Main Menu
File: tui/src/screens/home.rs:19
Displays all wallet accounts in a selectable list. Entry point for all other screens.
Persistence involved:
list_accounts()reads fromaccountsSQLite table viapaypunkdadd_account()readspre_derived_keystable for the viewing key, then inserts intoaccountstable
sequenceDiagram
participant U as User
participant TUI as HomeScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant SQLite as paypunkd.db (SQLite)
Note over TUI: init() called
TUI->>API: home_state()
API->>Client: list_accounts()
Client->>paypunkd: IpcMessage(ListAccounts)
paypunkd->>SQLite: SELECT id, protocol, derivation_path, name, address, viewing_key, created_at FROM accounts
SQLite-->>paypunkd: account rows
paypunkd-->>Client: AccountsList { accounts }
Client-->>API: Ok(accounts)
API->>API: Enrich with protocol metadata (chain_id, ticker)
API-->>TUI: ApiState::Loaded(HomeData { accounts })
Note over TUI: Renders account list
U->>TUI: Enter (select account)
TUI->>TUI: Nav::Push(AssetsScreen)
U->>TUI: 's' (send from account)
TUI->>TUI: Nav::Push(SendScreen)
U->>TUI: 'o' (receive to account)
TUI->>TUI: Nav::Push(ReceiveScreen)
U->>TUI: 'a' (add account)
TUI->>API: add_account()
API->>Client: list_accounts() — check existing accounts
Client->>paypunkd: IpcMessage(ListAccounts)
paypunkd->>SQLite: SELECT * FROM accounts
SQLite-->>paypunkd: accounts
paypunkd-->>Client: accounts
Note over API: Pick protocol with most accounts, compute next index
API->>Client: create_account(protocol, derivation_path, index, name)
Client->>paypunkd: IpcMessage(CreateAccount { protocol, derivation_path, account_index, name })
paypunkd->>SQLite: SELECT viewing_key FROM pre_derived_keys WHERE protocol=?1 AND account_index=?2
SQLite-->>paypunkd: viewing_key bytes
paypunkd->>paypunkd: derive address from viewing key via protocol
paypunkd->>SQLite: INSERT INTO accounts (id, protocol, derivation_path, name, address, viewing_key, created_at) VALUES (...)
SQLite-->>paypunkd: ok
paypunkd-->>Client: AccountCreated { account }
Client-->>API: Ok(account)
API-->>TUI: Ok(())
TUI->>API: refresh_home() + home_state()
API-->>TUI: Updated account list
U->>TUI: 'r' (refresh)
TUI->>API: refresh_home() + home_state()
API-->>TUI: Refreshed account list
U->>TUI: 'q' (quit)
TUI->>TUI: Nav::Quit
U->>TUI: '?' (help)
TUI->>TUI: Nav::Push(HelpScreen)
Reactivation Flow (returning from child screen)
sequenceDiagram
participant TUI as HomeScreen
participant API as RealWalletApi
Note over TUI: on_reactivate() called by App.process_nav(Nav::Pop)
TUI->>API: refresh_home()
API-->>TUI: (clears in-memory cache)
TUI->>API: home_state()
API->>Client: list_accounts() — fresh DB read
Client->>paypunkd: IpcMessage(ListAccounts)
paypunkd->>SQLite: SELECT * FROM accounts
SQLite-->>paypunkd: accounts
paypunkd-->>Client: accounts
API-->>TUI: ApiState::Loaded(HomeData)
TUI->>TUI: rebuild_list() with fresh data
AssetsScreen — Balance View
File: tui/src/screens/assets.rs:27
Shows account’s asset holdings. Has Send/Receive/History buttons and sync status polling.
Persistence involved:
get_assets()reads fromaccountsSQLite table to get the account’s address and protocol- Balance is fetched from the chain (RPC call via protocol implementation), NOT from the DB
- Sync status is from the protocol layer (no DB)
sequenceDiagram
participant U as User
participant TUI as AssetsScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant SQLite as paypunkd.db (SQLite)
participant Chain as Blockchain RPC
Note over TUI: init(account_id) called
TUI->>API: get_assets(account_id)
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount { id })
paypunkd->>SQLite: SELECT id, protocol, derivation_path, name, address, viewing_key, created_at FROM accounts WHERE id=?1
SQLite-->>paypunkd: account row
paypunkd-->>Client: AccountFound { account }
Client-->>API: Ok(Some(account))
API->>API: Enrich with protocol metadata (chain_id, decimals, ticker, asset)
API->>Client: get_balance(caip10_address, asset)
Client->>paypunkd: IpcMessage(GetBalance { address, asset })
paypunkd->>Chain: protocol.get_balance(address, asset) — RPC call
Chain-->>paypunkd: balance
paypunkd-->>Client: Balance { spendable, pending, total }
Client-->>API: Ok(balance)
API-->>TUI: AssetsData { assets: [AssetRow { holdings_amount, ... }] }
Note over TUI: Renders asset table + button bar
loop Every render tick (~50ms)
TUI->>API: get_sync_status(protocol)
API->>Client: get_sync_status(protocol_id)
Client->>paypunkd: IpcMessage(GetSyncStatus { protocol })
Note over paypunkd: Currently returns hardcoded SyncStatus{ is_syncing: false }
paypunkd-->>Client: SyncStatusResult { status }
Client-->>API: Ok(status)
API-->>TUI: SyncStatus { is_syncing, current_height, target_height }
Note over TUI: If syncing, renders progress bar in header
end
U->>TUI: Click "Send" button (or Enter on button)
TUI->>TUI: Nav::Push(SendScreen)
U->>TUI: Click "Receive" button
TUI->>TUI: Nav::Push(ReceiveScreen)
U->>TUI: Click "History" button
TUI->>TUI: Nav::Push(HistoryScreen)
U->>TUI: Esc
TUI->>TUI: Nav::Pop
Reactivation Flow
sequenceDiagram
participant TUI as AssetsScreen
participant API as RealWalletApi
participant SQLite as paypunkd.db (SQLite)
participant Chain as Blockchain RPC
Note over TUI: on_reactivate() called after child screen pops
TUI->>API: get_assets(account_id)
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount)
paypunkd->>SQLite: SELECT * FROM accounts WHERE id=?
SQLite-->>paypunkd: account
paypunkd-->>Client: account
API->>Client: get_balance(address, asset)
Client->>paypunkd: IpcMessage(GetBalance)
paypunkd->>Chain: protocol.get_balance()
Chain-->>paypunkd: balance
paypunkd-->>Client: Balance
API-->>TUI: Fresh AssetsData
TUI->>TUI: Rebuild asset list
SendScreen — Create and Send a Transfer
File: tui/src/screens/send.rs:78
Four-step flow: Form → Review → Sending → Confirm. This is the most complex usecase, spanning TUI → API → paypunkd → keypunkd with two-phase authorization.
Persistence involved:
send_state()readsaccountsSQLite table for address + protocol metadata- Balance is fetched from chain RPC (not DB)
- Address book is persisted in the paypunkd SQLite database via
AddAddressBookEntry/GetAddressBookIPC messages
Step 1: Form (enter recipient, amount, memo)
sequenceDiagram
participant U as User
participant TUI as SendScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant SQLite as paypunkd.db (SQLite)
participant Chain as Blockchain RPC
Note over TUI: init(account) called
TUI->>API: send_state(account_id)
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount { id })
paypunkd->>SQLite: SELECT * FROM accounts WHERE id=?1
SQLite-->>paypunkd: account row
paypunkd-->>Client: account
API->>Client: get_balance(caip10, asset)
Client->>paypunkd: IpcMessage(GetBalance { address, asset })
paypunkd->>Chain: protocol.get_balance(address, asset)
Chain-->>paypunkd: balance
paypunkd-->>Client: Balance
API-->>TUI: ApiState::Loaded(SendData { from_address, spendable_balance, decimals, chain_id })
TUI->>API: get_address_book()
Note over API: Reads from paypunkd SQLite via GetAddressBook IPC + local accounts from ListAccounts
Client->>paypunkd: IpcMessage(ListAccounts)
paypunkd->>SQLite: SELECT * FROM accounts
SQLite-->>paypunkd: accounts
paypunkd-->>Client: accounts
API-->>TUI: AddressBookData { entries: [own accounts + previously-sent contacts] }
Note over TUI: Renders: To picker (with address book), Amount field, Memo field (Zcash only), Balance display
U->>TUI: Fill in recipient (type or pick from dropdown), amount, memo
U->>TUI: Enter (submit review)
Step 2: Review (submit intent, show preview)
sequenceDiagram
participant TUI as SendScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant Protocol as Protocol impl
participant SQLite as paypunkd.db (SQLite)
participant SeedFile as seed.enc (disk)
TUI->>API: submit_send_review(SendReviewInput { to_address, amount, token_id, chain_id, account_id, memo })
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount { id })
paypunkd->>SQLite: SELECT * FROM accounts WHERE id=?1
SQLite-->>paypunkd: account
paypunkd-->>Client: account
Note over API: Build Intent based on protocol (Ethereum or Zcash)
API->>Client: submit_intent(intent, derivation_path)
Client->>paypunkd: IpcMessage(SubmitIntent { intent, derivation_path })
paypunkd->>Protocol: build(intent) → unsigned artifact (no persistence)
paypunkd->>keypunkd: preview_artifact(raw_artifact, protocol_id, derivation_path)
keypunkd->>Protocol: parse_artifact(raw_artifact) → ArtifactSummary
Note over keypunkd: preview_artifact only parses — does NOT read seed.enc
keypunkd-->>paypunkd: ArtifactPreview { raw_artifact, parsed_summary, signature, keypunkd_public_key }
paypunkd-->>Client: SignablePreview { ... }
Client-->>API: (raw_artifact, parsed_summary, keypunkd_signature, keypunkd_public_key)
Note over API: Deserialize ArtifactSummary from parsed_summary
Note over API: Store PendingSend in-memory Mutex (raw_artifact, signature, public_key, derivation_path, protocol)
API-->>TUI: SendReviewData { to_address, amount, fee_estimate, total_amount, chain_id, nonce }
Note over TUI: Renders review screen: From, To, Amount, Fee, Nonce, Total, Chain
Note over TUI: Shows password field for authorization
U->>TUI: Enter password + Enter
TUI->>TUI: Set step = Sending, store PendingSend { review, password }
Step 3: Sending (approve + broadcast, via tick)
sequenceDiagram
participant TUI as SendScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant Protocol as Protocol impl
participant SeedFile as seed.enc (disk)
participant Chain as Blockchain RPC
Note over TUI: tick() fires on next render cycle
TUI->>API: submit_send_confirm(SendConfirmInput { reviewed, auth_confirmation: { type: "password", value }, signed_tx: "" })
API->>API: Take pending PendingSend from in-memory Mutex
Note over API: Add recipient to address book via AddAddressBookEntry IPC (persisted to SQLite)
API->>Client: approve_signature(raw_artifact, keypunkd_signature, password, derivation_path)
Note over Client: Encrypt payload: [raw_len(4B) | raw_artifact | sig_len(4B) | signature | password_hash] to keypunkd's public key
Client->>paypunkd: IpcMessage(ApproveSignature { encrypted_payload, ephemeral_public_key, derivation_path })
paypunkd->>keypunkd: authorize_artifact(encrypted_payload, ephemeral_public_key, derivation_path)
keypunkd->>keypunkd: decrypt payload via X25519 keystore
keypunkd->>keypunkd: verify keypunkd_signature over H(raw, parsed, path)
keypunkd->>SeedFile: read() — load encrypted seed blob
keypunkd->>keypunkd: decrypt_seed(encrypted_blob, password_hash) — Argon2id + AES-256-GCM
keypunkd->>keypunkd: sign artifact with decrypted seed
keypunkd-->>paypunkd: SignatureApproved { signed_artifact }
paypunkd-->>Client: signed_artifact
Client-->>API: Ok(signed_artifact)
API->>Client: broadcast_transaction(protocol, signed_artifact)
Client->>paypunkd: IpcMessage(BroadcastTransaction { protocol, raw_tx })
paypunkd->>Protocol: finalize(signed_artifact) → finalized transaction bytes
paypunkd->>Chain: protocol.broadcast(finalized_bytes) → RPC call
Chain-->>paypunkd: tx_hash
paypunkd-->>Client: TransactionBroadcasted { tx_hash }
Client-->>API: Ok(tx_hash)
Note over API: Build block explorer URL from protocol metadata template
API-->>TUI: SendResult { tx_hash, status: "broadcasted", block_explorer_url }
TUI->>TUI: Set step = Confirm
Step 4: Confirm (show result)
sequenceDiagram
participant U as User
participant TUI as SendScreen
Note over TUI: Renders: ✓ Transaction Broadcasted, TX Hash, Status, Block Explorer URL
U->>TUI: 'c' (copy TX hash)
TUI->>TUI: arboard::Clipboard::set_text(tx_hash)
TUI->>TUI: Show "Copied!" feedback
U->>TUI: Enter / Esc
TUI->>TUI: Nav::Pop (back to AssetsScreen)
ReceiveScreen — Display Receiving Address
File: tui/src/screens/receive.rs:15
Shows the account’s receiving address, format info, QR payload, and a simulated QR code.
Persistence involved:
receive_state()reads fromaccountsSQLite table to get the account’s address- No writes to any persistence layer
sequenceDiagram
participant U as User
participant TUI as ReceiveScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant SQLite as paypunkd.db (SQLite)
Note over TUI: init(account) called
TUI->>API: receive_state(account_id)
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount { id })
paypunkd->>SQLite: SELECT id, protocol, derivation_path, name, address, viewing_key, created_at FROM accounts WHERE id=?1
SQLite-->>paypunkd: account row
paypunkd-->>Client: AccountFound { account }
Client-->>API: Ok(Some(account))
API-->>TUI: ApiState::Loaded(ReceiveData { address, chain_id, address_format: "hex", qr_payload: address })
Note over TUI: Renders: Address, Format, QR Payload, simulated QR box
U->>TUI: 'c' (copy address)
TUI->>TUI: arboard::Clipboard::set_text(address)
TUI->>TUI: Show "Copied!" feedback
U->>TUI: Esc
TUI->>TUI: Nav::Pop
Reactivation Flow
sequenceDiagram
participant TUI as ReceiveScreen
participant API as RealWalletApi
participant SQLite as paypunkd.db (SQLite)
Note over TUI: on_reactivate() called
TUI->>API: refresh_receive(account_id)
API-->>TUI: (clears in-memory cache)
TUI->>API: receive_state(account_id)
API->>Client: get_account(account_id)
Client->>paypunkd: IpcMessage(GetAccount)
paypunkd->>SQLite: SELECT * FROM accounts WHERE id=?
SQLite-->>paypunkd: account
paypunkd-->>Client: account
API-->>TUI: Fresh ReceiveData
SettingsScreen — Settings Management
File: tui/src/screens/settings.rs:21
Two sub-actions: Main (edit preferences) and RevealPhrase (authenticate to show mnemonic).
Persistence: Settings are persisted in the paypunkd SQLite database via GetSettings/SaveSettings IPC messages. Reveal phrase is implemented end-to-end via keypunkd’s ExportMnemonic.
Main Settings Flow
sequenceDiagram
participant U as User
participant TUI as SettingsScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant SQLite as paypunkd.db (SQLite)
Note over TUI: init() called
TUI->>API: get_settings()
API->>Client: get_settings()
Client->>paypunkd: IpcMessage(GetSettings)
paypunkd->>SQLite: SELECT auto_lock_minutes, fiat_currency FROM settings
SQLite-->>paypunkd: settings row
paypunkd-->>Client: SettingsResult { auto_lock_minutes, fiat_currency }
Client-->>API: (auto_lock_minutes, fiat_currency)
API-->>TUI: SettingsData { security: { auto_lock_minutes }, fiat_currency, app_version: "0.1.0" }
Note over TUI: Renders: Auto-Lock field, Fiat Currency field, "Reveal Recovery Phrase" option, "Save Settings" option
U->>TUI: Edit Auto-Lock value
U->>TUI: Edit Fiat Currency value
U->>TUI: Navigate to "Save Settings" + Enter
TUI->>API: submit_settings(SettingsInput { updated_security: { auto_lock_minutes }, fiat_currency })
API->>Client: save_settings(auto_lock_minutes, fiat_currency)
Client->>paypunkd: IpcMessage(SaveSettings { auto_lock_minutes, fiat_currency })
paypunkd->>SQLite: INSERT OR REPLACE INTO settings
paypunkd-->>Client: SettingsSaved
Client-->>API: Ok(())
API-->>TUI: Ok(())
U->>TUI: Esc
TUI->>TUI: Nav::Pop
Reveal Recovery Phrase Flow
sequenceDiagram
participant U as User
participant TUI as SettingsScreen
participant API as RealWalletApi
participant Client as paypunk-api Client
participant paypunkd as paypunkd (IPC)
participant keypunkd as keypunkd (IPC)
participant SeedFile as seed.enc (disk)
Note over TUI: In Main action, focus on "Reveal Recovery Phrase" + Enter
TUI->>TUI: Set action = RevealPhrase
Note over TUI: Renders password field for authentication
U->>TUI: Type password + Enter
TUI->>API: submit_reveal_phrase(RevealPhraseInput { auth_type: "password", value })
API->>Client: reveal_phrase(password)
Client->>Client: encrypt password to keypunkd's public key via ephemeral X25519 keypair
Client->>paypunkd: IpcMessage(RevealPhrase { encrypted_password, client_public_key })
paypunkd->>keypunkd: forward ExportMnemonic
keypunkd->>keypunkd: decrypt password via X25519 keystore
keypunkd->>SeedFile: read() — load encrypted seed blob
keypunkd->>keypunkd: decrypt_mnemonic(encrypted_blob, password) — Argon2id + AES-256-GCM
keypunkd->>keypunkd: encrypt mnemonic to client's public key
keypunkd-->>paypunkd: MnemonicExported { encrypted_mnemonic }
paypunkd-->>Client: PhraseRevealed { encrypted_mnemonic }
Client->>Client: decrypt mnemonic via ephemeral keypair
Client-->>API: Ok(mnemonic words)
API-->>TUI: Ok(vec!["ribbon", "velvet", ..., "anchor"]) — 12 words
Note over TUI: Renders 12-word grid with warning: "Never share your recovery phrase"
U->>TUI: Esc
TUI->>TUI: Set action = Main, clear phrase
HelpScreen — Keybinding Overlay
File: tui/src/screens/help.rs:11
Context-sensitive help overlay pushed on top of the current screen. Dismissed with Esc, q, or ?.
Persistence: None. Pure UI overlay — no API calls, no IPC, no DB or file access.
sequenceDiagram
participant U as User
participant CurrentScreen as Active Screen
participant TUI as HelpScreen
Note over CurrentScreen: User presses '?' while on any screen
CurrentScreen->>CurrentScreen: Nav::Push(HelpScreen::new(self.name()))
App->>HelpScreen: init() — no-op
Note over TUI: Renders: keybinding reference for current_screen name
alt Screen name matches known bindings
Note over TUI: Shows bindings for: Wallets, Assets, Home, Send, Receive, Lock, Settings, or Setup
else Unknown screen name
Note over TUI: Shows generic: Esc/q to close
end
U->>TUI: Esc / q / ?
TUI->>TUI: Nav::Pop (returns to previous screen)
The HelpScreen is a pure UI overlay — it makes no API calls, has no IPC interactions, and touches no persistence layer. It reads the current screen name from its constructor and returns static keybinding data based on that name.