
Ladon
- 2 installs
- 1 repo stars
- Updated July 14, 2026
- melonask/ladon
Derives multi-chain HD wallet addresses for EVM, Bitcoin, and Solana, manages encrypted wallets, and runs an address-pool daemon via a Rust CLI.
About
A multi-chain HD wallet CLI, Rust library, and address-pool daemon supporting EVM, Bitcoin, and Solana with BIP-32/44 and SLIP-0010 derivation. A developer uses it to derive addresses, generate mnemonics, run watch-only xpub workflows, or manage a persistent address pool.
- derive, decrypt, and pool sub-commands with TOML config
- BIP-39 mnemonics and batch multi-chain address generation
Ladon by the numbers
- 2 all-time installs (skills.sh)
- Ranked #408 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/melonask/ladon --skill ladonAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 14, 2026 |
| Repository | melonask/ladon ↗ |
What it does
Derives multi-chain HD wallet addresses for EVM, Bitcoin, and Solana, manages encrypted wallets, and runs an address-pool daemon via a Rust CLI.
Files
Ladon — Multi-Chain HD Wallet
Like the hundred-headed serpent of Greek myth, Ladon generates as many addresses as you need.
Ladon is a fast, minimal, multi-chain HD wallet CLI, Rust library, and address-pool daemon. It supports EVM, Bitcoin, and Solana chains with BIP-32/44 and SLIP-0010 derivation.
Install
cargo install ladonModes
Ladon has three sub-commands:
| Sub-command | Purpose |
|---|---|
derive | Derive one or more addresses and print to stdout |
decrypt | Decrypt an encrypted wallet file |
pool | Run the address-pool daemon (requires [database] in config) |
---
CLI Usage
Configuration
All settings live in Config.toml (or any path passed with --config / -C). Per-flag overrides are available on derive for ad-hoc use.
ladon --config /etc/ladon/Config.toml poolDerive
Output goes to stdout; redirect it to whatever format you need:
# JSON (default) — pipe or redirect
ladon derive --chain evm --num 5
ladon derive --chain evm --num 20 > wallet.json
# CSV
ladon derive --chain evm --num 20 --format csv > wallet.csv
# Plain text (one address per line)
ladon derive --chain solana --num 10 --format text > addresses.txt
# Encrypted output
ladon derive --chain evm --num 5 --encrypt --password "secret" > wallet.enc
# Bitcoin from existing mnemonic
ladon derive --chain btc --mnemonic "word1 ... word12"
# Specific indexes or ranges
ladon derive --chain evm --indexes "0,5,22-44,55,66-109"
# Watch-only from xpub
ladon derive --chain evm --xpub xpub6C...
# From xpriv
ladon derive --chain evm --xpriv xprv9s...Key Flags
| Flag | Default | Description |
|---|---|---|
--chain / -c | evm | evm, btc, solana |
--num / -n | 1 | Number of addresses |
--index / -i | — | Single specific index |
--indexes | — | Comma-separated indexes/ranges |
--account | 0 | BIP-44 account |
--change | 0 | BIP-44 change |
--network | bitcoin | Bitcoin network |
--passphrase | "" | BIP-39 passphrase |
--strength / -s | 12 | Mnemonic word count |
--solana-mode | full | full, cold-export, hsm-sim, pda |
--program-id | — | Base58 program ID for PDA mode |
--xpub | — | Watch-only from xpub |
--xpriv | — | Derive from xpriv |
--format / -f | json | json, csv, text |
--encrypt | false | Encrypt output |
--password | — | Encryption password |
Decrypt
ladon decrypt wallet.enc --password "secret"
ladon decrypt wallet.enc --password "secret" > wallet.json---
Address-Pool Daemon
The pool sub-command runs a long-lived service that:
1. Connects to a SQLite or Postgres database. 2. Polls the pool table on a configurable interval. 3. Derives and inserts new addresses when the count drops below [pool].threshold. 4. Keeps the total at [pool].target addresses per chain.
Your application removes rows from the pool table as it assigns addresses to users.
Example Config.toml (pool mode)
# ── Derivation ──────────────────────────────────────────────────────────────────
[derive]
# How to obtain the master secret. Supported "kind" values:
# "env" — mnemonic from an environment variable
# "xpriv_env" — raw hex xpriv from an environment variable
# "file" — mnemonic read from a plain-text file
[derive.secret]
kind = "env"
var = "LADON_MNEMONIC"
# Optional: BIP-39 passphrase read from a separate env var
# passphrase_var = "LADON_PASSPHRASE"
# One [[derive.chains]] section per blockchain.
[[derive.chains]]
name = "evm"
account = 0
change = 0
[[derive.chains]]
name = "solana"
account = 0
change = 0
solana_mode = "cold-export" # "full" | "cold-export" | "hsm-sim" | "pda"
# program_id = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
# [[derive.chains]]
# name = "btc"
# account = 0
# change = 0
# network = "bitcoin" # "bitcoin" | "testnet" | "signet" | "regtest"
# ── Pool daemon ─────────────────────────────────────────────────────────────────
[pool]
target = 1000 # keep this many addresses in the pool at all times
threshold = 200 # start refilling when the count drops below this
batch = 100 # derive this many at a time
interval_secs = 10 # poll the database every N seconds
# ── Database — SQLite ───────────────────────────────────────────────────────────
[database.sqlite]
path = "data/addresses.db"
[database.sqlite.table]
name = "derived_addresses"
[database.sqlite.table.columns]
id = "id"
chain = "chain"
address = "address"
path = "path"
index = "index"
created_at = "created_at"
# ── Database — Postgres (comment out the sqlite section above if using this) ────
# [database.postgres]
# url = "postgres://user:password@localhost:5432/ladon"
# # Alternatively, set the env var DATABASE_URL and omit `url` here.
# # url_env = "DATABASE_URL"
# pool_size = 5
#
# [database.postgres.table]
# name = "derived_addresses"
#
# [database.postgres.table.columns]
# id = "id"
# chain = "chain"
# address = "address"
# path = "path"
# index = "index"
# created_at = "created_at"---
Docker
# Build and start the pool daemon with Postgres
docker compose -f deploy/docker-compose.yml up -dSet LADON_MNEMONIC and DATABASE_URL in the environment or in deploy/.env. The container uses restart: unless-stopped so it recovers automatically.
Systemd (bare-metal)
sudo cp deploy/ladon.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ladonStore secrets in /etc/ladon/env:
LADON_MNEMONIC=word1 word2 ... word12
DATABASE_URL=postgres://ladon:secret@localhost/ladon
RUST_LOG=ladon=info---
Library Usage (Rust)
use ladon::{derive, decrypt_data, encrypt_data, Params, WalletOutput};
let wallet: WalletOutput = derive(Params {
chain: "evm".into(),
num: 5,
..Default::default()
})?;
for key in &wallet.keys {
println!("{}: {}", key.index, key.address);
}---
Chains and Derivation Paths
| Chain | Default path |
|---|---|
| EVM | m/44'/60'/0'/0/* |
| Bitcoin | m/44'/0'/0'/0/* |
| Solana | m/44'/501'/0'/0' (hardened, SLIP-0010) |
---
Security Notes
- Private keys are zeroized on drop.
- Use
--solana-mode cold-exportor--encryptwhen storing derive output. --passwordon the CLI is visible in shell history; prefer environment-specific secret management (LADON_MNEMONIC, vault agents, etc.) in automation.- Ed25519 derivation follows SLIP-0010 (all path segments hardened).
- In the pool daemon, the mnemonic is never written to disk — it lives only in the process environment.
#!/usr/bin/env bash
set -euo pipefail
echo "==> cargo fmt --all"
cargo fmt --all
git add -u
echo " Formatting applied."
echo "==> cargo clippy -- -D warnings"
cargo clippy -- -D warnings
echo " Lint with clippy."
echo "==> cargo test"
cargo test
echo " Tests passed."
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Check formatting
run: cargo fmt --check
- name: Lint with clippy
run: cargo clippy -- -D warnings
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
test-msrv:
runs-on: ubuntu-latest
strategy:
matrix:
rust: ["1.96", stable, beta]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Verify crate package
run: cargo package --allow-dirty --verbose
name: Publish to crates.io
on:
release:
types: [created]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Verify version
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
CARGO_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)"/\1/p' Cargo.toml)
if [ "$VERSION" != "$CARGO_VERSION" ]; then
echo "Tag version $VERSION does not match Cargo.toml version $CARGO_VERSION"
exit 1
fi
- name: Run tests
run: cargo test --verbose
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish
/target
**/*.rs.bk
.DS_Store
.env
*.enc.json
*.json
[package]
name = "ladon"
version = "0.2.0"
edition = "2024"
authors = ["Melon Ask"]
description = "Fast multi-chain HD wallet CLI and address-pool daemon: EVM, Bitcoin, Solana"
license = "MIT"
repository = "https://github.com/melonask/ladon"
homepage = "https://github.com/melonask/ladon"
keywords = ["crypto", "hd-wallet", "ethereum", "bitcoin", "solana"]
categories = ["command-line-utilities", "cryptography"]
rust-version = "1.96"
# ── Crypto / HD wallet ──────────────────────────────────────────────────────────
[dependencies]
bip39 = { version = "2.2", features = ["rand"] }
bitcoin = { version = "0.32", features = ["std", "rand"], default-features = false }
ed25519-dalek = "2.2"
solana-pubkey = { version = "4.2", features = ["curve25519", "sha2"] }
tiny-keccak = { version = "2.0", features = ["keccak"] }
hmac = "0.13"
sha2 = "0.11"
aes-gcm = "0.10"
scrypt = "0.12"
hex = "0.4"
base64 = "0.22"
rand = "0.8"
zeroize = { version = "1.8", features = ["derive"] }
# ── Serialisation ───────────────────────────────────────────────────────────────
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# ── CLI ─────────────────────────────────────────────────────────────────────────
clap = { version = "4.5", features = ["derive", "env"] }
# ── Async runtime ───────────────────────────────────────────────────────────────
tokio = { version = "1.44", features = ["full"] }
# ── Database (feature-gated so the lib stays lean) ──────────────────────────────
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "any", "sqlite", "postgres", "uuid", "chrono"], default-features = false }
ulid = "1.2"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# ── Observability ───────────────────────────────────────────────────────────────
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# ── Utilities ───────────────────────────────────────────────────────────────────
anyhow = "1.0"
thiserror = "2.0"
[lib]
name = "ladon"
path = "src/lib.rs"
[[bin]]
name = "ladon"
path = "src/main.rs"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
[dev-dependencies]
serde_json = "1.0"
tokio = { version = "1.44", features = ["full"] }
# ── ladon Config.toml ──────────────────────────────────────────────────────────
# Every setting has a default; uncomment and adjust only what you need.
# ── Derivation ──────────────────────────────────────────────────────────────────
[derive]
# How to obtain the master secret. Supported "kind" values:
# "env" — mnemonic from an environment variable
# "xpriv_env" — raw hex xpriv from an environment variable
# "file" — mnemonic read from a plain-text file
[derive.secret]
kind = "env"
var = "LADON_MNEMONIC"
# Optional: BIP-39 passphrase read from a separate env var
# passphrase_var = "LADON_PASSPHRASE"
# One [[derive.chains]] section per blockchain.
[[derive.chains]]
name = "evm"
account = 0
change = 0
[[derive.chains]]
name = "solana"
account = 0
change = 0
solana_mode = "cold-export" # "full" | "cold-export" | "hsm-sim" | "pda"
# program_id = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
# [[derive.chains]]
# name = "btc"
# account = 0
# change = 0
# network = "bitcoin" # "bitcoin" | "testnet" | "signet" | "regtest"
# ── Pool daemon ─────────────────────────────────────────────────────────────────
[pool]
target = 1000 # keep this many addresses in the pool at all times
threshold = 200 # start refilling when the count drops below this
batch = 100 # derive this many at a time
interval_secs = 10 # poll the database every N seconds
# ── Database — SQLite ───────────────────────────────────────────────────────────
[database.sqlite]
path = "data/addresses.db"
[database.sqlite.table]
name = "derived_addresses"
[database.sqlite.table.columns]
id = "id"
chain = "chain"
address = "address"
path = "path"
index = "index"
created_at = "created_at"
# ── Database — Postgres (comment out the sqlite section above if using this) ────
# [database.postgres]
# url = "postgres://user:password@localhost:5432/ladon"
# # Alternatively, set the env var DATABASE_URL and omit `url` here.
# # url_env = "DATABASE_URL"
# pool_size = 5
#
# [database.postgres.table]
# name = "derived_addresses"
#
# [database.postgres.table.columns]
# id = "id"
# chain = "chain"
# address = "address"
# path = "path"
# index = "index"
# created_at = "created_at"
# Postgres-backed pool-daemon config used by deploy/docker-compose.yml.
[derive]
[derive.secret]
kind = "env"
var = "LADON_MNEMONIC"
[[derive.chains]]
name = "evm"
[[derive.chains]]
name = "solana"
solana_mode = "cold-export"
[[derive.chains]]
name = "btc"
network = "bitcoin"
[pool]
target = 10
threshold = 5
batch = 5
interval_secs = 10
[database.postgres]
url_env = "DATABASE_URL"
pool_size = 5
[database.postgres.table]
name = "derived_addresses"
[database.postgres.table.columns]
id = "id"
chain = "chain"
address = "address"
path = "path"
index = "index"
created_at = "created_at"
services:
# ── Postgres (optional; remove if using SQLite) ──────────────────────────────
postgres:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ladon
POSTGRES_PASSWORD: changeme
POSTGRES_DB: ladon
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ladon -d ladon"]
interval: 5s
timeout: 5s
retries: 10
# ── ladon pool daemon ─────────────────────────────────────────────────────────
ladon:
build:
context: ..
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# Provide the mnemonic via the environment; never bake secrets into the image.
LADON_MNEMONIC: "${LADON_MNEMONIC:?set LADON_MNEMONIC}"
# Point the Postgres URL at the service above.
DATABASE_URL: "postgres://ladon:changeme@postgres:5432/ladon"
# Adjust log verbosity: error | warn | info | debug | trace
RUST_LOG: "ladon=info"
volumes:
- ./Config.toml:/app/Config.toml:ro
# Only needed for the SQLite backend:
# - ./data:/app/data
volumes:
pgdata:
[Unit]
Description=ladon address-pool daemon
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=ladon
Group=ladon
WorkingDirectory=/opt/ladon
ExecStart=/usr/local/bin/ladon --config /etc/ladon/Config.toml pool
# Restart automatically on failure.
Restart=on-failure
RestartSec=5
# Keep secrets out of the unit file — load them from a protected env file.
EnvironmentFile=/etc/ladon/env
# Harden the process.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/ladon
# Log via journald.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ladon
[Install]
WantedBy=multi-user.target
# ── Build stage ──────────────────────────────────────────────────────────────
FROM rust:1.96-slim AS builder
WORKDIR /build
# Cache dependencies before copying source.
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src && echo 'fn main(){}' > src/main.rs \
&& cargo fetch --locked
COPY src/ src/
RUN cargo build --release --locked
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system ladon && useradd --system --gid ladon ladon
WORKDIR /app
RUN mkdir -p data && chown ladon:ladon data
COPY --from=builder /build/target/release/ladon /usr/local/bin/ladon
COPY Config.toml ./
USER ladon
# Run the pool daemon by default.
# Override CMD to use the derive or decrypt sub-commands instead.
CMD ["ladon", "--config", "/app/Config.toml", "pool"]
MIT License
Copyright (c) 2026 Melon Ask
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
ladon
<img align="right" src="https://raw.githubusercontent.com/melonask/ladon/refs/heads/main/logo.svg" alt="Fast, minimal, multi-chain HD wallet CLI and library — EVM, Bitcoin, Solana" width="160" />
Like the hundred-headed serpent of Greek myth, Ladon generates as many addresses as you need.
Fast, minimal, multi-chain HD wallet CLI, library, and address-pool daemon — EVM, Bitcoin, Solana.
---
Install
cargo install ladon---
Modes
ladon has three sub-commands:
| Sub-command | Purpose |
|---|---|
derive | Derive one or more addresses and print to stdout |
decrypt | Decrypt an encrypted wallet file |
pool | Run the address-pool daemon (requires [database] in config) |
---
CLI
Configuration file
All settings live in Config.toml (or any path passed with --config/-C). Per-flag overrides are available on derive for ad-hoc use.
ladon --config /etc/ladon/Config.toml poolSee Config.toml for a fully annotated example.
---
Derive
Output goes to stdout; redirect it to whatever format you need:
# JSON (default) — pipe or redirect
ladon derive --chain evm --num 5
ladon derive --chain evm --num 20 > wallet.json
# CSV
ladon derive --chain evm --num 20 --format csv > wallet.csv
# Plain text (one address per line)
ladon derive --chain solana --num 10 --format text > addresses.txt
# Encrypted output
ladon derive --chain evm --num 5 --encrypt --password "secret" > wallet.enc
# Bitcoin from existing mnemonic
ladon derive --chain btc --mnemonic "word1 ... word12"
# Specific indexes or ranges
ladon derive --chain evm --indexes "0,5,22-44,55,66-109"
# Watch-only from xpub
ladon derive --chain evm --xpub xpub6C...
# From xpriv
ladon derive --chain evm --xpriv xprv9s...Key flags
| Flag | Default | Description |
|---|---|---|
--chain / -c | evm | evm, btc, solana |
--num / -n | 1 | Number of addresses |
--index / -i | — | Single specific index |
--indexes | — | Comma-separated indexes/ranges |
--account | 0 | BIP-44 account |
--change | 0 | BIP-44 change |
--network | bitcoin | Bitcoin network |
--passphrase | "" | BIP-39 passphrase |
--strength / -s | 12 | Mnemonic word count |
--solana-mode | full | full, cold-export, hsm-sim, pda |
--program-id | — | Base58 program ID for PDA mode |
--xpub | — | Watch-only from xpub |
--xpriv | — | Derive from xpriv |
--format / -f | json | json, csv, text |
--encrypt | false | Encrypt output |
--password | — | Encryption password |
Decrypt
ladon decrypt wallet.enc --password "secret"
ladon decrypt wallet.enc --password "secret" > wallet.json---
Address-pool daemon
The pool sub-command runs a long-lived service that:
1. Connects to a SQLite or Postgres database. 2. Polls the pool table on a configurable interval. 3. Derives and inserts new addresses when the count drops below [pool].threshold. 4. Keeps the total at [pool].target addresses per chain.
Your application removes rows from the pool table as it assigns addresses to users.
Example Config.toml (pool mode)
[derive.secret]
kind = "env"
var = "LADON_MNEMONIC"
[[derive.chains]]
name = "evm"
[pool]
target = 1000
threshold = 200
batch = 100
interval_secs = 10
[database.sqlite]
path = "data/addresses.db"
[database.sqlite.table]
name = "derived_addresses"
[database.sqlite.table.columns]
id = "id"
chain = "chain"
address = "address"
path = "path"
index = "index"
created_at = "created_at"---
Docker
# Build and start the pool daemon with Postgres
docker compose -f deploy/docker-compose.yml up -dSet LADON_MNEMONIC and DATABASE_URL in the environment or in deploy/.env. The container uses restart: unless-stopped so it recovers automatically.
---
Systemd (bare-metal)
sudo cp deploy/ladon.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ladonStore secrets in /etc/ladon/env:
LADON_MNEMONIC=word1 word2 ... word12
DATABASE_URL=postgres://ladon:secret@localhost/ladon
RUST_LOG=ladon=info---
Library
use ladon::{derive, decrypt_data, encrypt_data, Params, WalletOutput};
let wallet: WalletOutput = derive(Params {
chain: "evm".into(),
num: 5,
..Default::default()
})?;
for key in &wallet.keys {
println!("{}: {}", key.index, key.address);
}---
Chains & derivation paths
| Chain | Default path |
|---|---|
| EVM | m/44'/60'/0'/0/* |
| Bitcoin | m/44'/0'/0'/0/* |
| Solana | m/44'/501'/0'/0' (hardened, SLIP-0010) |
---
Security notes
- Private keys are zeroized on drop.
- Use
--solana-mode cold-exportor--encryptwhen storing derive output. --passwordon the CLI is visible in shell history; prefer environment-specific
secret management (LADON_MNEMONIC, vault agents, etc.) in automation.
- Ed25519 derivation follows SLIP-0010 (all path segments hardened).
- In the pool daemon, the mnemonic is never written to disk — it lives only
in the process environment.
---
License
MIT
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
// ── Top-level config ──────────────────────────────────────────────────────────
/// Root configuration loaded from `Config.toml` (or the path given by `--config`).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// Derivation settings (mnemonic, chains, batch size, etc.)
pub derive: DeriveConfig,
/// Pool-daemon settings (threshold, interval, etc.)
#[serde(default)]
pub pool: PoolConfig,
/// Database backend. Exactly one of `sqlite` or `postgres` must be set.
pub database: DbConfig,
}
// ── Derivation ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DeriveConfig {
/// Chains to generate addresses for, e.g. `["evm", "solana"]`.
pub chains: Vec<ChainConfig>,
/// Where to obtain the master secret.
pub secret: SecretSource,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ChainConfig {
/// `"evm"` | `"btc"` | `"solana"`
pub name: String,
/// BIP-44 account (default `0`).
#[serde(default)]
pub account: u32,
/// BIP-44 change (default `0`).
#[serde(default)]
pub change: u32,
/// Bitcoin network: `"bitcoin"` | `"testnet"` | `"signet"` | `"regtest"`.
#[serde(default = "default_btc_network")]
pub network: String,
/// Solana derivation mode: `"full"` | `"cold-export"` | `"hsm-sim"` | `"pda"`.
#[serde(default = "default_solana_mode")]
pub solana_mode: String,
/// Base58 program ID for PDA mode.
#[serde(default)]
pub program_id: String,
}
fn default_btc_network() -> String {
"bitcoin".to_string()
}
fn default_solana_mode() -> String {
"full".to_string()
}
/// How to obtain the master secret.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SecretSource {
/// Read a BIP-39 mnemonic from an environment variable.
Env {
/// Environment variable name, e.g. `"LADON_MNEMONIC"`.
var: String,
/// Optional BIP-39 passphrase (read from this env var if set).
#[serde(default)]
passphrase_var: Option<String>,
},
/// Read a raw hex xpriv from an environment variable.
XprivEnv { var: String },
/// Read a BIP-39 mnemonic from a file (newline-terminated).
File {
path: String,
#[serde(default)]
passphrase_var: Option<String>,
},
}
// ── Pool daemon ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PoolConfig {
/// Target number of pre-generated addresses to keep in the pool.
#[serde(default = "default_target")]
pub target: u32,
/// Refill when the pool drops below this count.
#[serde(default = "default_threshold")]
pub threshold: u32,
/// How many addresses to generate in one batch.
#[serde(default = "default_batch")]
pub batch: u32,
/// Poll interval in seconds.
#[serde(default = "default_interval")]
pub interval_secs: u64,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
target: default_target(),
threshold: default_threshold(),
batch: default_batch(),
interval_secs: default_interval(),
}
}
}
fn default_target() -> u32 {
1000
}
fn default_threshold() -> u32 {
200
}
fn default_batch() -> u32 {
100
}
fn default_interval() -> u64 {
10
}
// ── Database ──────────────────────────────────────────────────────────────────
/// Exactly one backend must be present.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DbConfig {
pub sqlite: Option<SqliteConfig>,
pub postgres: Option<PostgresConfig>,
}
impl DbConfig {
/// Validate that exactly one backend is configured.
pub fn validate(&self) -> Result<()> {
match (&self.sqlite, &self.postgres) {
(Some(_), None) | (None, Some(_)) => Ok(()),
(None, None) => {
anyhow::bail!("database: at least one of `sqlite` or `postgres` must be set")
}
(Some(_), Some(_)) => {
anyhow::bail!("database: only one of `sqlite` or `postgres` may be set")
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SqliteConfig {
/// Path to the SQLite file, e.g. `"data/addresses.db"`.
pub path: String,
pub table: TableConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PostgresConfig {
/// `postgres://user:password@host:5432/dbname`
/// May also be set via the `DATABASE_URL` environment variable; this field
/// takes precedence when both are present.
pub url: Option<String>,
/// Env var that holds the connection URL (alternative to inline `url`).
#[serde(default)]
pub url_env: Option<String>,
/// Connection-pool size (default: 5).
#[serde(default = "default_pool_size")]
pub pool_size: u32,
pub table: TableConfig,
}
fn default_pool_size() -> u32 {
5
}
impl PostgresConfig {
/// Resolve the connection URL from inline value or env var.
pub fn url(&self) -> Result<String> {
if let Some(u) = &self.url {
return Ok(u.clone());
}
if let Some(var) = &self.url_env {
return std::env::var(var).with_context(|| format!("env var `{var}` not set"));
}
std::env::var("DATABASE_URL").context("No postgres URL: set `database.postgres.url`, `database.postgres.url_env`, or `DATABASE_URL`")
}
}
/// Column / table names written to the database.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TableConfig {
pub name: String,
pub columns: ColumnConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ColumnConfig {
/// Primary key column (ULID text).
#[serde(default = "col_id")]
pub id: String,
/// Chain identifier column, e.g. `"evm"`.
#[serde(default = "col_chain")]
pub chain: String,
/// Address column.
#[serde(default = "col_address")]
pub address: String,
/// Derivation-path column.
#[serde(default = "col_path")]
pub path: String,
/// HD index column.
#[serde(default = "col_index")]
pub index: String,
/// ISO-8601 creation timestamp column.
#[serde(default = "col_created_at")]
pub created_at: String,
}
fn col_id() -> String {
"id".to_string()
}
fn col_chain() -> String {
"chain".to_string()
}
fn col_address() -> String {
"address".to_string()
}
fn col_path() -> String {
"path".to_string()
}
fn col_index() -> String {
"index".to_string()
}
fn col_created_at() -> String {
"created_at".to_string()
}
// ── Loader ────────────────────────────────────────────────────────────────────
/// Load and validate a [`Config`] from a TOML file.
pub fn load(path: &Path) -> Result<Config> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let cfg: Config = toml::from_str(&raw)
.with_context(|| format!("Failed to parse config: {}", path.display()))?;
cfg.database.validate()?;
Ok(cfg)
}
use crate::config::{ColumnConfig, DbConfig};
use anyhow::{Context, Result};
use sqlx::{AnyPool, Row, any::AnyPoolOptions};
use tracing::info;
/// A thin handle around an [`AnyPool`] plus the resolved table/column names.
pub struct Db {
pool: AnyPool,
table: String,
cols: ColumnConfig,
}
impl Db {
/// Open a connection pool from `cfg`.
pub async fn connect(cfg: &DbConfig) -> Result<Self> {
sqlx::any::install_default_drivers();
if let Some(sqlite) = &cfg.sqlite {
let url = format!("sqlite://{}?mode=rwc", sqlite.path);
let pool = AnyPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.context("SQLite connection failed")?;
info!(path = %sqlite.path, "Connected to SQLite");
return Ok(Self {
pool,
table: sqlite.table.name.clone(),
cols: sqlite.table.columns.clone(),
});
}
if let Some(pg) = &cfg.postgres {
let url = pg.url()?;
let pool = AnyPoolOptions::new()
.max_connections(pg.pool_size)
.connect(&url)
.await
.context("Postgres connection failed")?;
info!("Connected to Postgres");
return Ok(Self {
pool,
table: pg.table.name.clone(),
cols: pg.table.columns.clone(),
});
}
anyhow::bail!("No database backend configured");
}
/// Create the address table if it doesn't already exist.
pub async fn ensure_schema(&self) -> Result<()> {
let sql = format!(
r#"
CREATE TABLE IF NOT EXISTS "{table}" (
"{id}" TEXT PRIMARY KEY,
"{chain}" TEXT NOT NULL,
"{address}" TEXT NOT NULL,
"{path}" TEXT NOT NULL,
"{index}" INTEGER NOT NULL,
"{created_at}" TEXT NOT NULL
)
"#,
table = self.table,
id = self.cols.id,
chain = self.cols.chain,
address = self.cols.address,
path = self.cols.path,
index = self.cols.index,
created_at = self.cols.created_at,
);
sqlx::query(&sql)
.execute(&self.pool)
.await
.context("Schema creation failed")?;
Ok(())
}
/// Count available addresses in the pool for `chain`.
pub async fn count(&self, chain: &str) -> Result<i64> {
let sql = format!(
r#"SELECT COUNT(*) as cnt FROM "{}" WHERE "{}" = $1"#,
self.table, self.cols.chain,
);
let row = sqlx::query(&sql)
.bind(chain)
.fetch_one(&self.pool)
.await
.context("count query failed")?;
Ok(row.try_get::<i64, _>("cnt").unwrap_or(0))
}
/// Insert a batch of addresses for `chain`.
pub async fn insert(&self, rows: &[AddressRow]) -> Result<u64> {
if rows.is_empty() {
return Ok(0);
}
let mut tx = self.pool.begin().await.context("begin transaction")?;
let sql = format!(
r#"INSERT INTO "{table}" ("{id}", "{chain}", "{address}", "{path}", "{index}", "{created_at}")
VALUES ($1, $2, $3, $4, $5, $6)"#,
table = self.table,
id = self.cols.id,
chain = self.cols.chain,
address = self.cols.address,
path = self.cols.path,
index = self.cols.index,
created_at = self.cols.created_at,
);
for row in rows {
sqlx::query(&sql)
.bind(&row.id)
.bind(&row.chain)
.bind(&row.address)
.bind(&row.path)
.bind(row.index as i64)
.bind(&row.created_at)
.execute(&mut *tx)
.await
.context("insert failed")?;
}
tx.commit().await.context("commit failed")?;
Ok(rows.len() as u64)
}
/// Return the highest derivation index stored for `chain`, or `None`.
pub async fn max_index(&self, chain: &str) -> Result<Option<u32>> {
let sql = format!(
r#"SELECT MAX("{}") as mx FROM "{}" WHERE "{}" = $1"#,
self.cols.index, self.table, self.cols.chain,
);
let row = sqlx::query(&sql)
.bind(chain)
.fetch_optional(&self.pool)
.await
.context("max_index query failed")?;
Ok(row
.and_then(|r| r.try_get::<i64, _>("mx").ok())
.map(|v| v as u32))
}
}
/// A single address row ready for insertion.
#[derive(Debug)]
pub struct AddressRow {
pub id: String,
pub chain: String,
pub address: String,
pub path: String,
pub index: u32,
pub created_at: String,
}
use anyhow::{Context, Result};
use bip39::{Language, Mnemonic};
use bitcoin::{
Network, NetworkKind,
bip32::{ChainCode, ChildNumber, DerivationPath, Fingerprint, Xpriv, Xpub},
};
use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use solana_pubkey::Pubkey;
use std::str::FromStr;
use zeroize::Zeroize;
// ── Constants ────────────────────────────────────────────────────────────────
pub const HARDENED: u32 = 0x8000_0000;
// ── Public types ─────────────────────────────────────────────────────────────
/// Derivation parameters for [`derive`]. All fields have sensible defaults.
#[derive(Clone, Debug, Default)]
pub struct Params {
pub chain: String,
pub mnemonic: Option<String>,
pub passphrase: String,
pub index: Option<u32>,
/// Comma-separated indexes / inclusive ranges, e.g. `"0,5,22-44"`.
/// Supersedes `index` and `num`.
pub indexes: Option<String>,
pub account: u32,
pub change: u32,
pub num: u32,
/// Bitcoin address network: `bitcoin`, `testnet`, `signet`, or `regtest`.
pub network: String,
/// Mnemonic word count for generation: `12` or `24`.
pub strength: u32,
pub hw_sim: bool,
pub xpub: Option<String>,
pub xpub_path: Option<String>,
pub xpriv: Option<String>,
pub xpriv_path: Option<String>,
/// `"full"` | `"cold-export"` | `"hsm-sim"` | `"pda"`
pub solana_mode: String,
pub program_id: String,
}
/// A single derived key / address tuple.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct KeyInfo {
pub index: u32,
pub path: String,
pub xprv: Option<String>,
pub xpub: Option<String>,
pub private_key: String,
pub public_key: String,
pub address: String,
pub wif: Option<String>,
}
impl Drop for KeyInfo {
fn drop(&mut self) {
self.private_key.zeroize();
if let Some(ref mut x) = self.xprv {
x.zeroize();
}
if let Some(ref mut w) = self.wif {
w.zeroize();
}
}
}
/// Full wallet output returned by [`derive`] and all lower-level generators.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct WalletOutput {
pub mnemonic: String,
pub passphrase: String,
pub chain: String,
pub master_xprv: Option<String>,
pub master_xpub: Option<String>,
pub keys: Vec<KeyInfo>,
}
/// On-disk envelope produced by [`encrypt_data`], consumed by [`decrypt_data`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EncryptedWallet {
pub version: u32,
pub salt: String,
pub nonce: String,
pub ciphertext: String,
}
// ── Top-level API ─────────────────────────────────────────────────────────────
/// Generate one or more keys / addresses. Single entry-point for programmatic use.
pub fn derive(p: Params) -> Result<WalletOutput> {
let chain = {
let s = p.chain.to_lowercase();
if s.is_empty() { "evm".to_string() } else { s }
};
let num = if p.num == 0 { 1 } else { p.num };
let base = default_path(&chain, p.account, p.change, p.hw_sim);
if let Some(xpriv_str) = p.xpriv {
let base = p
.xpriv_path
.unwrap_or_else(|| base.trim_end_matches("/0").to_string());
return derive_from_xpriv(
&xpriv_str,
&base,
p.index,
num,
&chain,
&p.solana_mode,
&p.program_id,
&p.indexes,
);
}
if let Some(xpub_str) = p.xpub {
let base = p
.xpub_path
.unwrap_or_else(|| base.trim_end_matches("/0").to_string());
return derive_from_xpub(&xpub_str, &base, p.index, num, &chain);
}
let mnemonic = mnemonic_for(p.mnemonic, p.strength)?;
let seed = mnemonic.to_seed(&p.passphrase);
derive_from_seed(
&seed,
&base,
p.index,
num,
&mnemonic,
&p.passphrase,
&chain,
&p.solana_mode,
&p.program_id,
&p.network,
&p.indexes,
)
}
// ── Path helpers ──────────────────────────────────────────────────────────────
/// Return the default BIP-44 derivation base path for `chain`.
pub fn default_path(chain: &str, account: u32, change: u32, _hw_sim: bool) -> String {
match chain {
"evm" | "ethereum" => format!("m/44'/60'/{account}'/{change}/0"),
"btc" | "bitcoin" => format!("m/44'/0'/{account}'/{change}/0"),
"solana" => format!("m/44'/501'/{account}'/{change}'"),
_ => format!("m/44'/60'/{account}'/{change}/0"),
}
}
/// Build a child derivation path from `base` and `index` for `chain`.
pub fn child_path(base: &str, index: u32, chain: &str) -> String {
if is_ed25519(chain) {
let normalised: String = base
.split('/')
.map(|seg| {
if seg == "m" || seg.is_empty() || seg.ends_with('\'') {
seg.to_string()
} else {
format!("{seg}'")
}
})
.collect::<Vec<_>>()
.join("/");
format!("{normalised}/{index}'")
} else {
let base = base.trim_end_matches(|c: char| c.is_ascii_digit() || c == '/');
format!("{base}/{index}")
}
}
/// Parse a derivation-path string into a sequence of BIP-32 child indexes.
pub fn parse_path(path: &str) -> Result<Vec<u32>> {
let trimmed = path
.strip_prefix("m/")
.or_else(|| path.strip_prefix('m'))
.unwrap_or(path);
if trimmed.is_empty() {
return Ok(vec![]);
}
trimmed
.split('/')
.filter(|s| !s.is_empty())
.map(|part| {
if let Some(num_str) = part.strip_suffix('\'') {
let n: u32 = num_str.parse().context("Invalid hardened path segment")?;
if n >= HARDENED {
anyhow::bail!("Hardened index {n} out of range (max {})", HARDENED - 1);
}
Ok(n + HARDENED)
} else {
let n: u32 = part.parse().context("Invalid path segment")?;
if n >= HARDENED {
anyhow::bail!("Standard index {n} out of range (max {})", HARDENED - 1);
}
Ok(n)
}
})
.collect()
}
/// Parse a comma-separated index / range list into a `Vec<u32>`.
pub fn parse_indexes(s: &str) -> Result<Vec<u32>> {
s.split(',')
.flat_map(|raw| {
let token = raw.trim();
if token.is_empty() {
return vec![Err(anyhow::anyhow!("Empty index token"))];
}
if let Some((start, end)) = token.split_once('-') {
let start = match start.trim().parse::<u32>() {
Ok(v) => v,
Err(_) => {
return vec![Err(anyhow::anyhow!(
"Invalid range start: '{}'",
start.trim()
))];
}
};
let end = match end.trim().parse::<u32>() {
Ok(v) => v,
Err(_) => {
return vec![Err(anyhow::anyhow!("Invalid range end: '{}'", end.trim()))];
}
};
if start > end {
return vec![Err(anyhow::anyhow!("Descending range: '{token}'"))];
}
return (start..=end).map(Ok).collect();
}
vec![
token
.parse::<u32>()
.with_context(|| format!("Invalid index: '{token}'")),
]
})
.collect()
}
// ── Seed-based generation ─────────────────────────────────────────────────────
/// Parse or generate a mnemonic.
pub fn mnemonic_for(raw: Option<String>, strength: u32) -> Result<Mnemonic> {
match raw {
Some(s) => {
let sanitised = s
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
Mnemonic::parse_in(Language::English, sanitised).context("Invalid mnemonic")
}
None => {
let words = if strength == 24 { 24 } else { 12 };
Mnemonic::generate_in(Language::English, words).context("Failed to generate mnemonic")
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn derive_from_seed(
seed: &[u8],
base_path: &str,
specific_index: Option<u32>,
num: u32,
mnemonic: &Mnemonic,
passphrase: &str,
chain: &str,
solana_mode: &str,
program_id: &str,
network: &str,
indexes: &Option<String>,
) -> Result<WalletOutput> {
let btc_network = bitcoin_network(network);
let indices = resolve_indices(indexes, specific_index, num)?;
let keys = indices
.iter()
.map(|&idx| {
let path = child_path(base_path, idx, chain);
match chain {
"evm" | "ethereum" => derive_evm(seed, &path, idx),
"btc" | "bitcoin" => derive_btc(seed, &path, idx, btc_network),
"solana" => derive_solana(seed, &path, idx, solana_mode, program_id),
other => anyhow::bail!("Unsupported chain '{other}'. Use: evm, btc, solana"),
}
})
.collect::<Result<Vec<_>>>()?;
let mut wallet = WalletOutput {
mnemonic: mnemonic.to_string(),
passphrase: passphrase.to_string(),
chain: chain.to_string(),
master_xprv: Some("hidden".to_string()),
master_xpub: None,
keys,
};
let account_base = base_path.trim_end_matches(|c: char| c.is_ascii_digit() || c == '/');
if is_ed25519(chain) {
if let Ok(idxs) = parse_path(account_base)
&& let Ok(derived) = derive_slip10(seed, &idxs)
{
let sk: [u8; 32] = derived[..32].try_into().unwrap();
let pub_bytes = SigningKey::from_bytes(&sk).verifying_key().to_bytes();
let mut xpub = derived[32..].to_vec();
xpub.extend_from_slice(&pub_bytes);
wallet.master_xpub = Some(hex::encode(xpub));
}
} else if let Ok(key) = secp_key(seed, account_base, network_kind(btc_network)) {
let secp = bitcoin::secp256k1::Secp256k1::new();
wallet.master_xpub = Some(Xpub::from_priv(&secp, &key).to_string());
}
Ok(wallet)
}
// ── xpub / xpriv derivation ───────────────────────────────────────────────────
pub fn derive_from_xpub(
xpub_str: &str,
base: &str,
specific_index: Option<u32>,
num: u32,
chain: &str,
) -> Result<WalletOutput> {
if is_ed25519(chain) {
anyhow::bail!("xpub mode is not supported for {chain} (Ed25519 curve)");
}
let xpub = parse_xpub(xpub_str)?;
let secp = bitcoin::secp256k1::Secp256k1::new();
let indices = resolve_indices(&None, specific_index, num)?;
let keys = indices
.iter()
.map(|&idx| {
let path = format!("{}/{idx}", base.trim_end_matches('/'));
let child = xpub.ckd_pub(&secp, ChildNumber::from_normal_idx(idx)?)?;
let pk_bytes = child.public_key.serialize_uncompressed();
let address = match chain {
"evm" | "ethereum" => eth_address(&pk_bytes),
"btc" | "bitcoin" => {
let cpk =
bitcoin::CompressedPublicKey::from_slice(&child.public_key.serialize())
.expect("valid compressed pk");
bitcoin::Address::p2wpkh(&cpk, bitcoin::Network::Bitcoin).to_string()
}
other => anyhow::bail!("xpub mode not supported for '{other}'"),
};
Ok(KeyInfo {
index: idx,
path,
xprv: None,
xpub: Some(xpub_str.to_string()),
private_key: "WATCH-ONLY".to_string(),
public_key: hex::encode(pk_bytes),
address,
wif: None,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(WalletOutput {
mnemonic: "WATCH-ONLY".to_string(),
passphrase: String::new(),
chain: chain.to_string(),
master_xprv: None,
master_xpub: Some(xpub_str.to_string()),
keys,
})
}
#[allow(clippy::too_many_arguments)]
pub fn derive_from_xpriv(
xpriv_str: &str,
base: &str,
specific_index: Option<u32>,
num: u32,
chain: &str,
solana_mode: &str,
program_id: &str,
indexes: &Option<String>,
) -> Result<WalletOutput> {
let indices = resolve_indices(indexes, specific_index, num)?;
let keys = if is_ed25519(chain) {
let raw = hex::decode(xpriv_str.strip_prefix("0x").unwrap_or(xpriv_str))
.context("Invalid hex for Ed25519 xpriv")?;
let parent: [u8; 64] = raw
.try_into()
.map_err(|_| anyhow::anyhow!("Ed25519 xpriv must be 64 bytes (key + chain code)"))?;
indices
.iter()
.map(|&idx| {
let path = format!("{}/{idx}'", base.trim_end_matches('/'));
let child = slip10_child(&parent, idx + HARDENED)?;
let sk: [u8; 32] = child[..32].try_into().unwrap();
let chain_code = &child[32..];
let signing = SigningKey::from_bytes(&sk);
let pubkey = Pubkey::new_from_array(signing.verifying_key().to_bytes());
let (address, private_key, xprv, xpub) =
solana_key_info(idx, &pubkey, sk, chain_code, solana_mode, program_id)?;
Ok(KeyInfo {
index: idx,
path,
xprv,
xpub,
private_key,
public_key: hex::encode(signing.verifying_key().to_bytes()),
address,
wif: None,
})
})
.collect::<Result<Vec<_>>>()?
} else {
let secp = bitcoin::secp256k1::Secp256k1::new();
let parent = Xpriv::from_str(xpriv_str).context("Invalid BIP32 xpriv")?;
indices
.iter()
.map(|&idx| {
let path = format!("{}/{idx}", base.trim_end_matches('/'));
let child = parent.derive_priv(&secp, &[ChildNumber::from_normal_idx(idx)?])?;
let mut priv_key = child.to_priv();
priv_key.compressed = true;
let pub_key = priv_key.public_key(&secp);
let pub_uncompressed = pub_key.inner.serialize_uncompressed();
let pub_compressed = pub_key.inner.serialize();
let sk_bytes = child.private_key.secret_bytes();
let (address, private_key, wif) = match chain {
"evm" | "ethereum" => (
eth_address(&pub_uncompressed),
format!("0x{}", hex::encode(sk_bytes)),
None,
),
"btc" | "bitcoin" => {
let cpk = bitcoin::CompressedPublicKey::from_slice(&pub_compressed)
.expect("valid pk");
let addr =
bitcoin::Address::p2wpkh(&cpk, bitcoin::Network::Bitcoin).to_string();
let wif = priv_key.to_wif();
(addr, wif.clone(), Some(wif))
}
other => anyhow::bail!("Chain '{other}' not supported for xpriv mode"),
};
Ok(KeyInfo {
index: idx,
path,
xprv: Some(child.to_string()),
xpub: Some(Xpub::from_priv(&secp, &child).to_string()),
private_key,
public_key: hex::encode(pub_compressed),
address,
wif,
})
})
.collect::<Result<Vec<_>>>()?
};
Ok(WalletOutput {
mnemonic: "derived from xpriv".to_string(),
passphrase: String::new(),
chain: chain.to_string(),
master_xprv: Some(xpriv_str.to_string()),
master_xpub: None,
keys,
})
}
// ── Per-chain key generators ──────────────────────────────────────────────────
pub fn derive_evm(seed: &[u8], path: &str, idx: u32) -> Result<KeyInfo> {
let secp = bitcoin::secp256k1::Secp256k1::new();
let child = secp_key(seed, path, NetworkKind::Main)?;
let pub_key = child.to_priv().public_key(&secp);
let pub_uncompressed = pub_key.inner.serialize_uncompressed();
let sk_bytes = child.private_key.secret_bytes();
Ok(KeyInfo {
index: idx,
path: path.to_string(),
xprv: Some(child.to_string()),
xpub: Some(Xpub::from_priv(&secp, &child).to_string()),
private_key: format!("0x{}", hex::encode(sk_bytes)),
public_key: format!("0x{}", hex::encode(pub_uncompressed)),
address: eth_address(&pub_uncompressed),
wif: None,
})
}
pub fn derive_btc(seed: &[u8], path: &str, idx: u32, network: Network) -> Result<KeyInfo> {
let secp = bitcoin::secp256k1::Secp256k1::new();
let child = secp_key(seed, path, network_kind(network))?;
let mut priv_key = child.to_priv();
priv_key.compressed = true;
let pub_key = priv_key.public_key(&secp);
let cpk =
bitcoin::CompressedPublicKey::from_slice(&pub_key.inner.serialize()).expect("valid pk");
let address = bitcoin::Address::p2wpkh(&cpk, network).to_string();
let wif = priv_key.to_wif();
Ok(KeyInfo {
index: idx,
path: path.to_string(),
xprv: Some(child.to_string()),
xpub: Some(Xpub::from_priv(&secp, &child).to_string()),
private_key: wif.clone(),
public_key: hex::encode(pub_key.inner.serialize()),
address,
wif: Some(wif),
})
}
pub fn derive_solana(
seed: &[u8],
path: &str,
idx: u32,
mode: &str,
program_id: &str,
) -> Result<KeyInfo> {
let path_idxs = parse_path(path)?;
let derived = derive_slip10(seed, &path_idxs)?;
let sk: [u8; 32] = derived[..32].try_into().unwrap();
let signing = SigningKey::from_bytes(&sk);
let pubkey = Pubkey::new_from_array(signing.verifying_key().to_bytes());
let (address, private_key, xprv, xpub) =
solana_key_info(idx, &pubkey, sk, &derived[32..], mode, program_id)?;
Ok(KeyInfo {
index: idx,
path: path.to_string(),
xprv,
xpub,
private_key,
public_key: hex::encode(signing.verifying_key().to_bytes()),
address,
wif: None,
})
}
// ── Cryptographic primitives ──────────────────────────────────────────────────
/// Derive a secp256k1 extended private key at `path` from `seed`.
pub fn secp_key(seed: &[u8], path: &str, network: NetworkKind) -> Result<Xpriv> {
let secp = bitcoin::secp256k1::Secp256k1::new();
let master = Xpriv::new_master(network, seed).context("Master key creation failed")?;
let stripped = path.strip_prefix("m/").unwrap_or(path);
let dp = DerivationPath::from_str(stripped).context("Invalid derivation path")?;
master
.derive_priv(&secp, &dp)
.context("Key derivation failed")
}
/// SLIP-0010 Ed25519 derivation from `seed` over a hardened `path`.
pub fn derive_slip10(seed: &[u8], path: &[u32]) -> Result<[u8; 64]> {
use hmac::{KeyInit, Mac};
let mut i: [u8; 64] = {
let mut mac = <hmac::Hmac<sha2::Sha512>>::new_from_slice(b"ed25519 seed")
.expect("HMAC accepts any key length");
mac.update(seed);
mac.finalize().into_bytes().into()
};
for &idx in path {
if idx < HARDENED {
anyhow::bail!(
"SLIP-0010: path index {idx:#x} is unhardened — all Ed25519 segments must be hardened"
);
}
let mut mac = <hmac::Hmac<sha2::Sha512>>::new_from_slice(&i[32..])
.expect("HMAC accepts any key length");
mac.update(&[0u8]);
mac.update(&i[..32]);
mac.update(&idx.to_be_bytes());
i = mac.finalize().into_bytes().into();
}
Ok(i)
}
/// Derive a single hardened SLIP-0010 child from `parent` (key + chain code).
pub fn slip10_child(parent: &[u8; 64], child_index: u32) -> Result<[u8; 64]> {
use hmac::{KeyInit, Mac};
if child_index < HARDENED {
anyhow::bail!("SLIP-0010: child index {child_index:#x} must be hardened");
}
let mut mac = <hmac::Hmac<sha2::Sha512>>::new_from_slice(&parent[32..])
.context("HMAC init from parent chain code")?;
mac.update(&[0u8]);
mac.update(&parent[..32]);
mac.update(&child_index.to_be_bytes());
Ok(mac.finalize().into_bytes().into())
}
/// Compute an EIP-55 checksummed Ethereum address from an uncompressed public key.
pub fn eth_address(pubkey_uncompressed: &[u8]) -> String {
use tiny_keccak::{Hasher, Keccak};
let mut hash = [0u8; 32];
let mut k = Keccak::v256();
k.update(&pubkey_uncompressed[1..]);
k.finalize(&mut hash);
let addr_hex = hex::encode(&hash[12..]);
let mut cs_hash = [0u8; 32];
let mut k2 = Keccak::v256();
k2.update(addr_hex.as_bytes());
k2.finalize(&mut cs_hash);
let cs = hex::encode(cs_hash);
let mut out = String::with_capacity(42);
out.push_str("0x");
for (i, c) in addr_hex.chars().enumerate() {
let nibble = match cs.as_bytes()[i] {
b @ b'0'..=b'9' => b - b'0',
b @ b'a'..=b'f' => b - b'a' + 10,
b @ b'A'..=b'F' => b - b'A' + 10,
_ => 0,
};
out.push(if nibble > 7 {
c.to_ascii_uppercase()
} else {
c
});
}
out
}
// ── Encryption / decryption ───────────────────────────────────────────────────
/// Encrypt a UTF-8 string with AES-256-GCM (key derived via scrypt).
/// Returns a JSON-serialised [`EncryptedWallet`].
pub fn encrypt_data(data: &str, password: &str) -> Result<String> {
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
use base64::Engine;
use rand::RngCore;
use scrypt::scrypt;
let mut rng = rand::thread_rng();
let mut salt = [0u8; 16];
rng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; 12];
rng.fill_bytes(&mut nonce_bytes);
let mut key = [0u8; 32];
let params = scrypt::Params::new(16, 8, 1).context("Invalid scrypt params")?;
scrypt(password.as_bytes(), &salt, ¶ms, &mut key).context("scrypt KDF failed")?;
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("AES init: {e:?}"))?;
let ciphertext = cipher
.encrypt(&nonce_bytes.into(), data.as_bytes())
.map_err(|e| anyhow::anyhow!("Encryption failed: {e:?}"))?;
let b64 = base64::engine::general_purpose::STANDARD;
serde_json::to_string_pretty(&EncryptedWallet {
version: 1,
salt: b64.encode(salt),
nonce: b64.encode(nonce_bytes),
ciphertext: b64.encode(ciphertext),
})
.context("Serialisation of encrypted wallet failed")
}
/// Decrypt an [`EncryptedWallet`] with AES-256-GCM and return the plaintext.
pub fn decrypt_data(enc: &EncryptedWallet, password: &str) -> Result<String> {
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
use base64::Engine;
use scrypt::scrypt;
if enc.version != 1 {
anyhow::bail!("Unsupported wallet version: {}", enc.version);
}
let b64 = base64::engine::general_purpose::STANDARD;
let salt = b64.decode(&enc.salt).context("Invalid salt")?;
let nonce_vec = b64.decode(&enc.nonce).context("Invalid nonce")?;
let ciphertext = b64.decode(&enc.ciphertext).context("Invalid ciphertext")?;
let nonce: [u8; 12] = nonce_vec
.try_into()
.map_err(|_| anyhow::anyhow!("Nonce must be 12 bytes"))?;
let mut key = [0u8; 32];
scrypt(
password.as_bytes(),
&salt,
&scrypt::Params::new(16, 8, 1)?,
&mut key,
)?;
let cipher = Aes256Gcm::new_from_slice(&key)?;
let plain = cipher
.decrypt(&nonce.into(), ciphertext.as_ref())
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong password?"))?;
Ok(String::from_utf8(plain)?)
}
// ── Private helpers ───────────────────────────────────────────────────────────
fn is_ed25519(chain: &str) -> bool {
chain == "solana"
}
fn bitcoin_network(network: &str) -> Network {
match network {
"testnet" => Network::Testnet,
"signet" => Network::Signet,
"regtest" => Network::Regtest,
_ => Network::Bitcoin,
}
}
fn network_kind(network: Network) -> NetworkKind {
match network {
Network::Bitcoin => NetworkKind::Main,
_ => NetworkKind::Test,
}
}
/// Resolve an index specification into a concrete `Vec<u32>`.
fn resolve_indices(indexes: &Option<String>, specific: Option<u32>, num: u32) -> Result<Vec<u32>> {
if let Some(s) = indexes {
return parse_indexes(s);
}
if let Some(i) = specific {
return Ok(vec![i]);
}
Ok((0..num).collect())
}
/// Assemble Solana key-info fields based on the operating mode.
fn solana_key_info(
idx: u32,
pubkey: &Pubkey,
sk: [u8; 32],
chain_code: &[u8],
mode: &str,
program_id: &str,
) -> Result<(String, String, Option<String>, Option<String>)> {
match mode {
"pda" => {
let program = if program_id.is_empty() {
Pubkey::from_str("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").unwrap()
} else {
Pubkey::from_str(program_id).context("Invalid program ID")?
};
let seed_label = format!("user_deposit_{idx}");
let seeds: &[&[u8]; 2] = &[seed_label.as_bytes(), &pubkey.to_bytes()];
let (pda, _) = Pubkey::derive_program_address(seeds, &program)
.context("Unable to derive Solana PDA")?;
Ok((pda.to_string(), "PDA_RECEIVE_ONLY".to_string(), None, None))
}
"cold-export" => Ok((pubkey.to_string(), "HIDDEN".to_string(), None, None)),
_ => {
let mut xpub_bytes = chain_code.to_vec();
xpub_bytes.extend_from_slice(&pubkey.to_bytes());
Ok((
pubkey.to_string(),
hex::encode(sk),
Some(hex::encode(sk)),
Some(hex::encode(xpub_bytes)),
))
}
}
}
/// Parse an xpub from standard base58 BIP-32 or fallback hex `chain_code(32) || pubkey(33)`.
fn parse_xpub(s: &str) -> Result<Xpub> {
if let Ok(x) = Xpub::from_str(s) {
return Ok(x);
}
let stripped = s.strip_prefix("xpub").unwrap_or(s);
if stripped.len() >= 64 && stripped.chars().all(|c| c.is_ascii_hexdigit()) {
let bytes = hex::decode(stripped).context("Invalid hex xpub")?;
if bytes.len() < 32 {
anyhow::bail!("Hex xpub too short");
}
let mut chain = [0u8; 32];
chain.copy_from_slice(&bytes[..32]);
let cc = ChainCode::from_hex(&hex::encode(chain)).expect("32 bytes is always valid");
let pk = bitcoin::secp256k1::PublicKey::from_slice(&bytes[32..])
.context("Invalid public key in hex xpub")?;
return Ok(Xpub {
network: NetworkKind::Main,
depth: 0,
parent_fingerprint: Fingerprint::default(),
child_number: ChildNumber::Normal { index: 0 },
public_key: pk,
chain_code: cc,
});
}
anyhow::bail!("Invalid xpub: expected BIP32 base58 or hex chain_code(32)+pubkey(33)")
}
mod config;
mod db;
mod output;
mod pool;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ladon::{EncryptedWallet, Params, decrypt_data, derive, encrypt_data};
use output::Format;
use std::{fs, path::PathBuf};
use tracing_subscriber::{EnvFilter, fmt};
// ── CLI types ─────────────────────────────────────────────────────────────────
/// Fast multi-chain HD wallet CLI and address-pool daemon.
#[derive(Parser, Debug)]
#[command(name = "ladon", version, about, long_about = None)]
struct Cli {
/// Path to the TOML configuration file.
#[arg(long, short = 'C', value_name = "FILE", default_value = "Config.toml")]
config: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
/// Derive addresses and write to stdout (redirect to file as needed).
Derive(Box<DeriveArgs>),
/// Decrypt an encrypted wallet file.
Decrypt(DecryptArgs),
/// Run the address-pool daemon (requires a config file with `[database]`).
Pool,
}
// ── derive sub-command ────────────────────────────────────────────────────────
#[derive(Parser, Debug)]
struct DeriveArgs {
/// Target chain: `evm`, `btc`, `solana`.
#[arg(long, short = 'c', default_value = "evm")]
chain: String,
/// BIP-39 mnemonic (12 or 24 words).
#[arg(long, short = 'm')]
mnemonic: Option<String>,
/// BIP-39 passphrase.
#[arg(long, default_value = "")]
passphrase: String,
/// Single derivation index.
#[arg(long, short = 'i')]
index: Option<u32>,
/// Comma-separated indexes / ranges, e.g. `0,3,7-12`. Overrides `--index`/`--num`.
#[arg(long)]
indexes: Option<String>,
/// BIP-44 account.
#[arg(long, default_value_t = 0)]
account: u32,
/// BIP-44 change.
#[arg(long, default_value_t = 0)]
change: u32,
/// Number of addresses.
#[arg(long, short = 'n', default_value_t = 1)]
num: u32,
/// Bitcoin network: `bitcoin`, `testnet`, `signet`, `regtest`.
#[arg(long, default_value = "bitcoin")]
network: String,
/// Mnemonic word count for generation: `12` or `24`.
#[arg(long, short = 's', default_value_t = 12)]
strength: u32,
/// Watch-only derivation from an xpub.
#[arg(long)]
xpub: Option<String>,
/// Base path for xpub derivation.
#[arg(long)]
xpub_path: Option<String>,
/// Derive from xpriv.
#[arg(long)]
xpriv: Option<String>,
/// Base path for xpriv derivation.
#[arg(long)]
xpriv_path: Option<String>,
/// Solana mode: `full`, `cold-export`, `hsm-sim`, `pda`.
#[arg(long, default_value = "full")]
solana_mode: String,
/// Base58 program ID (PDA mode).
#[arg(long, default_value = "")]
program_id: String,
/// Output format (default: json; redirect stdout for file output).
#[arg(long, short = 'f', default_value = "json")]
format: Format,
/// Write output to a file instead of stdout.
#[arg(long, short = 'o')]
output: Option<PathBuf>,
/// Encrypt the output with the given password.
#[arg(long)]
encrypt: bool,
/// Encryption password (required when `--encrypt` is set).
#[arg(long)]
password: Option<String>,
}
// ── decrypt sub-command ───────────────────────────────────────────────────────
#[derive(Parser, Debug)]
struct DecryptArgs {
/// Encrypted wallet file.
input: PathBuf,
/// Decryption password.
#[arg(long)]
password: String,
}
// ── entry point ───────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<()> {
fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Derive(args) => cmd_derive(*args),
Cmd::Decrypt(args) => cmd_decrypt(args),
Cmd::Pool => cmd_pool(cli.config).await,
}
}
// ── sub-command handlers ──────────────────────────────────────────────────────
fn cmd_derive(args: DeriveArgs) -> Result<()> {
let wallet = derive(Params {
chain: args.chain,
mnemonic: args.mnemonic,
passphrase: args.passphrase,
index: args.index,
indexes: args.indexes,
account: args.account,
change: args.change,
num: args.num,
network: args.network,
strength: args.strength,
xpub: args.xpub,
xpub_path: args.xpub_path,
xpriv: args.xpriv,
xpriv_path: args.xpriv_path,
solana_mode: args.solana_mode,
program_id: args.program_id,
..Default::default()
})?;
let out = output::render(&wallet, args.format)?;
let out = if args.encrypt {
let password = args
.password
.context("--password required with --encrypt")?;
encrypt_data(&out, &password)?
} else {
out
};
if let Some(path) = args.output {
fs::write(&path, out).with_context(|| format!("Failed to write {}", path.display()))?;
} else {
print!("{out}");
}
Ok(())
}
fn cmd_decrypt(args: DecryptArgs) -> Result<()> {
let raw = fs::read_to_string(&args.input)
.with_context(|| format!("Failed to read {}", args.input.display()))?;
let enc: EncryptedWallet = serde_json::from_str(&raw).context("Invalid encrypted wallet")?;
let plain = decrypt_data(&enc, &args.password)?;
print!("{plain}");
Ok(())
}
async fn cmd_pool(config_path: PathBuf) -> Result<()> {
let cfg = config::load(&config_path)?;
let db = db::Db::connect(&cfg.database).await?;
db.ensure_schema().await?;
pool::run(&cfg, &db).await
}
use anyhow::Result;
use ladon::WalletOutput;
/// Output format selected by the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Format {
Json,
Csv,
Text,
}
/// Render `wallet` in the requested `format`.
pub fn render(wallet: &WalletOutput, format: Format) -> Result<String> {
match format {
Format::Json => Ok(serde_json::to_string_pretty(wallet)?),
Format::Csv => render_csv(wallet),
Format::Text => Ok(render_text(wallet)),
}
}
fn render_csv(w: &WalletOutput) -> Result<String> {
let mut out = String::from("index,path,address,public_key,private_key\n");
for k in &w.keys {
out.push_str(&format!(
"{},{},{},{},{}\n",
k.index, k.path, k.address, k.public_key, k.private_key,
));
}
Ok(out)
}
fn render_text(w: &WalletOutput) -> String {
let mut out = String::new();
for k in &w.keys {
out.push_str(&format!("[{}] {} — {}\n", k.index, k.path, k.address));
}
out
}
use crate::{
config::{ChainConfig, Config, SecretSource},
db::{AddressRow, Db},
};
use anyhow::{Context, Result};
use chrono::Utc;
use ladon::{Params, derive};
use tokio::time::{Duration, sleep};
use tracing::{error, info};
use ulid::Ulid;
/// Run the pool-daemon loop forever.
///
/// Periodically checks how many pre-generated addresses remain for each
/// configured chain. When the count falls below [`PoolConfig::threshold`],
/// new addresses are derived and inserted until the pool reaches
/// [`PoolConfig::target`].
pub async fn run(cfg: &Config, db: &Db) -> Result<()> {
let interval = Duration::from_secs(cfg.pool.interval_secs);
info!(
target = cfg.pool.target,
threshold = cfg.pool.threshold,
batch = cfg.pool.batch,
interval_secs = cfg.pool.interval_secs,
"Pool daemon started",
);
loop {
for chain_cfg in &cfg.derive.chains {
if let Err(e) = tick(cfg, db, chain_cfg).await {
error!(chain = %chain_cfg.name, error = %e, "Pool tick failed");
}
}
sleep(interval).await;
}
}
async fn tick(cfg: &Config, db: &Db, chain_cfg: &ChainConfig) -> Result<()> {
let count = db.count(&chain_cfg.name).await?;
if count >= cfg.pool.threshold as i64 {
return Ok(());
}
let needed = (cfg.pool.target as i64 - count).max(0) as u32;
let batches = needed.div_ceil(cfg.pool.batch);
info!(chain = %chain_cfg.name, pool = count, target = cfg.pool.target, "Refilling pool");
let next_index = db
.max_index(&chain_cfg.name)
.await?
.map(|i| i + 1)
.unwrap_or(0);
let (mnemonic, passphrase, xpriv) = resolve_secret(&cfg.derive.secret)?;
let mut global_idx = next_index;
for batch_idx in 0..batches {
let remaining = needed - (batch_idx * cfg.pool.batch);
let batch_size = remaining.min(cfg.pool.batch);
let indexes: Vec<u32> = (global_idx..global_idx + batch_size).collect();
let index_str = indexes
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let wallet = derive(Params {
chain: chain_cfg.name.clone(),
mnemonic: mnemonic.clone(),
passphrase: passphrase.clone().unwrap_or_default(),
indexes: Some(index_str),
account: chain_cfg.account,
change: chain_cfg.change,
network: chain_cfg.network.clone(),
solana_mode: chain_cfg.solana_mode.clone(),
program_id: chain_cfg.program_id.clone(),
xpriv: xpriv.clone(),
strength: 12,
num: batch_size,
..Default::default()
})?;
let rows: Vec<AddressRow> = wallet
.keys
.iter()
.map(|k| AddressRow {
id: Ulid::new().to_string(),
chain: chain_cfg.name.clone(),
address: k.address.clone(),
path: k.path.clone(),
index: k.index,
created_at: Utc::now().to_rfc3339(),
})
.collect();
let inserted = db.insert(&rows).await?;
global_idx += batch_size;
info!(chain = %chain_cfg.name, inserted, "Batch inserted");
}
Ok(())
}
/// Resolve mnemonic / passphrase / xpriv from the configured [`SecretSource`].
fn resolve_secret(src: &SecretSource) -> Result<(Option<String>, Option<String>, Option<String>)> {
match src {
SecretSource::Env {
var,
passphrase_var,
} => {
let mnemonic =
std::env::var(var).with_context(|| format!("Env var `{var}` not set"))?;
let passphrase = passphrase_var
.as_ref()
.map(|v| {
std::env::var(v).with_context(|| format!("Passphrase env var `{v}` not set"))
})
.transpose()?;
Ok((Some(mnemonic), passphrase, None))
}
SecretSource::XprivEnv { var } => {
let xpriv = std::env::var(var).with_context(|| format!("Env var `{var}` not set"))?;
Ok((None, None, Some(xpriv)))
}
SecretSource::File {
path,
passphrase_var,
} => {
let mnemonic = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read mnemonic file: {path}"))?;
let mnemonic = mnemonic.trim().to_string();
let passphrase = passphrase_var
.as_ref()
.map(|v| {
std::env::var(v).with_context(|| format!("Passphrase env var `{v}` not set"))
})
.transpose()?;
Ok((Some(mnemonic), passphrase, None))
}
}
}
use ladon::{
EncryptedWallet, HARDENED, Params, child_path, decrypt_data, default_path, derive, derive_btc,
derive_evm, derive_slip10, derive_solana, encrypt_data, mnemonic_for, parse_indexes,
parse_path, secp_key,
};
const MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn path_and_index_helpers_are_strict() {
assert_eq!(default_path("evm", 1, 2, false), "m/44'/60'/1'/2/0");
assert_eq!(default_path("btc", 1, 2, false), "m/44'/0'/1'/2/0");
assert_eq!(default_path("solana", 1, 2, false), "m/44'/501'/1'/2'");
assert_eq!(child_path("m/44'/60'/0'/0", 7, "evm"), "m/44'/60'/0'/7");
assert_eq!(
child_path("m/44'/501'/0'/0'", 7, "solana"),
"m/44'/501'/0'/0'/7'"
);
assert_eq!(parse_indexes("0, 2,9").unwrap(), vec![0, 2, 9]);
assert_eq!(
parse_indexes("0,5,22-24,12,11").unwrap(),
vec![0, 5, 22, 23, 24, 12, 11]
);
assert!(parse_indexes("0,nope").is_err());
assert!(parse_indexes("5-2").is_err());
assert!(parse_indexes("1-").is_err());
assert_eq!(
parse_path("m/44'/60'/0'/7").unwrap(),
vec![44 + HARDENED, 60 + HARDENED, HARDENED, 7]
);
assert!(parse_path(&format!("{}'", HARDENED)).is_err());
}
#[test]
fn mnemonic_and_encryption_helpers_work_and_reject_bad_input() {
let parsed = mnemonic_for(Some(MNEMONIC.into()), 12).unwrap();
assert_eq!(parsed.to_string(), MNEMONIC);
assert_eq!(mnemonic_for(None, 12).unwrap().word_count(), 12);
assert_eq!(mnemonic_for(None, 24).unwrap().word_count(), 24);
assert!(mnemonic_for(Some("not a mnemonic".into()), 12).is_err());
let encrypted = encrypt_data("secret payload", "correct horse").unwrap();
let envelope: EncryptedWallet = serde_json::from_str(&encrypted).unwrap();
assert_eq!(
decrypt_data(&envelope, "correct horse").unwrap(),
"secret payload"
);
assert!(decrypt_data(&envelope, "wrong horse").is_err());
}
#[test]
fn explicit_generators_match_top_level_derivation() {
let mnemonic = mnemonic_for(Some(MNEMONIC.into()), 12).unwrap();
let seed = mnemonic.to_seed("");
let evm = derive(Params {
chain: "evm".into(),
mnemonic: Some(MNEMONIC.into()),
num: 1,
..Default::default()
})
.unwrap();
let btc = derive(Params {
chain: "btc".into(),
mnemonic: Some(MNEMONIC.into()),
num: 1,
..Default::default()
})
.unwrap();
let sol = derive(Params {
chain: "solana".into(),
mnemonic: Some(MNEMONIC.into()),
num: 1,
..Default::default()
})
.unwrap();
assert_eq!(
derive_evm(&seed, &evm.keys[0].path, 0).unwrap().address,
evm.keys[0].address
);
assert_eq!(
derive_btc(&seed, &btc.keys[0].path, 0, bitcoin::Network::Bitcoin)
.unwrap()
.address,
btc.keys[0].address
);
assert_eq!(
derive_solana(&seed, &sol.keys[0].path, 0, "full", "")
.unwrap()
.address,
sol.keys[0].address
);
}
#[test]
fn xpub_xpriv_indexes_and_modes_are_usable() {
let base = derive(Params {
chain: "evm".into(),
mnemonic: Some(MNEMONIC.into()),
num: 3,
..Default::default()
})
.unwrap();
let from_xpub = derive(Params {
chain: "evm".into(),
xpub: base.master_xpub.clone(),
num: 3,
..Default::default()
})
.unwrap();
assert_eq!(
from_xpub
.keys
.iter()
.map(|k| &k.address)
.collect::<Vec<_>>(),
base.keys.iter().map(|k| &k.address).collect::<Vec<_>>()
);
assert!(from_xpub.keys.iter().all(|k| k.private_key == "WATCH-ONLY"));
let from_xpriv = derive(Params {
chain: "evm".into(),
xpriv: base.keys[0].xprv.clone(),
xpriv_path: Some(base.keys[0].path.clone()),
index: Some(1),
..Default::default()
})
.unwrap();
assert_eq!(from_xpriv.keys.len(), 1);
assert!(from_xpriv.keys[0].private_key.starts_with("0x"));
let sparse = derive(Params {
chain: "evm".into(),
mnemonic: Some(MNEMONIC.into()),
indexes: Some("0,2-3".into()),
..Default::default()
})
.unwrap();
assert_eq!(
sparse.keys.iter().map(|k| k.index).collect::<Vec<_>>(),
vec![0, 2, 3]
);
}
#[test]
fn solana_modes_and_low_level_derivation_are_validated() {
let cold = derive(Params {
chain: "solana".into(),
mnemonic: Some(MNEMONIC.into()),
solana_mode: "cold-export".into(),
..Default::default()
})
.unwrap();
assert_eq!(cold.keys[0].private_key, "HIDDEN");
let pda = derive(Params {
chain: "solana".into(),
mnemonic: Some(MNEMONIC.into()),
solana_mode: "pda".into(),
..Default::default()
})
.unwrap();
assert_eq!(pda.keys[0].private_key, "PDA_RECEIVE_ONLY");
assert_ne!(pda.keys[0].address, cold.keys[0].address);
let mnemonic = mnemonic_for(Some(MNEMONIC.into()), 12).unwrap();
assert!(derive_slip10(&mnemonic.to_seed(""), &[0]).is_err());
}
#[test]
fn secp_key_rejects_invalid_paths() {
let mnemonic = mnemonic_for(Some(MNEMONIC.into()), 12).unwrap();
assert!(
secp_key(
&mnemonic.to_seed(""),
"m/44'/60'/0'/0",
bitcoin::NetworkKind::Main
)
.is_ok()
);
assert!(
secp_key(
&mnemonic.to_seed(""),
"not/a/path",
bitcoin::NetworkKind::Main
)
.is_err()
);
}
use ladon::{Params, WalletOutput, derive};
use std::process::Command;
const MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn programmatic_derive_creates_three_addresses_per_chain() {
for chain in ["evm", "btc", "solana"] {
let wallet = derive(Params {
chain: chain.into(),
mnemonic: Some(MNEMONIC.into()),
num: 3,
..Default::default()
})
.unwrap();
assert_eq!(wallet.chain, chain);
assert_eq!(wallet.keys.len(), 3);
assert_eq!(
wallet
.keys
.iter()
.map(|k| &k.address)
.collect::<std::collections::HashSet<_>>()
.len(),
3
);
assert!(wallet.keys.iter().all(|k| !k.private_key.is_empty()));
}
}
#[test]
fn cli_derive_creates_three_addresses_per_chain() {
for chain in ["evm", "btc", "solana"] {
let output = Command::new(env!("CARGO_BIN_EXE_ladon"))
.args([
"derive",
"--chain",
chain,
"--mnemonic",
MNEMONIC,
"--num",
"3",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let wallet: WalletOutput = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(wallet.chain, chain);
assert_eq!(wallet.keys.len(), 3);
assert!(wallet.keys.iter().all(|k| !k.address.is_empty()));
}
}
#[test]
fn cli_encrypt_and_decrypt_roundtrip() {
let dir = std::env::temp_dir();
let enc = dir.join(format!("ladon-{}-wallet.enc", std::process::id()));
let derive_output = Command::new(env!("CARGO_BIN_EXE_ladon"))
.args([
"derive",
"--chain",
"evm",
"--mnemonic",
MNEMONIC,
"--output",
enc.to_str().unwrap(),
"--encrypt",
"--password",
"test-password",
])
.output()
.unwrap();
assert!(
derive_output.status.success(),
"{}",
String::from_utf8_lossy(&derive_output.stderr)
);
let decrypt_output = Command::new(env!("CARGO_BIN_EXE_ladon"))
.args([
"decrypt",
enc.to_str().unwrap(),
"--password",
"test-password",
])
.output()
.unwrap();
let _ = std::fs::remove_file(enc);
assert!(
decrypt_output.status.success(),
"{}",
String::from_utf8_lossy(&decrypt_output.stderr)
);
let wallet: WalletOutput = serde_json::from_slice(&decrypt_output.stdout).unwrap();
assert_eq!(wallet.keys.len(), 1);
}
#[test]
fn cli_writes_plain_output_file() {
let path = std::env::temp_dir().join(format!("ladon-{}-wallet.json", std::process::id()));
let output = Command::new(env!("CARGO_BIN_EXE_ladon"))
.args([
"derive",
"--chain",
"btc",
"--mnemonic",
MNEMONIC,
"--num",
"3",
"--output",
path.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let data = std::fs::read(&path).unwrap();
let _ = std::fs::remove_file(path);
let wallet: WalletOutput = serde_json::from_slice(&data).unwrap();
assert_eq!(wallet.chain, "btc");
assert_eq!(wallet.keys.len(), 3);
}
#[test]
fn cli_rejects_unknown_options() {
let output = Command::new(env!("CARGO_BIN_EXE_ladon"))
.args(["derive", "--wat"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("unexpected argument '--wat'"));
}
use ladon::{KeyInfo, Params, derive};
use std::{fs, process::Command};
const MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const ANVIL_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
#[ignore = "requires anvil and cast running at http://127.0.0.1:8545"]
fn anvil_transfers_to_generated_evm_addresses_and_between_them() {
let keys = keys("evm");
for key in &keys {
run(
"cast",
&[
"send",
&key.address,
"--value",
"1ether",
"--private-key",
ANVIL_KEY,
"--rpc-url",
"http://127.0.0.1:8545",
],
);
}
run(
"cast",
&[
"send",
&keys[1].address,
"--value",
"0.1ether",
"--private-key",
&keys[0].private_key,
"--rpc-url",
"http://127.0.0.1:8545",
],
);
}
#[test]
#[ignore = "requires solana-test-validator and solana CLI configured for localhost"]
fn solana_transfers_to_generated_addresses_and_between_them() {
let keys = keys("solana");
let payer =
std::env::temp_dir().join(format!("ladon-solana-payer-{}.json", std::process::id()));
run(
"solana-keygen",
&[
"new",
"--no-bip39-passphrase",
"--silent",
"--force",
"--outfile",
payer.to_str().unwrap(),
],
);
let payer_address = read("solana-keygen", &["pubkey", payer.to_str().unwrap()]);
run(
"solana",
&["airdrop", "10", payer_address.trim(), "--url", "localhost"],
);
for key in &keys {
run(
"solana",
&[
"transfer",
&key.address,
"1",
"--keypair",
payer.to_str().unwrap(),
"--allow-unfunded-recipient",
"--url",
"localhost",
],
);
}
let from = solana_keypair_file(&keys[0]);
run(
"solana",
&[
"transfer",
&keys[1].address,
"0.1",
"--keypair",
from.to_str().unwrap(),
"--url",
"localhost",
],
);
let _ = fs::remove_file(from);
let _ = fs::remove_file(payer);
}
#[test]
#[ignore = "requires bitcoind regtest and bitcoin-cli"]
fn bitcoin_regtest_transfers_to_generated_addresses_and_between_them() {
let keys = keys("btc");
let datadir = std::env::var("LADON_BITCOIN_DATADIR").unwrap_or_else(|_| {
std::env::temp_dir()
.join("ladon-bitcoin-regtest")
.display()
.to_string()
});
let wallet = format!("ladon-test-{}", std::process::id());
let wallet_arg = format!("-rpcwallet={wallet}");
let _ = Command::new("bitcoin-cli")
.args([
"-regtest",
&format!("-datadir={datadir}"),
"createwallet",
&wallet,
])
.output();
let miner = bitcoin_read(&datadir, &[&wallet_arg, "getnewaddress"]);
bitcoin_run(&datadir, &["generatetoaddress", "101", miner.trim()]);
for key in &keys {
bitcoin_run(&datadir, &[&wallet_arg, "sendtoaddress", &key.address, "1"]);
}
let desc_info = bitcoin_read(
&datadir,
&[
"getdescriptorinfo",
&format!("wpkh({})", keys[0].wif.as_ref().unwrap()),
],
);
let desc = serde_json::from_str::<serde_json::Value>(&desc_info).unwrap()["descriptor"]
.as_str()
.unwrap()
.to_string();
let import = format!(r#"[{{"desc":"{desc}","timestamp":"now"}}]"#);
bitcoin_run(&datadir, &[&wallet_arg, "importdescriptors", &import]);
bitcoin_run(&datadir, &["generatetoaddress", "1", miner.trim()]);
bitcoin_run(
&datadir,
&[&wallet_arg, "sendtoaddress", &keys[1].address, "0.1"],
);
}
fn keys(chain: &str) -> Vec<KeyInfo> {
derive(Params {
chain: chain.into(),
mnemonic: Some(MNEMONIC.into()),
num: 3,
network: if chain == "btc" {
"regtest".into()
} else {
String::new()
},
..Default::default()
})
.unwrap()
.keys
}
fn solana_keypair_file(key: &KeyInfo) -> std::path::PathBuf {
let mut bytes = hex::decode(&key.private_key).unwrap();
bytes.extend(hex::decode(&key.public_key).unwrap());
let json = format!(
"[{}]",
bytes
.iter()
.map(u8::to_string)
.collect::<Vec<_>>()
.join(",")
);
let path = std::env::temp_dir().join(format!("ladon-solana-{}.json", std::process::id()));
fs::write(&path, json).unwrap();
path
}
fn run(bin: &str, args: &[&str]) {
let output = Command::new(bin).args(args).output().unwrap();
assert!(
output.status.success(),
"{} {}\nstdout: {}\nstderr: {}",
bin,
args.join(" "),
out(&output.stdout),
out(&output.stderr)
);
}
fn read(bin: &str, args: &[&str]) -> String {
let output = Command::new(bin).args(args).output().unwrap();
assert!(
output.status.success(),
"{} {}\nstdout: {}\nstderr: {}",
bin,
args.join(" "),
out(&output.stdout),
out(&output.stderr)
);
out(&output.stdout)
}
fn bitcoin_run(datadir: &str, args: &[&str]) {
let datadir_arg = format!("-datadir={datadir}");
let mut all = vec!["-regtest", datadir_arg.as_str()];
all.extend_from_slice(args);
run("bitcoin-cli", &all);
}
fn bitcoin_read(datadir: &str, args: &[&str]) -> String {
let datadir_arg = format!("-datadir={datadir}");
let mut all = vec!["-regtest", datadir_arg.as_str()];
all.extend_from_slice(args);
read("bitcoin-cli", &all)
}
fn out(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}