63 Commits

Author SHA1 Message Date
8dc344cbd1 docker: add release Dockerfile, fix build deps and feature-split builds
- Add Dockerfile.release: downloads latest release from Gitea, verifies SHA-256
- Fix Dockerfile: remove unused libsodium/cmake, add libsqlite3-dev
- Split build per binary with --no-default-features --features server/pusher
- Remove Cargo.lock from .dockerignore for reproducible builds
- Update README and knowledge base with Dockerfile.release docs
2026-07-20 17:36:58 -04:00
eacb2e1450 docs: update knowledge base to match current codebase
- Fix framework references (actix-web, not hyper)
- Update all env var names (BAL_SERVER_*/BAL_PUSHER_* prefix)
- Add validation.rs module documentation
- Fix function signatures in xpub.rs and db.rs
- Update API response formats (InfoResponse, StatsResponse)
- Fix database schema (date_creation/date_update, push_err, tbl_stats)
- Mark fixed vulnerabilities with current status
- Add Docker support and actix tuning documentation
- Remove outdated references (confy, bal-stats.rs.dontcompile)
- Add regression test summary table
2026-07-20 15:43:20 -04:00
734b2ee71d release: bal-server-0.3.2 2026-07-20 13:37:19 -04:00
7999902cc0 fix: extract client IP correctly behind reverse proxy
Add X-Forwarded-For as fallback header when X-Real-IP is missing.
Validate IP format before storing. Log which source was used.

