Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

Paypunk logo

Paypunk Project

This is experimental software and should not be used with real funds

CI License: AGPL-3.0 Rust Status

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 Protocol and SignerProtocol traits. 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/SignerProtocol traits, 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 by ProtocolId (Zcash, Ethereum). Hides IPC and actor details from consumers.
  • paypunkd — App daemon (library crate, launched via paypunk paypunkd). Hosts the Paypunkd actor, usecases, service orchestration, chain backend injection.
  • keypunkd — Key daemon (library crate, launched via paypunk keypunkd). Hosts the Keypunkd actor. 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 the Protocol and SignerProtocol traits from paypunk-types.
  • cli — Command-line interface binary (paypunk). Uses api for 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 api library. 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:

KeyAction
?Help overlay (context-sensitive)
EnterSelect / confirm
EscBack / cancel
qQuit
sSend
oReceive
aAdd account
rRefresh
cCopy 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:

NetworkLightwalletd defaultData directory
regtesthttp://127.0.0.1:9067 (local)~/.local/share/paypunk/regtest/
testnethttps://testnet.zec.rocks:443~/.local/share/paypunk/testnet/
mainnethttps://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:

  1. Wallet (desktop) constructs a transaction and encodes it as QR codes
  2. User scans the QR with the signer app (or the bridge web page)
  3. Signer app previews the transaction (recipient, amount, fee)
  4. User approves — signer signs with the on-device seed
  5. Signed artifact is displayed as QR codes
  6. User scans the result back into the wallet, which broadcasts it

Roadmap

  1. DB encryption from signer password entropy — encrypt paypunkd.db at rest using key material derived from the signer password, with separate compartmentalization from seed encryption
  2. Tauri Desktop UI — replace the throwaway TUI with a proper desktop application using the same IPC backend
  3. Tauri Mobile UI — full mobile wallet experience (the signer app is the first step; a full mobile wallet follows)
  4. Transparent / Sapling addresses — extend Zcash support beyond Orchard to include Sapling and transparent pools
  5. Zcash / Ethereum hardening — production-grade error handling, edge cases, replay protection, and test coverage for existing chains
  6. Cross-chain asset swaps — near-intent-level swaps between assets (e.g. ZEC ↔ ETH) using the Intent enum pattern
  7. ZSAs — Zcash Shielded Assets support
  8. 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

  1. Signing/wallet separation — Keys live in a separate process (keypunkd) or on an air-gapped device. The wallet daemon never holds key material.
  2. Multi-token by design — Chain-specific logic is isolated behind Protocol and SignerProtocol traits. Adding a chain means implementing traits, not rearchitecting.
  3. 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.
  4. 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 messages
  • IpcReceiver — accepts connections, performs the server-side handshake, verifies MACs, dispatches to a handler actor
  • UnixSocketTransport — 4-byte LE length-prefixed framing over UnixStream
  • IpcMessage — tactix message carrying opaque Vec<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

ByteMessage typePayload
0x00MSG_GET_PUBLIC_KEYnone
0x01MSG_PUBLIC_KEY32-byte X25519 public key
0x02MSG_REGISTER_CLIENT32-byte client public key
0x03MSG_REGISTER_CLIENT_ACKnone
0x04MSG_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

  • paypunkdProtocolService::register(Box<dyn Protocol>) in run.rs
  • keypunkdProtocolService::register(ProtocolId, Box<dyn SignerProtocol>) in run.rs

Both registries are HashMap-based and require no changes when adding a chain — just call register.

Data Storage

keypunkd

FileFormatEncryption
{data_dir}/seed.enc[salt(16B)][nonce(12B)][AES-256-GCM ciphertext]Argon2id key derivation + AES-256-GCM
{data_dir}/seed.mnemonic.encSame formatSame encryption

Atomic writes via .tmp + rename.

paypunkd

FileFormatEncryption
{data_dir}/paypunkd.dbSQLite (rusqlite, bundled)Plaintext (encryption planned)
{data_dir}/.wallet_initializedMarker fileNone

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

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 --password flag 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

FlagShortDefaultDescription
--socket-path-s/tmp/paypunkd.sockpaypunkd IPC socket path
--signerfalseEnable offline signer mode (spawns bridge instead of keypunkd)
--zcash-networkregtestZcash network: regtest, testnet, or mainnet
--lightwalletd-hostNetwork defaultOverride lightwalletd endpoint
--data-dir~/.local/share/paypunk/<network>/Override data directory

Environment variables

All config fields can be overridden via environment variables:

Env varConfig field
PAYPUNK_SOCKET_PATHpaypunkd_socket_path
KEYPUNKD_SOCKET_PATHkeypunkd_socket_path
PAYPUNK_DATA_DIRdata_dir
PAYPUNK_CONFIG_DIRconfig_dir
PAYPUNK_ETHEREUM_RPC_URLethereum_rpc_url
PAYPUNK_LIGHTWALLETD_HOSTlightwalletd_host
PAYPUNK_ZCASH_NETWORKzcash_network
PAYPUNK_BRIDGE_SOCKET_PATHbridge_socket_path
PAYPUNK_OFFLINE_SIGNERoffline_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>
FlagShortTypeRequiredDefault
--password-pStringYes

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
FlagShortTypeRequiredDefault
--mnemonic-mStringYes
--password-pStringYes
--birthday-heightu64NoNone

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>
FlagShortTypeRequiredDefault
--password-pStringYes

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
FlagShortTypeRequiredDefault
--to-tStringYes
--amount-aStringYes
--from-fStringYes
--assetStringNoeip155:1/slip44:60
--protocol-pStringNoInferred from asset
--data-dStringNoNone
--memo-mStringNoNone
--accountu32No0

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>
FlagShortTypeRequiredDefault
--password-pStringYes
--accountu32No0

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...
FlagShortTypeRequiredDefault
--protocol-pStringNozcash
--account-au32No0
--addressStringNoNone (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"
FlagShortTypeRequiredDefault
--protocol-pStringNozcash
--account-indexu32No0
--name-nStringNo"{Protocol} Account {index}"
--birthday-heightu64NoNone

tui

Launch the terminal UI. Does NOT start daemons — they must be running.

paypunk tui
paypunk tui --signer    # connect to bridge instead of keypunkd
FlagShortTypeRequiredDefault
--signerboolNofalse

keypunkd

Launch the key daemon as a foreground process.

paypunk keypunkd --zcash-network regtest
FlagShortTypeRequiredDefault
--socket-path-sStringNo/tmp/keypunkd.sock
--data-dir-dStringNo~/.local/share/paypunk/<network>/
--zcash-network-zStringNoregtest

paypunkd

Launch the app daemon as a foreground process.

paypunk paypunkd --keypunkd-socket /tmp/keypunkd.sock --lightwalletd-host http://127.0.0.1:9067
FlagShortTypeRequiredDefault
--socket-path-sStringNo/tmp/paypunkd.sock
--keypunkd-socket-kStringNo/tmp/keypunkd.sock
--ethereum-rpc-url-eStringNohttp://127.0.0.1:8545
--data-dir-dStringNo~/.local/share/paypunk/<network>/
--lightwalletd-host-lStringNoNetwork default
--zcash-network-zStringNoregtest

bridge

Run the QR bridge web server for air-gapped signing.

paypunk bridge --port 12345 --socket-path /tmp/keypunkd.sock
FlagShortTypeRequiredDefault
--portu16No12345
--socket-pathStringNo/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
FlagShortTypeRequiredDefault
--force-fboolNofalse

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:

  1. Checks if paypunkd is already running on the socket (500ms connect probe)
  2. If not, spawns keypunkd (or bridge in signer mode) + paypunkd as child processes
  3. Waits up to 30 seconds for sockets to appear
  4. Runs the command/TUI
  5. 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

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

CommandPurpose
cargo buildBuild the workspace
cargo testRun all tests (unit + integration)
cargo fmt --all --checkCheck formatting (CI enforces this)
cargo clippy --all-targetsRun lints (recommended, not yet CI-gated)
cargo runAuto-launch daemons + TUI
cargo run -- tuiLaunch TUI only (daemons must be running)

devenv scripts

If using devenv, these shortcuts are available:

ScriptCommandPurpose
cbcargo buildBuild
ctcargo testTest
setupscripts/setup-test-wallet.shReset + restore test wallet (pass a block height as the first arg for mainnet birthday)
ethereumscripts/start-ethereum.shStart anvil Docker stack
zcashscripts/start-zcash.shStart zcashd + lightwalletd regtest
kpscripts/key-daemon.shRun keypunkd
ppscripts/wallet-daemon.shRun paypunkd
tuiscripts/ui.shRun TUI
balscripts/get-balance.shQuery 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. Uses TestBuilder to 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 fmt defaults (no rustfmt.toml). CI checks cargo 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 root Cargo.toml. Reference as foo.workspace = true in crate manifests.
  • Serialization: postcard for IPC payloads, toml for config, serde for derive macros.
  • Actor framework: tactix throughout. Actors implement Actor + 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

  1. Fork the repo and create a branch
  2. cargo fmt --all — format your code
  3. cargo test — make sure tests pass
  4. cargo clippy --all-targets — fix any warnings (recommended)
  5. 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), and paypunk (CLI/TUI). Both daemons are library crates launched via the paypunk CLI 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):

  1. 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.
  2. CLI — Command-line interface using api for scripting and automation. Integrates the TUI as a library for interactive use.
  3. 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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. As a CLI user, I want to create a new wallet from the command line, so that I can script wallet provisioning
  12. As a CLI user, I want to generate addresses from the command line, so that I can get payment destinations programmatically
  13. As a CLI user, I want to check balance from the command line, so that I can monitor funds via scripts
  14. As a CLI user, I want to send Transfers from the command line, so that I can automate payments
  15. As a CLI user, I want to view transaction history from the command line, so that I can audit my wallet activity programmatically
  16. 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
  17. 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
  18. As an agent, I want to call the wallet API over IPC, so that I can integrate Zcash payments into my workflows
  19. 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
  20. 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
  21. 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 modelkeypunkd (key daemon), paypunkd (app daemon), paypunk (CLI/TUI). Both daemons are library crates launched via the paypunk CLI binary. Process separation from v1 enforces the security boundary — neither the CLI nor the application daemon ever hold key material.
  • Key isolation — The Keypunkd actor (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 each AuthorizeArtifact and ExportViewingKey call — 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 loggingtracing crate 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/SignerProtocol traits, etc.). No chain-specific logic.
  • api (library) — Chain-agnostic public API. Dispatches to the correct chain backend by ProtocolId. Hides IPC/tactix details. CLI and TUI depend on this.
  • paypunkd (library, launched via CLI) — App daemon. Hosts Paypunkd actor, usecases, service orchestration, chain backend injection.
  • keypunkd (library, launched via CLI) — Key daemon. Hosts Keypunkd actor. 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 the Protocol and SignerProtocol traits from paypunk-types.
  • tui — Ratatui screens and widgets. Library crate consumed by CLI, also builds as standalone binary. Reusable by future Tauri desktop app.
  • cli (binary) — Links api and tui. Runs in CLI mode (single command) or TUI mode (interactive session). Also launches daemons via subcommands.