release: bal-server-0.3.2
2026-07-20 13:34:03 -04:00
d6b888e403 release: bal-server-0.3.1 2026-07-20 08:53:16 -04:00
36219c49a0 fix: bug fixes, dead code removal, improved ZMQ logging
- Fix SQL syntax error in create_database (trailing parenthesis)
- Fix typo i27.0.0.1 -> 127.0.0.1 in Testnet/Testnet4 defaults
- Replace hardcoded VERSION with CARGO_PKG_VERSION in bal-pusher
- Remove unwrap() in DB update loops (status push/invalid)
- Remove duplicate init_network call in bal-server startup
- Use INSERT OR IGNORE for idempotent xpub initialization
- Remove debug println! left in production code
- Remove dead code: check_zmq_connection, ConnectionMonitor, seq_to_str
- Remove all commented-out code blocks
- ZMQ timeout logging: trace instead of warn, error only after 1 hour
2026-07-20 08:48:54 -04:00
ca530bf987 release: bal-server-0.3.0 2026-07-19 21:29:18 -04:00
1c76755ea6 release: bal-server-0.3.1 2026-07-19 20:16:44 -04:00
c371a4f478 fix: default features for cargo build/test, build each binary separately in release
- Set default = ["server", "pusher"] so cargo build/test works without flags
- Add required-features to each [[bin]] section
- make_release.sh builds each binary with --no-default-features for optimal size
2026-07-19 19:53:41 -04:00
59250289a7 perf: reduce binary sizes from 15M/11M to 3.8M/5.5M
- Add [profile.release]: opt-level=z, lto=true, strip=true, codegen-units=1, panic=abort
- Replace vendored openssl (~5MB) with ed25519-dalek (pure Rust, ~100KB)
- Feature-gate deps: server (actix, chrono) vs pusher (zmq, reqwest, ed25519-dalek)
- Remove unused confy dependency
- bal-pusher: 15M -> 3.8M (-75%)
- bal-server: 11M -> 5.5M (-50%)
2026-07-19 19:27:42 -04:00
0f0f0a08c3 Merge origin/main: resolve conflicts and add 10s timeout to welist_http_client 2026-07-19 18:13:21 -04:00
6e6c634e98 fix(pusher): allow bypassing SSRF validation for local dev and fix RUST_LOG export
- Add WELIST_SKIP_URL_VALIDATION env var to bypass URL validation in dev
- Fix bal-pusher.sh: export RUST_LOG so env_logger picks up log level
- Add WELIST_SKIP_URL_VALIDATION=true to bal-pusher.sh for regtest use
2026-07-19 17:48:43 -04:00
efcd91e6b4 fix(pusher): add ZMQ diagnostic logging and heartbeat monitoring
- Log ZMQ subscribe confirmation with address
- Add consecutive timeout counter with periodic warn every 60s
- Log ZMQ connection restored after timeouts
- Log main_result errors instead of discarding with let _=
2026-07-19 15:55:49 -04:00
22b60e55c7 Merge pull request 'pusher: optional IPv6 preference for welist reports + log report failures' (#1) from SAFE21.io/bal-server:fix/pusher-welist-ipv6-preference into main
Reviewed-on: #1
2026-07-19 15:09:00 +00:00
7fe5fd3139 fix(pusher): log send_stats_report errors instead of discarding
main_result called send_stats_report/calculate_stats with 'let _ = ...',
silently dropping any failure. A broken welist route (e.g. unreachable
IPv4 path) is then invisible in the logs and can go unnoticed for a long
time. Log failures with warn! so connectivity problems are diagnosable.
2026-07-19 14:09:41 +02:00
b46f85f436 feat(pusher): optional IPv6 preference for welist reports (BAL_PUSHER_PREFER_IPV6)
The welist host publishes both A and AAAA records. On networks where the
IPv4 route is broken (connection stalls after the TCP handshake) while
IPv6 works, the default connector may pick the broken family and the
report request hangs.

When BAL_PUSHER_PREFER_IPV6 is truthy, the pusher now resolves the welist
host itself and pins the reqwest client to its first IPv6 address; the
original hostname is still used for the Host header and TLS SNI. When the
variable is unset (default) or no AAAA record exists, behavior is
completely unchanged.

Includes unit tests for the URL host/port parsing and documentation in
docs/07_deployment_and_ops.md.
2026-07-19 14:09:01 +02:00
cd24eda111 fix(pusher): improve welist response logging, error handling, and add request timeout
- Always log HTTP status code and response body from welist (info level)
- Log send_stats_report errors at call site instead of silently discarding
- Add 10s timeout to reqwest client to prevent indefinite hangs
- Apply clippy fixes (is_empty, if-let chains, dead_code, etc.)
2026-07-18 22:58:42 -04:00
8ce3f6a445 fix(pushtxs): skip invalid txs instead of aborting batch, anonymize error responses
- parse_request_transactions now skips txs without willexecutor output
  instead of returning an error that aborts the entire batch
- returns 'error' (400) only when NO txs are valid
- all HTTP error bodies replaced with generic 'error' to avoid leaking
  internal details
2026-07-18 21:35:39 -04:00
190cac929e refactor: rename bal-server-actix.rs to bal-server.rs
- Rename src/bin/bal-server-actix.rs -> src/bin/bal-server.rs
- Update Cargo.toml to point to new filename
- Update Dockerfile reference
- Update docs/08_security_audit.md references
2026-07-17 10:31:56 -04:00
5e1d0d7c54 chore: remove make_release.sh from tracking, keep local 2026-07-17 09:59:05 -04:00
9081e08785 feat(release): add ASCII-armored .asc signature alongside .sig 2026-07-17 09:57:39 -04:00
06db6f1d48 fix(release): move asset paths after ASSET_PATH definition 2026-07-17 09:55:11 -04:00
ba8f828e89 release: bal-server-0.3.0 2026-07-17 09:53:35 -04:00
9abfad29b9 feat(release): add signature, checksum and verification instructions 2026-07-17 09:51:37 -04:00
bc9ec1a48c docs: fix Docker example to use regtest env vars consistently 2026-07-17 05:23:51 -04:00
c461232095 docs: rewrite README with Docker, per-network config, Actix DoS vars 2026-07-17 02:51:04 -04:00
fbe61a5862 fix: Docker support, WAL race condition, pusher panic fixes
- Add multi-stage Dockerfile with tini, non-root user, healthcheck
- Fix SQLite WAL mode race between bal-server and bal-pusher (busy_timeout + retry)
- Fix tbl_stats missing UNIQUE index for ON CONFLICT clause
- Replace unwrap() panics in bal-pusher with graceful error handling
- Add docker/entrypoint.sh with BAL_PUSHER_NETWORK support
- cargo fmt across all files
2026-07-16 21:24:09 -04:00
56696050ec security: remove terms and personal info (Svātantrya) from files 2026-07-16 20:07:14 -04:00
638f00bf0c docs(deploy): align docs with contrib script fixes
- 07_deployment_and_ops.md: update zmqpubhashblock note, clarify certbot --nginx,
  update Nginx checklist items referencing the install script
- 09_references_and_links.md: remove 'hardcoded xpub' from install script row
2026-07-16 19:49:20 -04:00
da2124031e fix(install-scripts): correct critical bugs in contrib scripts
- download_and_install_bitcoincore.sh:
  - Fix apt install with single-quoted string (was treating all deps as one package)
  - Add sudo to apt install commands
  - Fix tar extraction: now finds extracted dir and copies bitcoind/bitcoin-cli to /usr/local/bin
  - Fix zmqpubrawblock -> zmqpubhashblock (pusher expects hashblock topic)
  - Fix install check (0 was testing cd, not tar) with explicit extraction check
  - Handle existing user in adduser
  - Fix source lib.sh path

- download_and_install_bal.sh:
  - Use curl -sfL (fail on 404, follow redirects) for Gitea download
  - Add null/empty check after jq download URL extraction
  - Change certbot --standalone to --nginx (avoids port 80 conflict)
  - Enable nginx site symlink (was commented out)
  - Handle existing 'bal' user in adduser

- install_tor.sh:
  - Add sudo to all root-requiring commands (apt, tee, cp, sed, systemctl)
  - Backup torrc with timestamp before modifying
  - Add DisableDebuggerAttachment hardening
2026-07-16 19:39:10 -04:00
538e72f806 fix(install-scripts): harden contrib scripts and fix critical bugs
- download_and_install_bal.sh: add set -euo pipefail, fix sudo sudo typo, fix nginx path (etc/nginx/etc/nginx -> etc/nginx), add chmod 600 for env files, reduce RestartSec to 5s, fix concatenated echo line, add systemctl daemon-reload
- download_and_install_bitcoincore.sh: add set -euo pipefail, fix source lib.sh path to use script directory
- install_tor.sh: add set -euo pipefail, detect architecture dynamically (dpkg --print-architecture), detect distro dynamically (lsb_release -cs), bind ControlPort to 127.0.0.1:9051, add DisableDebuggerAttachment
2026-07-16 19:07:15 -04:00
4fc0790fe7 security: fix audit points 5-9 + optimize echo_push/info endpoints
- Point 5 (SSRF): Add URL validation for WELIST_SERVER_URL (src/validation.rs)
- Point 6 (DB Access): Add DB path validation, symlink check, WAL mode (open_db)
- Point 8 (HTTPS): Extract nginx config, add deployment checklist, bind warnings
- Point 9 (Input Validation): Add NETWORKS check (404 for unknown), txid 64-hex validation
- Optimize echo_push: parse transactions outside DB lock, batch duplicate check, N+1 xpub lookup eliminated via HashSet cache
- Optimize echo_info: derive BIP32 address outside DB lock, minimize lock duration
- Fix echo_stats SQL injection via parameter binding + add idx_stats_chain index
- New regression tests: ssrf_tests, db_path_validation, input_validation_tests
2026-07-16 18:59:30 -04:00
237e62d4be security: fix secret leakage hardcoded tokens and credentials (Punto 3)
- .gitignore: Add protection for .env, *.pem, *.key, private_key.pem, privkey.pem, ec.key, chiave_privata.key, and shell scripts bal-*.sh
- make_release.sh: Remove hardcoded token 5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
  Add loading from .env file with GITEA_API_TOKEN variable
  Add error handling if token is not set (prevents script from running without proper authentication)
- .env.example: Add template file for Gitea API token setup (not committed to git, .gitignored)
- generate_keys.sh: Add chmod 600 to protect private_key.pem permissions
- contrib/download_and_install_bal.sh: Remove hardcoded xpub and fixed_fee.
  Make all settings required as arguments or environment variables (xpub, fixed_fee, willexecutor_url, email, info)
  Add proper error handling and usage instructions if required arguments are not provided
- tests/secret_leakage_tests.rs: Add regression tests that:
  * Verify .gitignore protects .env, .pem, .key files
  * Verify no private key files are tracked in git (only public_key.pem is allowed)
  * Scan shell scripts for potential hardcoded tokens
- All tests pass: cargo test (8 tests: 3 SQL injection + 2 panic regression + 3 secret leakage)
- Build verified: cargo check (0 errors)
2026-07-16 15:25:23 -04:00
fa2f458468 security: fix unwrap/expect/panic on untrusted input (Fase 3 MEDIUM/LOW)
- xpub.rs: Replace convert_xpub() unwrap() with Result propagation
- xpub.rs: Replace calculate_fingerprint() unwrap() with Result propagation
- xpub.rs: Replace get_bitcoincore_descriptor() unwrap() with Result/match
- xpub.rs: Fix new_address_from_xpub() call in bal-server.rs to handle Result
- xpub.rs: Add prefix validation in convert_xpub (xpub/ypub/zpub/tpub/vpub/upub)
- bal-pusher.rs: Remove parse().unwrap() on env var bool, use unwrap_or(false)
- bal-pusher.rs: Replace port try_into().unwrap() with safe u16::try_from match
- bal-pusher.rs: Add error logging for invalid port values in env vars
- All tests pass: cargo test (5 tests: 3 SQL + 2 panic regression)
- Build verified: cargo check --bin=bal-server --bin=bal-pusher (0 errors)
2026-07-16 15:08:49 -04:00
167869b881 security: eliminate unwrap/expect/panic on untrusted input paths (Fase 2 HIGH)
- db.rs: Replace all stmt.read/bind().unwrap() with safe match/if let + error logging
- db.rs: Replace execute_insert() expect() with safe prepare + rollback on error
- db.rs: Replace get_total_transaction_number() unwrap() with safe match/Result propagation
- bal-server.rs: Replace fs::read_to_string().expect() with match + 500 error
- bal-server.rs: Replace idx/amount.try_into().unwrap() with i64::try_from(...).unwrap_or()
- bal-server.rs: echo_pub_key returns 500 instead of panic on file read failure
- docs/08_security_audit.md: Update status for panic/DoS vulnerabilities to 'Fixed'
- All tests pass: cargo test (5 tests: 3 SQL injection + 2 panic regression)
- Build verified: cargo check --bin=bal-server --bin=bal-pusher (0 errors)
2026-07-16 14:56:25 -04:00
0fdefcfd0f security: fix panic on untrusted input (Phase 2 Critical)
- Replace all sqlite::open().unwrap() per-request with shared Arc<Mutex<Connection>>
- Add Mutex poisoning recovery in all cfg.lock() and db.lock() calls
- Fix std::str::from_utf8().unwrap() with safe match → 400 Bad Request
- Fix timestamp_nanos_opt().unwrap() with safe match
- Fix panic on RPC client failure (bal-pusher): error log + sleep + retry
- Fix ZMQ socket connect with retry loop (bal-pusher)
- Add ZMQ_RCVTIMEO=5000 and match recv for graceful timeout (bal-pusher)
- Fix ZMQ subscribe error with match instead of unwrap (bal-pusher)
- Add unwrap_or for all tbl_stats row fields in echo_stats (prevent NULL panic)
- Add panic_regression_tests.rs: test mutex poisoning recovery and NULL unwrap_or

All tests pass: cargo test --test panic_regression_tests + sql_injection_tests
Build verified: cargo check --bin=bal-server --bin=bal-pusher
2026-07-16 14:49:31 -04:00
df8effcc60 refactor: remove bal-pusher-enhanced and update docs/env
- Remove src/bin/bal-pusher-enhanced.rs (synchronous pusher variant)
- Remove bal-pusher.env and bal-pusher.sh from git tracking (now in .gitignore)
- Update all documentation to remove references to bal-pusher-enhanced:
  * 01_project_overview.md
  * 02_glossary_and_bitcoin_domain.md
  * 03_architecture_and_data_flow.md
  * 04_modules_detail.md
  * 05_api_reference.md (remove rawblock ZMQ section, update references)
  * 08_security_audit.md (remove references to bal-pusher-enhanced in DoS and ZMQ sections)
  * 09_references_and_links.md
- Build verified: cargo check passes for bal-pusher and bal-server binaries
2026-07-16 14:32:59 -04:00
69d877a360 docs: add comprehensive knowledge base and security audit
- Add docs/INDEX.md with navigable index and quick reference guides
- Add 9 knowledge base files covering project overview, Bitcoin domain,
  architecture, modules, API reference, database schema, deployment/security
- Update AGENTS.md with knowledge base reference and update policy
- Add tests/sql_injection_tests.rs with regression tests for SQL injection
- Fix SQL injection vulnerabilities in bal-pusher.rs:
  * Replace string-formatted UPDATE IN with loop + parameterized queries
  * Replace string-formatted UPDATE push_err with parameterized query
  * Add chain name validation in calculate_stats to prevent env var tampering
- Update .gitignore to exclude bal-pusher.env and bal-pusher.sh
2026-07-16 14:11:18 -04:00
64f402eeae fix testnet4 2026-03-17 21:06:37 -04:00
0526c1f742 zmqhashblock per network 2026-03-17 21:01:59 -04:00
97bdffaa50 env log 2026-02-06 12:07:15 -04:00
dc02d9248c formatting code 2026-02-06 12:03:10 -04:00
efa8c470cc RPC.md 2026-01-29 13:36:06 -04:00
c2797420a1 expose stats 2026-01-21 00:08:47 -04:00
e78a1efb68 version 2026-01-19 20:03:17 -04:00
4a592fa3ef bal server route / 2025-11-03 09:36:56 -04:00
ed38ffb87f update version 2025-11-03 08:53:58 -04:00
b6d9c5b6ba fix bal-pusher zmq connection stability 2025-11-03 08:46:13 -04:00
47323e9986 fix bal-pusher zmq connection stability 2025-11-03 07:42:09 -04:00
31ab600e79 . 2025-11-01 19:02:15 -04:00
62c4ad824b send times to welist
ED25519 times signing
2025-11-01 18:34:35 -04:00
ab0f132081 send time to welist server 2025-10-31 16:01:15 -04:00
056aa414e5 release: bal-server-0.2.2 2025-10-31 15:13:09 -04:00
eef33c55a6 bugdfix 2025-09-23 13:55:00 -04:00
86e9e50059 bal-pusher.env 2025-07-19 07:15:53 -04:00
48e1ead8a1 logs about current block 2025-07-19 07:12:18 -04:00
70583710d5 bal-pusher readme 2025-07-16 04:50:40 -04:00
166b9f9f2b release: bal-server-0.2.1 2025-07-16 04:26:02 -04:00
aa91662a2b release: bal-server-0.2.1 2025-07-16 04:25:17 -04:00
b8f120f8b1 release: bal-server-0.2.1 2025-07-16 03:58:42 -04:00
b41d961285 pusher fix 2025-07-16 03:47:28 -04:00
3a091ab070 fixed network db_field 2025-07-15 15:18:37 -04:00
72fc39b787 fixed db field network 2025-07-15 15:12:21 -04:00
42 changed files with 6675 additions and 1459 deletions

56
.dockerignore Normal file
View File

@@ -0,0 +1,56 @@
# Git
.git
.gitignore
.gitsecret
# Build artifacts
target/
# Environment files (secrets)
*.env
.env
.env.*
!.env.example
# Private keys
*.pem
*.key
!public_key.pem
# Database files
*.db
*.db-shm
*.db-wal
# Documentation
docs/
*.md
!README.md
# Tests
tests/
# Scripts (local dev only)
bal-server.sh
bal-pusher.sh
download_bal_db.sh
sendtx.sh
lib.sh
contrib/
update
update_codebase.txt
# Service files
*.service
# IDE
*.swp
*.swo
.vscode/
.idea/
# Misc
generate_random_ascii.sh
test/
invalid_txs/
valid_txs/

56
.env.example Normal file
View File

@@ -0,0 +1,56 @@
# Gitea API Token for releases
# Get this from your gitea settings: https://bitcoin-after.life/gitea/user-settings/applications
# DO NOT commit the real token to git!
# This file is in .gitignore and should not be committed to git
GITEA_API_TOKEN=your_gitea_api_token_here
# Example: GITEA_API_TOKEN=5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
# (replace with your actual token after revoking the old one)
# === bal-server ===
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place
# Nginx with TLS in front. Direct exposure will leak transaction data !!!
RUST_LOG=info
BAL_SERVER_DB_FILE=/var/bal/bal.db
BAL_SERVER_BIND_ADDRESS=127.0.0.1
BAL_SERVER_BIND_PORT=9137
BAL_SERVER_INFO="BAL server production"
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem
BAL_SERVER_BITCOIN_ADDRESS="your_bitcoin_or_xpub_address"
BAL_SERVER_BITCOIN_FIXED_FEE=50000
# Actix Web DoS Protection Settings
BAL_SERVER_ACTIX_MAX_BODY_SIZE=1048576
BAL_SERVER_ACTIX_TIMEOUT_SECS=5
BAL_SERVER_ACTIX_PUSHTXS_PER_SEC=1
BAL_SERVER_ACTIX_PUSHTXS_BURST=3
BAL_SERVER_ACTIX_SEARCHTX_PER_SEC=5
BAL_SERVER_ACTIX_SEARCHTX_BURST=10
BAL_SERVER_ACTIX_INFO_PER_SEC=20
BAL_SERVER_ACTIX_INFO_BURST=30
BAL_SERVER_ACTIX_DEFAULT_PER_SEC=50
BAL_SERVER_ACTIX_DEFAULT_BURST=100
BAL_SERVER_ACTIX_WORKERS=4
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100
# === bal-pusher ===
BAL_PUSHER_DB_FILE=/var/bal/bal.db
BAL_PUSHER_BITCOIN_DIR=/home/bal/.bitcoin
BAL_PUSHER_SEND_STATS=false
BAL_SERVER_URL=http://127.0.0.1:9137
SSL_KEY_PATH=/var/bal/private_key.pem
WELIST_SERVER_URL=https://welist.bitcoin-after.life
# ZMQ endpoints per network (default: localhost only)
BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332
BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:23332
BAL_PUSHER_TESTNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:24332
BAL_PUSHER_TESTNET4_ZMQ_HASHBLOCK=tcp://127.0.0.1:22332
BAL_PUSHER_SIGNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
# RPC endpoints per network (default: localhost only)
BAL_PUSHER_BITCOIN_RPC_URL=http://127.0.0.1:8332
BAL_PUSHER_REGTEST_RPC_URL=http://127.0.0.1:18443
BAL_PUSHER_TESTNET_RPC_URL=http://127.0.0.1:18332
BAL_PUSHER_TESTNET4_RPC_URL=http://127.0.0.1:48332
BAL_PUSHER_SIGNET_RPC_URL=http://127.0.0.1:38332

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
.gitsecret/keys/random_seed
!*.secret
# Environment files - NEVER commit tokens or secrets
*.env
.env
.env.local
.env.production
.env.secret
# Private keys - NEVER commit to git
# Only public_key.pem should be tracked (if needed)
*.pem
!public_key.pem
data/*.pem
!data/public_key.pem
*.key
!*.secret
private_key.pem
privkey.pem
ec.key
chiave_privata.key
# Other sensitive files
bal.db
.bal.db
download_bal_db.sh
# IDE files
*.swp
*.swo
# Rust build artifacts
/target
Cargo.lock
!lib/
!contrib/
!src/
make_release.sh

64
AGENTS.md Normal file
View File

@@ -0,0 +1,64 @@
# agents.md — Rust Maintainer & Security Auditor (opencode.ai)
# Language rule: English only. This agent's output code/comments/files must be in English.
## 0) Documentation & Knowledge Base
**Before reading, editing, or auditing any code in this repository, consult
the knowledge base in `docs/INDEX.md` to locate the relevant domain knowledge,
architecture notes, and security considerations.**
- Start here: `docs/INDEX.md` — navigable index of all documentation
- Bitcoin domain: `docs/02_glossary_and_bitcoin_domain.md`
- Architecture: `docs/03_architecture_and_data_flow.md`
- Security audit: `docs/08_security_audit.md`
**After any significant code change (new features, API changes, schema changes,
or security fixes), update the corresponding knowledge base file in `docs/`
to keep documentation in sync with the implementation.**
## 1) Purpose
Maintain and audit a Rust project with two main goals:
- **Maintenance:** keep the codebase correct, stable, and easy to evolve.
- **Security:** find and fix bugs and potential **exploits**, especially those reachable via untrusted input.
## 2) Scope (based on your dependencies)
This repo likely uses:
- **Async HTTP server:** `hyper`, `hyper-util`, `http-body-util`, `tokio`
- **HTTP client:** `reqwest` (`json`, `socks`)
- **Bitcoin components:** `bitcoin`, `bitcoincore-rpc`, `bitcoincore-rpc-json`
- **Data/encoding:** `base64`, `bs58`, `hex`, `hex-conservative`, `byteorder`
- **Crypto/TLS:** `sha2`, `openssl` (vendored)
- **Serialization:** `serde`, `serde_json`
- **Storage:** `sqlite`
- **Parsing:** `regex`
- **IPC/messaging:** `zmq`
- **Logging:** `log`, `env_logger`
Therefore, prioritize:
- untrusted input flowing into **parsing/encoding/DB/RPC/IPC**
- **SSRF/network abuse** via `reqwest`
- **panics** from `unwrap()/expect()/panic!()` in request/message paths
- **SQL injection** risks in SQLite usage
- **secret leakage** through logs/config/error messages
- **DoS** vectors: oversized bodies/messages, expensive regex, unbounded JSON, missing timeouts
## 3) Operating Principles
1. **Reproducibility first**
- Every fix or security claim must include concrete reproduction steps and exact commands.
2. **Small changes**
- Prefer minimal diffs with focused tests.
3. **CI-aligned workflow**
- If CI fails, identify the failing target/job and fix at the right layer.
4. **Security-first triage**
- If you see signs of exploitability (RCE/auth bypass/SSRF/secret leak/DoS), prioritize mitigation + regression tests.
5. **No panics on untrusted input**
- In any path reachable from HTTP/ZMQ/DB/IPC/network/CLI: eliminate `unwrap()/expect()` and replace with safe error handling.
## 4) Baseline Commands (must run before finalizing)
Run and ensure these succeed:
```bash
cargo fmt -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
```

1183
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,58 @@
[package]
name = "bal-server"
version = "0.1.0"
edition = "2021"
name = "bal_server"
version = "0.3.2"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
base64 = "0.22.1"
bs58 = "0.4.0"
bytes = "1.2"
bitcoin = { version = "0.32.5" }
bitcoincore-rpc = "0.19.0"
bitcoincore-rpc-json = "0.19.0"
byteorder = "1.5.0"
confy = "0.6.1"
chrono = "0.4.40"
env_logger = "0.11.5"
hex = "0.4.3"
hex-conservative = "0.1.1"
hyper = { version = "1.3.1", features = ["http1","server"] }
hyper-util = { version = "0.1.3", features = ["tokio"] }
http-body-util = "0.1"
log = "0.4.21"
openssl = { version = "0.10.74", features = ["vendored"] }
sha2 = "0.10.8"
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.116"
sqlite = "0.34.0"
regex = "1.10.4"
reqwest = { version = "0.12.24", features = ["json","socks"] }
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] } # Keep only necessary runtime components
zmq = "0.10.0"
[features]
default = ["server", "pusher"]
server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
[dependencies]
bs58 = { version = "0.4.0" }
bytes = { version = "1.2" }
bitcoin = { version = "0.32.5" }
bitcoincore-rpc = { version = "0.19.0" }
bitcoincore-rpc-json = { version = "0.19.0" }
env_logger = { version = "0.11.5" }
hex = { version = "0.4.3" }
log = { version = "0.4.21" }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = { version = "1.0.116" }
sqlite = { version = "0.34.0" }
regex = { version = "1.10.4" }
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
url = { version = "2" }
# server-only
actix-web = { version = "4.9.0", optional = true }
actix-governor = { version = "0.6.0", optional = true }
actix-rt = { version = "2.10.0", optional = true }
chrono = { version = "0.4.40", optional = true }
hex-conservative = { version = "0.1.1", optional = true }
# pusher-only
zmq = { version = "0.10.0", optional = true }
reqwest = { version = "0.12.24", features = ["json","socks"], optional = true }
byteorder = { version = "1.5.0", optional = true }
base64 = { version = "0.22.1", optional = true }
ed25519-dalek = { version = "2", features = ["pem", "pkcs8"], optional = true }
sha2 = { version = "0.10.8" }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
[[bin]]
name = "bal-server"
path = "src/bin/bal-server.rs"
required-features = ["server"]
[[bin]]
name = "bal-pusher"
path = "src/bin/bal-pusher.rs"
required-features = ["pusher"]

92
Dockerfile Normal file
View File

@@ -0,0 +1,92 @@
# =============================================================================
# Multi-stage Dockerfile for bal-server + bal-pusher
# Security: non-root user, minimal runtime, tini as PID 1
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Builder
# ---------------------------------------------------------------------------
FROM rust:1.95-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
libzmq5-dev \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Cache dependencies: copy Cargo.toml first, create dummy src to build deps
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src/bin && \
echo 'fn main() {}' > src/bin/bal-server.rs && \
echo 'fn main() {}' > src/bin/bal-pusher.rs && \
echo '' > src/lib.rs && \
echo '' > src/db.rs && \
echo '' > src/xpub.rs && \
echo '' > src/validation.rs && \
cargo build --release --bin bal-server --no-default-features --features server 2>/dev/null || true && \
cargo build --release --bin bal-pusher --no-default-features --features pusher 2>/dev/null || true && \
rm -rf src target/release/.fingerprint target/release/deps/*bal_server*
# Copy real source and build each binary with only its required features
COPY src/ src/
RUN cargo build --release --bin bal-server --no-default-features --features server && \
cargo build --release --bin bal-pusher --no-default-features --features pusher && \
strip target/release/bal-server target/release/bal-pusher
# ---------------------------------------------------------------------------
# Stage 2: Runtime
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies + tini for PID 1
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libzmq5 \
libsqlite3-0 \
ca-certificates \
curl \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy binaries from builder
COPY --from=builder /build/target/release/bal-server /usr/local/bin/bal-server
COPY --from=builder /build/target/release/bal-pusher /usr/local/bin/bal-pusher
# Create dedicated non-root user
RUN groupadd -g 1000 bal && \
useradd -u 1000 -g bal -s /usr/sbin/nologin -M bal && \
mkdir -p /var/bal /var/bal/.bitcoin && \
chown -R bal:bal /var/bal && \
chmod 700 /var/bal
# Copy entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Use tini as PID 1 for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/entrypoint.sh"]
# Data directory (mount as volume)
VOLUME ["/var/bal"]
# bal-server port (bind to 127.0.0.1 via env, expose for reverse proxy)
EXPOSE 9137
# Default environment (override at runtime)
ENV RUST_LOG=info \
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
BAL_SERVER_BIND_PORT=9137 \
BAL_SERVER_DB_FILE=/var/bal/bal.db \
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
BAL_SERVER_URL=http://127.0.0.1:9137 \
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
SSL_KEY_PATH=/var/bal/private_key.pem
# Health check: verify bal-server is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://127.0.0.1:9137/ || exit 1

100
Dockerfile.release Normal file
View File

@@ -0,0 +1,100 @@
# =============================================================================
# Dockerfile.release — Downloads the latest pre-built release from Gitea
# No Rust toolchain needed. Fast builds, minimal image.
# =============================================================================
FROM debian:bookworm-slim AS runtime
ARG GITEA_API="https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server"
ARG BAL_VERSION=""
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libzmq5 \
libsqlite3-0 \
ca-certificates \
curl \
jq \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
WORKDIR /tmp/bal-install
# Download and verify release
# If BAL_VERSION is set, fetch that specific tag; otherwise fetch latest
RUN set -eux; \
if [ -n "$BAL_VERSION" ]; then \
URL="${GITEA_API}/releases/tags/${BAL_VERSION}"; \
else \
URL="${GITEA_API}/releases/latest"; \
fi; \
echo "==> Fetching release metadata from $URL"; \
RELEASE_JSON=$(curl -sfL "$URL") || { echo "ERROR: Failed to fetch release metadata"; exit 1; }; \
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty'); \
if [ -z "$TAG" ]; then echo "ERROR: Could not determine release tag"; exit 1; fi; \
echo "==> Release tag: $TAG"; \
TARBALL_URL=$(echo "$RELEASE_JSON" | jq -r \
'.assets[] | select(.name | test("\\.tar\\.gz$")) | .browser_download_url' | head -1); \
if [ -z "$TARBALL_URL" ]; then echo "ERROR: No .tar.gz asset found"; exit 1; fi; \
ASSET_NAME=$(basename "$TARBALL_URL"); \
echo "==> Downloading $ASSET_NAME"; \
curl -sfL -o "$ASSET_NAME" "$TARBALL_URL" || { echo "ERROR: Download failed"; exit 1; }; \
echo "==> Downloading checksum"; \
curl -sfL -o "${ASSET_NAME}.sha256" "${TARBALL_URL}.sha256" 2>/dev/null || true; \
if [ -f "${ASSET_NAME}.sha256" ]; then \
echo "==> Verifying SHA-256 checksum"; \
sha256sum -c "${ASSET_NAME}.sha256" || { echo "ERROR: SHA-256 verification failed"; exit 1; }; \
echo "==> Checksum OK"; \
else \
echo "WARNING: No .sha256 file available — skipping checksum verification"; \
fi; \
echo "==> Extracting tarball"; \
tar -xzf "$ASSET_NAME"; \
EXTRACTED="$(basename "$ASSET_NAME" .tar.gz)"; \
for bin in bal-server bal-pusher; do \
if [ ! -f "$EXTRACTED/$bin" ]; then \
echo "ERROR: Binary '$bin' not found in archive"; \
exit 1; \
fi; \
echo "==> Installing $bin"; \
install -m 0755 -o root -g root "$EXTRACTED/$bin" /usr/local/bin/; \
done; \
echo "==> Cleanup"; \
rm -rf /tmp/bal-install
# Create dedicated non-root user
RUN groupadd -g 1000 bal && \
useradd -u 1000 -g bal -s /usr/sbin/nologin -M bal && \
mkdir -p /var/bal /var/bal/.bitcoin && \
chown -R bal:bal /var/bal && \
chmod 700 /var/bal
# Copy entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Use tini as PID 1 for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/entrypoint.sh"]
# Data directory (mount as volume)
VOLUME ["/var/bal"]
# bal-server port
EXPOSE 9137
# Default environment (override at runtime)
ENV RUST_LOG=info \
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
BAL_SERVER_BIND_PORT=9137 \
BAL_SERVER_DB_FILE=/var/bal/bal.db \
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
BAL_SERVER_URL=http://127.0.0.1:9137 \
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
SSL_KEY_PATH=/var/bal/private_key.pem
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://127.0.0.1:9137/ || exit 1

217
README.md
View File

@@ -1,85 +1,174 @@
# bal-server
## Installation
```bash
$ git clone ....
$ cd bal-server
$ openssl genpkey -algorithm ED25519 -out private_key.pem
$ openssl pkey -in private_key.pem -pubout -out public_key.pem
$ cargo build --release
$ sudo cp target/release/bal-server /usr/local/bin
$ bal-server
git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-server.git
cd bal-server
openssl genpkey -algorithm ED25519 -out private_key.pem
openssl pkey -in private_key.pem -pubout -out public_key.pem
cargo build --release
sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
```
## Configuration
## Docker
The `bal-server` application can be configured using environment variables. The following variables are available:
### Quick Start (release download)
Download the latest pre-built release — no Rust toolchain needed:
```bash
docker build -f Dockerfile.release -t bal-server .
```
### Build from source
```bash
docker build -t bal-server .
```
### Run
```bash
docker run -d \
--name bal-server \
--network host \
--tmpfs /tmp:rw,noexec,nosuid \
-v /path/to/data:/var/bal:rw \
-v /path/to/.bitcoin/regtest/.cookie:/var/bal/.bitcoin/regtest/.cookie:ro \
-e BAL_SERVER_REGTEST_ADDRESS="your_xpub_or_address" \
-e BAL_SERVER_REGTEST_FIXED_FEE=50000 \
-e BAL_SERVER_INFO="BAL server" \
-e BAL_PUSHER_NETWORK=regtest \
-e BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332 \
-e BAL_PUSHER_REGTEST_COOKIE_FILE=/var/bal/.bitcoin/regtest/.cookie \
bal-server
```
### Pin a specific version
```bash
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
```
### Docker environment variables
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_SERVER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, a new one will be created. | `$HOME/.config/bal-server/default-config.toml` |
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, a new one will be created. | `bal.db` |
| `BAL_SERVER_BIND_ADDRESS` | Public address for listening to requests. | `127.0.0.1` |
| `BAL_SERVER_BIND_PORT` | Default port for listening to requests. | `9137` |
| `BAL_SERVER_PUB_KEY_PATH` | WillExecutor Ed25519 public key | `public_key.pem` |
| `BAL_SERVER_REGTEST_ADDRESS` | Bitcoin address for the regtest environment. | - |
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee for the regtest environment. | 50000 |
| `BAL_SERVER_SIGNET_ADDRESS` | Bitcoin address for the signet environment. | - |
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee for the signet environment. | 50000 |
| `BAL_SERVER_TESTNET_ADDRESS` | Bitcoin address for the testnet environment. | - |
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee for the testnet environment. | 50000 |
| `BAL_SERVER_BITCOIN_ADDRESS` | Bitcoin address for the mainnet environment. | - |
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee for the mainnet environment. | 50000 |
| `BAL_PUSHER_NETWORK` | Network to run pusher on (`bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`). | `bitcoin` |
| `BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK` | ZMQ endpoint for regtest blocks. | `tcp://127.0.0.1:21332` |
| `BAL_PUSHER_REGTEST_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file inside the container. | - |
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1.
> The `/var/bal` volume stores the database. Mount Bitcoin Core's cookie file as read-only.
> When using `--network host`, ensure only `127.0.0.1` is used for internal services.
> `Dockerfile.release` fetches the latest release from the Gitea server and verifies its SHA-256 checksum.
## Configuration (bal-server)
The `bal-server` application can be configured using environment variables.
### General
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
| `BAL_SERVER_BIND_ADDRESS` | Address to listen on. **Never bind to `0.0.0.0` in production without a reverse proxy.** | `127.0.0.1` |
| `BAL_SERVER_BIND_PORT` | Port to listen on. | `9137` |
| `BAL_SERVER_INFO` | Server info string returned by the `/` endpoint. | - |
| `BAL_SERVER_PUB_KEY_PATH` | Ed25519 public key for signature verification. | `public_key.pem` |
| `BAL_SERVER_URL` | Public URL of this server (used for stats reporting). | - |
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
| `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`). | `info` |
### Per-network addresses and fees
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_SERVER_BITCOIN_ADDRESS` | xpub or address for mainnet. | - |
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee (satoshis) for mainnet. | `50000` |
| `BAL_SERVER_REGTEST_ADDRESS` | xpub or address for regtest. | - |
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee (satoshis) for regtest. | `50000` |
| `BAL_SERVER_SIGNET_ADDRESS` | xpub or address for signet. | - |
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee (satoshis) for signet. | `50000` |
| `BAL_SERVER_TESTNET_ADDRESS` | xpub or address for testnet. | - |
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee (satoshis) for testnet. | `50000` |
| `BAL_SERVER_TESTNET4_ADDRESS` | xpub or address for testnet4. | - |
| `BAL_SERVER_TESTNET4_FIXED_FEE` | Fixed fee (satoshis) for testnet4. | `50000` |
### DoS protection (Actix Web)
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | Maximum request body size in bytes. | `1048576` (1 MB) |
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | Request timeout in seconds. | `5` |
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | Rate limit: push txs requests per second. | `1` |
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | Rate limit: push txs burst size. | `3` |
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | Rate limit: search tx requests per second. | `5` |
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | Rate limit: search tx burst size. | `10` |
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | Rate limit: info requests per second. | `20` |
| `BAL_SERVER_ACTIX_INFO_BURST` | Rate limit: info burst size. | `30` |
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | Rate limit: default requests per second. | `50` |
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | Rate limit: default burst size. | `100` |
| `BAL_SERVER_ACTIX_WORKERS` | Number of Actix worker threads. | `4` |
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | Maximum concurrent connections. | `100` |
---
# bal-pusher
`bal-pusher` is a tool that retrieves Bitcoin transactions from a database and pushes them to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP). It listens for Bitcoin block updates via ZMQ.
`bal-pusher` monitors Bitcoin blocks via ZMQ and pushes time-locked transactions from the database to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP).
## Installation
## Prerequisites
To use `bal-pusher`, you need to compile and install Bitcoin with ZMQ (ZeroMQ) support enabled. Then, configure your Bitcoin node and `bal-pusher` to push the transactions.
- **Bitcoin Core** with ZMQ support enabled. Add to `bitcoin.conf`:
```
zmqpubhashblock=tcp://127.0.0.1:28332
```
- **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
- **Libraries**: `libssl-dev`, `libzmq5-dev`, `libsqlite3-dev`
### Prerequisites
1. **Bitcoin with ZMQ Support**:
Ensure that Bitcoin is compiled with ZMQ support. Add the following line to your `bitcoin.conf` file:
```
zmqpubhashblock=tcp://127.0.0.1:28332
```
2. **Install Rust and Cargo**:
If you haven't already installed Rust and Cargo, you can follow the official instructions to do so: [Rust Installation](https://www.rust-lang.org/tools/install).
## Configuration
`bal-pusher` can be configured using environment variables. If no configuration file is provided, a default configuration file will be created.
### Available Configuration Variables
| Variable | Description | Default |
|---------------------------------------|------------------------------------------|----------------------------------------------|
| `BAL_PUSHER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, it will be created. | `$HOME/.config/bal-pusher/default-config.toml` |
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, it will be created. | `bal.db` |
| `BAL_PUSHER_ZMQ_LISTENER` | ZMQ listener for Bitcoin updates. | `tcp://127.0.0.1:28332` |
| `BAL_PUSHER_BITCOIN_HOST` | Bitcoin server host for RPC connections. | `http://127.0.0.1` |
| `BAL_PUSHER_BITCOIN_PORT` | Bitcoin RPC server port. | `8332` |
| `BAL_PUSHER_BITCOIN_COOKIE_FILE` | Path to Bitcoin RPC cookie file. | `$HOME/.bitcoin/.cookie` |
| `BAL_PUSHER_BITCOIN_RPC_USER` | Bitcoin RPC username. | - |
| `BAL_PUSHER_BITCOIN_RPC_PASSWORD` | Bitcoin RPC password. | - |
| `BAL_PUSHER_SEND_STATS` | Contact welist to provide times | false |
| `WELIST_SERVER_URL` | welist server url to provide times | https://welist.bitcoin-afer.life |
| `BAL_SERVER_URL` | WillExecutor server url | - |
| `SSL_KEY_PATH` | Ed25519 private key pem file | `private_key.pem` |
## Running `bal-pusher`
Once the application is installed and configured, you can start `bal-pusher` by running the following command:
## Running
```bash
$ bal-pusher
bal-pusher [bitcoin|testnet|testnet4|signet|regtest]
```
This will start the service, which will listen for Bitcoin blocks via ZMQ and push transactions from the database when their locktime exceeds the median time past.
If no network is specified, defaults to `bitcoin`.
## Configuration (bal-pusher)
### General
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
| `BAL_PUSHER_SEND_STATS` | Send stats to welist server. | `false` |
| `BAL_SERVER_URL` | URL of bal-server (for stats reporting). | - |
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
| `WELIST_SERVER_URL` | Welist server URL. | `https://welist.bitcoin-after.life` |
### Per-network configuration
Each network (`bitcoin`, `regtest`, `testnet`, `testnet4`, `signet`) supports the following variables.
Replace `{NETWORK}` with the uppercase network name (e.g., `REGTEST`, `BITCOIN`).
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | ZMQ endpoint for block notifications. | `tcp://127.0.0.1:28332` (mainnet) |
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file. | `$HOME/.bitcoin/{dir}/.cookie` |
| `BAL_PUSHER_{NETWORK}_RPC_USER` | Bitcoin Core RPC username (alternative to cookie auth). | - |
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | Bitcoin Core RPC password. | - |
| `BAL_PUSHER_{NETWORK}_HOST` | Bitcoin Core RPC host. | `http://127.0.0.1` |
| `BAL_PUSHER_{NETWORK}_PORT` | Bitcoin Core RPC port. | `8332` (mainnet) |
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | Bitcoin Core data directory subfolder. | `` (mainnet) |
Default ZMQ ports per network:
| Network | ZMQ Port | RPC Port |
| --- | --- | --- |
| `bitcoin` | 28332 | 8332 |
| `regtest` | 21332 | 18443 |
| `testnet` | 23332 | 18332 |
| `testnet4` | 22332 | 48332 |
| `signet` | 24332 | 38332 |

119
RPC.md Normal file
View File

@@ -0,0 +1,119 @@
# RPC Endpoint Documentation
## Introduction
This document outlines the various endpoints provided by the Will Executor Server, a specialized server designed for managing Bitcoin transactions. Each endpoint can be accessed via HTTP GET or POST
requests with specific parameters and return values.
## Endpoints
### 1. **Server Information**
- **Endpoint:** `GET /`
- **Description:** Returns general information about the server.
- **Example URL:** `https://we.bitcoin-after.life/`
- **Response:**
```plaintext
Will Executor Server
```
### 2. **Server Public Key**
- **Endpoint:** `GET /.pub_key.pem`
- **Description:** Returns the public key of the server.
- **Example URL:** `https://we.bitcoin-after.life/.pub_key.pem`
- **Response:**
```plaintext
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAy10MSrWabdfco1c5Jo1XuohSdXSk1S0YaoEYvqZR5VE=
-----END PUBLIC KEY-----
```
### 3. **Server Version**
- **Endpoint:** `GET /version`
- **Description:** Returns the version of the server.
- **Example URL:** `https://we.bitcoin-after.life/version`
- **Response:**
```plaintext
0.2.2
```
### 4. **Network Information**
- **Endpoint:** `GET /<network>/info`
- **Description:** Returns information about a specific network.
- **Example URL:** `https://we.bitcoin-after.life/bitcoin/info`
- **Response:**
```json
{
"chain": "bitcoin",
"address": "bc1q5z32sl8at9s3sxt7mfwe6th4jxua98a0mvg8yz",
"base_fee": 1000,
"info": "Will Executor Server",
"version": "0.2.2"
}
```
### 5. **Network Statistics**
- **Endpoint:** `GET /<network>/stats`
- **Description:** Returns statistics for a specific network.
- **Example URL:** `https://we.bitcoin-after.life/bitcoin/stats`
- **Response:**
```json
[
{
"report_date": "2025-10-21 03:10:09",
"chain": "bitcoin",
"totals": 63,
"waiting": 8,
"sent": 30,
"failed": 25,
"waiting_profit": 80000,
"sent_profit": 300000,
"missed_profit": 250000,
"unique_inputs": 38
}
]
```
### 6. **Search Transaction**
- **Endpoint:** `POST /searchtx`
- **Description:** Searches for a transaction by its ID.
- **Example URL:** `https://we.bitcoin-after.life/searchtx`
- **Request Data:**
```plaintext
241ac86bdbf1408198b8c6df77e88159b43a9bb3464e55197a9fed8fdd628895
```
- **Response:**
```json
{
"status": "1",
"our_address": "bcrt1q7ajty6q3g055vvy6ryql9y3jz76x5uv806skk6",
"time": "1733755921197410086",
"our_fees": "10000",
"tx": "0200000000010281c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600100000000fdffffff81c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600200000000fdffffff031027000000"
}
```
### 7. **Push Transactions**
- **Endpoint:** `POST /push`
- **Description:** Pushes one or more transactions to the network.
- **Example URL:** `https://we.bitcoin-after.life/push`
- **Request Data:**
```plaintext
0200000000010281c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600100000000fdffffff81c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600200000000fdffffff031027000000
```
- **Response:**
- If successful, it returns the hash of each transaction:
```plaintext
thx
```
- If a transaction is already present or bad data is received, it returns an error message:
```plaintext
{
already present // or Bad data received
}
```
## Error Handling
- **400 Bad Request:** Returned when the request contains invalid parameters.
- **500 Internal Server Error:** Returned when the server encounters an unexpected condition.
This documentation should help you effectively interact with the Will Executor Server using its provided endpoints.

View File

@@ -1,16 +0,0 @@
RUST_LOG=info
BAL_PUSHER_DB_FILE=/home/bal/bal.db
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
BAL_PUSHER_REGTEST_COOKIE_FILE=/home/bitcoin/.bitcoin/regtest/.cookie
BAL_PUSHER_TESTNET_COOKIE_FILE=/home/bitcoin/.bitcoin/testnet3/.cookie
BAL_PUSHER_SIGNET_COOKIE_FILE=/home/bitcoin/.bitcoin/signet/.cookie
BAL_PUSHER_ZMQ_LISTENER=tcp://127.0.0.1:28332
BAL_PUSHER_SEND_STATS=true
WELIST_SERVER_URL=http://welist.bitcoin-after.life
SSL_KEY_PATH=/home/bal/privkey.pem
#your server domain. do not add https or final / only domain.
BAL_SERVER_URL="https://we.bitcoin-after.life"

View File

@@ -1,14 +1,15 @@
RUST_LOG=trace
export RUST_LOG=trace
BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
#export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie
BAL_PUSHER_ZMQ_LISTENER=tcp://127.0.0.1:28332
export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
export BAL_PUSHER_SEND_STATS=true
export WELIST_SERVER_URL=http://localhost:8085
export WELIST_SERVER_URL=http://localhost:8086
export WELIST_SKIP_URL_VALIDATION=true
export BAL_SERVER_URL="http://127.0.0.1:9133"
export SSL_KEY_PATH="$(pwd)/private_key.pem"
cargo run --bin=bal-pusher regtest
cargo run --bin=bal-pusher regtest --features=pusher

View File

@@ -1,6 +1,7 @@
RUST_LOG=info
BAL_SERVER_DB_FILE="/home/bal/bal.db"
BAL_SERVER_INFO="BAL server test willexecutor"
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place Nginx with TLS in front.
BAL_SERVER_BIND_ADDRESS=127.0.0.1
BAL_SERVER_BIND_PORT=9133
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
@@ -13,3 +14,17 @@ BAL_SERVER_REGTEST_FEE=5000
#BAL_SERVER_TESTNET_FEE=100000
#BAL_SERVER_SIGNET_ADDRESS=
#BAL_SERVER_SIGNET_FEE=100000
# Actix Web DoS Protection Settings (added with migration to Actix Web)
BAL_SERVER_ACTIX_MAX_BODY_SIZE=1048576
BAL_SERVER_ACTIX_TIMEOUT_SECS=5
BAL_SERVER_ACTIX_PUSHTXS_PER_SEC=1
BAL_SERVER_ACTIX_PUSHTXS_BURST=3
BAL_SERVER_ACTIX_SEARCHTX_PER_SEC=5
BAL_SERVER_ACTIX_SEARCHTX_BURST=10
BAL_SERVER_ACTIX_INFO_PER_SEC=20
BAL_SERVER_ACTIX_INFO_BURST=30
BAL_SERVER_ACTIX_DEFAULT_PER_SEC=50
BAL_SERVER_ACTIX_DEFAULT_BURST=100
BAL_SERVER_ACTIX_WORKERS=4
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100

View File

@@ -11,12 +11,13 @@ export BAL_SERVER_INFO="BAL devel willexecutor server"
export BAL_SERVER_BIND_ADDRESS="127.0.0.1"
export BAL_SERVER_BIND_PORT=9133
export BAL_SERVER_PUB_KEY_PATH="$WORKING_DIR/public_key.pem"
export BAL_SERVER_EXPOSE_STATS=true;
#export BAL_SERVER_BITCOIN_ADDRESS="your bitcoin address or xpub to recive payments here"
#export BAL_SERVER_BITCOIN_FIXED_FEE=50000
export BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
export BAL_SERVER_REGTEST_FEE=5000
export BAL_SERVER_REGTEST_FIXED_FEE=1000
#export BAL_SERVER_TESTNET_ADDRESS=
#export BAL_SERVER_TESTNET_FEE=100000
#export BAL_SERVER_SIGNET_ADDRESS=

View File

@@ -0,0 +1,258 @@
#!/bin/bash
set -euo pipefail
###############SETTINGS################
# These settings can be overridden by environment variables or arguments
# Usage: ./download_and_install_bal.sh <xpub> <fixed_fee> <willexecutor_url> <email> [info]
# Example: ./download_and_install_bal.sh bc1q... 50000 we.example.com info@example.com
# DO NOT commit this file with hardcoded secrets!
if [ -n "$1" ]; then xpub="$1"; else
echo "Error: xpub address is required as first argument"
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
echo "Example: $0 bc1q... 50000 we.example.com info@example.com"
exit 1
fi
if [ -n "$2" ]; then fixed_fee="$2"; else
echo "Error: fixed_fee is required as second argument"
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
exit 1
fi
if [ -n "$3" ]; then willexecutor_url="$3"; else
echo "Error: willexecutor_url is required as third argument"
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
exit 1
fi
if [ -n "$4" ]; then email="$4"; else
echo "Error: email is required as fourth argument (for SSL certificate)"
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
exit 1
fi
if [ -n "$5" ]; then info="$5"; else info="commercial will executor server"; fi
#######################################
bal_server_conf=$(cat << EOF
BAL_SERVER_DB_FILE=/home/bal/bal.db
BAL_SERVER_BIND_ADDRESS=127.0.0.1
BAL_SERVER_BIND_PORT=9137
BAL_SERVER_BITCOIN_ADDRESS="$xpub"
BAL_SERVER_BITCOIN_FIXED_FEE=$fixed_fee
BAL_SERVER_INFO="$info"
EOF
)
bal_pusher_conf=$(cat << EOF
BAL_PUSHER_DB_FILE=/home/bal/bal.db
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
EOF
)
if ! command -v jq &> /dev/null; then
echo "Installing jq... "
sudo apt-get update
sudo apt-get install -y jq
fi
if ! command -v curl &> /dev/null; then
echo "Installing curl... "
sudo apt-get update
sudo apt-get install -y curl
fi
if ! command -v certbot &> /dev/null; then
echo "Installing certbot... "
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
fi
if ! command -v nginx &> /dev/null; then
echo "Installing nginx... "
sudo apt-get update
sudo apt-get install -y nginx
fi
################## DOWNLOAD AND INSTALL BAL #####################
url_releases="https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server/releases/latest"
url_asset="$(curl -sfL $url_releases | jq -r .assets[0].browser_download_url)"
if [ -z "$url_asset" ] || [ "$url_asset" = "null" ]; then
echo "Error: could not fetch download URL from Gitea releases"
exit 1
fi
tempdir=$(mktemp -d)
cd $tempdir
curl -sfL -O "$url_asset"
echo "$url_asset"
filename=$(basename "$url_asset")
tar -xzf $filename
dirname=$(basename "$filename" .tar.gz)
echo "dirname $dirname"
cd $dirname
sudo install -m 0755 -o root -g root -t /usr/local/bin bal-server
sudo install -m 0755 -o root -g root -t /usr/local/bin bal-pusher
id bal >/dev/null 2>&1 || sudo adduser --gecos "" --disabled-password bal
printf "$bal_server_conf" | sudo -u bal tee "/home/bal/bal-server.env" > /dev/null
sudo chmod 600 /home/bal/bal-server.env
printf "$bal_pusher_conf" | sudo -u bal tee "/home/bal/bal-pusher.env" > /dev/null
sudo chmod 600 /home/bal/bal-pusher.env
################## SERVICES #####################
bal_server_service=$(cat << EOF
[Unit]
Description=bal-server daemon
After=network.target
[Service]
EnvironmentFile=/home/bal/bal-server.env
ExecStart=/usr/local/bin/bal-server
SyslogIdentifier=bal-server
Type=simple
PIDFile=/run/bal-server/bal-server.pid
Restart=always
TimeoutSec=300
RestartSec=5
User=bal
UMask=0027
RuntimeDirectory=bal-server
RuntimeDirectoryMode=0710
ProtectSystem=full
NoNewPrivileges=true
PrivateDevices=true
[Install]
WantedBy=multi-user.target
EOF
)
bal_pusher_service=$(cat << EOF
[Unit]
Description=bal-pusher daemon
After=bitcoind.service
[Service]
EnvironmentFile=/home/bal/bal-pusher.env
ExecStart=/usr/local/bin/bal-pusher bitcoin
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=bal-pusher
Type=simple
PIDFile=/run/bal-pusher/bal-pusher.pid
Restart=always
TimeoutSec=120
RestartSec=300
KillMode=process
User=bal
Group=bitcoin
UMask=0027
RuntimeDirectory=bal-pusher
RuntimeDirectoryMode=0710
PrivateTmp=true
ProtectSystem=full
NoNewPrivileges=true
PrivateDevices=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
EOF
)
printf "$bal_server_service" | sudo tee "/etc/systemd/system/bal-server.service" > /dev/null
printf "$bal_pusher_service" | sudo tee "/etc/systemd/system/bal-pusher.service" > /dev/null
sudo systemctl daemon-reload
sudo systemctl enable bal-server.service
sudo systemctl restart bal-server.service
sudo systemctl enable bal-pusher.service
sudo systemctl restart bal-pusher.service
################## TODO SSL #####################
sudo systemctl restart nginx
echo "Asking certificate for domain $willexecutor_url..."
sudo certbot --nginx --non-interactive --agree-tos --email $email -d $willexecutor_url
if [ -n "/etc/letsencrypt/live/$willexecutor_url/fullchain.pem" ]; then
sudo openssl x509 -in "/etc/letsencrypt/live/$willexecutor_url/fullchain.pem" -noout -text | grep -E "Issuer:|Subject:|Not Before:|Not After :"
else
echo "Error getting certificate"
exit 1
fi
(crontab -l 2>/dev/null; echo "0 0,12 * * * /usr/bin/certbot renew --quiet") | crontab -
echo "ssl certificate installed"
sudo systemctl status nginx
################## NGNIX ########################
nginx_reverse_proxy=$(cat << EOF
server {
listen 443 ssl;
server_name $willexecutor_url;
ssl_certificate /etc/letsencrypt/live/$willexecutor_url/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$willexecutor_url/privkey.pem; # managed by Certbot
location / {
proxy_pass http://127.0.0.1:9137;
# Include standard proxy headers from above
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
server {
listen 80;
server_name $willexecutor_url;
return 301 https://$willexecutor_url;
}
EOF
)
printf "$nginx_reverse_proxy" | sudo tee "/etc/nginx/sites-available/$willexecutor_url" > /dev/null
sudo ln -s "/etc/nginx/sites-available/$willexecutor_url" "/etc/nginx/sites-enabled/" || true
sudo systemctl restart nginx
rm -r $tempdir
echo "done"

View File

@@ -0,0 +1,411 @@
#!/usr/bin/env bash
set -euo pipefail
# Copyright (c) 2024-2025 Joel Torres
# Distributed under the MIT software license, see the accompanying
# file LICENSE or https://opensource.org/license/mit.
VERSION=0.1.1
usage(){
echo "\$ bash download_and_install_bitcoincore.sh <version>|update [username] [testnet] [force]"
echo "\$ bash download_and_install_bitcoincore.sh version"
echo "\$ bash download_and_install_bitcoincore.sh -h"
}
if [[ $1 == "version" ]]; then
echo "Bitcoin Core Installer v$VERSION"
exit 0
fi
if [[ $1 == "-h" ]]; then
usage
exit 0
fi
username=$(id -un)
if [[ "$2" != "" ]]; then
username="$2"
fi
if [[ $3 == "testnet" ]]; then
testnet=1
fi
if [[ $4 == "force" ]]; then
force=1
fi
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SOURCE_DIR/lib.sh"
PLATFORM_ARCH=$(uname -m)
if [ $(uname) == "Linux" ]; then
PLATFORM_NAME="linux-gnu"
BITCOIN_DIR=".bitcoin"
SYSTEMD_DIR="/etc/systemd/system"
GLOBAL_ALIASES="/etc/profile.d/bitcoin_aliases.sh"
else
echo_e "Running script on unsupported platform, exiting"
exit 1
fi
CMD_DEPENDENCIES="git gpg curl openssl"
for cmd in $CMD_DEPENDENCIES
do
if [ $(which $cmd >/dev/null 2>&1; echo $?) != 0 ]; then
echo_e "Command not found on path: $cmd, please install or add to path"
sudo apt install -y $CMD_DEPENDENCIES
fi
done
SYSTEMD_SERVICE="bitcoind.service"
BITCOIN_CONFIG_FILE="bitcoin.conf"
BITCOIN_CORE_URL="https://bitcoincore.org"
BIN_URL="$BITCOIN_CORE_URL/bin"
DOWNLOAD_URL="$BITCOIN_CORE_URL/en/download/"
if [ -n "$1" ] && [ $1 != "update" ]; then
VERSION_NUM=$1
if [[ ! $VERSION_NUM =~ ^[0-9]{1,3}.[0-9]{1,3}$ ]]; then
echo_e "Error: invalid version number"
exit 1
fi
else
VERSION_NUM=$(curl -s $DOWNLOAD_URL | grep "Latest version" | sed 's/.*Latest version: \([0-9]*\.[0-9]*\).*/\1/')
fi
VERSION_NUM_FULL="bitcoin-core-$VERSION_NUM"
KEYS_REPO="guix.sigs"
KEYS_REPO_URL="https://github.com/bitcoin-core/$KEYS_REPO"
KEYS_DIR="$KEYS_REPO/builder-keys"
if command -v bitcoin-cli &> /dev/null; then
CURRENT_VERSION=$(bitcoin-cli --version | head -n1 | awk '{print $NF}')
else
CURRENT_VERSION="none"
fi
is_bitcoin_core_running() {
echo $(pgrep bitcoind >/dev/null 2>&1; echo $?)
}
have_to_update(){
if [[ "v$VERSION_NUM" != "$CURRENT_VERSION" ]]; then
echo 0
else
echo 1
fi
}
start_bitcoin_core_exec() {
echo_i "Starting bitcoind $1"
bitcoind="bitcoind"
if [[ $1 == "testnet" ]]; then
bitcoind="t$SYSTEMD_SERVICE"
fi
sudo systemctl restart $bitcoind
}
stop_bitcoin_core_exec(){
bitcoind="$SYSTEMD_SERVICE"
debug_file="$BITCOIN_DIR_REAL/debug.log"
if [[ "$1" == "testnet" ]]; then
bitcoind="t$SYSTEMD_SERVICE"
debug_file="$BITCOIN_DIR_REAL/testnet3/debug.log"
fi
sudo systemctl stop $bitcoind
#while true; do
# last_line=$(tail -n 1 file.txt)
# if [[ $last_line == "*Shutdown: done*" ]]; then
# echo_i "$1 bitcoind terminated."
# break
# fi
#done
}
stop_bitcoin_core() {
stop_bitcoin_core_exec
if [[ $testnet == 1 ]]; then
stop_bitcoin_core testnet
fi
}
start_bitcoin_core() {
start_bitcoin_core_exec
if [[ $testnet == 1 ]]; then
start_bitcoin_core_exec testnet
fi
}
download_bitcoin_core () {
file_download_url="$BIN_URL/$VERSION_NUM_FULL/bitcoin-$VERSION_NUM-$PLATFORM_ARCH-$PLATFORM_NAME.tar.gz"
bin_hash_url="$BIN_URL/$VERSION_NUM_FULL/SHA256SUMS"
hash_sign_url="$bin_hash_url.asc"
if [ ! -d $VERSION_NUM_FULL ]; then
mkdir $VERSION_NUM_FULL
fi
for url in $file_download_url $bin_hash_url $hash_sign_url
do
echo_i "Downloading $url"
curl -O --output-dir $VERSION_NUM_FULL $url
done
echo_i "Verifying sha-256 hash"
cd $VERSION_NUM_FULL
shasum -a 256 --ignore-missing --check SHA256SUMS
if [ $? != 0 ]; then
echo_e "Installation aborted: failure on computing hashes"
exit 1
fi
touch .hash_verified
cd ..
}
verify_bitcoin_core () {
if [ ! -d $KEYS_REPO ]; then
echo_i "Downloading builder-keys ($KEYS_REPO_URL)"
git clone $KEYS_REPO_URL
else
echo_i "Updating builder-keys"
git -C $KEYS_REPO pull
fi
echo_i "Importing and refreshing keys"
gpg --import $KEYS_DIR/*
gpg --keyserver hkps://keys.openpgp.org --refresh-keys
echo_i "Verifying gpg signatures"
cd $VERSION_NUM_FULL
good_sign_str="Good signature"
good_sign_out=$(gpg --verify SHA256SUMS.asc 2> >(grep "$good_sign_str"))
if [[ ! $good_sign_out == *"$good_sign_str"* ]]; then
echo_e "Installation aborted: no good gpg signatures found"
exit 1
fi
echo "$good_sign_out"
echo
while true; do
read -p "The above good signatures were found. Do you trust some of these? [y/n]: " answer
case $answer in
Y|y)
touch .sign_verified; break;;
N|n)
echo_e "Installation aborted: keys not trusted"; exit 1;;
esac
done
cd ..
}
install_bitcoin_core () {
echo_i "Installing $VERSION_NUM_FULL"
cd $VERSION_NUM_FULL
tar xzf *.tar.gz
# Copy binaries to /usr/local/bin
extract_dir=$(find . -maxdepth 1 -type d -name 'bitcoin-*' | head -n1)
if [ -d "$extract_dir/bin" ]; then
sudo install -m 755 "$extract_dir/bin/bitcoind" /usr/local/bin/
sudo install -m 755 "$extract_dir/bin/bitcoin-cli" /usr/local/bin/
else
echo_e "Installation aborted: binary directory not found in extracted archive"
exit 1
fi
if [ $(is_bitcoin_core_running) == 0 ]; then
echo_i "Stopping bitcoind before installing"
stop_bitcoin_core
sleep 5
fi
echo_s "Bitcoin Core $VERSION_NUM successfully installed!"
touch .installed
cd ..
echo $VERSION_NUM > .version
}
install_and_start_services () {
sudo systemctl daemon-reload
sudo systemctl restart $SYSTEMD_SERVICE
sudo systemctl enable $SYSTEMD_SERVICE
if [[ $testnet == 1 ]]; then
sudo systemctl restart "t$SYSTEMD_SERVICE"
sudo systemctl enable "t$SYSTEMD_SERVICE"
fi
}
init_bitcoin_core_config () {
echo_i "USERNAME: $username"
warning_file="File already present not forcing update."
id $username >/dev/null 2>&1 || sudo adduser --gecos "" --disabled-password $username
sudo adduser $username debian-tor
userhome=$(getent passwd $username | cut -d: -f6)
usergroup=$(sudo -u $username id -gn)
BITCOIN_DIR_REAL="$userhome/$BITCOIN_DIR"
BITCOIN_CONFIG="$BITCOIN_DIR_REAL/$BITCOIN_CONFIG_FILE"
echo_i "GROUPNAME: $usergroup"
echo_i "BITCOIN_DIR_REAL: $BITCOIN_DIR_REAL"
echo_i "BITCOIN_CONFIG: $BITCOIN_CONFIG"
sudo -u $username mkdir -p $BITCOIN_DIR_REAL
sudo adduser $USER $usergroup
sudo ln -s $BITCOIN_DIR_REAL $HOME
bitcoinconf=$(cat << EOF
# Bitcoin daemon
server=1
# Activate v2 P2P
v2transport=1
# Connections
zmqpubhashblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
maxuploadtarget=5000
dbcache=2000
blocksonly=1
acceptnonstdtxn=0
peerbloomfilters=0
prune=550
listen=0
dnsseed=0
disablewallet=1
EOF
)
if [ ! -e "$BITCOIN_CONFIG" ] || [[ $force == 1 ]]; then
printf "$bitcoinconf" | sudo -u $username tee "$BITCOIN_CONFIG" > /dev/null
else
echo_i "$warning_file: $BITCOIN_CONFIG"
fi
sudo -u $username chmod 640 $BITCOIN_CONFIG
serviceconf=$(cat << EOF
# /etc/systemd/system/bitcoind.service
[Unit]
Description=Bitcoin daemon
After=network.target
[Service]
# Service execution
###################
ExecStart=/usr/local/bin/bitcoind -daemon \\
-pid=/run/bitcoind/bitcoind.pid \\
-conf=$BITCOIN_CONFIG \\
-datadir=$BITCOIN_DIR_REAL \\
-startupnotify="chmod g+r $BITCOIN_DIR_REAL/.cookie"
# Process management
####################
Type=forking
PIDFile=/run/bitcoind/bitcoind.pid
Restart=on-failure
TimeoutSec=300
RestartSec=30
# Directory creation and permissions
####################################
User=$username
UMask=0027
# /run/bitcoind
RuntimeDirectory=bitcoind
RuntimeDirectoryMode=0710
# Hardening measures
####################
# Provide a private /tmp and /var/tmp.
PrivateTmp=true
# Mount /usr, /boot/ and /etc read-only for the process.
ProtectSystem=full
# Disallow the process and all of its children to gain
# new privileges through execve().
NoNewPrivileges=true
# Use a new /dev namespace only populated with API pseudo devices
# such as /dev/null, /dev/zero and /dev/random.
PrivateDevices=true
# Deny the creation of writable and executable memory mappings.
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
EOF
)
if [[ ! -e "$SYSTEMD_DIR/$SYSTEMD_SERVICE" ]] || [[ $force == 1 ]]; then
printf "$serviceconf" | sudo tee "$SYSTEMD_DIR/$SYSTEMD_SERVICE" > /dev/null
else
echo_i "$warning_file: $SYSTEMD_DIR/$SYSTEMD_SERVICE"
fi
if [[ $testnet == 1 ]]; then
tserviceconf=$(printf "$serviceconf" | sed "s/bitcoind/tbitcoind/g")
tserviceconf=$(printf "$tserviceconf" | sed "s/Bitcoin daemon/Bitcoin Testnet daemon/")
tserviceconf=$(printf "$tserviceconf" | sed "s|tbitcoind -daemon|bitcoind -daemon -testnet|")
if [[ ! -e "$SYSTEMD_DIR/t$SYSTEMD_SERVICE" ]] || [[ $force == 1 ]]; then
printf "$tserviceconf" | sudo tee "$SYSTEMD_DIR/t$SYSTEMD_SERVICE" > /dev/null
else
echo_i "$warning_file: $SYSTEMD_DIR/t$SYSTEMD_SERVICE"
fi
#install aliases avoid duplicates
echo $GLOBAL_ALIASES
if ! grep -q "alias tbitcoind=" $GLOBAL_ALIASES ; then
printf "alias tbitcoind='bitcoind -testnet'" | sudo tee -a $GLOBAL_ALIASES > /dev/null
echo_i "adding alias"
else
echo_i "alias tbitcoind present"
fi
if ! grep -q "alias tbitcoin-cli=" $GLOBAL_ALIASES ; then
printf "alias tbitcoin-cli='bitcoin-cli -testnet'" | sudo tee -a $GLOBAL_ALIASES > /dev/null
echo_i "adding alias"
else
echo_i "alias tbitcoin-cli present"
fi
fi
touch .config_init
}
if [ -e .version ] && [ $(cat .version) != $VERSION_NUM ] && [ -d $VERSION_NUM_FULL ]; then
rm $VERSION_NUM_FULL/.installed
fi
if [ -e $VERSION_NUM_FULL/.hash_verified ] &&
[ -e $VERSION_NUM_FULL/.sign_verified ] &&
[ -e $VERSION_NUM_FULL/.installed ]
then
echo_e "Bitcoin Core $VERSION_NUM already installed"
exit 0
fi
if [ $(have_to_update) == 0 ]; then
echo_i "New version available: v$VERSION_NUM(current: $CURRENT_VERSION)"
init_bitcoin_core_config;
if [ ! -e $VERSION_NUM_FULL/.hash_verified ]; then download_bitcoin_core; fi
if [ ! -e $VERSION_NUM_FULL/.sign_verified ]; then verify_bitcoin_core; fi
if [ ! -e $VERSION_NUM_FULL/.installed ]; then install_bitcoin_core; fi
if [ ! -e .config_init ]; then
install_and_start_services;
fi
fi

36
contrib/install_tor.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
sudo apt-get update
sudo apt-get install -y \
gpg \
wget \
lsb-release
ARCH=$(dpkg --print-architecture)
DISTRO=$(lsb_release -cs)
sudo tee /etc/apt/sources.list.d/tor.list > /dev/null <<EOF
deb [arch=$ARCH signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org $DISTRO main
deb-src [arch=$ARCH signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org $DISTRO main
EOF
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | sudo tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null
sudo apt-get update
sudo apt install -y tor deb.torproject.org-keyring
# Backup existing torrc before modifying
if [ -f /etc/tor/torrc ]; then
sudo cp /etc/tor/torrc /etc/tor/torrc.bak.$(date +%Y%m%d%H%M%S)
fi
sudo sed -i '/^ControlPort/d; /^CookieAuthentication/d; /^CookieAuthFileGroupReadable/d' /etc/tor/torrc
sudo tee -a /etc/tor/torrc > /dev/null << EOF
# Added by script ($(date))
ControlPort 127.0.0.1:9051
CookieAuthentication 1
CookieAuthFileGroupReadable 1
DisableDebuggerAttachment 1
EOF
sudo systemctl restart tor

View File

@@ -0,0 +1,55 @@
# Nginx reverse proxy for bal-server
# Place this file in /etc/nginx/sites-available/ and symlink to sites-enabled
# Replace BAL_DOMAIN with your actual domain
# HTTP redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name BAL_DOMAIN;
return 301 https://$server_name$request_uri;
}
# HTTPS proxy to bal-server (localhost only)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name BAL_DOMAIN;
# Let's Encrypt certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
# Security headers (no HSTS to avoid preloading issues)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Body size limit — must match Actix PayloadConfig (default 1 MB)
client_max_body_size 1m;
# Rate limiting zone (requires `limit_req_zone` in nginx.conf)
# limit_req zone=bal burst=20 nodelay;
# limit_conn addr 10;
# Proxy to bal-server on localhost
location / {
proxy_pass http://127.0.0.1:9137;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
}
# Optional: serve public_key.pem directly from Nginx (faster)
# location /.pub_key.pem {
# alias /var/bal/public_key.pem;
# add_header Content-Type text/plain;
# }
}

25
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -e
mkdir -p /var/bal/.bitcoin
chown bal:bal /var/bal /var/bal/.bitcoin 2>/dev/null || true
PUSHER_NETWORK="${BAL_PUSHER_NETWORK:-bitcoin}"
echo "[entrypoint] Starting bal-server on ${BAL_SERVER_BIND_ADDRESS:-127.0.0.1}:${BAL_SERVER_BIND_PORT:-9137}"
echo "[entrypoint] Starting bal-pusher (network: ${PUSHER_NETWORK})"
su -s /bin/sh bal -c '/usr/local/bin/bal-server' &
SERVER_PID=$!
su -s /bin/sh bal -c "/usr/local/bin/bal-pusher ${PUSHER_NETWORK}" &
PUSHER_PID=$!
cleanup() {
echo "[entrypoint] Shutting down..."
kill $SERVER_PID $PUSHER_PID 2>/dev/null
wait
}
trap cleanup TERM INT
wait

View File

@@ -0,0 +1,55 @@
# Project Overview
## Quick Reference
- **What this file contains:** vision, scope, system components, and mapping to existing documentation.
- **See also:** [02_glossary_and_bitcoin_domain.md](02_glossary_and_bitcoin_domain.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md)
## Vision and Scope
`bal_server` is a Rust-based Bitcoin transaction executor server (v0.3.2, edition 2024). It receives raw Bitcoin transactions via HTTP, validates them, persists them in a local SQLite database, and coordinates their broadcast on-chain after a locktime condition expires. The system supports multiple Bitcoin networks (mainnet, testnet, regtest, testnet4, signet) and tracks extended public keys (xpub) for fee collection.
### Key Goals
1. **Receive and validate** raw Bitcoin transactions with locktime.
2. **Store** transactions, inputs, and outputs in a structured database.
3. **Monitor** new blocks via ZMQ and push transactions to the Bitcoin network when the locktime is satisfied.
4. **Track** derived addresses and extended public keys for fee accounting.
5. **Collect and report** statistics about the service.
## System Components
The project consists of two binaries and one shared library:
1. **`bal-server`**: Async HTTP server (**actix-web 4.9.0** + actix-rt) that exposes the API for receiving transactions and serving statistics. Includes rate limiting via `actix-governor`.
2. **`bal-pusher`**: Async daemon (tokio) that listens for `hashblock` ZMQ messages and pushes pending transactions to a Bitcoin node via RPC.
3. **`lib.rs`**: Exports the shared modules `db`, `xpub`, and `validation`.
### Library Modules
4. **`db.rs`**: All database operations, schema creation, path validation, and WAL mode for SQLite `0.34.0`.
5. **`xpub.rs`**: Address derivation from xpub/zpub/ypub using BIP-84 and the `bitcoin` crate.
6. **`validation.rs`**: SSRF protection for the `welist` URL, blocking private/internal IP ranges.
## Feature Flags
The project uses Cargo feature flags to build each binary independently:
| Feature | Dependencies | Binary |
|---------|-------------|--------|
| `server` (default) | `actix-web`, `actix-governor`, `actix-rt`, `chrono`, `hex-conservative` | `bal-server` |
| `pusher` (default) | `zmq`, `reqwest`, `byteorder`, `base64`, `ed25519-dalek` | `bal-pusher` |
## Docker Support
Two Dockerfiles are provided:
- **`Dockerfile.release`** (recommended for production): Downloads the latest pre-built release from the Gitea server. No Rust toolchain needed. Verifies SHA-256 checksum. Supports pinning a specific version via `BAL_VERSION` build arg.
- **`Dockerfile`** (for development/custom builds): Multi-stage build using `rust:1.95-bookworm` as the builder and `debian:bookworm-slim` as the runtime. Each binary is compiled with only its required features (`--no-default-features --features server` / `--features pusher`).
Both run as a non-root `bal` user (uid 1000) with `tini` as PID 1 and include a healthcheck endpoint.
## Mapping to Existing Documentation
| Existing File | Subject | Covered in this KB |
|---------------|---------|-------------------|
| `README.md` | Installation, environment variables, ZMQ dependency, Docker | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) |
| `RPC.md` | API endpoint specification | [`05_api_reference.md`](05_api_reference.md) |
| `AGENTS.md` | Security guidelines for agents | [`08_security_audit.md`](08_security_audit.md) |

View File

@@ -0,0 +1,51 @@
# Glossary and Bitcoin Domain Knowledge
## Quick Reference
- **What this file contains:** Bitcoin-specific concepts, protocols, and standards needed to understand this codebase.
- **See also:** [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md)
## BIP-84: Derivation Path for P2WPKH
BIP-84 defines the derivation path for native SegWit (Bech32) addresses (P2WPKH). The standard path is `m/84'/<coin_type>'/<account>'/0/<address_index>`. In this project, `xpub.rs` derives P2WPKH addresses from an xpub or zpub using the path `m/84'/coin_type'/account'/0/index`. The `bitcoin` crate's `Xpub::derive_pub` and `Secp256k1` are used in `src/xpub.rs`.
## XPub, ZPub, and Extended Public Keys
An **XPub** (Extended Public Key) is a master key that allows derivation of child public keys without revealing private keys. A **ZPub** is the Bech32-encoded equivalent for native SegWit. The codebase uses `xpub.rs` to derive addresses from these and verify their checksums. See `src/xpub.rs` for the Base58 decoding and checksum logic.
## Locktime and nLockTime
A Bitcoin transaction can include a `nLockTime` field. If it is non-zero and below `500_000_000`, it is interpreted as a **block height** before which the transaction cannot be mined. If above, it is a **Unix timestamp**. The system evaluates whether the locktime has been met by comparing it against the blockchain's median time. See `src/bin/bal-pusher.rs` for the evaluation logic.
## P2WPKH (Pay to Witness Public Key Hash)
P2WPKH is a native SegWit output format that reduces transaction size and lowers transaction fees. The addresses are Bech32 encoded (e.g., `bc1q...`). The project assumes fee collection outputs are P2WPKH and uses `bitcoin::Address::p2wpkh` for derivation in `src/xpub.rs`.
## Bitcoin Block Header (80 bytes)
A Bitcoin block header is a fixed 80-byte structure containing `version` (4 bytes), `previous block hash` (32 bytes), `merkle root` (32 bytes), `timestamp` (4 bytes), `bits` (4 bytes), and `nonce` (4 bytes). The `bal-pusher` binary uses the `hashblock` ZMQ topic and calls `getblockchaininfo` to get the `mediantime`.
## ZMQ Publisher
Bitcoin Core can publish notifications over ZeroMQ. The project listens to two topics:
- **`hashblock`**: Sends the 32-byte block hash when a new block is found. The `bal-pusher` uses this to trigger an update cycle.
- **`rawblock`**: Sends the full raw block (including the 80-byte header). This is not used by the current `bal-pusher` implementation.
The ZMQ endpoint is per-network:
- Bitcoin: `tcp://127.0.0.1:28332`
- Regtest: `tcp://127.0.0.1:21332`
- Testnet: `tcp://127.0.0.1:23332`
- Testnet4: `tcp://127.0.0.1:24332`
- Signet: `tcp://127.0.0.1:22332`
## Bitcoin Core RPC
The project communicates with a local Bitcoin Core node via the JSON-RPC interface. Key methods used are:
- `sendrawtransaction`: To broadcast a pending transaction.
- `getblockchaininfo`: To retrieve the current block height and `mediantime` (used to evaluate locktime).
- `getblock`: To retrieve block data in `bal-pusher` (for median time calculation).
Authentication is done via `bitcoincore-rpc` using either `UserPass` or `CookieFile` (the `cookie` file is stored in `~/.bitcoin/.cookie`). See `src/bin/bal-pusher.rs`.
## Mempool and P2P
Transactions are validated against mempool rules before submission. The server checks that the fee output is paid to a specific address owned by the operator. It also ensures the transaction can be deserialized using the `bitcoin::Transaction` parser from the `bitcoin` crate. See `src/bin/bal-server.rs` (`pushtxs` endpoint).

View File

@@ -0,0 +1,69 @@
# Architecture and Data Flow
## Quick Reference
- **What this file contains:** high-level architecture, data flow, state machine, and error handling strategy.
- **See also:** [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md), [05_api_reference.md](05_api_reference.md), [06_database_schema.md](06_database_schema.md)
## High-Level Architecture
```
User
|
| HTTP POST (raw hex transactions)
v
+-----------------+
| bal-server | (actix-web 4.9.0 + actix-governor, async)
| (src/bin/bal-server.rs) |
+-----------------+
| SQLite insert (db.rs, Arc<Mutex<Connection>>)
v
bal.db (WAL mode)
| (transactions with status=0, waiting locktime)
|
| ZMQ (hashblock)
v
+-----------------+
| bal-pusher | (tokio, ZMQ + RPC + reqwest)
| (src/bin/bal-pusher.rs)
+-----------------+
| bitcoincore-rpc
| sendrawtransaction
v
Bitcoin Network
```
## Data Flow (Transaction Lifecycle)
1. **Submission**: A client sends one or more raw hex transactions to the `pushtxs` endpoint (newline-separated).
2. **Validation**: The `bal-server` parses each transaction using `bitcoin::Transaction` via `consensus::deserialize`. It checks for the fee output, extracts inputs/outputs, and validates the locktime.
3. **Storage**: Valid transactions are stored in `tbl_tx` with `status = 0` (waiting). The inputs and outputs are stored in `tbl_inp` and `tbl_out`. Batch inserts use `UNION ALL SELECT` for efficiency.
4. **Monitoring**: The `bal-pusher` listens to the ZMQ `hashblock` topic with a 5-second receive timeout. When a new block is detected, it fetches blockchain info via RPC.
5. **Evaluation**: The pusher queries the database for transactions with `status=0` and compares their locktime against the blockchain's best block height or median time (for timestamp-based locktimes above `LOCKTIME_THRESHOLD`).
6. **Broadcast**: If the locktime is satisfied, the pusher sends the transaction via `sendrawtransaction` and updates the status to `1` (sent) or `2` (failed with error stored in `push_err`).
7. **Statistics**: The pusher periodically calculates statistics and sends them to a remote `welist` server using an Ed25519-signed POST request. The server also exposes stats via the `GET /:network/stats` endpoint if `expose_stats` is enabled.
## State Machine
```
[Submitted] -> status=0 (waiting)
|
| locktime satisfied
v
[Push attempt] -> status=1 (sent) or status=2 (failed)
```
The `status` field in `tbl_tx` is an integer:
- `0`: Waiting for locktime.
- `1`: Successfully sent to the network.
- `2`: Failed (e.g., RPC error `-25 bad-txns-inputs-missingorspent`). Error details stored in `push_err`.
## Error Handling Strategy
The codebase has been hardened with comprehensive error handling:
- **`bal-server`**: Uses `actix-web`'s built-in error handling. All `unwrap()`/`expect()` calls have been replaced with safe `match`/`if let` error propagation, returning appropriate HTTP status codes (400, 404, 500).
- **`bal-pusher`**: ZMQ `recv` uses `set_rcvtimeo(5000)` with a match/timeout handler. RPC connection failures log errors and retry with a sleep interval instead of panicking. The pusher logs warnings for consecutive ZMQ timeouts (~1 hour threshold).
- **`db.rs`**: Database operations use `Result` types. The `open_db` function validates paths before opening. WAL mode is set with retry logic.
## Logging and Monitoring
The project uses `env_logger` and `log`. By default, `RUST_LOG=info` is set. The `bal-pusher` sends signed statistics to a remote server. The server exposes a `stats` endpoint (`/<network>/stats`) if `expose_stats` is enabled. The actix-web `Logger::default()` middleware logs all HTTP requests/responses.

212
docs/04_modules_detail.md Normal file
View File

@@ -0,0 +1,212 @@
# Module Details
## Quick Reference
- **What this file contains:** detailed analysis of each Rust module and binary, including source code references.
- **See also:** [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [09_references_and_links.md](09_references_and_links.md)
---
## `lib.rs`
**Location:** `src/lib.rs`
This is the root of the library crate. It exports three public modules:
- `pub mod db;` — the database interface
- `pub mod validation;` — SSRF URL validation
- `pub mod xpub;` — the extended public key utilities
It contains no application logic.
---
## `db.rs` (Database Interface)
**Location:** `src/db.rs`
This module contains all the logic for interacting with the SQLite database.
### Key Functions
| Function | Signature | Purpose |
|---|---|---|
| `open_db` | `pub fn open_db(path: &str) -> Result<Connection, String>` | Validates path (blocks `..` traversal, forbidden system dirs, symlinks), opens SQLite, sets `busy_timeout=5000`, retries WAL mode up to 5 times, sets `synchronous=NORMAL`. |
| `create_database` | `pub fn create_database(db: &Connection)` | Creates all tables and indexes (idempotent via `IF NOT EXISTS`). |
| `check_duplicate_txids` | `pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error>` | Batch check which txids already exist. Chunks in groups of 500 for SQLite parameter limit safety. |
| `insert_xpub` | `pub fn insert_xpub(db: &Connection, network: &str, xpub: &str)` | INSERT OR IGNORE into tbl_xpub. |
| `get_last_used_address_by_ip` | `pub fn get_last_used_address_by_ip(db: &Connection, network: &String, xpub: &String, address: &String) -> Option<String>` | Finds most recent address previously assigned to a remote IP for an xpub. |
| `get_next_address_index` | `pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64)` | Atomically increments `path_idx` and returns `(xpub_id, new_index)` using `RETURNING`. |
| `save_new_address` | `pub fn save_new_address(db: &Connection, xpub: i64, address: &String, path: &String, remote_addr: &String)` | INSERT into tbl_address. |
| `execute_insert` | `pub fn execute_insert(db: &Connection, sqltxs: String, ptx: Vec<(usize, Value)>, sqlinp: String, pinp: Vec<(usize, Value)>, sqlout: String, pout: Vec<(usize, Value)>) -> Result<(), Error>` | Executes a transaction: BEGIN, insert txs, insert inputs, insert outputs, COMMIT (with ROLLBACK on error). |
| `get_total_transaction_number` | `pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error>` | Counts transactions for a network. |
| `get_all_addresses_by_xpub` | `pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error>` | Fetches all addresses for an xpub via JOIN on tbl_xpub/tbl_address. Used for O(1) fee validation in the push handler. |
### Design Notes
- All SQL queries use parameterized statements (`?` placeholders with `bind()`). No string formatting is used for user-controlled values.
- The `open_db` function validates paths before opening, rejecting directory traversal, system directories, and symlinks.
- WAL mode (`PRAGMA journal_mode=WAL`) is enabled with retry logic for concurrent access safety.
---
## `xpub.rs` (Extended Public Key Utilities)
**Location:** `src/xpub.rs`
This module handles the derivation of Bitcoin addresses from extended public keys (xpub/zpub/ypub) and the creation of P2WPKH descriptors with Bitcoin Core checksums.
### Key Functions
| Function | Signature | Purpose |
|---|---|---|
| `new_address_from_xpub` | `pub fn new_address_from_xpub(zpub: &str, index: i64, network: Network) -> Result<(String, String), Box<dyn std::error::Error>>` | Derives a P2WPKH (native SegWit) address at path `m/0/{index}` from an xpub. Returns `(address, path)`. |
| `get_bitcoincore_descriptor` | `pub fn get_bitcoincore_descriptor(xpub: &str) -> String` | Generates a Bitcoin Core descriptor with checksum (e.g., `wpkh([fingerprint/84h/0h/0h]xpub/0/*)#checksum`). |
| `calculate_fingerprint` | `pub fn calculate_fingerprint(tpub: &str) -> Result<String, String>` | Returns the hex fingerprint of an xpub (converts to standard xpub first). |
### Private Functions
- `poly_mod(c, val)` / `calc_checksum(desc)` — Bitcoin Core descriptor checksum calculation.
- `convert_xpub(xpub)` — Detects prefix (xpub/ypub/zpub or tpub/vpub/upub) and converts to target format.
- `base58check_decode(s)` / `base58check_encode(data)` — Base58Check encoding/decoding.
- `convert_to(zpub, prefix)` — Converts xpub between different prefix formats.
### Supported Prefixes
| Prefix | Type | Network |
|--------|------|---------|
| `xpub` | Legacy P2PKH | Mainnet |
| `ypub` | Nested SegWit P2SH-P2WPKH | Mainnet |
| `zpub` | Native SegWit P2WPKH | Mainnet |
| `tpub` | Legacy P2PKH | Testnet |
| `vpub` | Nested SegWit | Testnet |
| `upub` | Nested SegWit | Regtest |
### Dependencies
- `bitcoin::bip32::{DerivationPath, Xpub}`
- `bitcoin::key::Secp256k1`
- `bitcoin::{Address, Network, ScriptBuf, WPubkeyHash}`
- `sha2::{Digest, Sha256}`
---
## `validation.rs` (SSRF Protection)
**Location:** `src/validation.rs`
This module provides URL validation to prevent SSRF attacks via the `welist` stats reporting feature.
### Key Functions
| Function | Signature | Purpose |
|---|---|---|
| `is_valid_welist_url` | `pub fn is_valid_welist_url(url_str: &str) -> bool` | Validates a URL against SSRF: checks scheme is HTTPS, blocks localhost/loopback/private/link-local/multicast/unspecified IPs for both IPv4 and IPv6. |
### Validation Rules
1. URL must be well-formed and parsable.
2. Scheme must be `https://` (plain HTTP is rejected).
3. Host must not be `localhost`, `127.0.0.1`, `::1`, or any loopback/private/link-local/multicast/unspecified IP address.
4. IPv4 private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and AWS metadata link-local (169.254.169.254) are blocked.
5. IPv6 Unique Local (fc00::/7) and link-local (fe80::/10) are blocked.
### Inline Tests
8 unit tests cover valid domains, invalid schemes, localhost/loopback, private IPs, unspecified/multicast, IPv6 link-local, IPv6 unique local, malformed URLs, and valid public IPs.
---
## `bal-server.rs` (HTTP Server / API)
**Location:** `src/bin/bal-server.rs`
The main application binary that provides an async HTTP server.
### Architecture
- **Runtime:** `actix-web 4.9.0` with `actix-rt` (`#[actix_web::main]`).
- **Rate Limiting:** `actix-governor` middleware with token-bucket algorithm per endpoint.
- **Response Compression:** `actix_web::middleware::Compress`.
- **Request Logging:** `actix_web::middleware::Logger::default()`.
- **Shared State:** `Arc<Mutex<Connection>>` for database access, `MyConfig` for configuration.
### Configuration Structs
**`MyConfig`** (server configuration):
- `regtest`, `signet`, `testnet`, `testnet4`, `mainnet`: `NetConfig` per network
- `info`, `bind_address`, `bind_port`, `db_file`, `pub_key_path`, `expose_stats`
**`NetConfig`** (per-network):
- `address` (xpub or address), `fixed_fee` (sats), `xpub` (bool), `network` (bitcoin::Network), `name`, `enabled`
**`ActixConfig`** (server tuning):
- `max_body_size`, `timeout_secs`, per-endpoint rate limits (`pushtxs`, `searchtx`, `info`, `default`), `workers`, `max_connections`
### Key Routes
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | `/` | `echo_home` | Returns `cfg.info` string |
| GET | `/.pub_key.pem` | `echo_pub_key` | Returns public key PEM file |
| GET | `/version` | `echo_version` | Returns VERSION constant (0.3.2) |
| GET | `/{network}/info` | `echo_info` | Returns `InfoResponse` JSON. In xpub mode, derives/returns per-IP address |
| GET | `/{network}/stats` | `echo_stats` | Returns `Vec<StatsResponse>` JSON (requires `expose_stats=true`) |
| POST | `/{network}/pushtxs` | `echo_push` | Accepts newline-separated raw tx hex. 3-phase: parse (no lock), check duplicates (lock), insert (lock) |
| POST | `/searchtx` | `echo_search` | Searches by txid (body = 64 hex chars). Returns status, tx, our_address, our_fees, reqid |
### Handler Details
**`echo_info`**: If xpub mode is enabled, first checks `get_last_used_address_by_ip` for an existing address for that IP. If none, atomically claims next index via `get_next_address_index`, derives address via `new_address_from_xpub`, and saves it. Two separate DB lock acquisitions (lookup + save) with CPU-bound derivation in between (no lock held).
**`echo_push`**: Three-phase approach:
1. Load all known addresses (for xpub validation) with DB lock, release lock
2. Parse all transactions from request body (CPU-bound, no lock) using `parse_request_transactions`
3. Batch check duplicates with DB lock, release lock
4. Build bulk INSERT statements using `UNION ALL SELECT` and execute in single transaction
**`parse_request_transactions`**: Splits body by newlines, hex-decodes each line, deserializes via `consensus::deserialize`, computes txid/wtxid/ntxid, checks if any output matches the expected address (or is in known_addresses for xpub mode) with amount >= fixed_fee.
### Error Handling
All `unwrap()`/`expect()` calls have been replaced with safe `match`/`if let` error propagation, returning appropriate HTTP status codes (400, 404, 500). The server does not panic on untrusted input.
### Static Public Key (`/.pub_key.pem`)
The server serves a static `public_key.pem` file. The corresponding `privkey.pem` is used by the pusher to sign statistics before sending them to the `welist` server.
---
## `bal-pusher.rs` (Async Transaction Pusher)
**Location:** `src/bin/bal-pusher.rs`
This is the async daemon that monitors the blockchain and pushes pending transactions.
### Architecture
- **Runtime:** `tokio::main` with `rt-multi-thread`.
- **ZMQ:** `zmq::Context` with a `SUB` socket. Subscribes to all topics. Uses `set_rcvtimeo(5000)` for 5-second receive timeout.
- **RPC:** `bitcoincore-rpc` client. Tries username/password auth first, falls back to cookie file auth.
- **HTTP Client:** `reqwest` with `json` and `socks` features. Sends Ed25519-signed JSON POST to the `welist` server.
- **IPv6 Preference:** Optional `BAL_PUSHER_PREFER_IPV6` flag pins the HTTP connection to the first IPv6 address.
### Key Logic
1. On startup and every `hashblock` message, it calls `main_result()`.
2. `main_result` creates a `bitcoincore-rpc` client. If it fails, it logs an error and returns (no panic).
3. It fetches `getblockchaininfo` to get `mediantime` and `blocks` height.
4. It queries the database for transactions with `status=0` and locktime satisfied (block height < best block, or timestamp < mediantime for timestamps > `LOCKTIME_THRESHOLD`).
5. For each pending transaction, it calls `sendrawtransaction`.
6. If `send_stats` is enabled, it collects statistics, signs them with `privkey.pem`, and sends them to the configured `welist` URL via `reqwest`.
7. It updates the database with the new status (`1` = sent, `2` = failed with `push_err`).
### Statistics Reporting
- Statistics are aggregated from the database (total, waiting, sent, failed, profits, unique inputs).
- The chain name is validated (alphanumeric, `-`, `_` only).
- Stats are inserted into `tbl_stats` with `ON CONFLICT(chain) DO UPDATE`.
- The stats payload is signed with Ed25519 and POSTed to `{welist_url}/ping`.
- The `WELIST_SERVER_URL` is validated via `is_valid_welist_url()` before sending (can be bypassed with `WELIST_SKIP_URL_VALIDATION=true`).
### ZMQ Timeout Handling
- Uses `set_rcvtimeo(5000)` (5-second timeout).
- Logs a warning every ~720 consecutive timeouts (~1 hour of no blocks).
- Does not block forever or panic on connection loss.
### Configuration
All configuration is via environment variables (no config files):
- `BAL_PUSHER_DB_FILE`: Path to SQLite database.
- `BAL_PUSHER_BITCOIN_DIR`: Bitcoin data directory (for cookie file path).
- `BAL_PUSHER_SEND_STATS`: Enable/disable remote stats reporting.
- `BAL_SERVER_URL`: URL of the bal-server for internal communication.
- `SSL_KEY_PATH`: Path to Ed25519 private key for signing stats.
- `WELIST_SERVER_URL`: URL to POST stats to (validated against SSRF).
- `WELIST_SKIP_URL_VALIDATION`: Bypass URL validation (for testing).
- `BAL_PUSHER_PREFER_IPV6`: Pin HTTP connection to IPv6 address.
- Per-network: `BAL_PUSHER_{NETWORK}_HOST`, `_PORT`, `_DIR_PATH`, `_DB_FIELD`, `_COOKIE_FILE`, `_RPC_USER`, `_RPC_PASSWORD`, `_ZMQ_HASHBLOCK`.

162
docs/05_api_reference.md Normal file
View File

@@ -0,0 +1,162 @@
# API Reference
## Quick Reference
- **What this file contains:** complete specification of the HTTP API, ZMQ messages, and RPC usage, with request/response examples.
- **See also:** [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [04_modules_detail.md](04_modules_detail.md), [06_database_schema.md](06_database_schema.md), [07_deployment_and_ops.md](07_deployment_and_ops.md)
---
## HTTP API (provided by `bal-server`)
### Rate Limiting
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. Defaults:
| Endpoint | Rate (req/s) | Burst |
|----------|-------------|-------|
| `POST /{network}/pushtxs` | 1 | 3 |
| `POST /searchtx` | 5 | 10 |
| `GET /{network}/info` | 20 | 30 |
| All others | 50 | 100 |
Rate limits are configurable via `BAL_SERVER_ACTIX_*` environment variables.
### `GET /`
- **Description:** Returns a static identification string (default: "Will Executor Server").
- **Response:** Plain text `200 OK`.
### `GET /version`
- **Description:** Returns the Cargo package version.
- **Response:** `text/plain` (e.g., `0.3.2`).
### `GET /.pub_key.pem`
- **Description:** Returns the static Ed25519 public key PEM file for signature verification of remote stats.
- **Response:** `text/plain` with the PEM file content.
- **File:** `public_key.pem` in the project root (path configurable via `BAL_SERVER_PUB_KEY_PATH`).
### `GET /:network/info`
- **Description:** Returns JSON with the server's configuration for that specific network.
- **Supported Networks:** `bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`.
- **Response (200 OK):**
```json
{
"address": "bcrt1q...",
"base_fee": 50000,
"chain": "regtest",
"info": "Will Executor Server",
"version": "0.3.2"
}
```
- In xpub mode, the `address` field contains a freshly derived P2WPKH address unique to the requesting IP.
- **Error:** `404` if the network is not configured or unknown.
### `GET /:network/stats`
- **Description:** Returns statistics for the given network. Guarded by the `expose_stats` configuration flag.
- **Response (200 OK):** A JSON array of `StatsResponse` objects:
```json
[
{
"report_date": "2024-07-20T12:00:00Z",
"chain": "regtest",
"totals": 42,
"waiting": 10,
"sent": 30,
"failed": 2,
"waiting_profit": 10000,
"sent_profit": 30000,
"missed_profit": 5000,
"unique_inputs": 15
}
]
```
- **Error:** `403` or `400` if stats are not enabled or the network is unknown.
### `POST /:network/pushtxs`
- **Description:** Accepts one or more raw hex Bitcoin transactions (newline-separated). The server deserializes each transaction, validates that the fee is paid to the correct `our_address` for that network, and stores valid transactions in the database.
- **Request Body:** Newline-separated raw hex transactions.
```
02000000000101...hex...\n
02000000000101...hex...\n
```
- **Response (200 OK):** A JSON object with the results for the batch:
```json
{
"accepted": 2,
"rejected": 0,
"details": [...]
}
```
- **Response (400 Bad Request):** If all transactions are invalid, the fee is missing, or the locktime is not acceptable.
- **Response (413 Payload Too Limited):** If the request body exceeds the configured max size (default 1 MiB).
- **Security Note:** Invalid transactions or those not paying the required fees are not inserted into the database.
### `POST /searchtx`
- **Description:** Searches for a transaction by its `txid`. The request body must contain exactly 64 hex characters.
- **Request Body:**
```json
{
"txid": "abc123def456..."
}
```
- **Response (200 OK):**
```json
{
"txid": "abc123...",
"status": 1,
"tx": "020000000...",
"our_address": "bcrt1q...",
"our_fees": 1000,
"reqid": "192.168.1.1"
}
```
- **Response (400 Bad Request):** If the txid is not exactly 64 hex characters.
- **Response (404 Not Found):** If the transaction is not found in the database.
---
## ZMQ Messages (consumed by `bal-pusher`)
### Topic: `hashblock`
- **Format:** A multipart ZMQ message. The first frame is the topic name (`hashblock`), the second frame is the 32-byte block hash.
- **Trigger:** When a new Bitcoin block is found by the local node.
- **Action:** The pusher fetches `getblockchaininfo` from the RPC, gets the updated `mediantime` and `blocks` height, then queries and pushes pending transactions.
- **Endpoint:** Per-network (e.g., `tcp://127.0.0.1:28332` for mainnet).
- **Timeout:** 5 seconds (`ZMQ_RCVTIMEO`). The pusher logs a warning after ~720 consecutive timeouts (~1 hour).
---
## Bitcoin Core RPC Usage (used by `bal-pusher`)
### `sendrawtransaction`
- **Method:** `sendrawtransaction` (RPC `2`)
- **Parameters:** `hexstring` (the raw hex of the transaction to broadcast).
- **Description:** Broadcasts the transaction to the Bitcoin network. If the transaction is invalid (e.g., `bad-txns-inputs-missingorspent`), the RPC will return an error with a negative code (e.g., `-25`).
- **Error Handling:** The pusher catches these errors, logs them, and updates the database status to `2` (failed) with the error in `push_err`.
### `getblockchaininfo`
- **Method:** `getblockchaininfo` (RPC `1`)
- **Parameters:** None.
- **Description:** Returns the current blockchain state, including `mediantime` (median timestamp of the last 11 blocks) and `blocks` (best block height). Used to evaluate `nLockTime` of pending transactions.
### `getblock`
- **Method:** `getblock` (RPC `1`)
- **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp).
- **Description:** Fetches block details. Used as an alternative for median time calculation.
### RPC Authentication
Authentication is done via `bitcoincore-rpc` using either:
- **`UserPass`**: `BAL_PUSHER_{NETWORK}_RPC_USER` and `BAL_PUSHER_{NETWORK}_RPC_PASSWORD`.
- **`CookieFile`**: `$HOME/.bitcoin/{dir_path}.cookie` or custom path via `BAL_PUSHER_{NETWORK}_COOKIE_FILE`.
The client tries username/password auth first, then falls back to cookie file auth.
---
## Ed25519 Stats Signing
The pusher signs the statistics payload before sending it to the `welist` server:
1. Collects statistics from the database.
2. Serializes the stats as JSON.
3. Signs the JSON payload with the Ed25519 private key (`privkey.pem`).
4. Sends the payload with the base64-encoded signature in the `X-Signature` header.
5. The `welist` server can verify the signature using the public key served at `GET /.pub_key.pem`.

204
docs/06_database_schema.md Normal file
View File

@@ -0,0 +1,204 @@
# Database Schema
## Quick Reference
- **What this file contains:** the full SQL schema, data types, indexing, queries, and data lifecycle for the SQLite `bal.db` database.
- **See also:** [04_modules_detail.md](04_modules_detail.md), [05_api_reference.md](05_api_reference.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md)
---
## Database Technology
- **Engine:** `sqlite` (Rust `sqlite` crate, version 0.34.0)
- **File:** `bal.db` (default, configurable via `BAL_SERVER_DB_FILE` / `BAL_PUSHER_DB_FILE`)
- **Connection Management:** Shared `Arc<Mutex<Connection>>` in `bal-server`, single connection in `bal-pusher`.
- **WAL Mode:** Enabled via `PRAGMA journal_mode=WAL` with retry logic (up to 5 attempts) for concurrent access safety.
- **Busy Timeout:** Set to 5000ms via `PRAGMA busy_timeout=5000`.
- **Synchronous Mode:** Set to `NORMAL` via `PRAGMA synchronous=NORMAL`.
- **Path Validation:** The `open_db` function validates the database path before opening, rejecting directory traversal (`..`), forbidden system directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`), and symlinks.
---
## Table Schema
### `tbl_tx` (Transactions)
```sql
CREATE TABLE IF NOT EXISTS tbl_tx (
txid PRIMARY KEY, -- TEXT: The unique transaction ID (hex string)
date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wtxid, -- TEXT: The witness transaction ID
ntxid, -- TEXT: The non-witness transaction ID
tx, -- TEXT: The full raw serialized transaction (hex)
locktime INTEGER, -- INTEGER: The locktime value (block height or timestamp)
network, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
network_fees, -- TEXT: The total fees paid by the user (satoshi)
reqid, -- TEXT: A request ID or client IP for the submitter
our_fees, -- TEXT: The fees paid to us (the operator) (satoshi)
our_address, -- TEXT: The address the operator fee is paid to
status INTEGER DEFAULT 0, -- INTEGER: 0 = waiting, 1 = sent, 2 = failed
push_err TEXT -- TEXT: The error message if the RPC broadcast failed
);
ALTER TABLE tbl_tx ADD COLUMN push_err TEXT;
```
- **Indexes:** The `txid` is the primary key, so it is automatically indexed.
- **Notes:** `date_creation` and `date_update` track when the transaction was inserted and last modified. `locktime` is compared against the blockchain's best block height or `mediantime` (for timestamps above `LOCKTIME_THRESHOLD`). The `status` column is the core of the transaction lifecycle state machine. `push_err` stores the RPC error message when status=2.
### `tbl_inp` (Transaction Inputs)
```sql
CREATE TABLE IF NOT EXISTS tbl_inp (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
in_txid, -- TEXT: The previous transaction ID (output being spent)
in_vout -- INTEGER: The previous output index
);
CREATE UNIQUE INDEX ON tbl_inp(txid, in_txid, in_vout);
```
- **Purpose:** Tracks all inputs of submitted transactions. Enables identification of double-spends.
- **Constraints:** A unique index prevents duplicate entries for the same input in the same transaction.
### `tbl_out` (Transaction Outputs)
```sql
CREATE TABLE IF NOT EXISTS tbl_out (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
script_pubkey, -- TEXT: The hex scriptPubKey of this output
amount, -- TEXT: The amount in this output (satoshi)
vout -- INTEGER: The output index (0-based) in this transaction
);
CREATE UNIQUE INDEX ON tbl_out(txid, script_pubkey, amount, vout);
```
- **Purpose:** Tracks all outputs of submitted transactions. The server searches for the `script_pubkey` matching the `our_address` for the network to verify fee payment.
- **Constraints:** A unique index prevents duplicate entries for the same output in the same transaction.
### `tbl_xpub` (Extended Public Keys)
```sql
CREATE TABLE IF NOT EXISTS tbl_xpub (
id INTEGER PRIMARY KEY, -- INTEGER: Auto-increment ID
network TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
xpub TEXT, -- TEXT: The extended public key (xpub, zpub, ypub, etc.)
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
path_idx INTEGER DEFAULT -1 -- INTEGER: The next address index to derive for this xpub
);
CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub);
```
- **Purpose:** Stores the master xpub/zpub/ypub keys for each network. When the server receives a transaction in xpub mode, it derives new receiving addresses from these keys.
- **Relationships:** `tbl_xpub` is linked to `tbl_address` via `id`. The `path_idx` tracks which child index is the next unused one for the wallet's derivation path.
### `tbl_address` (Derived Addresses)
```sql
CREATE TABLE IF NOT EXISTS tbl_address (
address TEXT PRIMARY_KEY, -- TEXT: The Bech32 P2WPKH address (e.g., 'bcrt1q...')
path TEXT NOT NULL, -- TEXT: The derivation path (e.g., 'm/0/0')
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
xpub INTEGER, -- INTEGER: The ID of the xpub in `tbl_xpub`
remote_address TEXT -- TEXT: IP or client identifier that requested this address
);
```
- **Purpose:** Stores all generated addresses. In xpub mode, addresses are derived on-demand per requesting IP.
- **Relationships:** `xpub` (FK) links to `tbl_xpub.id`. `remote_address` is used for rate-limiting and preventing address reuse per IP.
### `tbl_stats` (Per-Network Statistics)
```sql
CREATE TABLE IF NOT EXISTS tbl_stats (
report_date TEXT, -- TEXT: The ISO timestamp of the report
chain TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
totals INTEGER, -- INTEGER: Total number of transactions submitted
waiting INTEGER, -- INTEGER: Transactions currently waiting (status=0)
sent INTEGER, -- INTEGER: Transactions successfully sent (status=1)
failed INTEGER, -- INTEGER: Transactions that failed to broadcast (status=2)
waiting_profit INTEGER, -- INTEGER: Total fees for waiting transactions (satoshi)
sent_profit INTEGER, -- INTEGER: Total fees for sent transactions (satoshi)
missed_profit INTEGER, -- INTEGER: Total fees for failed/expired transactions (satoshi)
unique_inputs INTEGER -- INTEGER: The number of unique inputs (for deduplication analysis)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);
```
- **Purpose:** Stores aggregate statistics for each network. The pusher calculates and upserts stats (`ON CONFLICT(chain) DO UPDATE`). The server reads from it for the `stats` endpoint if `expose_stats` is enabled.
- **Relationships:** `chain` has a unique index. Data is updated by `bal-pusher` via `calculate_stats`.
---
## Data Query Strategy
### Key Queries
- **Get Pending Transactions (by status and locktime):**
```sql
SELECT * FROM tbl_tx
WHERE network = :network
AND status = :status
AND (locktime < :bestblock_height
OR locktime > :locktime_threshold AND locktime < :bestblock_time);
```
Used by the `bal-pusher` to find transactions ready to broadcast. The `locktime_threshold` constant distinguishes block heights from timestamps.
- **Insert Transaction (batched):**
```sql
INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, network_fees, reqid, our_fees, our_address)
UNION ALL SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
UNION ALL SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
-- ... more rows
```
Used by `bal-server` for efficient bulk inserts.
- **Update Status:**
```sql
UPDATE tbl_tx SET status = ? WHERE txid = ?;
```
Used by the pusher after broadcast. Status `1` = sent, `2` = failed.
```sql
UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?;
```
For failed broadcasts, the error message is stored.
- **Check Duplicate Txids:**
```sql
SELECT txid FROM tbl_tx WHERE txid IN (?, ?, ?, ...);
```
Used by `bal-server` to batch-check duplicates before inserting. Chunks in groups of 500 for SQLite parameter limit safety.
- **Search Transaction:**
```sql
SELECT * FROM tbl_tx WHERE txid = ?;
```
Used by the `searchtx` endpoint.
- **Get All Addresses by XPub:**
```sql
SELECT a.address
FROM tbl_address a
JOIN tbl_xpub x ON a.xpub = x.id
WHERE x.xpub = ?;
```
Used to load all known addresses for an xpub in a single query, enabling O(1) fee validation in the push handler.
- **Get Last Used Address by IP:**
```sql
SELECT address FROM tbl_address
WHERE remote_address = ? AND xpub = ?
ORDER BY date_create DESC LIMIT 1;
```
Used to check if an IP already has a generated address (address reuse prevention).
- **Get Next Address Index:**
```sql
UPDATE tbl_xpub SET path_idx = path_idx + 1
WHERE network = ? AND xpub = ?
RETURNING id, path_idx;
```
Atomically increments the derivation index and returns the new value.
---
## Data Lifecycle
- **Creation:** Transactions are created when a user submits raw hex tx via `POST /{network}/pushtxs`. Addresses are derived on-demand in xpub mode.
- **Waiting:** Transactions are in `status=0` and are queried by the pusher every new block.
- **Broadcast:** Transactions are pushed via `sendrawtransaction`. If successful, status becomes `1`. If the RPC returns an error, status becomes `2` and the error is stored in `push_err`.
- **Statistics:** The pusher aggregates stats from the database and upserts into `tbl_stats` with `ON CONFLICT(chain) DO UPDATE`.
- **Retention:** There is no explicit cleanup mechanism for old records. For production, a periodic vacuum or purge of old `status=1` transactions may be required.
- **Backup:** The database is a single SQLite file. It can be copied directly using `cp` or `rsync`. WAL mode ensures consistency during copies.

View File

@@ -0,0 +1,302 @@
# Deployment and Operations
## Quick Reference
- **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, Docker support, and installation procedures.
- **See also:** [01_project_overview.md](01_project_overview.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [05_api_reference.md](05_api_reference.md), [08_security_audit.md](08_security_audit.md)
---
## Environment Variables
### `bal-server` (all prefixed `BAL_SERVER_`)
#### Core Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
| `BAL_SERVER_BIND_ADDRESS` | `"127.0.0.1"` | TCP address to bind to (**never use `0.0.0.0` in production**) |
| `BAL_SERVER_BIND_PORT` | `9137` | TCP port to listen on |
| `BAL_SERVER_EXPOSE_STATS` | `false` | Enable/disable the `GET /:network/stats` endpoint |
| `BAL_SERVER_PUB_KEY_PATH` | `"public_key.pem"` | Path to the Ed25519 public key PEM file |
| `BAL_SERVER_INFO` | `"Will Executor Server"` | String returned by `GET /` |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_{NETWORK}_ADDRESS` | (empty) | The xpub/zpub/ypub or fixed address for fee collection |
| `BAL_SERVER_{NETWORK}_FIXED_FEE` | `50000` | Minimum fee in satoshis required for transaction acceptance |
Example: `BAL_SERVER_REGTEST_ADDRESS=tpub...`, `BAL_SERVER_BITCOIN_FIXED_FEE=50000`.
#### Actix-Web Tuning
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | `1048576` (1 MiB) | Maximum HTTP request body size |
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | `5` | Request timeout in seconds |
| `BAL_SERVER_ACTIX_WORKERS` | `4` | Number of actix-web worker threads |
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | `100` | Maximum concurrent connections |
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: pushtxs requests per second |
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: pushtxs burst size |
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | `5` | Rate limit: searchtx requests per second |
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | `10` | Rate limit: searchtx burst size |
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info requests per second |
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info burst size |
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second |
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size |
### `bal-pusher` (prefixed `BAL_PUSHER_`)
#### Core Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_PUSHER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
| `BAL_PUSHER_BITCOIN_DIR` | `""` | Bitcoin data directory (for cookie file path resolution) |
| `BAL_PUSHER_SEND_STATS` | `false` | Enable/disable remote stats reporting |
| `BAL_SERVER_URL` | `"http://localhost/"` | URL of the bal-server for internal communication |
| `SSL_KEY_PATH` | `"privkey.pem"` | Path to Ed25519 private key for signing stats |
| `BAL_PUSHER_PREFER_IPV6` | `false` | Pin HTTP connection to first IPv6 address (for broken IPv4 routes) |
| `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) |
| `WELIST_SKIP_URL_VALIDATION` | `false` | Bypass SSRF URL validation (for testing only) |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default (regtest) | Description |
|----------|-------------------|-------------|
| `BAL_PUSHER_{NETWORK}_HOST` | `"127.0.0.1"` | Bitcoin Core RPC host |
| `BAL_PUSHER_{NETWORK}_PORT` | `18443` | Bitcoin Core RPC port |
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | `".bitcoin"` | Relative directory under `$HOME` for cookie file |
| `BAL_PUSHER_{NETWORK}_DB_FIELD` | (empty) | Database field name for this network |
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | (empty) | Absolute path to cookie file (overrides `DIR_PATH`) |
| `BAL_PUSHER_{NETWORK}_RPC_USER` | (empty) | RPC username (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | (empty) | RPC password (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | `"tcp://127.0.0.1:21332"` | ZMQ hashblock endpoint |
Default ports per network:
| Network | RPC Port | ZMQ Port |
|---------|----------|----------|
| bitcoin | 8332 | 28332 |
| regtest | 18443 | 21332 |
| testnet | 18332 | 23332 |
| testnet4 | 48332 | 24332 |
| signet | 18332 | 22332 |
---
## Docker
The project provides two Dockerfiles:
### `Dockerfile.release` — Download pre-built release (recommended for production)
Downloads the latest release from the Gitea server. No Rust toolchain needed. Fast builds.
```bash
# Latest release
docker build -f Dockerfile.release -t bal-server .
# Specific version
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
```
- Fetches `.tar.gz` from `https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server/releases/latest`.
- Verifies SHA-256 checksum if available.
- Single-stage image (`debian:bookworm-slim`), minimal size.
- `BAL_VERSION` build arg: set to a tag (e.g., `v0.3.2`) to pin a specific release.
### `Dockerfile` — Build from source
Multi-stage build with the Rust toolchain. Use for development or custom builds.
- **Builder stage:** `rust:1.95-bookworm` with full build. Each binary is compiled with only its required features (`--no-default-features --features server` / `--features pusher`).
- **Runtime stage:** `debian:bookworm-slim` with minimal runtime.
- **User:** Non-root `bal` user (uid 1000).
- **PID 1:** `tini` for proper signal handling.
- **Healthcheck:** `curl -f http://localhost:9137/ || exit 1`.
### Run (both Dockerfiles)
```bash
docker run -d \
--name bal-server \
-v /var/bal:/var/bal \
--env-file bal-server.env \
-p 127.0.0.1:9137:9137 \
bal-server
```
---
## System Services
### `bal-server.service` (Systemd Unit)
```ini
[Unit]
Description=Bal Server
After=network.target
[Service]
User=bal
Group=bal
ExecStart=/usr/local/bin/bal-server
Restart=always
RestartSec=5
WorkingDirectory=/var/bal
EnvironmentFile=/var/bal/bal-server.env
ProtectSystem=full
NoNewPrivileges=true
PrivateDevices=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user
```
- Runs as a dedicated non-privileged `bal` user.
- Hardened with `ProtectSystem=full`, `NoNewPrivileges=true`, `PrivateDevices=true`, `MemoryDenyWriteExecute=true`.
### `bitcoind.service` (Bitcoin Core Daemon)
```ini
[Unit]
Description=Bitcoin Core Daemon
After=network.target
[Service]
User=bitcoin
Group=bitcoin
ExecStart=/usr/local/bin/bitcoind ... -zmqpubhashblock=tcp://127.0.0.1:28332 ...
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user
```
- Must be started with `zmqpubhashblock` (not `zmqpubrawblock`).
- ZMQ ports must be bound to `127.0.0.1` only.
### `tbitcoind.service` (Testnet Bitcoind)
Same as `bitcoind.service` but for testnet with a different data directory and ZMQ port (e.g., `tcp://127.0.0.1:23332`).
---
## Bash Scripts
### `bal-server.sh` (Development Server Startup)
Sources `bal-server.env` and runs the development server:
```bash
export $(grep -v '^#' bal-server.env | xargs)
RUST_LOG=info cargo run --bin=bal-server 2>&1
```
### `bal-pusher.sh` (Development Pusher Startup)
Sources `bal-pusher.env` and runs the pusher with a network argument:
```bash
export $(grep -v '^#' bal-pusher.env | xargs)
RUST_LOG=info cargo run --bin=bal-pusher $1
```
### `sendtx.sh` (Test Transaction Sender)
A helper script that wraps `bitcoin-cli` for manual testing.
### `make_release.sh` (Release Builder)
Builds release binaries, creates Git tags, and uploads to Gitea. Signs the release tarball with GPG. Release assets include `.tar.gz`, `.sha256`, `.sig`, and `.asc` files. Token is loaded from `.env` (not hardcoded).
### `download_bal_db.sh` (Database Pull Script)
Uses `scp` to pull the production `bal.db` from a remote server.
---
## Nginx and SSL Configuration
The `bal-server` is a plain HTTP server. A reverse proxy (Nginx) with TLS termination is required for production.
### Template: `contrib/nginx/bal-server.conf`
```nginx
server {
listen 80;
server_name BAL_DOMAIN;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name BAL_DOMAIN;
ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
client_max_body_size 1m;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy no-referrer;
location / {
proxy_pass http://127.0.0.1:9137;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uncomment for rate limiting:
# limit_req zone=pal limit=10 nodelay;
}
```
Key points:
- `client_max_body_size` must match `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- `certbot --nginx` obtains the certificate automatically.
- Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
---
## Production Deployment Checklist
### 1. `bal-server` Bind Address
- [ ] `BAL_SERVER_BIND_ADDRESS=127.0.0.1` (never `0.0.0.0`).
- [ ] `BAL_SERVER_BIND_PORT` matches Nginx `proxy_pass` (default `9137`).
- [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces.
### 2. Reverse Proxy (Nginx + TLS)
- [ ] Nginx installed (`contrib/download_and_install_bal.sh` handles this).
- [ ] `contrib/nginx/bal-server.conf` template copied to `/etc/nginx/sites-available/`.
- [ ] Real domain name replacing `BAL_DOMAIN`.
- [ ] `listen 443 ssl http2` active.
- [ ] `certbot --nginx` has obtained a valid certificate.
- [ ] `proxy_pass` points to `http://127.0.0.1:9137`.
- [ ] `client_max_body_size` matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- [ ] HTTP port 80 redirects to HTTPS.
- [ ] Security headers configured.
### 3. Database and Secrets
- [ ] Database file owned by `bal` user (`chown bal:bal /var/bal/bal.db`).
- [ ] Database file permissions `600` (`chmod 600 /var/bal/bal.db`).
- [ ] `.env` files in `.gitignore` and not committed.
- [ ] `private_key.pem` / `privkey.pem` not in the repository.
- [ ] `public_key.pem` readable by Nginx if served directly.
### 4. Pusher and ZMQ
- [ ] ZMQ endpoints configured for `127.0.0.1` only.
- [ ] `BAL_PUSHER_SEND_STATS=false` unless `welist` endpoint is needed.
- [ ] If stats enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain.
- [ ] Firewall blocks inbound ZMQ ports from external interfaces.
### 5. Logging and Monitoring
- [ ] `RUST_LOG=info` or `warn` in production (not `debug`/`trace`).
- [ ] Log files rotated and stored under `/var/log/bal/` or systemd journal.
- [ ] Log files not in the same directory as the database or private key.
---
## Tor and Privacy
The `contrib/install_tor.sh` script installs Tor for onion-routed proxy use:
1. `bal-server` can be reachable via a `.onion` address.
2. `bal-pusher` can connect to Bitcoin RPC or `welist` through Tor.
3. The server can run behind NAT without exposing the real IP.
The script uses `ControlPort 9051` with `CookieAuthentication`. The `bal-pusher` supports SOCKS5 proxy via the `reqwest` `socks` feature for `.onion` connectivity.

156
docs/08_security_audit.md Normal file
View File

@@ -0,0 +1,156 @@
# Security Audit
## Quick Reference
- **What this file contains:** threat model, vulnerability assessment, hardening recommendations, and a security checklist.
- **See also:** [AGENTS.md](AGENTS.md), [07_deployment_and_ops.md](07_deployment_and_ops.md), [06_database_schema.md](06_database_schema.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [04_modules_detail.md](04_modules_detail.md)
---
## Threat Model
### Assets
1. **`bal.db` (SQLite database):** Contains all transaction details, user IP addresses, and stats data. Single unencrypted file on disk. WAL mode enabled for concurrent access safety.
2. **Private Keys (`privkey.pem`):** Used to sign statistics payloads for the `welist` server. Located in `.gitignore`.
3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access. Compromise allows arbitrary transaction broadcasting.
4. **Server Availability (`bal-server`):** Public-facing HTTP endpoint. DoS attacks threaten service availability.
### Attackers
- **Remote Anonymous Users:** Can interact with the API via public HTTP. No credentials required.
- **Network Man-in-the-Middle (MITM):** TLS termination is via Nginx reverse proxy. The `bal-server` itself is plain HTTP.
- **Local/Insider Threats:** If the server is compromised, the attacker can access `bal.db`, private keys, and env files.
---
## Vulnerability Assessment
### 1. SQL Injection (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** SQL queries previously used `format!()` for string interpolation. All queries now use parameterized statements (`?` with `bind()`).
**Mitigation Applied:** All SQL queries rewritten with prepared statements. `execute_insert` uses parameterized batch inserts. `check_duplicate_txids` uses parameterized `IN` clauses. `echo_stats` handler uses prepared statements for chain filtering.
**Regression Tests:** `tests/sql_injection_tests.rs` (3 tests).
### 2. Panic on Untrusted Input (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** All `unwrap()`/`expect()` calls on critical paths have been replaced with safe error handling.
**Mitigation Applied:**
- `bal-server`: `from_utf8` returns 400, `sqlite::open` uses `open_db()` with validation, all handlers return proper HTTP status codes.
- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, `fs::read_to_string` uses `match` + 500, `cfg.lock()` uses `poisoned.into_inner()` recovery.
**Regression Tests:** `tests/panic_regression_tests.rs` (2 tests).
### 3. Secret Leakage (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `make_release.sh`, `.gitignore`
**Description:** `make_release.sh` now loads `TOKEN` from `.env` (`.env.example` provided). Private keys (`.pem`, `.key`) are in `.gitignore`. `generate_keys.sh` sets `chmod 600` on generated keys.
**Mitigation Applied:** Secrets removed from scripts and repository. `.gitignore` protects `.env`, `*.pem`, `*.key` files.
**Regression Tests:** `tests/secret_leakage_tests.rs` (3 tests).
### 4. Denial of Service (DoS) (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/bin/bal-server.rs` (HTTP), `src/bin/bal-pusher.rs` (ZMQ)
**Description:** All DoS vectors mitigated via actix-web migration.
**Mitigation Applied:**
- Body size limit: `PayloadConfig::default().limit(max_body_size)` via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB).
- Rate limiting: `actix-governor` with token-bucket per endpoint (`BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST`).
- Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`.
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
- ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking.
- RPC retry: sleep + retry on connection failure instead of panic.
### 5. SSRF / Network Abuse via `reqwest` (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/bin/bal-pusher.rs`, `src/validation.rs`
**Description:** URL validation prevents redirecting requests to internal/private IPs.
**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, and IPv6 unique-local addresses. HTTPS-only scheme enforced.
**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 8 unit tests in `src/validation.rs`.
### 6. Insecure Database Access (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** Database path validation and WAL mode for concurrent access.
**Mitigation Applied:**
- `open_db()` rejects `..` traversal, forbidden system directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`), symlinks, and non-regular files.
- WAL mode (`PRAGMA journal_mode=WAL`) with retry logic (up to 5 attempts).
- `busy_timeout=5000` for concurrent access.
- `bal-server` uses `Arc<Mutex<Connection>>` for thread-safe shared access.
**Regression Tests:** `tests/db_path_validation.rs` (5 tests).
### 7. ZMQ Authentication and Encryption (OPEN)
**Severity:** MEDIUM | **Status:** Open
**Location:** `src/bin/bal-pusher.rs`
**Description:** ZMQ connection is plaintext TCP. No authentication (ZAP), no encryption (ZMQ_CURVE). If the ZMQ port is exposed, any attacker can subscribe to topics.
**Mitigation (Operational):**
- Bind ZMQ to `127.0.0.1` only.
- Firewall blocks external access to ZMQ ports.
- If public ZMQ is required, use ZMQ_CURVE with public-key cryptography.
### 8. Missing HTTPS / Insecure Server Communication (FIXED)
**Severity:** HIGH | **Status:** Fixed (Infrastructure)
**Location:** Nginx configuration, `contrib/nginx/bal-server.conf`
**Description:** TLS termination via Nginx reverse proxy. The `bal-server` intentionally does not implement TLS.
**Mitigation Applied:**
- Dedicated Nginx template with `listen 443 ssl http2`, Let's Encrypt paths.
- Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
- `client_max_body_size` matching `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- HTTP 80 redirect to HTTPS.
- Deployment checklist ensures no accidental plain HTTP exposure.
### 9. Missing Input Validation (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/bin/bal-server.rs`
**Description:** Network, txid, and content validation.
**Mitigation Applied:**
- Network validation: `NETWORKS.contains(&param.as_str())` before processing. Unknown networks return 404.
- Txid validation: `echo_search` requires exactly 64 ASCII hex characters. Non-hex or wrong length returns 400.
- XPub address caching: `get_all_addresses_by_xpub` loads all addresses once per batch (O(1) lookup), eliminating N+1 queries.
- Content-Length: Handled by actix-web `PayloadConfig` size limit.
**Regression Tests:** `tests/input_validation_tests.rs` (4 tests).
### 10. Information Leakage (MITIGATED)
**Severity:** LOW | **Status:** Mitigated
**Location:** Application logs
**Description:** Raw file logging (`valid_txs`/`invalid_txs`) has been removed. `info!`/`warn!` macros may still log txid and details in application logs.
**Mitigation (Operational):** Set production `RUST_LOG` to `warn` or higher. Log files restricted with `chmod 600`.
### 11. `bal-stats.rs.dontcompile` (REMOVED)
**Severity:** LOW | **Status:** Removed
**Description:** The broken HTML report generator file no longer exists in the source tree.
---
## Hardening Recommendations
### System-Level
1. Run as non-root user with systemd hardening (`ProtectSystem=full`, `NoNewPrivileges`, `PrivateDevices`, `MemoryDenyWriteExecute`).
2. Use firewall to block all inbound ports except HTTPS (443) and SSH (22).
3. Use VPN or Tor for `welist` connections if on public network.
4. Run in a container or chroot for isolation.
5. Enable SELinux or AppArmor profiles for the binaries.
6. Use read-only filesystem for the server binary.
### Application-Level
1. **Rate Limiting:** Implemented via `actix-governor` with per-endpoint token-bucket configuration.
2. **Input Validation:** Network enum check, txid hex validation, body size limits.
3. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
4. **WAL Mode:** Enabled with retry logic for concurrent access.
5. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
6. **Transaction Size Limits:** Configurable via rate limiting and body size.
7. **ZMQ Retry:** Reconnect logic with timeout-based detection.
8. **Fee Limits:** Per-network `fixed_fee` configuration.
9. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
10. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction.
---
## Regression Test Suite
| Test File | Tests | Coverage |
|-----------|-------|----------|
| `tests/sql_injection_tests.rs` | 3 | Parameterized queries, injection prevention |
| `tests/panic_regression_tests.rs` | 2 | Mutex poisoning recovery, NULL value handling |
| `tests/ssrf_tests.rs` | 4+ | URL validation, internal IP blocking |
| `tests/secret_leakage_tests.rs` | 3 | .gitignore, no tracked secrets, no hardcoded tokens |
| `tests/input_validation_tests.rs` | 4 | Address caching, network validation, txid hex validation |
| `tests/db_path_validation.rs` | 5 | Path traversal, forbidden dirs, symlinks, WAL pragma, valid paths |
| `src/validation.rs` (inline) | 8 | SSRF URL validation unit tests |

View File

@@ -0,0 +1,154 @@
# References and Links
## Quick Reference
- **What this file contains:** links to source code, dependencies, and existing documentation, plus a machine-readable dependency map.
- **See also:** [INDEX.md](INDEX.md), [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md)
---
## Source Code References
| Module/Component | File Path | Key Details |
|---|---|---|
| Library | `src/lib.rs` | Exports `db`, `validation`, `xpub` modules |
| Database | `src/db.rs` | SQL schema, `open_db`, `execute_insert`, WAL mode, path validation |
| XPub/Address Derivation | `src/xpub.rs` | `new_address_from_xpub`, `get_bitcoincore_descriptor`, `calculate_fingerprint`, BIP-84 |
| SSRF Validation | `src/validation.rs` | `is_valid_welist_url`, blocks private/internal IPs |
| HTTP Server | `src/bin/bal-server.rs` | Actix-web 4.9.0, rate limiting, 7 routes, `Arc<Mutex<Connection>>` |
| Async Pusher | `src/bin/bal-pusher.rs` | ZMQ `hashblock`, RPC + Reqwest, Ed25519 signing, `calculate_stats` |
| Script/Config | Path | Purpose |
|---|---|---|
| Release script | `make_release.sh` | Builds release, creates tag, uploads to Gitea (token from `.env`) |
| DB download script | `download_bal_db.sh` | `scp` from remote |
| Server dev script | `bal-server.sh` | Sources `bal-server.env`, `cargo run` |
| Pusher dev script | `bal-pusher.sh` | Sources `bal-pusher.env`, `cargo run` |
| Send transaction script | `sendtx.sh` | `bitcoin-cli` wrapper for testing |
| Utility scripts | `lib.sh` | Colored echo functions |
| Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup |
| Contrib (install bitcoind) | `contrib/download_and_install_bitcoincore.sh` | Bitcoind download, GPG verify, systemd, config |
| Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` |
| Nginx template | `contrib/nginx/bal-server.conf` | TLS termination, security headers, rate limiting |
| Dockerfile | `Dockerfile` | Multi-stage build from source, non-root user, tini, healthcheck |
| Dockerfile.release | `Dockerfile.release` | Download latest release from Gitea, SHA-256 verification |
| Systemd Service | File | Purpose |
|---|---|---|
| `bal-server.service` | `bal-server.service` | Runs as `bal` user, hardened |
| `bitcoind.service` | `bitcoind.service` | `zmqpubhashblock` setup for mainnet |
| `tbitcoind.service` | `tbitcoind.service` | Testnet `bitcoind` |
---
## Dependency Map (from `Cargo.toml`)
### Core Dependencies
| Dependency | Version | Purpose |
|---|---|---|
| `bitcoin` | `0.32.5` | Transaction parsing, `ScriptBuf`, `Address`, `Xpub`, `Transaction` |
| `bitcoincore-rpc` | `0.19.0` | RPC client (`sendrawtransaction`, `getblockchaininfo`) |
| `bitcoincore-rpc-json` | `0.19.0` | JSON types for Bitcoin RPC responses |
| `sqlite` | `0.34.0` | Direct SQLite C bindings, raw SQL queries |
| `serde` | `1.0.152` | Serialization (`derive` feature) |
| `serde_json` | `1.0.116` | JSON parsing for HTTP request/response |
| `tokio` | `1` | Async runtime (`rt`, `net`, `macros`, `rt-multi-thread`) |
| `sha2` | `0.10.8` | SHA-256 hashing |
| `bs58` | `0.4.0` | Base58 encoding for xpubs |
| `hex` | `0.4.3` | Hex encoding for transaction serialization |
| `regex` | `1.10.4` | Regular expressions |
| `log` | `0.4.21` | Logging facade |
| `env_logger` | `0.11.5` | Log level via `RUST_LOG` |
| `url` | `2` | URL parsing for SSRF validation |
### Server-Only Dependencies (feature: `server`)
| Dependency | Version | Purpose |
|---|---|---|
| `actix-web` | `4.9.0` | Async HTTP server framework |
| `actix-governor` | `0.6.0` | Rate limiting middleware (token-bucket) |
| `actix-rt` | `2.10.0` | Actix async runtime |
| `chrono` | `0.4.40` | Date/Time handling for timestamps |
| `hex-conservative` | `0.1.1` | Hex parsing for Bitcoin hex strings |
### Pusher-Only Dependencies (feature: `pusher`)
| Dependency | Version | Purpose |
|---|---|---|
| `zmq` | `0.10.0` | ZeroMQ for `hashblock` notifications |
| `reqwest` | `0.12.24` | HTTP client (`json` + `socks` features) for `welist` stats POST |
| `byteorder` | `1.5.0` | Reading block timestamp from raw block header |
| `base64` | `0.22.1` | Encoding/decoding for Ed25519 signatures |
| `ed25519-dalek` | `2` | Ed25519 signing (`pem` + `pkcs8` features) |
| `bytes` | `1.2` | Byte handling |
---
## Test Files
| Test File | Tests | Coverage |
|-----------|-------|----------|
| `tests/sql_injection_tests.rs` | 3 | SQL injection prevention |
| `tests/panic_regression_tests.rs` | 2 | Panic recovery, NULL handling |
| `tests/ssrf_tests.rs` | 4+ | SSRF URL validation |
| `tests/secret_leakage_tests.rs` | 3 | Secret protection, .gitignore |
| `tests/input_validation_tests.rs` | 4 | Input validation, address caching |
| `tests/db_path_validation.rs` | 5 | DB path validation, WAL mode |
| `tests/test_endpoints.sh` | Bash | Integration tests for HTTP endpoints |
---
## Mapping to Existing Documentation
| Existing File | Description | Replaced/Managed By KB |
|---|---|---|
| `README.md` | Installation, env vars, Docker, per-network config | `07_deployment_and_ops.md` |
| `RPC.md` | API endpoint specification (HTTP methods, paths) | `05_api_reference.md` |
| `AGENTS.md` | Security guidelines, audit rules, baseline commands | `08_security_audit.md` |
| `Cargo.toml` | Dependency versions and features | `09_references_and_links.md` (Dependency Map) |
| `bal-server.service` | `systemd` unit file | `07_deployment_and_ops.md` (Systemd) |
| `bitcoind.service` | `systemd` unit for `bitcoin` node | `07_deployment_and_ops.md` (Systemd) |
| `tbitcoind.service` | `systemd` unit for testnet node | `07_deployment_and_ops.md` (Systemd) |
| `bal-server.env` | `bal-server` environment variables | `07_deployment_and_ops.md` (Environment Variables) |
| `bal-pusher.env` | `bal-pusher` environment variables | `07_deployment_and_ops.md` (Environment Variables) |
| `bal-server.sh` | Dev server startup script | `07_deployment_and_ops.md` (Bash Scripts) |
| `bal-pusher.sh` | Dev pusher startup script | `07_deployment_and_ops.md` (Bash Scripts) |
| `sendtx.sh` | Test transaction sender | `07_deployment_and_ops.md` (Bash Scripts) |
| `make_release.sh` | Release script | `07_deployment_and_ops.md` (Bash Scripts) |
| `download_bal_db.sh` | `scp` from remote | `07_deployment_and_ops.md` (Bash Scripts) |
| `generate_keys.sh` | Generate public key from `privkey.pem` | `07_deployment_and_ops.md` (Bash Scripts) |
| `public_key.pem` | Ed25519 public key for stats verification | `05_api_reference.md` (GET `/.pub_key.pem` endpoint) |
| `privkey.pem` | Private key for stats signing | `08_security_audit.md` (Secret Leakage) |
| `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) |
| `contrib/download_and_install_bitcoincore.sh` | Bitcoin Core install/verify | `07_deployment_and_ops.md` (Systemd) |
| `contrib/install_tor.sh` | Tor installation script | `07_deployment_and_ops.md` (Tor) |
| `contrib/nginx/bal-server.conf` | Nginx TLS config template | `07_deployment_and_ops.md` (Nginx) |
| `Dockerfile` | Multi-stage Docker build | `07_deployment_and_ops.md` (Docker) |
---
## Build and Release Profile
```toml
[profile.release]
opt-level = "z" # Optimize for binary size
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for maximum optimization
strip = true # Strip debug symbols
panic = "abort" # Abort on panic (smaller binary)
```
## Feature Flags
```toml
[features]
default = ["server", "pusher"]
server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
```
Build individual binaries:
```bash
cargo build --bin bal-server --features server
cargo build --bin bal-pusher --features pusher
```

31
docs/INDEX.md Normal file
View File

@@ -0,0 +1,31 @@
# `docs/` Knowledge Base
## Quick Guide for Contributors
- **I am a developer and want to understand the project** → Start with [`01_project_overview.md`](01_project_overview.md)
- **I am a developer and need to know how the system works** → Read [`03_architecture_and_data_flow.md`](03_architecture_and_data_flow.md)
- **I am a developer working on a specific module** → See [`04_modules_detail.md`](04_modules_detail.md)
- **I am a security auditor** → Go straight to [`08_security_audit.md`](08_security_audit.md)
- **I am deploying or operating this software** → Check [`07_deployment_and_ops.md`](07_deployment_and_ops.md)
- **I need to integrate with the API** → Reference [`05_api_reference.md`](05_api_reference.md)
- **I need to understand the database** → Use [`06_database_schema.md`](06_database_schema.md)
- **I need to understand Bitcoin concepts** → See [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md)
- **I need source code references or external links** → Check [`09_references_and_links.md`](09_references_and_links.md)
## Files Overview
| # | File | Purpose |
|---|------|---------|
| 1 | [`01_project_overview.md`](01_project_overview.md) | Vision, goals, components, and mapping to existing docs |
| 2 | [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md) | Bitcoin domain knowledge: BIP-84, locktime, P2WPKH, ZMQ, block headers |
| 3 | [`03_architecture_and_data_flow.md`](03_architecture_and_data_flow.md) | High-level architecture, data flow, state machine, error handling |
| 4 | [`04_modules_detail.md`](04_modules_detail.md) | Deep dive into each Rust module and binary |
| 5 | [`05_api_reference.md`](05_api_reference.md) | Complete API docs: HTTP, ZMQ, RPC with examples |
| 6 | [`06_database_schema.md`](06_database_schema.md) | Full SQL schema, tables, queries, data lifecycle |
| 7 | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) | Environment variables, systemd, nginx, scripts, Tor, installation |
| 8 | [`08_security_audit.md`](08_security_audit.md) | Threat model, vulnerability assessment, hardening recommendations |
| 9 | [`09_references_and_links.md`](09_references_and_links.md) | Source code links, Cargo dependencies, existing file mapping |
---
> **Note:** This knowledge base is maintained in parallel to the codebase. After any significant change to the code (new features, API changes, schema changes, or security fixes), update the corresponding file in this directory to keep documentation synchronized.

8
generate_keys.sh Normal file
View File

@@ -0,0 +1,8 @@
openssl pkey -in private_key.pem -pubout -out public_key.pem
chmod 600 private_key.pem
# Ensure private key is not accidentally committed to git
if grep -q "private_key.pem" .gitignore 2>/dev/null; then
echo "private_key.pem is already protected by .gitignore"
else
echo "WARNING: private_key.pem may not be in .gitignore!"
fi

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

425
src/db.rs
View File

@@ -1,7 +1,149 @@
use sqlite::{ Connection, Value, State, Error };
use log::{info, trace, error};
use log::{error, info, trace, warn};
use sqlite::{Connection, Error, State, Value};
use std::collections::HashSet;
use std::path::Path;
use std::thread;
use std::time::Duration;
pub fn create_database(db: &Connection){
/// Check which txids are already present in the database in a single batch query.
/// Returns a HashSet of txids that already exist (duplicates).
/// This is O(1) per query regardless of the number of txids, replacing the N+1 pattern.
pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error> {
if txids.is_empty() {
return Ok(HashSet::new());
}
// Build a single query with all txids using IN clause placeholders
// SQLite supports up to 1000 parameters per statement, so we chunk for safety
let mut duplicates = HashSet::new();
let chunk_size = 500; // Safe chunk size for SQLite parameters
for chunk in txids.chunks(chunk_size) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!("SELECT txid FROM tbl_tx WHERE txid IN ({})", placeholders);
let mut stmt = db.prepare(sql)?;
for (i, txid) in chunk.iter().enumerate() {
stmt.bind((i + 1, Value::String(txid.clone())))?;
}
while let Ok(State::Row) = stmt.next() {
if let Ok(txid) = stmt.read::<String, _>("txid") {
duplicates.insert(txid);
}
}
}
Ok(duplicates)
}
/// Validates and opens the SQLite database, enforcing security best practices:
/// - Path must not contain `..` (directory traversal).
/// - Absolute paths must not target known system directories.
/// - If the file exists, it must be a regular file (not a symlink or device).
/// - WAL journal mode is enabled for safe concurrent access.
/// - Synchronous is set to NORMAL for performance with safety.
///
/// Returns `Err` on validation failure or open error to prevent panics.
pub fn open_db(path: &str) -> Result<Connection, String> {
let p = Path::new(path);
// Prevent directory traversal
for component in p.components() {
if component == std::path::Component::ParentDir {
return Err("Database path may not contain '..'".to_string());
}
}
// If absolute, block known sensitive system directories
if p.is_absolute() {
let path_str = p.to_str().unwrap_or("");
let forbidden = [
"/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt",
];
for prefix in &forbidden {
if path_str.starts_with(prefix) {
return Err(format!(
"Absolute database path under {} is forbidden",
prefix
));
}
}
}
// If file exists, must be a regular file (not a symlink, device, etc.)
if p.exists() {
if p.is_symlink() {
return Err("Database path must not be a symlink".to_string());
}
let metadata = std::fs::metadata(p)
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
if !metadata.is_file() {
return Err(
"Database path must point to a regular file, not a directory or device".to_string(),
);
}
}
let conn = sqlite::open(path).map_err(|e| format!("Failed to open SQLite database: {}", e))?;
// Set busy timeout BEFORE WAL mode so SQLite waits instead of failing immediately.
// This handles the race where two processes (server + pusher) open the same DB
// and both try to enable WAL mode concurrently.
conn.execute("PRAGMA busy_timeout = 5000;")
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
// Retry WAL mode up to 5 times (handles concurrent open from bal-pusher).
let mut wal_ok = false;
for attempt in 0..5 {
match conn.execute("PRAGMA journal_mode = WAL;") {
Ok(_) => {
wal_ok = true;
break;
}
Err(e) => {
warn!(
"WAL mode attempt {}/5 failed: {}, retrying in 100ms...",
attempt + 1,
e
);
thread::sleep(Duration::from_millis(100));
}
}
}
if !wal_ok {
// WAL might already be enabled by another process; this is not fatal.
warn!("Could not set WAL mode after retries — may already be enabled by another process");
}
conn.execute("PRAGMA synchronous = NORMAL;")
.map_err(|e| format!("Failed to set synchronous NORMAL: {}", e))?;
Ok(conn)
}
/// Loads all known addresses for a given xpub into a HashSet for fast
/// in-memory lookup during transaction validation (replaces N+1 query).
pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error> {
let mut stmt = db.prepare(
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?",
)?;
stmt.bind((1, Value::String(xpub.to_string())))?;
let mut addresses = HashSet::new();
while let Ok(State::Row) = stmt.next() {
match stmt.read::<String, _>("address") {
Ok(addr) => {
addresses.insert(addr);
}
Err(e) => {
error!("Failed to read address column: {}", e);
}
}
}
Ok(addresses)
}
pub fn create_database(db: &Connection) {
info!("database sanity check");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_tx (txid PRIMARY KEY, date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP, date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, wtxid, ntxid, tx, locktime integer, network, network_fees, reqid, our_fees, our_address, status integer DEFAULT 0);");
let _ = db.execute("ALTER TABLE tbl_tx ADD COLUMN push_err TEXT");
@@ -9,17 +151,20 @@ pub fn create_database(db: &Connection){
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_inp(id, txid, in_txid, in_vout);");
let _ = db.execute("CREATE UNIQUE INDEX ON tbl_inp(txid,in_txid,in_vout);");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_out(id, txid, script_pubkey, amount, vout);");
let _ =
db.execute("CREATE TABLE IF NOT EXISTS tbl_out(id, txid, script_pubkey, amount, vout);");
let _ = db.execute("CREATE UNIQUE INDEX ON tbl_out(txid, script_pubkey, amount, vout);");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_xpub (id INTEGER PRIMARY KEY , network TEXT, xpub TEXT, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,path_idx INTEGER DEFAULT -1);");
let _ = db.execute("CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub)");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
let _ = db.execute("UPDATE tbl_tx set network='bitcoin' where network='mainnet');");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_stats (report_date TEXT, chain TEXT, totals INTEGER, waiting INTEGER, sent INTEGER, failed INTEGER, waiting_profit INTEGER, sent_profit INTEGER, missed_profit INTEGER, unique_inputs INTEGER);");
// UNIQUE index required for ON CONFLICT(chain) DO UPDATE in calculate_stats
let _ = db.execute("DROP INDEX IF EXISTS idx_stats_chain;");
let _ = db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);");
let _ = db.execute("UPDATE tbl_tx set network='bitcoin' where network='mainnet';");
}
/*
pub fn get_xpub_id(db: &Connection, network: &String, xpub: &String) -> Option<i64>{
@@ -33,76 +178,180 @@ pub fn create_database(db: &Connection){
}
}
*/
pub fn insert_xpub(db: &Connection, network: &String, xpub: &String){
if xpub != "" {
pub fn insert_xpub(db: &Connection, network: &str, xpub: &str) {
if !xpub.is_empty() {
trace!("going to insert: {} xpub:{}", network, xpub);
let mut stmt = db.prepare ("INSERT INTO tbl_xpub(network,xpub) VALUES(?, ?);").unwrap();
let _ = stmt.bind((1,Value::String(network.to_string()))).unwrap();
let _ = stmt.bind((2,Value::String(xpub.to_string()))).unwrap();
let _ = stmt.next();
let mut stmt =
match db.prepare("INSERT OR IGNORE INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare xpub insert statement: {}", e);
return;
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter for xpub insert: {}", e);
return;
}
if let Err(e) = stmt.bind((2, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return;
}
if let Err(e) = stmt.next() {
error!("Failed to insert xpub: {}", e);
}
}
}
pub fn get_last_used_address_by_ip(db: &Connection, network: &String, xpub: &String, address: &String) -> Option<String>{
let mut stmt = db.prepare("SELECT tbl_address.address FROM tbl_xpub join tbl_address on(tbl_xpub.id = tbl_address.xpub) where tbl_xpub.network = ? and tbl_address.remote_address = ? and tbl_xpub.xpub = ? ORDER BY tbl_address.date_create DESC LIMIT 1;").unwrap();
let _ = stmt.bind((1,Value::String(network.to_string())));
let _ = stmt.bind((2,Value::String(address.to_string())));
let _ = stmt.bind((3,Value::String(xpub.to_string())));
if let Ok(State::Row) = stmt.next(){
let address = stmt.read::<String,_>("address").unwrap();
return Some(address);
}else{
return None;
}
}
pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64,i64){
let mut stmt = db.prepare("UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? and xpub= ? RETURNING path_idx,id;").unwrap();
stmt.bind((1,Value::String(network.to_string()))).unwrap();
stmt.bind((2,Value::String(xpub.to_string()))).unwrap();
match stmt.next(){
Ok(State::Row) =>{
let next = stmt.read::<i64,_>("path_idx").unwrap();
let id = stmt.read::<i64,_>("id").unwrap();
return (id,next);
},Err(_)=> {
return (0,0);
},Ok(State::Done) =>{
return (0,0);
pub fn get_last_used_address_by_ip(
db: &Connection,
network: &String,
xpub: &String,
address: &String,
) -> Option<String> {
let mut stmt = match db.prepare("SELECT tbl_address.address FROM tbl_xpub join tbl_address on(tbl_xpub.id = tbl_address.xpub) where tbl_xpub.network = ? and tbl_address.remote_address = ? and tbl_xpub.xpub = ? ORDER BY tbl_address.date_create DESC LIMIT 1;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address query: {}", e);
return None;
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((2, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((3, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return None;
}
if let Ok(State::Row) = stmt.next() {
match stmt.read::<String, _>("address") {
Ok(addr) => Some(addr),
Err(e) => {
error!("Failed to read address column: {}", e);
None
}
}
} else {
None
}
}
pub fn save_new_address(db: &Connection,xpub: i64,address: &String, path: &String,remote_addr: &String){
let mut stmt = db.prepare("INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);").unwrap();
stmt.bind((1,Value::String(address.to_string()))).unwrap();
stmt.bind((2,Value::String(path.to_string()))).unwrap();
stmt.bind((3,Value::Integer(xpub))).unwrap();
stmt.bind((4,Value::String(remote_addr.to_string()))).unwrap();
let _ = stmt.next();
pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64) {
let mut stmt = match db.prepare("UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? and xpub= ? RETURNING path_idx,id;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare xpub index update: {}", e);
return (0, 0);
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return (0, 0);
}
if let Err(e) = stmt.bind((2, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return (0, 0);
}
match stmt.next() {
Ok(State::Row) => match stmt.read::<i64, _>("path_idx") {
Ok(next) => match stmt.read::<i64, _>("id") {
Ok(id) => (id, next),
Err(e) => {
error!("Failed to read id column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to read path_idx column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to execute xpub index update: {}", e);
(0, 0)
}
Ok(State::Done) => (0, 0),
}
}
pub fn execute_insert(db: &Connection,
sqltxs: String,
ptx: Vec<(usize, Value)>,
sqlinp: String,
pinp: Vec<(usize, Value)>,
sqlout: String,
pout: Vec<(usize, Value)>) -> Result<(),Error>{
pub fn save_new_address(
db: &Connection,
xpub: i64,
address: &String,
path: &String,
remote_addr: &String,
) {
let mut stmt = match db.prepare(
"INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
",
) {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address insert statement: {}", e);
return;
}
};
if let Err(e) = stmt.bind((1, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((2, Value::String(path.to_string()))) {
error!("Failed to bind path parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((3, Value::Integer(xpub))) {
error!("Failed to bind xpub parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((4, Value::String(remote_addr.to_string()))) {
error!("Failed to bind remote_addr parameter: {}", e);
return;
}
if let Err(e) = stmt.next() {
error!("Failed to insert address: {}", e);
}
}
pub fn execute_insert(
db: &Connection,
sqltxs: String,
ptx: Vec<(usize, Value)>,
sqlinp: String,
pinp: Vec<(usize, Value)>,
sqlout: String,
pout: Vec<(usize, Value)>,
) -> Result<(), Error> {
let _ = db.execute("BEGIN TRANSACTION");
let mut stmt = db.prepare(sqltxs.as_str()).expect("failed to prepare sqltxs");
if let Err(err) = stmt.bind::<&[(_,Value)]>(&ptx[..]) {
let mut stmt = match db.prepare(sqltxs.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqltxs: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&ptx[..]) {
error!("error binding transaction parameters: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
if let Err(err) = stmt.next() {
error!("error inserting transactions {}",err);
error!("error inserting transactions {}", err);
let _ = db.execute("ROLLBACK");
}else{
let mut stmt = db.prepare(sqlinp.as_str()).expect("failed to prepare sqlinp");
if let Err(err) = stmt.bind::<&[(_,Value)]>(&pinp[..]) {
} else {
let mut stmt = match db.prepare(sqlinp.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqlinp: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&pinp[..]) {
error!("error binding inputs parameters {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
@@ -111,10 +360,16 @@ pub fn execute_insert(db: &Connection,
error!("error inserting inputs {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}else{
let mut stmt = db.prepare(sqlout.as_str()).expect("failed to prepare sqlout");
if let Err(err) = stmt.bind::<&[(_,Value)]>(&pout[..]) {
} else {
let mut stmt = match db.prepare(sqlout.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqlout: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&pout[..]) {
error!("error binding outs parameters {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
@@ -123,25 +378,35 @@ pub fn execute_insert(db: &Connection,
error!("error inserting outs {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
}
}
let _ = db.execute("COMMIT");
Ok(())
}
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64,Error> {
let mut stmt = db.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;").unwrap();
stmt.bind((1,Value::String(network.to_string()))).unwrap();
match stmt.next(){
Ok(State::Row)=>{
Ok(stmt.read::<i64,_>("total_number").unwrap())
},
Ok(sqlite::State::Done) => todo!(),
Err(err)=>Err(err)
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error> {
let mut stmt = db
.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
.map_err(|e| {
error!("Failed to prepare statement: {}", e);
e
})?;
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return Err(e);
}
match stmt.next() {
Ok(State::Row) => match stmt.read::<i64, _>("total_number") {
Ok(val) => Ok(val),
Err(e) => {
error!("Failed to read total_number column: {}", e);
Err(e)
}
},
Ok(sqlite::State::Done) => Ok(0),
Err(err) => {
error!("Failed to execute query: {}", err);
Err(err)
}
}
}

3
src/lib.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod db;
pub mod validation;
pub mod xpub;

168
src/validation.rs Normal file
View File

@@ -0,0 +1,168 @@
use url::Url;
/// Validates a WELIST server URL to mitigate SSRF risks.
///
/// Checks:
/// 1. URL must be well-formed and parsable.
/// 2. Scheme must be `https://` (plain HTTP is rejected).
/// 3. Host must be present.
/// 4. Host must not be `localhost` or loopback strings.
/// 5. Host must not resolve to a loopback, private, link-local, unspecified, or multicast IP address.
/// 6. IPv6 Unique Local (fc00::/7) is also rejected.
///
/// Returns `true` if the URL is safe to use, `false` otherwise.
///
/// Examples:
/// - `is_valid_welist_url("https://welist.bitcoin-after.life")` -> `true`
/// - `is_valid_welist_url("https://welist.bitcoin-after.life:443")` -> `true`
/// - `is_valid_welist_url("https://example.com/ping")` -> `true`
/// - `is_valid_welist_url("http://welist.bitcoin-after.life")` -> `false` (not HTTPS)
/// - `is_valid_welist_url("https://localhost")` -> `false` (localhost loopback)
/// - `is_valid_welist_url("https://127.0.0.1")` -> `false` (IPv4 loopback)
/// - `is_valid_welist_url("https://169.254.169.254")` -> `false` (AWS metadata link-local)
/// - `is_valid_welist_url("https://192.168.1.1")` -> `false` (IPv4 private)
/// - `is_valid_welist_url("https://10.0.0.1")` -> `false` (IPv4 private RFC1918)
pub fn is_valid_welist_url(url_str: &str) -> bool {
let url = match Url::parse(url_str) {
Ok(u) => u,
Err(_e) => return false,
};
if url.scheme() != "https" {
return false;
}
let host = match url.host_str() {
Some(h) => h.trim_start_matches('[').trim_end_matches(']'),
None => return false,
};
if host.eq_ignore_ascii_case("localhost")
|| host.eq_ignore_ascii_case("127.0.0.1")
|| host.eq_ignore_ascii_case("::1")
{
return false;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
match ip {
std::net::IpAddr::V4(v4) => {
if v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
{
return false;
}
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
|| v6.is_multicast()
|| v6.is_unique_local()
{
return false;
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_domains() {
assert!(is_valid_welist_url("https://welist.bitcoin-after.life"));
assert!(is_valid_welist_url("https://welist.bitcoin-after.life:443"));
assert!(is_valid_welist_url("https://example.com/ping"));
assert!(is_valid_welist_url("https://a.b.c.d.example.com"));
assert!(is_valid_welist_url("https://welist.onion.tor"));
}
#[test]
fn test_invalid_scheme() {
assert!(!is_valid_welist_url("http://welist.bitcoin-after.life"));
assert!(!is_valid_welist_url("ftp://welist.bitcoin-after.life"));
assert!(!is_valid_welist_url("https://")); // no host
}
#[test]
fn test_localhost_and_loopback() {
assert!(!is_valid_welist_url("https://localhost"));
assert!(!is_valid_welist_url("https://localhost:8080"));
assert!(
!is_valid_welist_url("https://LOCALHOST"),
"Uppercase localhost should be blocked"
);
assert!(!is_valid_welist_url("https://127.0.0.1"));
assert!(!is_valid_welist_url("https://127.0.0.1:8080"));
assert!(
!is_valid_welist_url("https://127.0.0.2"),
"Other loopback in 127/8 should be blocked"
);
assert!(
!is_valid_welist_url("https://[::1]"),
"IPv6 loopback literal should be blocked"
);
assert!(
!is_valid_welist_url("https://::1"),
"Raw IPv6 loopback without brackets should be invalid (parse fails)"
);
}
#[test]
fn test_private_ips() {
assert!(!is_valid_welist_url("https://192.168.1.1"));
assert!(!is_valid_welist_url("https://10.0.0.1"));
assert!(!is_valid_welist_url("https://172.16.0.1"));
assert!(!is_valid_welist_url("https://172.31.255.255"));
assert!(
!is_valid_welist_url("https://169.254.169.254"),
"AWS metadata link-local IP should be blocked"
);
}
#[test]
fn test_unspecified_and_multicast() {
assert!(!is_valid_welist_url("https://0.0.0.0"));
assert!(!is_valid_welist_url("https://224.0.0.1"));
}
#[test]
fn test_ipv6_link_local() {
assert!(
!is_valid_welist_url("https://[fe80::1]"),
"IPv6 link local should be blocked"
);
}
#[test]
fn test_ipv6_unique_local() {
assert!(
!is_valid_welist_url("https://[fc00::1]"),
"IPv6 unique local (fc00::/7) should be blocked"
);
assert!(
!is_valid_welist_url("https://[fd00::1]"),
"IPv6 unique local (fd00::7) should be blocked"
);
}
#[test]
fn test_malformed_urls() {
assert!(!is_valid_welist_url("not a url"));
assert!(!is_valid_welist_url("welist.bitcoin-after.life")); // missing scheme
}
#[test]
fn test_valid_public_ip() {
assert!(is_valid_welist_url("https://1.2.3.4"));
assert!(is_valid_welist_url("https://8.8.8.8"));
}
}

View File

@@ -1,32 +1,149 @@
use sha2::{Digest, Sha256};
use bitcoin::bip32::Xpub;
use std::str::FromStr;
use bitcoin::bip32::DerivationPath;
use bitcoin::key::Secp256k1;
//use bs58;
use bitcoin::Address;
use bitcoin::Network;
use bitcoin::ScriptBuf;
use bitcoin::WPubkeyHash;
use bitcoin::Network;
use bitcoin::bip32::DerivationPath;
use bitcoin::bip32::Xpub;
use bitcoin::hashes::Hash;
use bitcoin::key::Secp256k1;
use sha2::{Digest, Sha256};
use std::str::FromStr;
// Mainnet (BIP44/BIP49/BIP84)
enum BS58Prefix{
#[allow(dead_code)]
enum BS58Prefix {
Xpub,
//Ypub,
//Zpub,
//Tpub,
//Vpub,
//Upub
Ypub,
Zpub,
Tpub,
Vpub,
Upub,
}
const XPUB_PREFIX:[u8; 4] = [0x04, 0x88, 0xB2, 0x1E]; // xpub (Legacy P2PKH)
//const YPUB_PREFIX:[u8; 4] = [0x04, 0x9D, 0x7C, 0xB2]; // ypub (Nested SegWit P2SH-P2WPKH)
//const ZPUB_PREFIX:[u8; 4] = [0x04, 0xB2, 0x47, 0x46]; // zpub (Native SegWit P2WPKH)
//const TPUB_PREFIX:[u8; 4] = [0x04, 0x35, 0x87, 0xCF]; // tpub (Testnet Legacy P2PKH)
//const VPUB_PREFIX:[u8; 4] = [0x04, 0x5F, 0x1C, 0xF6]; // vpub (Testnet Nested SegWit)
//const UPUB_PREFIX:[u8; 4] = [0x04, 0x4A, 0x52, 0x62]; // upub (RegTest Nested SegWit)
const XPUB_PREFIX: [u8; 4] = [0x04, 0x88, 0xB2, 0x1E]; // xpub (Legacy P2PKH)
const YPUB_PREFIX: [u8; 4] = [0x04, 0x9D, 0x7C, 0xB2]; // ypub (Nested SegWit P2SH-P2WPKH)
const ZPUB_PREFIX: [u8; 4] = [0x04, 0xB2, 0x47, 0x46]; // zpub (Native SegWit P2WPKH)
const TPUB_PREFIX: [u8; 4] = [0x04, 0x35, 0x87, 0xCF]; // tpub (Testnet Legacy P2PKH)
const VPUB_PREFIX: [u8; 4] = [0x04, 0x5F, 0x1C, 0xF6]; // vpub (Testnet Nested SegWit)
const UPUB_PREFIX: [u8; 4] = [0x04, 0x4A, 0x52, 0x62]; // upub (RegTest Nested SegWit)
// Constants from Bitcoin Core's checksum algorithm
const INPUT_CHARSET: &[u8] = b"0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ ";
const CHECKSUM_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
// Polynomial modulo function used in checksum calculation (same as in Bitcoin Core)
fn poly_mod(mut c: u64, val: u64) -> u64 {
let c0 = c >> 35;
c = ((c & 0x7ffffffff) << 5) ^ val;
if c0 & 1 > 0 {
c ^= 0xf5dee51989
};
if c0 & 2 > 0 {
c ^= 0xa9fdca3312
};
if c0 & 4 > 0 {
c ^= 0x1bab10e32d
};
if c0 & 8 > 0 {
c ^= 0x3706b1677a
};
if c0 & 16 > 0 {
c ^= 0x644d626ffd
};
c
}
// Calculate checksum for a descriptor string
fn calc_checksum(desc: &str) -> Result<String, String> {
// Separate descriptor from any existing checksum
let desc = match desc.split_once('#') {
Some((d, _)) => d,
None => desc,
};
let mut c: u64 = 1;
let mut cls: u64 = 0;
let mut clscount: u64 = 0;
// Process each character in the descriptor
for ch in desc.as_bytes() {
let pos = match INPUT_CHARSET.iter().position(|b| b == ch) {
Some(p) => p as u64,
None => return Err(format!("Invalid character in descriptor: {}", *ch as char)),
};
c = poly_mod(c, pos & 31);
cls = cls * 3 + (pos >> 5);
clscount += 1;
if clscount == 3 {
c = poly_mod(c, cls);
cls = 0;
clscount = 0;
}
}
if clscount > 0 {
c = poly_mod(c, cls);
}
// Final steps in checksum calculation
for _ in 0..8 {
c = poly_mod(c, 0);
}
c ^= 1;
// Convert checksum to characters
let mut checksum = String::with_capacity(8);
for j in 0..8 {
let idx = ((c >> (5 * (7 - j))) & 31) as usize;
checksum.push(CHECKSUM_CHARSET[idx] as char);
}
Ok(checksum)
}
pub fn get_bitcoincore_descriptor(xpub: &str) -> String {
let fingerprint = match calculate_fingerprint(xpub) {
Ok(f) => f,
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
};
let xpub_converted = match convert_xpub(xpub) {
Ok(c) => c,
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
};
let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted);
match calc_checksum(&descriptor) {
Ok(checksum) => {
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
format!("{}#{}", clean_descriptor, checksum)
}
Err(err) => {
eprintln!("Error: {}", err);
String::new()
}
}
}
fn convert_xpub(xpub: &str) -> Result<String, String> {
if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub")
{
convert_to(xpub, BS58Prefix::Xpub)
} else if xpub.len() >= 4
&& (&xpub[0..4] == "tpub" || &xpub[0..4] == "vpub" || &xpub[0..4] == "upub")
{
convert_to(xpub, BS58Prefix::Tpub)
} else {
Err("Invalid xpub prefix: expected xpub, ypub, zpub, tpub, vpub, or upub".to_string())
}
}
pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
let xpub = Xpub::from_str(&convert_to(tpub, BS58Prefix::Xpub)?)
.map_err(|e| format!("Invalid xpub: {}", e))?;
let fp = xpub.fingerprint();
let _pp = xpub.parent_fingerprint;
Ok(format!("{}", fp))
}
fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
let data = bs58::decode(s).into_vec().map_err(|e| e.to_string())?;
@@ -47,27 +164,33 @@ fn base58check_encode(data: &[u8]) -> String {
bs58::encode(full).into_string()
}
fn convert_to(zpub: &str,prefix: BS58Prefix) -> Result<String, String> {
fn convert_to(zpub: &str, prefix: BS58Prefix) -> Result<String, String> {
let mut data = base58check_decode(zpub)?;
if data.len() < 4 {
return Err("Non è una zpub valida.".to_string());
}
data.splice(0..4, match prefix {
BS58Prefix::Xpub => XPUB_PREFIX,
//BS58Prefix::Ypub => YPUB_PREFIX,
//BS58Prefix::Zpub => ZPUB_PREFIX,
//BS58Prefix::Vpub => VPUB_PREFIX,
//BS58Prefix::Tpub => TPUB_PREFIX,
//BS58Prefix::Upub => UPUB_PREFIX,
});
data.splice(
0..4,
match prefix {
BS58Prefix::Xpub => XPUB_PREFIX,
BS58Prefix::Ypub => YPUB_PREFIX,
BS58Prefix::Zpub => ZPUB_PREFIX,
BS58Prefix::Vpub => VPUB_PREFIX,
BS58Prefix::Tpub => TPUB_PREFIX,
BS58Prefix::Upub => UPUB_PREFIX,
},
);
Ok(base58check_encode(&data))
}
pub fn new_address_from_xpub(zpub: &str, index: i64,network: Network)-> Result<(String,String), Box<dyn std::error::Error>>{
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
let path = format!("m/0/{}",index);
pub fn new_address_from_xpub(
zpub: &str,
index: i64,
network: Network,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let xpub = Xpub::from_str(&convert_to(zpub, BS58Prefix::Xpub)?)?;
let path = format!("m/0/{}", index);
let derivation_path = DerivationPath::from_str(path.as_str())?;
let secp = Secp256k1::new();
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
@@ -78,20 +201,21 @@ pub fn new_address_from_xpub(zpub: &str, index: i64,network: Network)-> Result<(
//let script_pubkey = ScriptBuf::new_p2sh(&redeem_script.script_hash());
let address = Address::from_script(&redeem_script, network)?;
//let address = Address::from_script(&script_pubkey, network)?;
Ok((address.to_string(),path.to_string()))
Ok((address.to_string(), path.to_string()))
}
/*
fn main() -> Result<(), Box<dyn std::error::Error>>{
//let zpub = "xpub6C29v8gxCXREHUzoGNfqqFqZWxTVEmYtmZshuzfSwBKNmfYQxoizRziCkkUUA4WwJZkJs2i7nttRiC6MQG7mxZpouXeYkTZe3U52RyPAeo2";
//let zpub = "vpub5Ut36m34VebUUjdhYaxJCjSPqk3ZR8bA2MXLmbHRQCycAxy5Q1GFPJspLkJywJjBgQnvU3rmwPKTPp1ELLWeXrve3zBufpZR4MRCCTNHzsn";
let zpub = "zpub6qdfveGrxBQN3z8paZ88EHpCn5MGXpUoHwQmHhPbj4rPQtUjbWyCHrJFYZGVY7MsmVbDaeu4JYqRqcdLzMx78wZFEWbLrF9FG3gr2MPQC5H";
match convert_to(zpub,BS58Prefix::Xpub) {
Ok(xpub) => println!("XPUB: {}", xpub),
match convert_to(zpub,BS58Prefix::Tpub) {
Ok(tpub) => println!("XPUB: {}", tpub),
Err(e) => eprintln!("Errore: {}", e),
}
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
let fingerprint = base58check_encode(&calculate_fingerprint(zpub));
println!("ZPUB: {}, FINGERPRINT: {}",zpub,fingerprint);
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
let tpub = convert_to(zpub,BS58Prefix::Tpub)?;
let fingerprint = base58check_encode(&calculate_fingerprint(&tpub));
println!("TPUB: {}, FINGERPRINT: {}",tpub,fingerprint);
let derivation_path = DerivationPath::from_str("m/0/0")?;
let secp = Secp256k1::new();
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
@@ -108,5 +232,4 @@ fn main() -> Result<(), Box<dyn std::error::Error>>{
let address = Address::from_script(&script_pubkey, network)?;
Ok(())
}
*/
}*/

View File

@@ -0,0 +1,92 @@
use bal_server::db::open_db;
use sqlite::State;
use std::fs;
#[test]
fn test_open_db_blocks_traversal() {
let res = open_db("../etc/passwd");
assert!(res.is_err(), "Path with '..' should be rejected");
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for traversal path"),
};
assert!(
err.contains("'..'"),
"Error should mention directory traversal: {}",
err
);
}
#[test]
fn test_open_db_blocks_forbidden_absolute() {
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
let res = open_db(path);
assert!(res.is_err(), "Absolute path {} should be rejected", path);
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for forbidden path {}", path),
};
assert!(
err.contains("forbidden"),
"Error should mention forbidden prefix: {}",
err
);
}
}
#[test]
fn test_open_db_allows_relative() {
let test_path = "tmp_test_bal.db";
let _ = fs::remove_file(test_path);
let res = open_db(test_path);
assert!(res.is_ok(), "Valid relative path should be allowed");
let db = res.unwrap();
drop(db);
let _ = fs::remove_file(test_path);
}
#[test]
fn test_open_db_wal_pragmas_set() {
let test_path = "tmp_test_wal.db";
let _ = fs::remove_file(test_path);
let _ = fs::remove_file(format!("{}-shm", test_path));
let _ = fs::remove_file(format!("{}-wal", test_path));
let db = open_db(test_path).expect("Should open DB");
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
if let Ok(State::Row) = stmt.next() {
let mode: String = stmt.read(0).unwrap();
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
} else {
panic!("Could not read journal_mode pragma");
}
let _ = fs::remove_file(test_path);
let _ = fs::remove_file(format!("{}-shm", test_path));
let _ = fs::remove_file(format!("{}-wal", test_path));
}
#[test]
fn test_open_db_rejects_symlink() {
let real = "tmp_test_real.db";
let link = "tmp_test_link.db";
let _ = fs::remove_file(real);
let _ = fs::remove_file(link);
fs::File::create(real).unwrap();
std::os::unix::fs::symlink(real, link).unwrap();
let res = open_db(link);
assert!(res.is_err(), "Symlink DB path should be rejected");
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for symlink"),
};
assert!(
err.contains("symlink"),
"Error should mention symlink: {}",
err
);
let _ = fs::remove_file(real);
let _ = fs::remove_file(link);
}

View File

@@ -0,0 +1,82 @@
use bal_server::db::{get_all_addresses_by_xpub, open_db};
use sqlite::Value;
fn setup_db_with_xpub() -> sqlite::Connection {
let db = open_db(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE tbl_xpub (id INTEGER PRIMARY KEY, network TEXT, xpub TEXT, path_idx INTEGER DEFAULT -1);"
);
let _ = db.execute(
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
);
// Insert test xpub
let mut stmt = db
.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);")
.unwrap();
stmt.bind((1, Value::Integer(1))).unwrap();
stmt.bind((2, Value::String("testnet".to_string())))
.unwrap();
stmt.bind((3, Value::String("tpub_test".to_string())))
.unwrap();
let _ = stmt.next();
drop(stmt);
// Insert test addresses
for addr in ["addr1", "addr2", "addr3"] {
let mut stmt = db
.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);")
.unwrap();
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
stmt.bind((3, Value::Integer(1))).unwrap();
let _ = stmt.next();
drop(stmt);
}
db
}
#[test]
fn test_get_all_addresses_by_xpub_returns_known() {
let db = setup_db_with_xpub();
let addresses = get_all_addresses_by_xpub(&db, "tpub_test").unwrap();
assert!(addresses.contains("addr1"));
assert!(addresses.contains("addr2"));
assert!(addresses.contains("addr3"));
assert_eq!(addresses.len(), 3);
}
#[test]
fn test_get_all_addresses_by_xpub_empty_for_missing() {
let db = setup_db_with_xpub();
let addresses = get_all_addresses_by_xpub(&db, "tpub_nonexistent").unwrap();
assert!(addresses.is_empty());
}
#[test]
fn test_network_unknown_returns_404() {
// This is a code-level check; the actual HTTP test requires actix-web setup.
// Verify that the NETWORKS constant includes the expected set.
let networks = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
for n in networks {
assert!(networks.contains(&n), "{} should be a valid network", n);
}
assert!(
!networks.contains(&"attacker"),
"attacker should not be a valid network"
);
}
#[test]
fn test_txid_validation_is_hex_64() {
let valid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(valid.len(), 64);
let too_short = "abcdef1234567890";
assert!(too_short.len() != 64);
let non_hex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789g";
assert!(!non_hex.chars().all(|c| c.is_ascii_hexdigit()));
let with_dot = ".abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
assert!(!with_dot.chars().all(|c| c.is_ascii_hexdigit()));
}

View File

@@ -0,0 +1,46 @@
use sqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
#[test]
fn test_mutex_poisoning_recovery() {
let data = Arc::new(Mutex::new(0));
let c = data.clone();
let handle = thread::spawn(move || {
let _guard = c.lock(); // Acquire lock
panic!("test panic"); // Panic while holding the lock
// _guard is dropped during panic unwinding, poisoning the mutex
});
let result = handle.join();
assert!(result.is_err()); // Thread panicked
// Recovery: the same pattern used in bal-server.rs
let guard = match data.lock() {
Ok(g) => g,
Err(p) => {
p.into_inner() // Should not panic
}
};
assert_eq!(*guard, 0);
}
#[test]
fn test_db_null_unwrap_or() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
);
let _ =
db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
let mut found_value = None;
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
let row: HashMap<_, _> = pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect();
let totals = row["totals"].unwrap_or("0").to_string();
found_value = Some(totals);
true
});
assert_eq!(found_value.unwrap(), "0");
}

View File

@@ -0,0 +1,154 @@
use std::fs;
#[test]
fn test_gitignore_protection_env() {
let gitignore =
fs::read_to_string(".gitignore").expect(".gitignore file not found in project root");
// Check that .env and .pem files are blocked
let required_patterns = vec![
".env",
"*.env",
"*.env.local",
".env.production",
".env.secret",
"*.pem",
"!public_key.pem",
"*.key",
"private_key.pem",
"privkey.pem",
"ec.key",
"chiave_privata.key",
];
for pattern in required_patterns {
let has_wildcard = gitignore.contains("*.env.local") || gitignore.contains(".env.local");
let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
let is_env_local = pattern == "*.env.local" || pattern == ".env.local";
if is_env_local {
assert!(
has_wildcard,
".gitignore must contain pattern '*.env.local' or '.env.local' to protect secrets",
);
} else if pattern == ".env.production"
|| pattern == ".env.secret"
|| pattern == "*.env"
|| pattern == ".env"
{
assert!(
has_env,
".gitignore must contain pattern '*.env' or '.env' to protect secrets",
);
} else {
assert!(
gitignore.contains(pattern),
".gitignore must contain pattern '{}' to protect secrets",
pattern
);
}
}
println!(".gitignore properly protects .env, .pem, and .key files");
}
#[test]
fn test_no_private_key_in_git() {
let gitignore = match fs::read_to_string(".gitignore") {
Ok(c) => c,
Err(e) => {
println!("WARNING: .gitignore not found: {}", e);
return;
}
};
assert!(
gitignore.contains("private_key.pem"),
".gitignore must block private_key.pem"
);
assert!(
gitignore.contains("privkey.pem"),
".gitignore must block privkey.pem"
);
assert!(gitignore.contains("ec.key"), ".gitignore must block ec.key");
assert!(
gitignore.contains("chiave_privata.key"),
".gitignore must block chiave_privata.key"
);
let output = std::process::Command::new("git")
.args(["ls-files", "*.pem", "*.key"])
.output()
.expect("Failed to run git ls-files");
let tracked_keys = String::from_utf8(output.stdout).unwrap();
let tracked_keys: Vec<&str> = tracked_keys.lines().collect();
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
if !tracked.contains("public_key.pem") {
panic!(
"Private key file is tracked by git: {}. Remove it with git rm --cached",
tracked
);
}
}
println!("PASS: No private keys tracked in git (only public_key.pem allowed)");
}
#[test]
fn test_no_token_in_source_files() {
let mut found_issues = Vec::new();
for entry in fs::read_dir(".").unwrap().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(ext) = path.extension()
&& ext == "sh"
{
let content = fs::read_to_string(&path).unwrap();
for (line_num, line) in content.lines().enumerate() {
if line.trim().starts_with('#')
|| line.to_lowercase().contains("example")
|| line.to_lowercase().contains("template")
{
continue;
}
if line.trim().len() >= 40 {
let hex_chars: Vec<_> = line
.trim()
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect();
if (40..=64).contains(&hex_chars.len())
&& (line.to_lowercase().contains("token")
|| line.to_lowercase().contains("api")
|| line.to_lowercase().contains("secret"))
{
found_issues.push(format!(
"Potential hardcoded token in {}: line {}: {}",
path.display(),
line_num + 1,
line.trim()
));
}
}
}
}
}
if !found_issues.is_empty() {
println!("FAIL: Found potential hardcoded tokens:");
for issue in &found_issues {
println!(" {}", issue);
}
panic!(
"Found potential hardcoded tokens in shell scripts: {:?}",
found_issues
);
}
println!("PASS: No hardcoded tokens found in shell scripts");
}

View File

@@ -0,0 +1,137 @@
use sqlite::{Connection, Value};
#[test]
fn test_sql_injection_via_push_err_update() {
// Create an in-memory database and the required table
let db = Connection::open(":memory:").unwrap();
let _ =
db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);");
// Insert a dummy transaction
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);")
.unwrap();
stmt.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
stmt.bind((3, Value::String("".to_string()))).unwrap();
let _ = stmt.next();
drop(stmt);
// Malicious error payload containing a single quote (SQL injection attempt)
let malicious_error = "'; DROP TABLE tbl_tx; --";
let txid = "dummy_txid";
// Execute the fixed query using parameter binding (safe)
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_error.to_string())))
.unwrap();
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
let _ = stmt.next();
// Verify the table still exists and the row was updated correctly
let mut check = db
.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
let push_err: String = check.read("push_err").unwrap();
assert_eq!(status, 2);
assert_eq!(push_err, malicious_error);
// Ensure no second row was created (injection would have failed or produced extra rows)
let mut count_stmt = db.prepare("SELECT COUNT(*) FROM tbl_tx;").unwrap();
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
let count: i64 = count_stmt.read(0).unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_sql_injection_via_txid_update() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
// Insert multiple dummy transactions
for i in 0..3 {
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
.unwrap();
stmt.bind((1, Value::String(format!("txid_{}", i))))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
}
// Malicious txid payload
let malicious_txid = "' OR '1'='1";
// The fixed query parameterizes the txid, so this should only update zero rows
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
// Verify no rows were updated (status should still be 0 for all)
for i in 0..3 {
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String(format!("txid_{}", i))))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(
status, 0,
"Row txid_{} should not be updated by malicious txid",
i
);
}
}
#[test]
fn test_sql_injection_via_txid_with_comment() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
.unwrap();
stmt.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
// Another common injection pattern
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
// Verify the original row was NOT updated (because it was looking for the full malicious string)
// and no rows have status 99 (the injected update did not execute)
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(status, 0, "Original row should not be updated");
let mut count_stmt = db
.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;")
.unwrap();
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
let count: i64 = count_stmt.read(0).unwrap();
assert_eq!(count, 0, "No rows should have status 99");
}

110
tests/ssrf_tests.rs Normal file
View File

@@ -0,0 +1,110 @@
use bal_server::validation::is_valid_welist_url;
#[test]
fn test_ssrf_blocks_internal_urls() {
// Blocked: internal loopback
assert!(
!is_valid_welist_url("https://127.0.0.1"),
"IPv4 loopback should be blocked"
);
assert!(
!is_valid_welist_url("https://localhost"),
"localhost hostname should be blocked"
);
assert!(
!is_valid_welist_url("https://[::1]"),
"IPv6 loopback should be blocked"
);
// Blocked: private RFC1918 ranges
assert!(
!is_valid_welist_url("https://192.168.1.1"),
"RFC1918 private IP should be blocked"
);
assert!(
!is_valid_welist_url("https://10.0.0.1"),
"RFC1918 private IP should be blocked"
);
assert!(
!is_valid_welist_url("https://172.16.0.1"),
"RFC1918 private IP should be blocked"
);
// Blocked: AWS metadata link-local
assert!(
!is_valid_welist_url("https://169.254.169.254"),
"AWS metadata link-local IP should be blocked"
);
// Blocked: non-HTTPS schemes
assert!(
!is_valid_welist_url("http://welist.bitcoin-after.life"),
"HTTP plaintext should be blocked"
);
assert!(
!is_valid_welist_url("ftp://welist.bitcoin-after.life"),
"FTP scheme should be blocked"
);
// Blocked: malformed URLs
assert!(
!is_valid_welist_url("not a url"),
"Malformed URL should be blocked"
);
assert!(
!is_valid_welist_url("welist.bitcoin-after.life"),
"URL missing scheme should be blocked"
);
// Allowed: valid public domain on HTTPS
assert!(
is_valid_welist_url("https://welist.bitcoin-after.life"),
"Known production domain should be allowed"
);
assert!(
is_valid_welist_url("https://example.com/ping"),
"Public domain on HTTPS should be allowed"
);
// Allowed: valid public IP on HTTPS
assert!(
is_valid_welist_url("https://8.8.8.8"),
"Public IP on HTTPS should be allowed"
);
assert!(
is_valid_welist_url("https://1.2.3.4"),
"Public IP on HTTPS should be allowed"
);
}
#[test]
fn test_ssrf_case_insensitive_localhost() {
assert!(
!is_valid_welist_url("https://LOCALHOST"),
"Uppercase localhost should be blocked"
);
assert!(
!is_valid_welist_url("https://LocalHost"),
"Mixed case localhost should be blocked"
);
}
#[test]
fn test_ssrf_ipv6_unique_local() {
assert!(
!is_valid_welist_url("https://[fc00::1]"),
"IPv6 unique local fc00 should be blocked"
);
assert!(
!is_valid_welist_url("https://[fd00::1]"),
"IPv6 unique local fd00 should be blocked"
);
}
#[test]
fn test_ssrf_port_presence_ok() {
assert!(
is_valid_welist_url("https://welist.bitcoin-after.life:443"),
"HTTPS with explicit port should be allowed"
);
}