Passphrase Input

  • Currently supports --password CLI 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.enc with the same encryption

Seed in memory

  • Decrypted on-demand for each AuthorizeArtifact or ExportViewingKey call
  • Held on the stack for the duration of the signing operation only
  • No long-lived unlocked session
  • Zeroizing wrappers 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.db is an unencrypted SQLite file
  • is_locked() always returns false
  • 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

  • keypunkd is 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 localhost or HTTPS for getUserMedia access.

Passwords on the command line

  • The CLI accepts passwords via --password flag 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)

AssetExposed?Notes
SeedNoEncrypted with Argon2id + AES-256-GCM
MnemonicNoEncrypted with same scheme
Viewing keysYesStored in paypunkd.db (plaintext)
Transaction historyYesStored in paypunkd.db (plaintext)
Address bookYesStored in paypunkd.db (plaintext)
SettingsYesStored in paypunkd.db (plaintext)

Attacker with filesystem access + keypunkd password

AssetExposed?Notes
SeedYesPassword decrypts the seed
MnemonicYesPassword decrypts the mnemonic
All fundsYesSeed allows signing transactions

Attacker with process memory access to keypunkd

AssetExposed?Notes
Decrypted seedYesOn the stack during signing operations
IPC keypairYesEphemeral, regenerated on restart
All fundsYesSeed allows signing transactions
MitigationRun keypunkd as a separate system user; limit memory access

Attacker with process memory access to paypunkd

AssetExposed?Notes
SeedNoNever held by paypunkd
Viewing keysYesStored in DB, loaded into memory
IPC keypairYesEphemeral, regenerated on restart
MitigationViewing keys are non-spending; risk is metadata exposure

Attacker on the network (bridge mode)

AssetExposed?Notes
Unsigned transactionsYesBridge WS has no auth, binds to 0.0.0.0
Forged signaturesYesAttacker can inject WS responses
MitigationUse bridge only on trusted networks; prefer the mobile signer app

Recommendations

  1. Run keypunkd as a separate system user — Use systemd User= and Group= directives. This provides file/memory isolation even though the code doesn’t enforce it.
  2. Restrict socket permissions — Place Unix sockets in a directory with restricted permissions (e.g., /run/paypunk/ with 0700).
  3. Use the mobile signer for high-value wallets — Air-gapped signing via QR codes eliminates network attack surface.
  4. Don’t use --password on shared systems — Passwords in process arguments are visible to other users via ps.
  5. Encrypt the wallet database — Until DB encryption is implemented, consider full-disk encryption (LUKS) for the data directory.
  6. 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 Intent enum 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:

  1. Protocol trait (wallet side) — builds unsigned transactions, finalizes/broadcasts signed ones, queries balances, exposes metadata. Lives in paypunkd.
  2. SignerProtocol trait (signer side) — derives viewing keys, parses artifacts for preview, signs with the seed. Lives in keypunkd.
  3. Intent variants — a new ProtocolId, Intent variant, and ArtifactSummary variant in paypunk-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 a create_protocol factory that spawns actor tasks, supports multi-network, uses a wallet cargo 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):

MethodPurpose
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):

MethodDefault
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.):

  1. Add fields to paypunkd/src/run.rs::Config
  2. Add corresponding fields to config/src/lib.rs::PaypunkConfig with defaults and env var overrides
  3. Plumb them through the CLI in cli/src/main.rs if 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.rsProtocolId, Intent, ArtifactSummary variants added
  • protocols/monero/ crate created and added to workspace
  • SignerProtocol implemented (3 methods)
  • Protocol implemented (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 build passes
  • cargo test passes
  • cargo fmt --all --check passes

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

MethodPathDescription
GET/Serves index.html — the WebSocket + animated QR page
GET/wsWebSocket 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:

StepMessageDirectionPayload
1MSG_GET_PUBLIC_KEY (0x00)Client → Bridgenone
2MSG_PUBLIC_KEY (0x01)Bridge → Client32-byte X25519 public key
3MSG_REGISTER_CLIENT (0x02)Client → Bridge32-byte client public key
4MSG_REGISTER_CLIENT_ACK (0x03)Bridge → Clientnone

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: UREncoder with MAX_FRAGMENT_LEN=200, rendered at 8 fps via QRCode.toCanvas with errorCorrectionLevel: 'L'
  • Scan: getUserMedia({ facingMode: 'environment' }) + jsQR for decoding + URDecoder for 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 include PreviewArtifact, AuthorizeArtifact, ExportViewingKey, RegisterViewingKeys, VerifySignerSession, etc.
  • Response: KeypunkdResponse — variants include ArtifactPreview, 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

TUI Wallet Usecases

#UsecaseFileDescriptionPersistence
1SetupScreentui/src/screens/setup.rs:32Wallet creation/import wizard (7 sub-steps)Writes seed.enc (keypunkd), writes pre_derived_keys + accounts tables (paypunkd)
2GreetingScreentui/src/screens/greeting.rs:16Initial unlock prompt for existing walletReads seed.enc (keypunkd), writes pre_derived_keys + accounts tables (paypunkd)
3LockScreentui/src/screens/lock.rs:17Re-authentication after auto-lockNone — no-op implementation
4HomeScreentui/src/screens/home.rs:19Account list and main navigationReads accounts table, writes accounts on add
5AssetsScreentui/src/screens/assets.rs:27Asset balance view with Send/Receive/History buttonsReads accounts table; balance from chain RPC
6SendScreentui/src/screens/send.rs:78Multi-step send flow (Form → Review → Sending → Confirm)Reads accounts table; address book persisted to SQLite; signing reads seed.enc
7ReceiveScreentui/src/screens/receive.rs:15Display receiving address + QR codeReads accounts table
8SettingsScreentui/src/screens/settings.rs:21Auto-lock, fiat currency, reveal recovery phraseReads/writes settings table; reveal reads seed.enc via keypunkd
9HelpScreentui/src/screens/help.rs:11Context-sensitive keybinding overlayNone — 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 via std::fs::read

paypunkd — SQLite Database

  • File: {data_dir}/paypunkd.db (plaintext — encryption at rest is planned but not yet implemented)
  • Tables:
    • accountsid TEXT PK, protocol TEXT, derivation_path TEXT, name TEXT, address TEXT, viewing_key BLOB, created_at INTEGER, birthday_height INTEGER
    • pre_derived_keysprotocol TEXT, account_index INTEGER, viewing_key BLOB, created_at INTEGER DEFAULT (strftime('%s','now')), PRIMARY KEY (protocol, account_index)
    • address_bookid INTEGER PK AUTOINCREMENT, name TEXT, address TEXT UNIQUE, protocol TEXT, created_at INTEGER
    • settingskey TEXT PK, value TEXT
    • signer_statesession_public_key BLOB, created_at INTEGER DEFAULT (strftime('%s','now'))
    • _migrationsversion INTEGER PK, applied_at TEXT DEFAULT (datetime('now'))
  • Lock/unlock: is_locked() always returns false — no DB encryption currently implemented. A .wallet_initialized marker file tracks wallet existence.

Address Book

  • Persisted in paypunkd SQLite address_book table via AddAddressBookEntry/GetAddressBook IPC 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 Screen trait — renders UI and handles keyboard input
  • RealWalletApi: tui/src/api/real.rs — implements WalletApi trait, communicates via paypunk-api::Client
  • paypunk-api Client: api/src/client.rs — high-level client wrapping PaypunkService IPC 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.enc from 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_keys table
  • paypunkd writes first account to accounts table
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 from accounts SQLite table via paypunkd
  • add_account() reads pre_derived_keys table for the viewing key, then inserts into accounts table
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 from accounts SQLite 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() reads accounts SQLite table for address + protocol metadata
  • Balance is fetched from chain RPC (not DB)
  • Address book is persisted in the paypunkd SQLite database via AddAddressBookEntry/GetAddressBook IPC 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 from accounts SQLite 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.