commit 0fe5f6d8b6ad8d09c4a9d2e4ead9cb3d7bc262aa
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 10:11:41 +0200
Initial commit
Diffstat:
30 files changed, 8468 insertions(+), 0 deletions(-)
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cargo fmt -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+/target
+/.mivora
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,1006 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "mivora"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "tokio",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "mivora"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.98"
+axum = "0.8.4"
+ed25519-dalek = "2.2.0"
+getrandom = "0.2.17"
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.150"
+sha2 = "0.10.9"
+rusqlite = { version = "0.32.1", features = ["bundled"] }
+tokio = { version = "1.45.1", features = ["full"] }
+
+[dev-dependencies]
+tempfile = "3.20.0"
diff --git a/README.md b/README.md
@@ -0,0 +1,117 @@
+# Mivora
+
+Mivora is a tiny L1 coin prototype built from the node first, then explained as it grows.
+
+The current devnet assumes friendly nodes. It has one binary that acts as wallet, node, miner, HTTP management UI, and P2P TCP listener. The core ledger is separated from the adapters so a whole network can be tested in memory without opening sockets.
+
+## Run
+
+```sh
+cargo run -- --start --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
+```
+
+Open `http://127.0.0.1:8443`. The wallet is generated into `.mivora/`.
+The validated chain is persisted to `.mivora/chain.sqlite3` and resumes automatically when the same data directory is started again.
+
+Mining is automatic. There is no "mine block" button and no exact sleep. Each node burns its configured amount once per chain height, then only the VRF-selected leader builds a block containing burned coins, performs the VDF work, and gossips the finished block. The VDF is the clock.
+
+The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it, selecting the starter as the first leader. Because non-genesis blocks must include a positive burn, that chain will wait until a wallet has spendable coins to burn. For a self-running local demo, leave one extra coin after genesis and burn it into the first block:
+
+```sh
+cargo run -- --start --genesis-amount 2 --burn-per-block 1 --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
+```
+
+The management UI is a small AlpineJS app served from local vendored assets. It polls JSON endpoints every few seconds and includes:
+
+- wallet and transaction controls,
+- fixed burn-per-block settings,
+- P2P peer status with gossip send/receive counters and last error,
+- a blockchain explorer and mempool view.
+
+For a second local node joining Alice's chain:
+
+```sh
+cargo run -- --name bob --data-dir .mivora-bob --http 127.0.0.1:8444 --p2p 127.0.0.1:9445 --join 127.0.0.1:9444
+```
+
+`--join` fetches a chain snapshot from the peer before mining starts and announces this node's P2P listener back to that peer, so newly mined blocks can flow back without restarting the first node. If the peer cannot provide a snapshot, the node exits instead of silently starting a separate chain. Plain `--peer` only adds a gossip peer and does not require bootstrap success.
+
+If `<data-dir>/chain.sqlite3` already exists, the node resumes that chain first. That makes restarts boring in the good way: `--start` will not create a new genesis over an existing local chain, and `--join` remains useful for reconnecting to peers without replacing local state. Pass `--chain-db path/to/chain.sqlite3` to override the database path.
+
+Nodes also run a self-healing sync loop. They periodically compare known peer heights and tip hashes, request missing block ranges when a peer is ahead, and validate those blocks before importing them. Full snapshots are kept for initial join and fallback cases, not as the normal catch-up path. The mempool tolerates future-nonce transactions from peers and mines them once the missing nonce gap is filled.
+
+The UI separates local height from shared height. Local height is the node's own validated tip. Shared height is the lowest recently reported peer height plus the local height, which is a better view of how far the connected network has actually converged.
+
+## Friend Net
+
+To start a small friendly network:
+
+1. Start your node with a public P2P bind:
+
+```sh
+cargo run -- --start --genesis-amount 2 --burn-per-block 1 --p2p 0.0.0.0:9444 --http 127.0.0.1:8443
+```
+
+2. Give friends your public `host:9444`.
+3. Friends join your chain:
+
+```sh
+cargo run -- --data-dir .mivora-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:8443 --join your-host:9444
+```
+
+Friends who join after you start will adopt your genesis and current chain. With the default genesis amount, the starter wallet begins with a 0 balance because genesis mints 1 coin and immediately burns it as the first lottery ticket. For a moving demo, `--genesis-amount 2 --burn-per-block 1` leaves the starter one coin to burn into block 1. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise.
+
+The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in the latest block select the leader for the next block through a deterministic VRF-style lottery. The selected leader creates the next block content and runs a hash-chain VDF before gossiping the block.
+
+Every non-genesis block must include at least one positive burn. Blocks without burns are rejected because they would leave the next height without lottery tickets. The VDF input is the pre-proof hash of the candidate block content, so changing the miner, timestamp, reward, rounds, previous hash, or transactions requires rerunning the VDF.
+
+The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain.
+
+The block reward is fixed at 100 coins. The default burn is 0 coins per block, so new nodes can join before they own coins. After a wallet has coins, raise the burn from the UI or with:
+
+```sh
+cargo run -- --burn-per-block 25
+```
+
+The default VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target. For fast local demos and tests, pass a smaller initial value:
+
+```sh
+cargo run -- --vdf-rounds 10000
+```
+
+## Wallet Storage
+
+Mivora creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.mivora/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file.
+
+There is no default wallet seed in the binary. Keep the wallet file private; it contains the local wallet seed used to derive the address.
+
+## Chain Storage
+
+Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.sqlite3`. The database is updated by a small background persistence task when the tip changes, so web requests, P2P sessions, and VDF work do not perform chain database writes on their main async paths.
+
+## Architecture
+
+- `src/domain.rs`: wallet, transactions, balances, genesis burn bootstrap, fixed 100-coin rewards, blocks, burn lottery, and VDF checks.
+- `src/app.rs`: node use cases, automatic VDF-paced mining, peer bookkeeping, and an in-memory network harness.
+- `src/adapters/http.rs`: HTTP management UI and status endpoint.
+- `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port.
+- `src/adapters/chain_store.rs`: SQLite chain snapshot persistence.
+- `src/adapters/wallet_store.rs`: local wallet file creation and loading.
+- `assets/`: vendored browser assets for the management UI.
+
+## Checks
+
+```sh
+cargo fmt -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test
+```
+
+To install the included pre-commit hook:
+
+```sh
+git config core.hooksPath .githooks
+chmod +x .githooks/pre-commit
+```
+
+The hook runs formatting, clippy, and tests before each commit.
diff --git a/assets/alpine.min.js b/assets/alpine.min.js
@@ -0,0 +1,5 @@
+(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;e<W.length;e++)W[e](),ot=e;W.length=0,ot=-1,it=!1}var T,N,$,at,st=!0;function Gt(e){st=!1,e(),st=!0}function Jt(e){T=e.reactive,$=e.release,N=t=>e.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),J(document,"alpine:init"),J(document,"alpine:initializing"),ue(),er(t=>S(t,D)),te(t=>P(t)),Ae((t,r)=>{pe(t,r).forEach(n=>n())});let e=t=>!Y(t.parentElement,!0);Array.from(document.querySelectorAll(br().join(","))).filter(e).forEach(t=>{S(t)}),J(document,"alpine:initialized"),setTimeout(()=>{Vn()})}var vt=[],xr=[];function yr(){return vt.map(e=>e())}function br(){return vt.concat(xr).map(e=>e())}function Le(e){vt.push(e)}function $e(e){xr.push(e)}function Y(e,t=!1){return j(e,r=>{if((t?br():yr()).some(i=>r.matches(i)))return!0})}function j(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return j(e.parentElement,t)}}function wr(e){return yr().some(t=>e.matches(t))}var Er=[];function vr(e){Er.push(e)}var Hn=1;function S(e,t=D,r=()=>{}){j(e,n=>n._x_ignore)||fr(()=>{t(e,(n,i)=>{n._x_marker||(r(n,i),Er.forEach(o=>o(n,i)),pe(n,n.attributes).forEach(o=>o()),n._x_ignore||(n._x_marker=Hn++),n._x_ignore&&i())})})}function P(e,t=D){t(e,r=>{tr(r),lt(r),delete r._x_marker})}function Vn(){[["ui","dialog",["[x-dialog], [x-popover]"]],["anchor","anchor",["[x-anchor]"]],["sort","sort",["[x-sort]"]]].forEach(([t,r,n])=>{lr(r)||n.some(i=>{if(document.querySelector(i))return E(`found "${i}", but missing ${t} plugin`),!0})})}var St=[],At=!1;function ie(e=()=>{}){return queueMicrotask(()=>{At||setTimeout(()=>{je()})}),new Promise(t=>{St.push(()=>{e(),t()})})}function je(){for(At=!1;St.length;)St.shift()()}function Sr(){At=!0}function me(e,t){return Array.isArray(t)?Ar(e,t.join(" ")):typeof t=="object"&&t!==null?qn(e,t):typeof t=="function"?me(e,t()):Ar(e,t)}function Ar(e,t){let r=o=>o.split(" ").filter(Boolean),n=o=>o.split(" ").filter(s=>!e.classList.contains(s)).filter(Boolean),i=o=>(e.classList.add(...o),()=>{e.classList.remove(...o)});return t=t===!0?t="":t||"",i(n(t))}function qn(e,t){let r=a=>a.split(" ").filter(Boolean),n=Object.entries(t).flatMap(([a,c])=>c?r(a):!1).filter(Boolean),i=Object.entries(t).flatMap(([a,c])=>c?!1:r(a)).filter(Boolean),o=[],s=[];return i.forEach(a=>{e.classList.contains(a)&&(e.classList.remove(a),s.push(a))}),n.forEach(a=>{e.classList.contains(a)||(e.classList.add(a),o.push(a))}),()=>{s.forEach(a=>e.classList.add(a)),o.forEach(a=>e.classList.remove(a))}}function X(e,t){return typeof t=="object"&&t!==null?Un(e,t):Wn(e,t)}function Un(e,t){let r={};return Object.entries(t).forEach(([n,i])=>{r[n]=e.style[n],n.startsWith("--")||(n=Gn(n)),e.style.setProperty(n,i)}),setTimeout(()=>{e.style.length===0&&e.removeAttribute("style")}),()=>{X(e,r)}}function Wn(e,t){let r=e.getAttribute("style",t);return e.setAttribute("style",t),()=>{e.setAttribute("style",r||"")}}function Gn(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function he(e,t=()=>{}){let r=!1;return function(){r?t.apply(this,arguments):(r=!0,e.apply(this,arguments))}}d("transition",(e,{value:t,modifiers:r,expression:n},{evaluate:i})=>{typeof n=="function"&&(n=i(n)),n!==!1&&(!n||typeof n=="boolean"?Yn(e,r,t):Jn(e,n,t))});function Jn(e,t,r){Or(e,me,""),{enter:i=>{e._x_transition.enter.during=i},"enter-start":i=>{e._x_transition.enter.start=i},"enter-end":i=>{e._x_transition.enter.end=i},leave:i=>{e._x_transition.leave.during=i},"leave-start":i=>{e._x_transition.leave.start=i},"leave-end":i=>{e._x_transition.leave.end=i}}[r](t)}function Yn(e,t,r){Or(e,X);let n=!t.includes("in")&&!t.includes("out")&&!r,i=n||t.includes("in")||["enter"].includes(r),o=n||t.includes("out")||["leave"].includes(r);t.includes("in")&&!n&&(t=t.filter((g,b)=>b<t.indexOf("out"))),t.includes("out")&&!n&&(t=t.filter((g,b)=>b>t.indexOf("out")));let s=!t.includes("opacity")&&!t.includes("scale"),a=s||t.includes("opacity"),c=s||t.includes("scale"),l=a?0:1,u=c?_e(t,"scale",95)/100:1,p=_e(t,"delay",0)/1e3,h=_e(t,"origin","center"),w="opacity, transform",F=_e(t,"duration",150)/1e3,Ee=_e(t,"duration",75)/1e3,f="cubic-bezier(0.4, 0.0, 0.2, 1)";i&&(e._x_transition.enter.during={transformOrigin:h,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${F}s`,transitionTimingFunction:f},e._x_transition.enter.start={opacity:l,transform:`scale(${u})`},e._x_transition.enter.end={opacity:1,transform:"scale(1)"}),o&&(e._x_transition.leave.during={transformOrigin:h,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${Ee}s`,transitionTimingFunction:f},e._x_transition.leave.start={opacity:1,transform:"scale(1)"},e._x_transition.leave.end={opacity:l,transform:`scale(${u})`})}function Or(e,t,r={}){e._x_transition||(e._x_transition={enter:{during:r,start:r,end:r},leave:{during:r,start:r,end:r},in(n=()=>{},i=()=>{}){Fe(e,t,{during:this.enter.during,start:this.enter.start,end:this.enter.end},n,i)},out(n=()=>{},i=()=>{}){Fe(e,t,{during:this.leave.during,start:this.leave.start,end:this.leave.end},n,i)}})}window.Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){let i=document.visibilityState==="visible"?requestAnimationFrame:setTimeout,o=()=>i(r);if(t){e._x_transition&&(e._x_transition.enter||e._x_transition.leave)?e._x_transition.enter&&(Object.entries(e._x_transition.enter.during).length||Object.entries(e._x_transition.enter.start).length||Object.entries(e._x_transition.enter.end).length)?e._x_transition.in(r):o():e._x_transition?e._x_transition.in(r):o();return}e._x_hidePromise=e._x_transition?new Promise((s,a)=>{e._x_transition.out(()=>{},()=>s(n)),e._x_transitioning&&e._x_transitioning.beforeCancel(()=>a({isFromCancelledTransition:!0}))}):Promise.resolve(n),queueMicrotask(()=>{let s=Cr(e);s?(s._x_hideChildren||(s._x_hideChildren=[]),s._x_hideChildren.push(e)):i(()=>{let a=c=>{let l=Promise.all([c._x_hidePromise,...(c._x_hideChildren||[]).map(a)]).then(([u])=>u?.());return delete c._x_hidePromise,delete c._x_hideChildren,l};a(e).catch(c=>{if(!c.isFromCancelledTransition)throw c})})})};function Cr(e){let t=e.parentNode;if(t)return t._x_hidePromise?t:Cr(t)}function Fe(e,t,{during:r,start:n,end:i}={},o=()=>{},s=()=>{}){if(e._x_transitioning&&e._x_transitioning.cancel(),Object.keys(r).length===0&&Object.keys(n).length===0&&Object.keys(i).length===0){o(),s();return}let a,c,l;Xn(e,{start(){a=t(e,n)},during(){c=t(e,r)},before:o,end(){a(),l=t(e,i)},after:s,cleanup(){c(),l()}})}function Xn(e,t){let r,n,i,o=he(()=>{m(()=>{r=!0,n||t.before(),i||(t.end(),je()),t.after(),e.isConnected&&t.cleanup(),delete e._x_transitioning})});e._x_transitioning={beforeCancels:[],beforeCancel(s){this.beforeCancels.push(s)},cancel:he(function(){for(;this.beforeCancels.length;)this.beforeCancels.shift()();o()}),finish:o},m(()=>{t.start(),t.during()}),Sr(),requestAnimationFrame(()=>{if(r)return;let s=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,a=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;s===0&&(s=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),m(()=>{t.before()}),n=!0,requestAnimationFrame(()=>{r||(m(()=>{t.end()}),je(),setTimeout(e._x_transitioning.finish,s+a),i=!0)})})}function _e(e,t,r){if(e.indexOf(t)===-1)return r;let n=e[e.indexOf(t)+1];if(!n||t==="scale"&&isNaN(n))return r;if(t==="duration"||t==="delay"){let i=n.match(/([0-9]+)ms/);if(i)return i[1]}return t==="origin"&&["top","right","left","center","bottom"].includes(e[e.indexOf(t)+2])?[n,e[e.indexOf(t)+2]].join(" "):n}var I=!1;function A(e,t=()=>{}){return(...r)=>I?t(...r):e(...r)}function Tr(e){return(...t)=>I&&e(...t)}var Rr=[];function H(e){Rr.push(e)}function Mr(e,t){Rr.forEach(r=>r(e,t)),I=!0,kr(()=>{S(t,(r,n)=>{n(r,()=>{})})}),I=!1}var Be=!1;function Nr(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),I=!0,Be=!0,kr(()=>{Zn(t)}),I=!1,Be=!1}function Zn(e){let t=!1;S(e,(n,i)=>{D(n,(o,s)=>{if(t&&wr(o))return s();t=!0,i(o,s)})})}function kr(e){let t=N;ct((r,n)=>{let i=t(r);return $(i),()=>{}}),e(),ct(t)}function ge(e,t,r,n=[]){switch(e._x_bindings||(e._x_bindings=T({})),e._x_bindings[t]=r,t=n.includes("camel")?si(t):t,t){case"value":Qn(e,r);break;case"style":ti(e,r);break;case"class":ei(e,r);break;case"selected":case"checked":ri(e,t,r);break;default:Pr(e,t,r);break}}function Qn(e,t){if(Ot(e))e.attributes.value===void 0&&(e.value=t),window.fromModel&&(typeof t=="boolean"?e.checked=xe(e.value)===t:e.checked=Dr(e.value,t));else if(ze(e))Number.isInteger(t)?e.value=t:!Array.isArray(t)&&typeof t!="boolean"&&![null,void 0].includes(t)?e.value=String(t):Array.isArray(t)?e.checked=t.some(r=>Dr(r,e.value)):e.checked=!!t;else if(e.tagName==="SELECT")oi(e,t);else{if(e.value===t)return;e.value=t===void 0?"":t}}function ei(e,t){e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedClasses=me(e,t)}function ti(e,t){e._x_undoAddedStyles&&e._x_undoAddedStyles(),e._x_undoAddedStyles=X(e,t)}function ri(e,t,r){Pr(e,t,r),ii(e,t,r)}function Pr(e,t,r){[null,void 0,!1].includes(r)&&ci(t)?e.removeAttribute(t):(Ir(t)&&(r=t),ni(e,t,r))}function ni(e,t,r){e.getAttribute(t)!=r&&e.setAttribute(t,r)}function ii(e,t,r){e[t]!==r&&(e[t]=r)}function oi(e,t){let r=[].concat(t).map(n=>n+"");Array.from(e.options).forEach(n=>{n.selected=r.includes(n.value)})}function si(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function Dr(e,t){return e==t}function xe(e){return[1,"1","true","on","yes",!0].includes(e)?!0:[0,"0","false","off","no",!1].includes(e)?!1:e?Boolean(e):null}var ai=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected","shadowrootclonable","shadowrootdelegatesfocus","shadowrootserializable"]);function Ir(e){return ai.has(e)}function ci(e){return!["aria-pressed","aria-checked","aria-expanded","aria-selected"].includes(e)}function Lr(e,t,r){return e._x_bindings&&e._x_bindings[t]!==void 0?e._x_bindings[t]:jr(e,t,r)}function $r(e,t,r,n=!0){if(e._x_bindings&&e._x_bindings[t]!==void 0)return e._x_bindings[t];if(e._x_inlineBindings&&e._x_inlineBindings[t]!==void 0){let i=e._x_inlineBindings[t];return i.extract=n,ke(()=>R(e,i.expression))}return jr(e,t,r)}function jr(e,t,r){let n=e.getAttribute(t);return n===null?typeof r=="function"?r():r:n===""?!0:Ir(t)?!![t,"true"].includes(n):n}function ze(e){return e.type==="checkbox"||e.localName==="ui-checkbox"||e.localName==="ui-switch"}function Ot(e){return e.type==="radio"||e.localName==="ui-radio"}function Ke(e,t){var r;return function(){var n=this,i=arguments,o=function(){r=null,e.apply(n,i)};clearTimeout(r),r=setTimeout(o,t)}}function He(e,t){let r;return function(){let n=this,i=arguments;r||(e.apply(n,i),r=!0,setTimeout(()=>r=!1,t))}}function Ve({get:e,set:t},{get:r,set:n}){let i=!0,o,s,a=N(()=>{let c=e(),l=r();if(i)n(Ct(c)),i=!1;else{let u=JSON.stringify(c),p=JSON.stringify(l);u!==o?n(Ct(c)):u!==p&&t(Ct(l))}o=JSON.stringify(e()),s=JSON.stringify(r())});return()=>{$(a)}}function Ct(e){return typeof e=="object"?JSON.parse(JSON.stringify(e)):e}function Fr(e){(Array.isArray(e)?e:[e]).forEach(r=>r(K))}var Z={},Br=!1;function zr(e,t){if(Br||(Z=T(Z),Br=!0),t===void 0)return Z[e];Z[e]=t,Te(Z[e]),typeof t=="object"&&t!==null&&t.hasOwnProperty("init")&&typeof t.init=="function"&&Z[e].init()}function Kr(){return Z}var Hr={};function Vr(e,t){let r=typeof t!="function"?()=>t:t;return e instanceof Element?Tt(e,r()):(Hr[e]=r,()=>{})}function qr(e){return Object.entries(Hr).forEach(([t,r])=>{Object.defineProperty(e,t,{get(){return(...n)=>r(...n)}})}),e}function Tt(e,t,r){let n=[];for(;n.length;)n.pop()();let i=Object.entries(t).map(([s,a])=>({name:s,value:a})),o=Et(i);return i=i.map(s=>o.find(a=>a.name===s.name)?{name:`x-bind:${s.name}`,value:`"${s.value}"`}:s),pe(e,i,r).map(s=>{n.push(s.runCleanups),s()}),()=>{for(;n.length;)n.pop()()}}var Ur={};function Wr(e,t){Ur[e]=t}function Gr(e,t){return Object.entries(Ur).forEach(([r,n])=>{Object.defineProperty(e,r,{get(){return(...i)=>n.bind(t)(...i)},enumerable:!1})}),e}var li={get reactive(){return T},get release(){return $},get effect(){return N},get raw(){return at},version:"3.14.9",flushAndStopDeferringMutations:nr,dontAutoEvaluateFunctions:ke,disableEffectScheduling:Gt,startObservingMutations:ue,stopObservingMutations:dt,setReactivityEngine:Jt,onAttributeRemoved:Oe,onAttributesAdded:Ae,closestDataStack:B,skipDuringClone:A,onlyDuringClone:Tr,addRootSelector:Le,addInitSelector:$e,interceptClone:H,addScopeToNode:k,deferMutations:rr,mapAttributes:ne,evaluateLater:x,interceptInit:vr,setEvaluator:ar,mergeProxies:z,extractProp:$r,findClosest:j,onElRemoved:te,closestRoot:Y,destroyTree:P,interceptor:Re,transition:Fe,setStyles:X,mutateDom:m,directive:d,entangle:Ve,throttle:He,debounce:Ke,evaluate:R,initTree:S,nextTick:ie,prefixed:C,prefix:cr,plugin:Fr,magic:y,store:zr,start:gr,clone:Nr,cloneNode:Mr,bound:Lr,$data:Ce,watch:ve,walk:D,data:Wr,bind:Vr},K=li;function Rt(e,t){let r=Object.create(null),n=e.split(",");for(let i=0;i<n.length;i++)r[n[i]]=!0;return t?i=>!!r[i.toLowerCase()]:i=>!!r[i]}var ui="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var Ls=Rt(ui+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");var Jr=Object.freeze({}),$s=Object.freeze([]);var fi=Object.prototype.hasOwnProperty,ye=(e,t)=>fi.call(e,t),V=Array.isArray,oe=e=>Yr(e)==="[object Map]";var di=e=>typeof e=="string",qe=e=>typeof e=="symbol",be=e=>e!==null&&typeof e=="object";var pi=Object.prototype.toString,Yr=e=>pi.call(e),Mt=e=>Yr(e).slice(8,-1);var Ue=e=>di(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var We=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},mi=/-(\w)/g,js=We(e=>e.replace(mi,(t,r)=>r?r.toUpperCase():"")),hi=/\B([A-Z])/g,Fs=We(e=>e.replace(hi,"-$1").toLowerCase()),Nt=We(e=>e.charAt(0).toUpperCase()+e.slice(1)),Bs=We(e=>e?`on${Nt(e)}`:""),kt=(e,t)=>e!==t&&(e===e||t===t);var Dt=new WeakMap,we=[],L,Q=Symbol("iterate"),Pt=Symbol("Map key iterate");function _i(e){return e&&e._isEffect===!0}function rn(e,t=Jr){_i(e)&&(e=e.raw);let r=xi(e,t);return t.lazy||r(),r}function nn(e){e.active&&(on(e),e.options.onStop&&e.options.onStop(),e.active=!1)}var gi=0;function xi(e,t){let r=function(){if(!r.active)return e();if(!we.includes(r)){on(r);try{return bi(),we.push(r),L=r,e()}finally{we.pop(),sn(),L=we[we.length-1]}}};return r.id=gi++,r.allowRecurse=!!t.allowRecurse,r._isEffect=!0,r.active=!0,r.raw=e,r.deps=[],r.options=t,r}function on(e){let{deps:t}=e;if(t.length){for(let r=0;r<t.length;r++)t[r].delete(e);t.length=0}}var se=!0,Lt=[];function yi(){Lt.push(se),se=!1}function bi(){Lt.push(se),se=!0}function sn(){let e=Lt.pop();se=e===void 0?!0:e}function M(e,t,r){if(!se||L===void 0)return;let n=Dt.get(e);n||Dt.set(e,n=new Map);let i=n.get(r);i||n.set(r,i=new Set),i.has(L)||(i.add(L),L.deps.push(i),L.options.onTrack&&L.options.onTrack({effect:L,target:e,type:t,key:r}))}function U(e,t,r,n,i,o){let s=Dt.get(e);if(!s)return;let a=new Set,c=u=>{u&&u.forEach(p=>{(p!==L||p.allowRecurse)&&a.add(p)})};if(t==="clear")s.forEach(c);else if(r==="length"&&V(e))s.forEach((u,p)=>{(p==="length"||p>=n)&&c(u)});else switch(r!==void 0&&c(s.get(r)),t){case"add":V(e)?Ue(r)&&c(s.get("length")):(c(s.get(Q)),oe(e)&&c(s.get(Pt)));break;case"delete":V(e)||(c(s.get(Q)),oe(e)&&c(s.get(Pt)));break;case"set":oe(e)&&c(s.get(Q));break}let l=u=>{u.options.onTrigger&&u.options.onTrigger({effect:u,target:e,key:r,type:t,newValue:n,oldValue:i,oldTarget:o}),u.options.scheduler?u.options.scheduler(u):u()};a.forEach(l)}var wi=Rt("__proto__,__v_isRef,__isVue"),an=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(qe)),Ei=cn();var vi=cn(!0);var Xr=Si();function Si(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...r){let n=_(this);for(let o=0,s=this.length;o<s;o++)M(n,"get",o+"");let i=n[t](...r);return i===-1||i===!1?n[t](...r.map(_)):i}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...r){yi();let n=_(this)[t].apply(this,r);return sn(),n}}),e}function cn(e=!1,t=!1){return function(n,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?Bi:dn:t?Fi:fn).get(n))return n;let s=V(n);if(!e&&s&&ye(Xr,i))return Reflect.get(Xr,i,o);let a=Reflect.get(n,i,o);return(qe(i)?an.has(i):wi(i))||(e||M(n,"get",i),t)?a:It(a)?!s||!Ue(i)?a.value:a:be(a)?e?pn(a):et(a):a}}var Ai=Oi();function Oi(e=!1){return function(r,n,i,o){let s=r[n];if(!e&&(i=_(i),s=_(s),!V(r)&&It(s)&&!It(i)))return s.value=i,!0;let a=V(r)&&Ue(n)?Number(n)<r.length:ye(r,n),c=Reflect.set(r,n,i,o);return r===_(o)&&(a?kt(i,s)&&U(r,"set",n,i,s):U(r,"add",n,i)),c}}function Ci(e,t){let r=ye(e,t),n=e[t],i=Reflect.deleteProperty(e,t);return i&&r&&U(e,"delete",t,void 0,n),i}function Ti(e,t){let r=Reflect.has(e,t);return(!qe(t)||!an.has(t))&&M(e,"has",t),r}function Ri(e){return M(e,"iterate",V(e)?"length":Q),Reflect.ownKeys(e)}var Mi={get:Ei,set:Ai,deleteProperty:Ci,has:Ti,ownKeys:Ri},Ni={get:vi,set(e,t){return console.warn(`Set operation on key "${String(t)}" failed: target is readonly.`,e),!0},deleteProperty(e,t){return console.warn(`Delete operation on key "${String(t)}" failed: target is readonly.`,e),!0}};var $t=e=>be(e)?et(e):e,jt=e=>be(e)?pn(e):e,Ft=e=>e,Qe=e=>Reflect.getPrototypeOf(e);function Ge(e,t,r=!1,n=!1){e=e.__v_raw;let i=_(e),o=_(t);t!==o&&!r&&M(i,"get",t),!r&&M(i,"get",o);let{has:s}=Qe(i),a=n?Ft:r?jt:$t;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Je(e,t=!1){let r=this.__v_raw,n=_(r),i=_(e);return e!==i&&!t&&M(n,"has",e),!t&&M(n,"has",i),e===i?r.has(e):r.has(e)||r.has(i)}function Ye(e,t=!1){return e=e.__v_raw,!t&&M(_(e),"iterate",Q),Reflect.get(e,"size",e)}function Zr(e){e=_(e);let t=_(this);return Qe(t).has.call(t,e)||(t.add(e),U(t,"add",e,e)),this}function Qr(e,t){t=_(t);let r=_(this),{has:n,get:i}=Qe(r),o=n.call(r,e);o?un(r,n,e):(e=_(e),o=n.call(r,e));let s=i.call(r,e);return r.set(e,t),o?kt(t,s)&&U(r,"set",e,t,s):U(r,"add",e,t),this}function en(e){let t=_(this),{has:r,get:n}=Qe(t),i=r.call(t,e);i?un(t,r,e):(e=_(e),i=r.call(t,e));let o=n?n.call(t,e):void 0,s=t.delete(e);return i&&U(t,"delete",e,void 0,o),s}function tn(){let e=_(this),t=e.size!==0,r=oe(e)?new Map(e):new Set(e),n=e.clear();return t&&U(e,"clear",void 0,void 0,r),n}function Xe(e,t){return function(n,i){let o=this,s=o.__v_raw,a=_(s),c=t?Ft:e?jt:$t;return!e&&M(a,"iterate",Q),s.forEach((l,u)=>n.call(i,c(l),c(u),o))}}function Ze(e,t,r){return function(...n){let i=this.__v_raw,o=_(i),s=oe(o),a=e==="entries"||e===Symbol.iterator&&s,c=e==="keys"&&s,l=i[e](...n),u=r?Ft:t?jt:$t;return!t&&M(o,"iterate",c?Pt:Q),{next(){let{value:p,done:h}=l.next();return h?{value:p,done:h}:{value:a?[u(p[0]),u(p[1])]:u(p),done:h}},[Symbol.iterator](){return this}}}}function q(e){return function(...t){{let r=t[0]?`on key "${t[0]}" `:"";console.warn(`${Nt(e)} operation ${r}failed: target is readonly.`,_(this))}return e==="delete"?!1:this}}function ki(){let e={get(o){return Ge(this,o)},get size(){return Ye(this)},has:Je,add:Zr,set:Qr,delete:en,clear:tn,forEach:Xe(!1,!1)},t={get(o){return Ge(this,o,!1,!0)},get size(){return Ye(this)},has:Je,add:Zr,set:Qr,delete:en,clear:tn,forEach:Xe(!1,!0)},r={get(o){return Ge(this,o,!0)},get size(){return Ye(this,!0)},has(o){return Je.call(this,o,!0)},add:q("add"),set:q("set"),delete:q("delete"),clear:q("clear"),forEach:Xe(!0,!1)},n={get(o){return Ge(this,o,!0,!0)},get size(){return Ye(this,!0)},has(o){return Je.call(this,o,!0)},add:q("add"),set:q("set"),delete:q("delete"),clear:q("clear"),forEach:Xe(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(o=>{e[o]=Ze(o,!1,!1),r[o]=Ze(o,!0,!1),t[o]=Ze(o,!1,!0),n[o]=Ze(o,!0,!0)}),[e,r,t,n]}var[Di,Pi,Ii,Li]=ki();function ln(e,t){let r=t?e?Li:Ii:e?Pi:Di;return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(ye(r,i)&&i in n?r:n,i,o)}var $i={get:ln(!1,!1)};var ji={get:ln(!0,!1)};function un(e,t,r){let n=_(r);if(n!==r&&t.call(e,n)){let i=Mt(e);console.warn(`Reactive ${i} contains both the raw and reactive versions of the same object${i==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var fn=new WeakMap,Fi=new WeakMap,dn=new WeakMap,Bi=new WeakMap;function zi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Ki(e){return e.__v_skip||!Object.isExtensible(e)?0:zi(Mt(e))}function et(e){return e&&e.__v_isReadonly?e:mn(e,!1,Mi,$i,fn)}function pn(e){return mn(e,!0,Ni,ji,dn)}function mn(e,t,r,n,i){if(!be(e))return console.warn(`value cannot be made reactive: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive))return e;let o=i.get(e);if(o)return o;let s=Ki(e);if(s===0)return e;let a=new Proxy(e,s===2?n:r);return i.set(e,a),a}function _(e){return e&&_(e.__v_raw)||e}function It(e){return Boolean(e&&e.__v_isRef===!0)}y("nextTick",()=>ie);y("dispatch",e=>J.bind(J,e));y("watch",(e,{evaluateLater:t,cleanup:r})=>(n,i)=>{let o=t(n),a=ve(()=>{let c;return o(l=>c=l),c},i);r(a)});y("store",Kr);y("data",e=>Ce(e));y("root",e=>Y(e));y("refs",e=>(e._x_refs_proxy||(e._x_refs_proxy=z(Hi(e))),e._x_refs_proxy));function Hi(e){let t=[];return j(e,r=>{r._x_refs&&t.push(r._x_refs)}),t}var Bt={};function zt(e){return Bt[e]||(Bt[e]=0),++Bt[e]}function hn(e,t){return j(e,r=>{if(r._x_ids&&r._x_ids[t])return!0})}function _n(e,t){e._x_ids||(e._x_ids={}),e._x_ids[t]||(e._x_ids[t]=zt(t))}y("id",(e,{cleanup:t})=>(r,n=null)=>{let i=`${r}${n?`-${n}`:""}`;return Vi(e,i,t,()=>{let o=hn(e,r),s=o?o._x_ids[r]:zt(r);return n?`${r}-${s}-${n}`:`${r}-${s}`})});H((e,t)=>{e._x_id&&(t._x_id=e._x_id)});function Vi(e,t,r,n){if(e._x_id||(e._x_id={}),e._x_id[t])return e._x_id[t];let i=n();return e._x_id[t]=i,r(()=>{delete e._x_id[t]}),i}y("el",e=>e);gn("Focus","focus","focus");gn("Persist","persist","persist");function gn(e,t,r){y(t,n=>E(`You can't use [$${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}d("modelable",(e,{expression:t},{effect:r,evaluateLater:n,cleanup:i})=>{let o=n(t),s=()=>{let u;return o(p=>u=p),u},a=n(`${t} = __placeholder`),c=u=>a(()=>{},{scope:{__placeholder:u}}),l=s();c(l),queueMicrotask(()=>{if(!e._x_model)return;e._x_removeModelListeners.default();let u=e._x_model.get,p=e._x_model.set,h=Ve({get(){return u()},set(w){p(w)}},{get(){return s()},set(w){c(w)}});i(h)})});d("teleport",(e,{modifiers:t,expression:r},{cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&E("x-teleport can only be used on a <template> tag",e);let i=xn(r),o=e.content.cloneNode(!0).firstElementChild;e._x_teleport=o,o._x_teleportBack=e,e.setAttribute("data-teleport-template",!0),o.setAttribute("data-teleport-target",!0),e._x_forwardEvents&&e._x_forwardEvents.forEach(a=>{o.addEventListener(a,c=>{c.stopPropagation(),e.dispatchEvent(new c.constructor(c.type,c))})}),k(o,{},e);let s=(a,c,l)=>{l.includes("prepend")?c.parentNode.insertBefore(a,c):l.includes("append")?c.parentNode.insertBefore(a,c.nextSibling):c.appendChild(a)};m(()=>{s(o,i,t),A(()=>{S(o)})()}),e._x_teleportPutBack=()=>{let a=xn(r);m(()=>{s(e._x_teleport,a,t)})},n(()=>m(()=>{o.remove(),P(o)}))});var qi=document.createElement("div");function xn(e){let t=A(()=>document.querySelector(e),()=>qi)();return t||E(`Cannot find x-teleport element for selector: "${e}"`),t}var yn=()=>{};yn.inline=(e,{modifiers:t},{cleanup:r})=>{t.includes("self")?e._x_ignoreSelf=!0:e._x_ignore=!0,r(()=>{t.includes("self")?delete e._x_ignoreSelf:delete e._x_ignore})};d("ignore",yn);d("effect",A((e,{expression:t},{effect:r})=>{r(x(e,t))}));function ae(e,t,r,n){let i=e,o=c=>n(c),s={},a=(c,l)=>u=>l(c,u);if(r.includes("dot")&&(t=Ui(t)),r.includes("camel")&&(t=Wi(t)),r.includes("passive")&&(s.passive=!0),r.includes("capture")&&(s.capture=!0),r.includes("window")&&(i=window),r.includes("document")&&(i=document),r.includes("debounce")){let c=r[r.indexOf("debounce")+1]||"invalid-wait",l=tt(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=Ke(o,l)}if(r.includes("throttle")){let c=r[r.indexOf("throttle")+1]||"invalid-wait",l=tt(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=He(o,l)}return r.includes("prevent")&&(o=a(o,(c,l)=>{l.preventDefault(),c(l)})),r.includes("stop")&&(o=a(o,(c,l)=>{l.stopPropagation(),c(l)})),r.includes("once")&&(o=a(o,(c,l)=>{c(l),i.removeEventListener(t,o,s)})),(r.includes("away")||r.includes("outside"))&&(i=document,o=a(o,(c,l)=>{e.contains(l.target)||l.target.isConnected!==!1&&(e.offsetWidth<1&&e.offsetHeight<1||e._x_isShown!==!1&&c(l))})),r.includes("self")&&(o=a(o,(c,l)=>{l.target===e&&c(l)})),(Ji(t)||wn(t))&&(o=a(o,(c,l)=>{Yi(l,r)||c(l)})),i.addEventListener(t,o,s),()=>{i.removeEventListener(t,o,s)}}function Ui(e){return e.replace(/-/g,".")}function Wi(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function tt(e){return!Array.isArray(e)&&!isNaN(e)}function Gi(e){return[" ","_"].includes(e)?e:e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()}function Ji(e){return["keydown","keyup"].includes(e)}function wn(e){return["contextmenu","click","mouse"].some(t=>e.includes(t))}function Yi(e,t){let r=t.filter(o=>!["window","document","prevent","stop","once","capture","self","away","outside","passive"].includes(o));if(r.includes("debounce")){let o=r.indexOf("debounce");r.splice(o,tt((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.includes("throttle")){let o=r.indexOf("throttle");r.splice(o,tt((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.length===0||r.length===1&&bn(e.key).includes(r[0]))return!1;let i=["ctrl","shift","alt","meta","cmd","super"].filter(o=>r.includes(o));return r=r.filter(o=>!i.includes(o)),!(i.length>0&&i.filter(s=>((s==="cmd"||s==="super")&&(s="meta"),e[`${s}Key`])).length===i.length&&(wn(e.type)||bn(e.key).includes(r[0])))}function bn(e){if(!e)return[];e=Gi(e);let t={ctrl:"control",slash:"/",space:" ",spacebar:" ",cmd:"meta",esc:"escape",up:"arrow-up",down:"arrow-down",left:"arrow-left",right:"arrow-right",period:".",comma:",",equal:"=",minus:"-",underscore:"_"};return t[e]=e,Object.keys(t).map(r=>{if(t[r]===e)return r}).filter(r=>r)}d("model",(e,{modifiers:t,expression:r},{effect:n,cleanup:i})=>{let o=e;t.includes("parent")&&(o=e.parentNode);let s=x(o,r),a;typeof r=="string"?a=x(o,`${r} = __placeholder`):typeof r=="function"&&typeof r()=="string"?a=x(o,`${r()} = __placeholder`):a=()=>{};let c=()=>{let h;return s(w=>h=w),En(h)?h.get():h},l=h=>{let w;s(F=>w=F),En(w)?w.set(h):a(()=>{},{scope:{__placeholder:h}})};typeof r=="string"&&e.type==="radio"&&m(()=>{e.hasAttribute("name")||e.setAttribute("name",r)});var u=e.tagName.toLowerCase()==="select"||["checkbox","radio"].includes(e.type)||t.includes("lazy")?"change":"input";let p=I?()=>{}:ae(e,u,t,h=>{l(Kt(e,t,h,c()))});if(t.includes("fill")&&([void 0,null,""].includes(c())||ze(e)&&Array.isArray(c())||e.tagName.toLowerCase()==="select"&&e.multiple)&&l(Kt(e,t,{target:e},c())),e._x_removeModelListeners||(e._x_removeModelListeners={}),e._x_removeModelListeners.default=p,i(()=>e._x_removeModelListeners.default()),e.form){let h=ae(e.form,"reset",[],w=>{ie(()=>e._x_model&&e._x_model.set(Kt(e,t,{target:e},c())))});i(()=>h())}e._x_model={get(){return c()},set(h){l(h)}},e._x_forceModelUpdate=h=>{h===void 0&&typeof r=="string"&&r.match(/\./)&&(h=""),window.fromModel=!0,m(()=>ge(e,"value",h)),delete window.fromModel},n(()=>{let h=c();t.includes("unintrusive")&&document.activeElement.isSameNode(e)||e._x_forceModelUpdate(h)})});function Kt(e,t,r,n){return m(()=>{if(r instanceof CustomEvent&&r.detail!==void 0)return r.detail!==null&&r.detail!==void 0?r.detail:r.target.value;if(ze(e))if(Array.isArray(n)){let i=null;return t.includes("number")?i=Ht(r.target.value):t.includes("boolean")?i=xe(r.target.value):i=r.target.value,r.target.checked?n.includes(i)?n:n.concat([i]):n.filter(o=>!Xi(o,i))}else return r.target.checked;else{if(e.tagName.toLowerCase()==="select"&&e.multiple)return t.includes("number")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return Ht(o)}):t.includes("boolean")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return xe(o)}):Array.from(r.target.selectedOptions).map(i=>i.value||i.text);{let i;return Ot(e)?r.target.checked?i=r.target.value:i=n:i=r.target.value,t.includes("number")?Ht(i):t.includes("boolean")?xe(i):t.includes("trim")?i.trim():i}}})}function Ht(e){let t=e?parseFloat(e):null;return Zi(t)?t:e}function Xi(e,t){return e==t}function Zi(e){return!Array.isArray(e)&&!isNaN(e)}function En(e){return e!==null&&typeof e=="object"&&typeof e.get=="function"&&typeof e.set=="function"}d("cloak",e=>queueMicrotask(()=>m(()=>e.removeAttribute(C("cloak")))));$e(()=>`[${C("init")}]`);d("init",A((e,{expression:t},{evaluate:r})=>typeof t=="string"?!!t.trim()&&r(t,{},!1):r(t,{},!1)));d("text",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{m(()=>{e.textContent=o})})})});d("html",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{m(()=>{e.innerHTML=o,e._x_ignoreSelf=!0,S(e),delete e._x_ignoreSelf})})})});ne(Pe(":",Ie(C("bind:"))));var vn=(e,{value:t,modifiers:r,expression:n,original:i},{effect:o,cleanup:s})=>{if(!t){let c={};qr(c),x(e,n)(u=>{Tt(e,u,i)},{scope:c});return}if(t==="key")return Qi(e,n);if(e._x_inlineBindings&&e._x_inlineBindings[t]&&e._x_inlineBindings[t].extract)return;let a=x(e,n);o(()=>a(c=>{c===void 0&&typeof n=="string"&&n.match(/\./)&&(c=""),m(()=>ge(e,t,c,r))})),s(()=>{e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedStyles&&e._x_undoAddedStyles()})};vn.inline=(e,{value:t,modifiers:r,expression:n})=>{t&&(e._x_inlineBindings||(e._x_inlineBindings={}),e._x_inlineBindings[t]={expression:n,extract:!1})};d("bind",vn);function Qi(e,t){e._x_keyExpression=t}Le(()=>`[${C("data")}]`);d("data",(e,{expression:t},{cleanup:r})=>{if(eo(e))return;t=t===""?"{}":t;let n={};fe(n,e);let i={};Gr(i,n);let o=R(e,t,{scope:i});(o===void 0||o===!0)&&(o={}),fe(o,e);let s=T(o);Te(s);let a=k(e,s);s.init&&R(e,s.init),r(()=>{s.destroy&&R(e,s.destroy),a()})});H((e,t)=>{e._x_dataStack&&(t._x_dataStack=e._x_dataStack,t.setAttribute("data-has-alpine-state",!0))});function eo(e){return I?Be?!0:e.hasAttribute("data-has-alpine-state"):!1}d("show",(e,{modifiers:t,expression:r},{effect:n})=>{let i=x(e,r);e._x_doHide||(e._x_doHide=()=>{m(()=>{e.style.setProperty("display","none",t.includes("important")?"important":void 0)})}),e._x_doShow||(e._x_doShow=()=>{m(()=>{e.style.length===1&&e.style.display==="none"?e.removeAttribute("style"):e.style.removeProperty("display")})});let o=()=>{e._x_doHide(),e._x_isShown=!1},s=()=>{e._x_doShow(),e._x_isShown=!0},a=()=>setTimeout(s),c=he(p=>p?s():o(),p=>{typeof e._x_toggleAndCascadeWithTransitions=="function"?e._x_toggleAndCascadeWithTransitions(e,p,s,o):p?a():o()}),l,u=!0;n(()=>i(p=>{!u&&p===l||(t.includes("immediate")&&(p?a():o()),c(p),l=p,u=!1)}))});d("for",(e,{expression:t},{effect:r,cleanup:n})=>{let i=ro(t),o=x(e,i.items),s=x(e,e._x_keyExpression||"index");e._x_prevKeys=[],e._x_lookup={},r(()=>to(e,i,o,s)),n(()=>{Object.values(e._x_lookup).forEach(a=>m(()=>{P(a),a.remove()})),delete e._x_prevKeys,delete e._x_lookup})});function to(e,t,r,n){let i=s=>typeof s=="object"&&!Array.isArray(s),o=e;r(s=>{no(s)&&s>=0&&(s=Array.from(Array(s).keys(),f=>f+1)),s===void 0&&(s=[]);let a=e._x_lookup,c=e._x_prevKeys,l=[],u=[];if(i(s))s=Object.entries(s).map(([f,g])=>{let b=Sn(t,g,f,s);n(v=>{u.includes(v)&&E("Duplicate key on x-for",e),u.push(v)},{scope:{index:f,...b}}),l.push(b)});else for(let f=0;f<s.length;f++){let g=Sn(t,s[f],f,s);n(b=>{u.includes(b)&&E("Duplicate key on x-for",e),u.push(b)},{scope:{index:f,...g}}),l.push(g)}let p=[],h=[],w=[],F=[];for(let f=0;f<c.length;f++){let g=c[f];u.indexOf(g)===-1&&w.push(g)}c=c.filter(f=>!w.includes(f));let Ee="template";for(let f=0;f<u.length;f++){let g=u[f],b=c.indexOf(g);if(b===-1)c.splice(f,0,g),p.push([Ee,f]);else if(b!==f){let v=c.splice(f,1)[0],O=c.splice(b-1,1)[0];c.splice(f,0,O),c.splice(b,0,v),h.push([v,O])}else F.push(g);Ee=g}for(let f=0;f<w.length;f++){let g=w[f];g in a&&(m(()=>{P(a[g]),a[g].remove()}),delete a[g])}for(let f=0;f<h.length;f++){let[g,b]=h[f],v=a[g],O=a[b],ee=document.createElement("div");m(()=>{O||E('x-for ":key" is undefined or invalid',o,b,a),O.after(ee),v.after(O),O._x_currentIfEl&&O.after(O._x_currentIfEl),ee.before(v),v._x_currentIfEl&&v.after(v._x_currentIfEl),ee.remove()}),O._x_refreshXForScope(l[u.indexOf(b)])}for(let f=0;f<p.length;f++){let[g,b]=p[f],v=g==="template"?o:a[g];v._x_currentIfEl&&(v=v._x_currentIfEl);let O=l[b],ee=u[b],ce=document.importNode(o.content,!0).firstElementChild,qt=T(O);k(ce,qt,o),ce._x_refreshXForScope=On=>{Object.entries(On).forEach(([Cn,Tn])=>{qt[Cn]=Tn})},m(()=>{v.after(ce),A(()=>S(ce))()}),typeof ee=="object"&&E("x-for key cannot be an object, it must be a string or an integer",o),a[ee]=ce}for(let f=0;f<F.length;f++)a[F[f]]._x_refreshXForScope(l[u.indexOf(F[f])]);o._x_prevKeys=u})}function ro(e){let t=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,r=/^\s*\(|\)\s*$/g,n=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,i=e.match(n);if(!i)return;let o={};o.items=i[2].trim();let s=i[1].replace(r,"").trim(),a=s.match(t);return a?(o.item=s.replace(t,"").trim(),o.index=a[1].trim(),a[2]&&(o.collection=a[2].trim())):o.item=s,o}function Sn(e,t,r,n){let i={};return/^\[.*\]$/.test(e.item)&&Array.isArray(t)?e.item.replace("[","").replace("]","").split(",").map(s=>s.trim()).forEach((s,a)=>{i[s]=t[a]}):/^\{.*\}$/.test(e.item)&&!Array.isArray(t)&&typeof t=="object"?e.item.replace("{","").replace("}","").split(",").map(s=>s.trim()).forEach(s=>{i[s]=t[s]}):i[e.item]=t,e.index&&(i[e.index]=r),e.collection&&(i[e.collection]=n),i}function no(e){return!Array.isArray(e)&&!isNaN(e)}function An(){}An.inline=(e,{expression:t},{cleanup:r})=>{let n=Y(e);n._x_refs||(n._x_refs={}),n._x_refs[t]=e,r(()=>delete n._x_refs[t])};d("ref",An);d("if",(e,{expression:t},{effect:r,cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&E("x-if can only be used on a <template> tag",e);let i=x(e,t),o=()=>{if(e._x_currentIfEl)return e._x_currentIfEl;let a=e.content.cloneNode(!0).firstElementChild;return k(a,{},e),m(()=>{e.after(a),A(()=>S(a))()}),e._x_currentIfEl=a,e._x_undoIf=()=>{m(()=>{P(a),a.remove()}),delete e._x_currentIfEl},a},s=()=>{e._x_undoIf&&(e._x_undoIf(),delete e._x_undoIf)};r(()=>i(a=>{a?o():s()})),n(()=>e._x_undoIf&&e._x_undoIf())});d("id",(e,{expression:t},{evaluate:r})=>{r(t).forEach(i=>_n(e,i))});H((e,t)=>{e._x_ids&&(t._x_ids=e._x_ids)});ne(Pe("@",Ie(C("on:"))));d("on",A((e,{value:t,modifiers:r,expression:n},{cleanup:i})=>{let o=n?x(e,n):()=>{};e.tagName.toLowerCase()==="template"&&(e._x_forwardEvents||(e._x_forwardEvents=[]),e._x_forwardEvents.includes(t)||e._x_forwardEvents.push(t));let s=ae(e,t,r,a=>{o(()=>{},{scope:{$event:a},params:[a]})});i(()=>s())}));rt("Collapse","collapse","collapse");rt("Intersect","intersect","intersect");rt("Focus","trap","focus");rt("Mask","mask","mask");function rt(e,t,r){d(t,n=>E(`You can't use [x-${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}K.setEvaluator(xt);K.setReactivityEngine({reactive:et,effect:rn,release:nn,raw:_});var Vt=K;window.Alpine=Vt;queueMicrotask(()=>{Vt.start()});})();
diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js
@@ -0,0 +1,271 @@
+window.mivoraApp = function mivoraApp() {
+ return {
+ tab: "wallet",
+ status: {},
+ blocks: [],
+ selectedBlock: null,
+ loadingOlder: false,
+ hasMoreBlocks: true,
+ mempool: [],
+ peers: [],
+ burnAmount: 0,
+ transferTo: "",
+ transferAmount: 25,
+ peerAddress: "",
+ flash: null,
+ flashTimer: null,
+ lastUpdated: null,
+ pollHandle: null,
+ newBlockHashes: new Set(),
+ newBlockTimer: null,
+
+ init() {
+ this.refresh();
+ this.pollHandle = setInterval(() => this.refresh(), 5000);
+ },
+
+ async refresh() {
+ try {
+ const [status, blocks, mempool, peers] = await Promise.all([
+ this.fetchJson("/api/status"),
+ this.fetchJson("/api/blocks"),
+ this.fetchJson("/api/mempool"),
+ this.fetchJson("/api/peers"),
+ ]);
+ this.status = status;
+ this.mergeFreshBlocks(blocks, { animateHead: true });
+ this.mempool = mempool;
+ this.peers = peers;
+ this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount;
+ this.lastUpdated = new Date();
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ async fetchJson(path) {
+ const response = await fetch(path, { headers: { Accept: "application/json" } });
+ if (!response.ok) {
+ throw new Error(`${path} returned ${response.status}`);
+ }
+ return response.json();
+ },
+
+ mergeFreshBlocks(freshBlocks, options = {}) {
+ const previousHeights = new Set(this.blocks.map((block) => block.height));
+ const previousHead = this.blocks[0]?.height;
+ const previousHeadHash = this.blocks[0]?.hash;
+ const wasFollowingHead =
+ !this.selectedBlock || (previousHeadHash && this.selectedBlock.hash === previousHeadHash);
+ const rail = this.$refs.blockRail;
+ const previousScrollWidth = rail?.scrollWidth ?? 0;
+ const known = new Map(this.blocks.map((block) => [block.hash, block]));
+ for (const block of freshBlocks) {
+ known.set(block.hash, block);
+ }
+ this.blocks = Array.from(known.values()).sort((left, right) => right.height - left.height);
+ const currentHead = this.blocks[0] || null;
+ if (wasFollowingHead) {
+ this.selectedBlock = currentHead;
+ } else if (!this.selectedBlock || !known.has(this.selectedBlock.hash)) {
+ this.selectedBlock = this.blocks[0] || null;
+ } else {
+ this.selectedBlock = known.get(this.selectedBlock.hash);
+ }
+ this.hasMoreBlocks = this.blocks.some((block) => block.height > 0);
+
+ const newHeadBlocks = options.animateHead
+ ? this.blocks.filter(
+ (block) =>
+ !previousHeights.has(block.height) &&
+ (typeof previousHead !== "number" || block.height > previousHead)
+ )
+ : [];
+ if (newHeadBlocks.length > 0) {
+ this.markNewBlocks(newHeadBlocks.map((block) => block.hash));
+ this.$nextTick(() =>
+ this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead })
+ );
+ }
+ },
+
+ markNewBlocks(hashes) {
+ this.newBlockHashes = new Set(hashes);
+ if (this.newBlockTimer) {
+ clearTimeout(this.newBlockTimer);
+ }
+ this.newBlockTimer = setTimeout(() => {
+ this.newBlockHashes = new Set();
+ this.newBlockTimer = null;
+ }, 650);
+ },
+
+ slideNewHeadBlocks(previousScrollWidth, options = {}) {
+ const rail = this.$refs.blockRail;
+ if (!rail || previousScrollWidth === 0 || (!options.force && rail.scrollLeft > 4)) return;
+ const addedWidth = rail.scrollWidth - previousScrollWidth;
+ if (addedWidth <= 0) return;
+ rail.scrollLeft = addedWidth;
+ rail.scrollTo({ left: 0, behavior: "smooth" });
+ },
+
+ selectBlock(block) {
+ this.selectedBlock = block;
+ },
+
+ async loadOlderBlocks() {
+ if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return;
+ const oldest = Math.min(...this.blocks.map((block) => block.height));
+ if (oldest <= 0) {
+ this.hasMoreBlocks = false;
+ return;
+ }
+ this.loadingOlder = true;
+ try {
+ const older = await this.fetchJson(`/api/blocks?before_height=${oldest}&limit=30`);
+ if (older.length === 0 || older.some((block) => block.height === 0)) {
+ this.hasMoreBlocks = false;
+ }
+ this.mergeFreshBlocks(older);
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ } finally {
+ this.loadingOlder = false;
+ }
+ },
+
+ maybeLoadOlderBlocks(event) {
+ const rail = event.currentTarget;
+ const remaining = rail.scrollWidth - rail.scrollLeft - rail.clientWidth;
+ if (remaining < 280) {
+ this.loadOlderBlocks();
+ }
+ },
+
+ async postForm(path, fields, successMessage) {
+ const body = new URLSearchParams();
+ for (const [key, value] of Object.entries(fields)) {
+ body.set(key, value);
+ }
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
+ body,
+ });
+ const payload = await response.json();
+ if (!response.ok || !payload.ok) {
+ throw new Error(payload.error || `${path} returned ${response.status}`);
+ }
+ await this.refresh();
+ this.showFlash(successMessage, "success");
+ },
+
+ async saveBurn() {
+ try {
+ await this.postForm(
+ "/api/settings/burn-per-block",
+ { amount: this.burnAmount || 0 },
+ `Burn rate set to ${this.burnAmount || 0} coin(s) per block`
+ );
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ async sendTransfer() {
+ try {
+ const amount = this.transferAmount || 0;
+ const recipient = this.short(this.transferTo);
+ await this.postForm(
+ "/api/transfer",
+ { to: this.transferTo, amount },
+ `Queued transfer of ${amount} coin(s) to ${recipient}`
+ );
+ this.transferTo = "";
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ async addPeer() {
+ try {
+ const peer = this.peerAddress;
+ await this.postForm("/api/peers", { peer }, `Added peer ${peer}`);
+ this.peerAddress = "";
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ showFlash(message, kind) {
+ this.flash = { message, kind };
+ if (this.flashTimer) {
+ clearTimeout(this.flashTimer);
+ }
+ this.flashTimer = setTimeout(() => {
+ this.flash = null;
+ this.flashTimer = null;
+ }, kind === "error" ? 7000 : 3500);
+ },
+
+ short(value) {
+ if (!value) return "-";
+ if (value.length <= 16) return value;
+ return `${value.slice(0, 8)}...${value.slice(-8)}`;
+ },
+
+ blockBurned(block) {
+ return block.transactions
+ .filter((tx) => tx.kind === "burn")
+ .reduce((sum, tx) => sum + tx.amount, 0);
+ },
+
+ blockBurnCount(block) {
+ return block.transactions.filter((tx) => tx.kind === "burn").length;
+ },
+
+ blockTransferCount(block) {
+ return block.transactions.filter((tx) => tx.kind === "transfer").length;
+ },
+
+ burnCountLabel(block) {
+ const count = this.blockBurnCount(block);
+ return `${count} burn${count === 1 ? "" : "s"}`;
+ },
+
+ transferCountLabel(block) {
+ const count = this.blockTransferCount(block);
+ return `${count} transfer${count === 1 ? "" : "s"}`;
+ },
+
+ isLeaderLabel() {
+ if (!this.status.mining) return "-";
+ return this.status.mining.wallet_is_current_leader ? "yes" : "no";
+ },
+
+ sharedHeightLabel() {
+ const local = this.status.chain?.height;
+ if (typeof local !== "number") return "-";
+ const peerHeights = this.peers
+ .filter((peer) => !peer.last_error)
+ .map((peer) => peer.last_known_height)
+ .filter((height) => typeof height === "number");
+ if (peerHeights.length === 0) return local;
+ return Math.min(local, ...peerHeights);
+ },
+
+ targetSecondsLabel() {
+ const ms = this.status.mining?.vdf_target_block_ms;
+ return ms ? `${Math.round(ms / 1000)}s` : "-";
+ },
+
+ olderButtonLabel() {
+ if (this.loadingOlder) return "Loading";
+ return this.hasMoreBlocks ? "Load older blocks" : "Genesis loaded";
+ },
+
+ lastUpdatedLabel() {
+ return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading";
+ },
+ };
+};
diff --git a/devlogs/001-node-first.md b/devlogs/001-node-first.md
@@ -0,0 +1,17 @@
+# Devlog 001: Node First
+
+Pakala started with a mountain of explanation before there was much to run. Mivora starts in the opposite direction.
+
+The first version is a single binary: wallet, node, miner, HTTP management UI, and P2P listener all in one place. It is not trying to survive hostile internet conditions yet. It is trying to make the coin feel alive as quickly as possible.
+
+The important design choice is the hexagonal split. The coin rules live in the domain layer. The TCP server and HTTP UI sit outside that. Because of that, tests can run a little Mivora network entirely in memory, without ports, sleeps, containers, or a pretend deployment.
+
+The consensus sketch is intentionally small:
+
+- burn coins into the latest block,
+- use those burns as lottery tickets for the next block,
+- delay the draw with a simple verifiable hash-chain VDF,
+- give the selected wallet the right to mine the next block,
+- forget the stake because the coins were already burned.
+
+That gives us something real to poke at now, while leaving plenty of room to make the cryptography and networking less toy-like later.
diff --git a/devlogs/002-vdf-clock.md b/devlogs/002-vdf-clock.md
@@ -0,0 +1,13 @@
+# Devlog 002: The VDF Is The Clock
+
+The first UI had a "mine next block" button. That was useful for proving the ledger worked, but it was the wrong feeling for Mivora.
+
+Now the node runs by itself. Each wallet has a fixed burn amount. If that amount is above zero, once per chain height the node creates a burn transaction for that amount. Those burns become lottery tickets in the block, and the latest block's burns choose who gets to make the next block.
+
+The important correction is that there is no exact timer like "sleep 10 minutes, then make a block." The selected leader makes the block content and then does the VDF work. When the VDF is finished, the block is gossiped. That means the VDF is the clock.
+
+The code also had to move the VDF outside the main node lock. If the VDF is supposed to be the thing that takes real time, the UI should not freeze just because the local node is hashing. So the node prepares the block content, runs the VDF separately, and then comes back to apply and gossip the block if it still fits the local chain.
+
+The management page is also starting to feel less like a toy console and more like a tiny node dashboard. It shows the current leader, the fixed block reward, the burn setting, recent blocks, and what peers the node knows about.
+
+Still friendly-node land. Still deliberately simple. But the rhythm is closer to the actual coin idea now.
diff --git a/devlogs/003-friend-join.md b/devlogs/003-friend-join.md
@@ -0,0 +1,13 @@
+# Devlog 003: Friends Join The Chain
+
+The first thought was a shared genesis file. That is fine for a lab, but it is not the friend-net experience I want.
+
+The better flow is: I start a chain, you point your node at mine, and your node joins what I already started.
+
+So the P2P port now does one extra friendly thing. When a node connects, the peer sends a chain snapshot: genesis allocations, VDF rounds, and the blocks it has. A joining node imports that snapshot before it starts mining. If it cannot get the snapshot, it refuses to start a separate chain.
+
+The default burn is now zero. That matters because a friend who just joined probably has no coins yet. They can still follow the chain, receive coins, and only then decide how much to burn per block.
+
+Genesis changed too. The starter does not begin rich anymore. The starter gets 1 synthetic coin in genesis and burns it immediately, so their visible balance is 0, but the chain has a first lottery ticket. That ticket lets the starter produce the first real reward block.
+
+This is still not real adversarial sync. It trusts the friend you join. But for the current Mivora phase, that is exactly the point: make a small network feel real first, then harden it later.
diff --git a/devlogs/004-wallet-file.md b/devlogs/004-wallet-file.md
@@ -0,0 +1,9 @@
+# Devlog 004: Wallet File
+
+The node no longer has a baked-in dev wallet seed.
+
+On first real startup, `--start` or `--join`, Mivora creates a wallet file and reuses it next time. The default is `.mivora/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory.
+
+That matters for friend testing. You can restart your node and keep the same address, but friends do not need to pass a seed just to be someone else. They join your chain, get their own fresh local wallet, and start with 0 coins until you send them some.
+
+This is still prototype-wallet simple: the file contains the seed, so it should be treated like a private key.
diff --git a/devlogs/005-burned-blocks-and-vdf.md b/devlogs/005-burned-blocks-and-vdf.md
@@ -0,0 +1,9 @@
+# Devlog 005: Burned Blocks And VDF
+
+I tightened the rule that felt wrong during local testing: a normal block cannot be empty of burns anymore.
+
+That means a block has to carry at least one positive burn transaction. Otherwise it would create a tip with no lottery tickets for the next leader, which is basically a protocol pothole.
+
+The VDF also now runs over the candidate block content hash instead of just the previous hash. So if the leader changes the timestamp, miner, reward, rounds, previous hash, or transactions after doing the VDF, peers reject it.
+
+One practical consequence: the default genesis still leaves the starter wallet at 0, so it creates the chain but waits. For a moving local demo, start with one extra genesis coin and burn it into block 1.
diff --git a/devlogs/006-dynamic-vdf-target.md b/devlogs/006-dynamic-vdf-target.md
@@ -0,0 +1,9 @@
+# Devlog 006: Let The Chain Aim For One Minute
+
+The sync problem was tempting to solve in the wrong place. We could make peers trust each other more, but that weakens the protocol exactly where it should be strongest.
+
+So this change keeps VDF validation as consensus, but makes the VDF round count dynamic. Blocks still carry the exact round count they used. Nodes validate that it is the round count the chain expected for that height.
+
+After each block, the chain looks at a rolling average of recent block times and nudges the next round count toward a 60 second target. The nudge is small, about 10% per block, so one weird timestamp cannot throw the chain completely off.
+
+This gives gossip and catch-up more breathing room while keeping the rule deterministic: every node can derive the same next VDF rounds from the chain it has validated.
diff --git a/devlogs/007-gossip-grows-up.md b/devlogs/007-gossip-grows-up.md
@@ -0,0 +1,9 @@
+# Devlog 007: Gossip Grows Up A Bit
+
+The first gossip protocol was basically "push whatever just happened, and if someone is behind, throw a full snapshot at them." That worked for tiny chains, but it was too blunt.
+
+Now peers announce both height and tip hash when a connection opens. If a node sees that a peer is behind, it sends a batch of missing blocks instead of a whole chain snapshot. The receiver still validates the blocks, including the VDF output, before importing them.
+
+Snapshots are still useful for initial join and fallback, but normal catch-up now has a more blockchain-shaped path: ask for the missing range, validate it, apply it.
+
+The UI also shows the last height and tip hash reported by each peer, which makes it much easier to see whether gossip is actually moving or just quietly stuck.
diff --git a/devlogs/008-persistent-peer-sessions.md b/devlogs/008-persistent-peer-sessions.md
@@ -0,0 +1,9 @@
+# Devlog 008: Persistent Peer Sessions
+
+The old P2P layer opened a fresh TCP connection for almost every little thing: send a burn, send a block, ask for status, ask for missing blocks. It was easy to write, but it made the logs noisy and the network feel twitchy. Lots of "connection reset by peer" messages were basically the sound of short-lived sockets closing at awkward moments.
+
+The new layer keeps one outbound session per known peer. Each peer gets a bounded queue, a reconnect loop with backoff, and a simple line-based message stream. Status messages keep flowing over the same connection, and if a peer reports that it is ahead, the node asks for the missing block range on that same session.
+
+This is still intentionally small. It is not trying to be libp2p. But it is much closer to how the coin should behave: peers stay connected, gossip is queued instead of redialed, quiet disconnects are treated as normal, and catch-up is driven by the protocol instead of a separate polling fetch path.
+
+The important part for testing is that the node core did not become network-shaped. The session layer is still an adapter around the same `GossipEnvelope` messages, so the fast deterministic tests can keep exercising the protocol without real sockets.
diff --git a/devlogs/009-longer-fork-reorgs.md b/devlogs/009-longer-fork-reorgs.md
@@ -0,0 +1,9 @@
+# Devlog 009: Longer Fork Reorgs
+
+Until now, Mivora mostly behaved like there was only one possible chain. If a snapshot disagreed with a block we already had, the node rejected it. That is nice and simple, but it is not how a real network behaves. Two friendly nodes can still mine competing blocks if messages arrive in a weird order.
+
+The new rule is intentionally small: a remote chain can replace the local chain only if it has the same genesis, fully validates, shares a common ancestor, and is strictly longer. Same-height forks do not cause flip-flopping. The node waits until one side grows longer.
+
+When a reorg happens, local pending transactions are not thrown away. Transactions from abandoned local blocks are also put back through the mempool rules, so useful burns/transfers get another chance on the new tip if they are still valid.
+
+This is not final chain-selection science yet. There is no cumulative-work score beyond height. But it is a real fork recovery path, and it gives the gossip layer something sane to do when peers briefly disagree.
diff --git a/devlogs/010-hello-and-inventory.md b/devlogs/010-hello-and-inventory.md
@@ -0,0 +1,16 @@
+# Devlog 010: Hello, Inventory
+
+The P2P protocol now starts with a real `Hello`. A node tells the peer its protocol version, network id, genesis hash, listen address, height, and tip hash. If the protocol, network, or genesis does not match, the session is rejected early.
+
+That matters because "it connected" is not enough for a coin. A node on a different genesis should not be able to quietly trade blocks with us and create weird local errors later.
+
+Gossip also changed. Instead of pushing full transactions and blocks every time, nodes announce inventory: transaction signatures and block hashes. Peers then request only the objects they do not have yet.
+
+So the flow is now more like:
+
+1. I have tx/block ids.
+2. You tell me which ones you need.
+3. I send the full objects.
+4. You validate before importing.
+
+It is still simple, but it is now much closer to a real P2P shape. Less duplicate payload spam, better validation boundary, and a cleaner place to add peer scoring/rate limits later.
diff --git a/devlogs/011-fast-vdf-verification.md b/devlogs/011-fast-vdf-verification.md
@@ -0,0 +1,9 @@
+# Fast VDF verification
+
+The nodes were still drifting because followers had to re-run the whole VDF for every block they imported.
+
+That was the wrong shape. The miner should spend the delay time, but peers should be able to verify the result quickly. Otherwise a node that is one block behind has to do the same work as the miner just to catch up, and if it misses a few blocks it is basically doomed to trail behind.
+
+This pass changes the block VDF output into a small `output:proof` receipt. Mining still does sequential work, but import checks the proof quickly. Combined with inventory/request and active catchup, peers should now catch up in seconds instead of one VDF at a time.
+
+This is still devnet-level crypto, not a final mainnet VDF construction, but the architecture is now pointed in the right direction: slow produce, fast verify.
diff --git a/devlogs/012-consensus-vocabulary.md b/devlogs/012-consensus-vocabulary.md
@@ -0,0 +1,13 @@
+# Consensus vocabulary
+
+Fork choice was working, but the code still read like loose booleans and hash comparisons.
+
+This pass gives the domain language names: `ForkPoint`, `LeaderScore`, `ForkQuality`, and `ForkChoice`. The behavior stays the same, but the code now says what it means:
+
+- find the common ancestor
+- reject finalized history rewrites
+- compare leader quality inside the reorg window
+- decide whether to keep local or switch
+- carry abandoned local transactions back into the mempool
+
+For now `LeaderScore` is still derived from the block hash. That is a devnet stand-in for an explicit VRF proof/score, but at least the concept now has a home in the domain model.
diff --git a/devlogs/013-chain-persistence.md b/devlogs/013-chain-persistence.md
@@ -0,0 +1,9 @@
+# 013 - Chain Persistence
+
+Until now the chain lived in memory. That made tests nice, but restarts were too fragile: a node could keep its wallet and still forget what chain it was on.
+
+The new piece is a SQLite adapter that stores the latest validated chain snapshot in the node's data directory. On startup, if that database exists, the node loads it before looking at `--start` or `--join`. So a restart keeps following the same chain instead of creating a fresh genesis or needing the bootstrap peer to be online at exactly that moment.
+
+This is still intentionally small. It saves one current snapshot, not a fully indexed block database. But it sits in the adapter layer, away from the ledger rules, and it is tested separately. That gives us the boring restart behavior now while leaving room to grow it into a richer block store later.
+
+Persistence runs in the background and only saves when the tip changes. The web UI, gossip loop, and miner should not care that SQLite exists.
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -0,0 +1,199 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::{Context, Result};
+use rusqlite::{Connection, OptionalExtension, params};
+
+use crate::domain::ChainSnapshot;
+
+const SCHEMA: &str = r#"
+CREATE TABLE IF NOT EXISTS chain_snapshots (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ height INTEGER NOT NULL,
+ tip_hash TEXT NOT NULL,
+ snapshot_json TEXT NOT NULL,
+ updated_at_ms INTEGER NOT NULL
+);
+"#;
+
+#[derive(Clone, Debug)]
+pub struct SqliteChainStore {
+ path: PathBuf,
+}
+
+impl SqliteChainStore {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let path = path.as_ref().to_path_buf();
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).with_context(|| {
+ format!(
+ "failed to create chain database directory {}",
+ parent.display()
+ )
+ })?;
+ }
+
+ let store = Self { path };
+ store.with_connection(|connection| {
+ connection
+ .execute_batch(SCHEMA)
+ .context("failed to initialize chain database schema")
+ })?;
+ Ok(store)
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn load(&self) -> Result<Option<ChainSnapshot>> {
+ self.with_connection(|connection| {
+ let snapshot_json = connection
+ .query_row(
+ "SELECT snapshot_json FROM chain_snapshots WHERE id = 1",
+ [],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()
+ .context("failed to load chain snapshot from database")?;
+
+ snapshot_json
+ .map(|json| {
+ serde_json::from_str(&json)
+ .context("failed to parse chain snapshot from database")
+ })
+ .transpose()
+ })
+ }
+
+ pub fn save(&self, snapshot: &ChainSnapshot) -> Result<()> {
+ let (height, tip_hash) = snapshot_tip(snapshot).context("cannot persist empty chain")?;
+ let snapshot_json =
+ serde_json::to_string(snapshot).context("failed to serialize chain snapshot")?;
+ let updated_at_ms = unix_ms();
+
+ self.with_connection(|connection| {
+ connection
+ .execute(
+ r#"
+INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms)
+VALUES (1, ?1, ?2, ?3, ?4)
+ON CONFLICT(id) DO UPDATE SET
+ height = excluded.height,
+ tip_hash = excluded.tip_hash,
+ snapshot_json = excluded.snapshot_json,
+ updated_at_ms = excluded.updated_at_ms
+"#,
+ params![height, tip_hash, snapshot_json, updated_at_ms],
+ )
+ .context("failed to persist chain snapshot")?;
+ Ok(())
+ })
+ }
+
+ fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
+ let connection = Connection::open(&self.path)
+ .with_context(|| format!("failed to open chain database {}", self.path.display()))?;
+ connection
+ .execute_batch(
+ r#"
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+"#,
+ )
+ .context("failed to configure chain database")?;
+ work(&connection)
+ }
+}
+
+fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> {
+ snapshot
+ .blocks
+ .last()
+ .map(|block| (block.height, block.hash.clone()))
+}
+
+fn unix_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as u64
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeMap;
+
+ use tempfile::tempdir;
+
+ use crate::domain::{GenesisBurn, Ledger, Wallet};
+
+ use super::SqliteChainStore;
+
+ #[test]
+ fn sqlite_chain_store_roundtrips_snapshot() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("nested/chain.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1);
+ let ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+
+ store.save(&ledger.snapshot()).unwrap();
+
+ assert_eq!(store.load().unwrap(), Some(ledger.snapshot()));
+ }
+
+ #[test]
+ fn sqlite_chain_store_overwrites_latest_snapshot() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 2);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap();
+ store.save(&ledger.snapshot()).unwrap();
+
+ let burn = wallet.burn(1, ledger.next_nonce(wallet.address()));
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(wallet.address(), 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ store.save(&ledger.snapshot()).unwrap();
+
+ let restored = store.load().unwrap().unwrap();
+ assert_eq!(restored.blocks.last().unwrap().height, 1);
+ assert_eq!(restored, ledger.snapshot());
+ }
+
+ #[test]
+ fn sqlite_chain_store_reports_invalid_snapshot_json() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ store
+ .with_connection(|connection| {
+ connection.execute(
+ r#"
+INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms)
+VALUES (1, 9, 'bad-tip', '{"not":"a chain"}', 0)
+"#,
+ [],
+ )?;
+ Ok(())
+ })
+ .unwrap();
+
+ let error = store.load().unwrap_err();
+
+ assert!(
+ format!("{error:#}").contains("failed to parse chain snapshot from database"),
+ "{error:#}"
+ );
+ }
+}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -0,0 +1,463 @@
+use std::net::SocketAddr;
+
+use anyhow::{Context, Result};
+use axum::{
+ Form, Json, Router,
+ extract::{Query, State},
+ http::header,
+ response::{Html, IntoResponse, Redirect, Response},
+ routing::{get, post},
+};
+use serde::{Deserialize, Serialize};
+use tokio::net::TcpListener;
+
+use crate::{
+ adapters::p2p::GossipNetwork,
+ app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
+ domain::{Amount, Block, Transaction},
+};
+
+const EXPLORER_LIMIT: usize = 50;
+const EXPLORER_PAGE_LIMIT: usize = 30;
+
+#[derive(Clone)]
+struct HttpState {
+ node: SharedNode,
+ peers: SharedPeerBook,
+ gossip: GossipNetwork,
+}
+
+#[derive(Debug, Deserialize)]
+struct AmountForm {
+ amount: Amount,
+}
+
+#[derive(Debug, Deserialize)]
+struct TransferForm {
+ to: String,
+ amount: Amount,
+}
+
+#[derive(Debug, Deserialize)]
+struct PeerForm {
+ peer: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct BlocksQuery {
+ before_height: Option<u64>,
+ limit: Option<usize>,
+}
+
+#[derive(Debug, Serialize)]
+struct ActionResponse {
+ ok: bool,
+ error: Option<String>,
+}
+
+pub async fn serve(
+ node: SharedNode,
+ peers: SharedPeerBook,
+ gossip: GossipNetwork,
+ addr: SocketAddr,
+) -> Result<()> {
+ let state = HttpState {
+ node,
+ peers,
+ gossip,
+ };
+ let app = Router::new()
+ .route("/", get(index))
+ .route("/assets/alpine.min.js", get(alpine_js))
+ .route("/assets/mivora-ui.js", get(app_js))
+ .route("/api/status", get(api_status))
+ .route("/api/blocks", get(api_blocks))
+ .route("/api/mempool", get(api_mempool))
+ .route("/api/peers", get(api_peers).post(api_peer_form))
+ .route(
+ "/api/settings/burn-per-block",
+ post(api_burn_per_block_form),
+ )
+ .route("/api/transfer", post(api_transfer_form))
+ .route("/settings/burn-per-block", post(burn_per_block_form))
+ .route("/transfer", post(transfer_form))
+ .route("/peers", post(peer_form))
+ .with_state(state);
+
+ let listener = TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("binding HTTP management UI on {addr}"))?;
+ axum::serve(listener, app.into_make_service())
+ .await
+ .context("serving HTTP management UI")
+}
+
+async fn index() -> Html<&'static str> {
+ Html(INDEX_HTML)
+}
+
+async fn alpine_js() -> impl IntoResponse {
+ (
+ [(
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )],
+ include_str!("../../assets/alpine.min.js"),
+ )
+}
+
+async fn app_js() -> impl IntoResponse {
+ (
+ [(
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )],
+ include_str!("../../assets/mivora-ui.js"),
+ )
+}
+
+async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> {
+ Json(state.node.lock().await.status())
+}
+
+async fn api_blocks(
+ State(state): State<HttpState>,
+ Query(query): Query<BlocksQuery>,
+) -> Json<Vec<Block>> {
+ let limit = query
+ .limit
+ .unwrap_or(EXPLORER_PAGE_LIMIT)
+ .min(EXPLORER_LIMIT);
+ let node = state.node.lock().await;
+ let blocks = match query.before_height {
+ Some(before_height) => node.blocks_before(before_height, limit),
+ None => node.recent_blocks(limit),
+ };
+ Json(blocks)
+}
+
+async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<Transaction>> {
+ Json(state.node.lock().await.pending_transactions())
+}
+
+async fn api_peers(State(state): State<HttpState>) -> Json<Vec<PeerInfo>> {
+ Json(state.peers.lock().await.list())
+}
+
+async fn api_burn_per_block_form(
+ State(state): State<HttpState>,
+ Form(form): Form<AmountForm>,
+) -> Json<ActionResponse> {
+ let result = set_burn_per_block(&state, form.amount).await;
+ action_json(result)
+}
+
+async fn burn_per_block_form(
+ State(state): State<HttpState>,
+ Form(form): Form<AmountForm>,
+) -> Response {
+ match set_burn_per_block(&state, form.amount).await {
+ Ok(_) => Redirect::to("/").into_response(),
+ Err(error) => api_error(error).into_response(),
+ }
+}
+
+async fn api_transfer_form(
+ State(state): State<HttpState>,
+ Form(form): Form<TransferForm>,
+) -> Json<ActionResponse> {
+ let result = transfer(&state, form).await;
+ action_json(result)
+}
+
+async fn transfer_form(State(state): State<HttpState>, Form(form): Form<TransferForm>) -> Response {
+ match transfer(&state, form).await {
+ Ok(_) => Redirect::to("/").into_response(),
+ Err(error) => api_error(error).into_response(),
+ }
+}
+
+async fn api_peer_form(
+ State(state): State<HttpState>,
+ Form(form): Form<PeerForm>,
+) -> Json<ActionResponse> {
+ state.peers.lock().await.add_peer(form.peer);
+ action_json(Ok(()))
+}
+
+async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Redirect {
+ state.peers.lock().await.add_peer(form.peer);
+ Redirect::to("/")
+}
+
+async fn set_burn_per_block(state: &HttpState, amount: Amount) -> Result<()> {
+ let result = {
+ let mut node = state.node.lock().await;
+ let result = node.set_burn_per_block(amount);
+ let outbox = node.drain_outbox();
+ (result, outbox)
+ };
+
+ match result.0 {
+ Ok(_) => state.gossip.broadcast(result.1).await,
+ Err(error) => Err(error),
+ }
+}
+
+async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> {
+ let result = {
+ let mut node = state.node.lock().await;
+ let result = node.transfer(form.to, form.amount);
+ let outbox = node.drain_outbox();
+ (result, outbox)
+ };
+
+ match result.0 {
+ Ok(_) => state.gossip.broadcast(result.1).await,
+ Err(error) => Err(error),
+ }
+}
+
+fn action_json(result: Result<()>) -> Json<ActionResponse> {
+ match result {
+ Ok(_) => Json(ActionResponse {
+ ok: true,
+ error: None,
+ }),
+ Err(error) => Json(ActionResponse {
+ ok: false,
+ error: Some(format!("{error:#}")),
+ }),
+ }
+}
+
+fn api_error(error: anyhow::Error) -> Json<ActionResponse> {
+ Json(ActionResponse {
+ ok: false,
+ error: Some(format!("{error:#}")),
+ })
+}
+
+const INDEX_HTML: &str = r#"<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Mivora</title>
+ <style>
+ [x-cloak] { display: none !important; }
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+ body { margin: 0; background: #f7f8fa; color: #17202a; }
+ main { max-width: 1180px; margin: 0 auto; padding: 18px 18px 48px; }
+ header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 16px; border-bottom: 1px solid #d9e0e7; }
+ h1 { margin: 0 0 4px; font-size: 26px; }
+ h2 { margin: 0 0 12px; font-size: 18px; }
+ h3 { margin: 0 0 10px; font-size: 15px; }
+ button { border: 1px solid #c9d2dc; border-radius: 6px; padding: 8px 11px; font: inherit; font-weight: 700; background: white; color: #17202a; cursor: pointer; }
+ button:hover { border-color: #157a6e; color: #0f665d; }
+ button.primary { background: #116149; border-color: #116149; color: white; }
+ button.primary:hover { background: #0b4f3b; color: white; }
+ button:disabled { cursor: default; opacity: .55; }
+ .tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0; }
+ .tabs button.active { background: #17202a; border-color: #17202a; color: white; }
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
+ .metric, .panel { background: white; border: 1px solid #dde3ea; border-radius: 8px; padding: 12px; }
+ .metric .label { color: #667789; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
+ .metric .value { margin-top: 6px; font-weight: 800; overflow-wrap: anywhere; }
+ .panel { margin-bottom: 12px; }
+ .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .7fr); gap: 12px; }
+ form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
+ label { display: grid; gap: 5px; color: #465564; font-size: 13px; }
+ input { min-width: 180px; border: 1px solid #b8c4cf; border-radius: 6px; padding: 9px 10px; font: inherit; background: white; }
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
+ th, td { text-align: left; border-bottom: 1px solid #e2e7ed; padding: 8px; vertical-align: top; }
+ th { color: #667789; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
+ code { overflow-wrap: anywhere; }
+ .table-wrap { overflow-x: auto; }
+ .muted { color: #667789; }
+ .flash { border-radius: 6px; padding: 10px 12px; margin: 12px 0; border: 1px solid; font-weight: 700; }
+ .flash.success { color: #0b5e43; background: #effbf4; border-color: #a7dfbd; }
+ .flash.error { color: #9b1c1c; background: #fff1f1; border-color: #f0b7b7; }
+ .ok { color: #0b5e43; }
+ .summary-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
+ .explorer-shell { display: grid; gap: 12px; }
+ .block-rail-wrap { background: white; border: 1px solid #dde3ea; border-radius: 8px; padding: 12px; overflow: hidden; }
+ .block-rail-head { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 10px; }
+ .block-rail { display: flex; gap: 8px; overflow-x: auto; padding: 1px 0 10px; scroll-snap-type: x proximity; }
+ .block-card { flex: 0 0 118px; min-height: 96px; display: grid; gap: 6px; border: 1px solid #dde3ea; border-radius: 8px; padding: 9px; background: #fbfcfd; color: #17202a; text-align: left; scroll-snap-align: start; }
+ .block-card:hover { border-color: #8bbdb5; color: #0f665d; }
+ .block-card.selected { background: #eef8f5; border-color: #157a6e; box-shadow: inset 0 0 0 1px #157a6e; }
+ .block-card.new-block { animation: block-arrive .45s ease both; }
+ @keyframes block-arrive { from { opacity: .2; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
+ .block-height { font-size: 18px; font-weight: 900; }
+ .block-meta { display: flex; gap: 8px; color: #667789; font-size: 12px; }
+ .block-hash { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #465564; }
+ .rail-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
+ .detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; }
+ .detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; }
+ .detail-kv .key { color: #667789; }
+ .tx-list { display: grid; gap: 8px; }
+ .tx-card { border: 1px solid #e2e7ed; border-radius: 8px; padding: 10px; background: #fbfcfd; }
+ .tx-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-weight: 800; }
+ .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #e8eef4; color: #34495e; }
+ .pill.burn { background: #fff0d9; color: #845400; }
+ .pill.transfer { background: #e5f5ee; color: #0b5e43; }
+ .mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
+ .mempool-item { flex: 0 0 200px; border: 1px solid #e2e7ed; border-radius: 8px; padding: 10px; background: white; }
+ @media (max-width: 920px) { .summary-row, .detail-grid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 760px) { .split, .summary-row, .detail-grid { grid-template-columns: 1fr; } input { min-width: 0; width: 100%; } .block-card { flex-basis: 108px; } }
+ </style>
+ <script defer src="/assets/mivora-ui.js?v=9"></script>
+ <script defer src="/assets/alpine.min.js"></script>
+</head>
+<body x-data="mivoraApp()" x-init="init()" x-cloak>
+ <main>
+ <header>
+ <div>
+ <h1>Mivora</h1>
+ <div class="muted">Burn lottery devnet</div>
+ </div>
+ <div class="muted" x-text="lastUpdatedLabel()"></div>
+ </header>
+
+ <div class="flash" :class="flash?.kind" x-show="flash" x-transition x-text="flash?.message"></div>
+
+ <section class="summary-row">
+ <div class="metric"><div class="label">Node</div><div class="value" x-text="status.name || '-'"></div></div>
+ <div class="metric"><div class="label">Local Height</div><div class="value" x-text="status.chain?.height ?? '-'"></div></div>
+ <div class="metric"><div class="label">Shared Height</div><div class="value" x-text="sharedHeightLabel()"></div></div>
+ <div class="metric"><div class="label">Wallet Balance</div><div class="value" x-text="status.wallet_balance ?? '-'"></div></div>
+ <div class="metric"><div class="label">Mempool</div><div class="value" x-text="mempool.length"></div></div>
+ </section>
+
+ <nav class="tabs">
+ <button :class="{ active: tab === 'wallet' }" @click="tab = 'wallet'">Wallet</button>
+ <button :class="{ active: tab === 'p2p' }" @click="tab = 'p2p'">P2P</button>
+ <button :class="{ active: tab === 'chain' }" @click="tab = 'chain'">Explorer</button>
+ </nav>
+
+ <section x-show="tab === 'wallet'">
+ <div class="split">
+ <div class="panel">
+ <h2>Wallet</h2>
+ <p><code x-text="status.wallet_address || '-'"></code></p>
+ <div class="grid">
+ <div class="metric"><div class="label">Balance</div><div class="value" x-text="status.wallet_balance ?? '-'"></div></div>
+ <div class="metric"><div class="label">Current Leader</div><div class="value" x-text="isLeaderLabel()"></div></div>
+ <div class="metric"><div class="label">Last Burn Height</div><div class="value" x-text="status.mining?.last_auto_burn_height ?? '-'"></div></div>
+ </div>
+ </div>
+ <div class="panel">
+ <h3>Burn Rate</h3>
+ <form @submit.prevent="saveBurn">
+ <label>Coins per block<input x-model.number="burnAmount" type="number" min="0"></label>
+ <button class="primary" type="submit">Save</button>
+ </form>
+ </div>
+ </div>
+ <div class="panel">
+ <h3>Send Coins</h3>
+ <form @submit.prevent="sendTransfer">
+ <label>Recipient<input x-model="transferTo" autocomplete="off"></label>
+ <label>Amount<input x-model.number="transferAmount" type="number" min="1"></label>
+ <button class="primary" type="submit">Send</button>
+ </form>
+ </div>
+ </section>
+
+ <section x-show="tab === 'p2p'">
+ <div class="panel">
+ <h2>P2P</h2>
+ <form @submit.prevent="addPeer">
+ <label>Peer address<input x-model="peerAddress" placeholder="127.0.0.1:9445"></label>
+ <button class="primary" type="submit">Add</button>
+ </form>
+ </div>
+ <div class="panel table-wrap">
+ <table>
+ <thead><tr><th>Address</th><th>Direction</th><th>Height</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th></tr></thead>
+ <tbody>
+ <template x-for="peer in peers" :key="peer.address">
+ <tr><td><code x-text="peer.address"></code></td><td x-text="peer.direction"></td><td x-text="peer.last_known_height ?? '-'"></td><td><code x-text="short(peer.last_known_tip_hash)"></code></td><td x-text="peer.messages_sent"></td><td x-text="peer.messages_received"></td><td x-text="peer.last_error || ''"></td></tr>
+ </template>
+ <tr x-show="peers.length === 0"><td colspan="7">No peers</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+
+ <section x-show="tab === 'chain'">
+ <div class="explorer-shell">
+ <div class="block-rail-wrap">
+ <div class="block-rail-head">
+ <h2>Blocks</h2>
+ <div class="muted"><span x-text="blocks.length"></span> loaded</div>
+ </div>
+ <div class="block-rail" x-ref="blockRail" @scroll.debounce.200ms="maybeLoadOlderBlocks($event)">
+ <template x-for="block in blocks" :key="block.hash">
+ <button class="block-card" :class="{ selected: selectedBlock?.hash === block.hash, 'new-block': newBlockHashes.has(block.hash) }" @click="selectBlock(block)" type="button">
+ <div class="block-height" x-text="block.height"></div>
+ <div class="block-meta">
+ <span x-text="burnCountLabel(block)"></span>
+ <span x-text="transferCountLabel(block)"></span>
+ </div>
+ <div class="block-hash" x-text="short(block.hash)"></div>
+ </button>
+ </template>
+ </div>
+ <div class="rail-actions">
+ <button @click="loadOlderBlocks" :disabled="loadingOlder || !hasMoreBlocks" x-text="olderButtonLabel()"></button>
+ </div>
+ </div>
+
+ <section class="panel">
+ <h2>Block Detail</h2>
+ <template x-if="selectedBlock">
+ <div class="detail-grid">
+ <div>
+ <div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div>
+ <div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div>
+ <div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div>
+ <div class="detail-kv"><div class="key">Miner</div><code x-text="short(selectedBlock.miner)"></code></div>
+ <div class="detail-kv"><div class="key">Reward</div><div x-text="selectedBlock.reward"></div></div>
+ <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div>
+ <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div>
+ <div class="detail-kv"><div class="key">Total Burned</div><div x-text="blockBurned(selectedBlock)"></div></div>
+ <div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div>
+ </div>
+ <div class="tx-list">
+ <h3>Transactions</h3>
+ <template x-for="tx in selectedBlock.transactions" :key="tx.signature">
+ <div class="tx-card">
+ <div class="tx-head"><span class="pill" :class="tx.kind" x-text="tx.kind"></span><strong x-text="tx.amount"></strong></div>
+ <div><span class="muted">from </span><code x-text="short(tx.from)"></code></div>
+ <div x-show="tx.to"><span class="muted">to </span><code x-text="short(tx.to)"></code></div>
+ <div class="muted">nonce <span x-text="tx.nonce"></span></div>
+ <div><code x-text="short(tx.signature)"></code></div>
+ </div>
+ </template>
+ <div class="muted" x-show="selectedBlock.transactions.length === 0">No transactions</div>
+ </div>
+ </div>
+ </template>
+ <div class="muted" x-show="!selectedBlock">Select a block</div>
+ </section>
+
+ <section class="panel" x-show="mempool.length > 0">
+ <h2>Mempool</h2>
+ <div class="mempool-strip">
+ <template x-for="tx in mempool" :key="tx.signature">
+ <div class="mempool-item">
+ <div class="tx-head"><span class="pill" :class="tx.kind" x-text="tx.kind"></span><strong x-text="tx.amount"></strong></div>
+ <div><span class="muted">from </span><code x-text="short(tx.from)"></code></div>
+ <div x-show="tx.to"><span class="muted">to </span><code x-text="short(tx.to)"></code></div>
+ <div class="muted">nonce <span x-text="tx.nonce"></span></div>
+ </div>
+ </template>
+ </div>
+ </section>
+ </div>
+ </section>
+ </main>
+</body>
+</html>"#;
diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs
@@ -0,0 +1,4 @@
+pub mod chain_store;
+pub mod http;
+pub mod p2p;
+pub mod wallet_store;
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -0,0 +1,1621 @@
+use std::{collections::BTreeMap, io::ErrorKind, net::SocketAddr, sync::Arc, time::Duration};
+
+use anyhow::{Context, Result};
+use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ net::{
+ TcpListener, TcpStream,
+ tcp::{OwnedReadHalf, OwnedWriteHalf},
+ },
+ sync::{Mutex, mpsc},
+ time::{Instant, interval, interval_at, sleep, timeout},
+};
+
+use crate::{
+ app::{
+ BlockInventory, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, SharedNode,
+ SharedPeerBook,
+ },
+ domain::{Block, ChainSnapshot, Ledger, verify_vdf},
+};
+
+const MAX_BLOCK_BATCH: usize = 128;
+const MAX_OBJECT_REQUESTS: usize = 128;
+const MAX_INVENTORY_ITEMS: usize = 512;
+const MAX_PEER_LIST: usize = 128;
+const MAX_SNAPSHOT_BLOCKS: usize = 10_000;
+const MAX_GOSSIP_LINE_BYTES: usize = 8 * 1024 * 1024;
+const PEER_QUEUE_SIZE: usize = 256;
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
+const SESSION_SYNC_INTERVAL: Duration = Duration::from_secs(2);
+const JOIN_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
+const MAX_JOIN_RESPONSE_ENVELOPES: usize = 16;
+const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1);
+const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
+
+type PeerStatus = (u64, String);
+type OutboundBatch = Vec<GossipEnvelope>;
+
+#[derive(Clone)]
+pub struct GossipNetwork {
+ inner: Arc<GossipNetworkInner>,
+}
+
+struct GossipNetworkInner {
+ node: SharedNode,
+ peers: SharedPeerBook,
+ listen_addr: SocketAddr,
+ sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>,
+}
+
+impl GossipNetwork {
+ pub async fn start(node: SharedNode, peers: SharedPeerBook, addr: SocketAddr) -> Result<Self> {
+ let listener = TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("binding p2p listener on {addr}"))?;
+ let network = Self {
+ inner: Arc::new(GossipNetworkInner {
+ node,
+ peers,
+ listen_addr: addr,
+ sessions: Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ tokio::spawn(accept_loop(network.clone(), listener));
+ tokio::spawn(outbound_supervisor(network.clone()));
+ network.ensure_outbound_sessions().await;
+ Ok(network)
+ }
+
+ pub async fn broadcast(&self, envelopes: Vec<GossipEnvelope>) -> Result<()> {
+ let envelopes = self.prepare_gossip(envelopes).await;
+ if envelopes.is_empty() {
+ return Ok(());
+ }
+
+ let sessions = self.inner.sessions.lock().await.clone();
+ for (peer, sender) in sessions {
+ match sender.try_send(envelopes.clone()) {
+ Ok(()) => {}
+ Err(mpsc::error::TrySendError::Full(_)) => {
+ self.inner
+ .peers
+ .lock()
+ .await
+ .record_error(&peer, "outbound gossip queue is full");
+ }
+ Err(mpsc::error::TrySendError::Closed(_)) => {
+ self.inner.sessions.lock().await.remove(&peer);
+ }
+ }
+ }
+ Ok(())
+ }
+
+ async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> {
+ let mut txs = Vec::new();
+ let mut blocks = Vec::new();
+ let mut passthrough = Vec::new();
+
+ for envelope in envelopes {
+ match envelope {
+ GossipEnvelope::Transaction(tx) => txs.push(tx.signature().to_string()),
+ GossipEnvelope::Transactions { transactions } => {
+ txs.extend(
+ transactions
+ .iter()
+ .map(|tx| tx.signature().to_string())
+ .collect::<Vec<_>>(),
+ );
+ }
+ GossipEnvelope::Block(block) => blocks.push(BlockInventory {
+ height: block.height,
+ hash: block.hash,
+ }),
+ GossipEnvelope::Blocks { blocks: batch } => {
+ blocks.extend(batch.into_iter().map(|block| BlockInventory {
+ height: block.height,
+ hash: block.hash,
+ }));
+ }
+ GossipEnvelope::Inventory {
+ txs: inv_txs,
+ blocks: inv_blocks,
+ } => {
+ txs.extend(inv_txs);
+ blocks.extend(inv_blocks);
+ }
+ other => passthrough.push(other),
+ }
+ }
+
+ txs.sort();
+ txs.dedup();
+ blocks.sort_by(|left, right| {
+ left.height
+ .cmp(&right.height)
+ .then_with(|| left.hash.cmp(&right.hash))
+ });
+ blocks.dedup_by(|left, right| left.hash == right.hash);
+
+ if !txs.is_empty() || !blocks.is_empty() {
+ passthrough.push(GossipEnvelope::Inventory { txs, blocks });
+ }
+ passthrough
+ }
+
+ pub async fn peer_exchange(&self) -> GossipEnvelope {
+ let self_addr = self.inner.listen_addr.to_string();
+ let peers = self.inner.peers.lock().await.addresses_except(&self_addr);
+ GossipEnvelope::PeerList {
+ peers: std::iter::once(self_addr)
+ .chain(peers.into_iter())
+ .collect(),
+ }
+ }
+
+ async fn ensure_outbound_sessions(&self) {
+ let addresses = self.inner.peers.lock().await.addresses();
+ let mut sessions = self.inner.sessions.lock().await;
+ for peer in addresses {
+ if sessions.contains_key(&peer) {
+ continue;
+ }
+
+ let (sender, receiver) = mpsc::channel(PEER_QUEUE_SIZE);
+ sessions.insert(peer.clone(), sender);
+ tokio::spawn(outbound_session(self.clone(), peer, receiver));
+ }
+ }
+
+ async fn forward_outbox(&self) {
+ let outbox = self.inner.node.lock().await.drain_outbox();
+ if let Err(error) = self.broadcast(outbox).await {
+ eprintln!("p2p rebroadcast failed: {error:#}");
+ }
+ }
+}
+
+async fn accept_loop(network: GossipNetwork, listener: TcpListener) {
+ loop {
+ match listener.accept().await {
+ Ok((stream, remote_addr)) => {
+ let network = network.clone();
+ tokio::spawn(async move {
+ let result =
+ session_loop(network, stream, remote_addr, None, mpsc::channel(1).1).await;
+ if let Err(error) = result {
+ if !is_quiet_disconnect(&error) {
+ eprintln!(
+ "p2p inbound connection from {remote_addr} failed: {error:#}"
+ );
+ }
+ }
+ });
+ }
+ Err(error) => eprintln!("p2p accept failed: {error:#}"),
+ }
+ }
+}
+
+async fn outbound_supervisor(network: GossipNetwork) {
+ let mut tick = interval(Duration::from_secs(2));
+ loop {
+ tick.tick().await;
+ network.ensure_outbound_sessions().await;
+ }
+}
+
+async fn outbound_session(
+ network: GossipNetwork,
+ peer: String,
+ mut receiver: mpsc::Receiver<OutboundBatch>,
+) {
+ let mut reconnect_delay = INITIAL_RECONNECT_DELAY;
+ loop {
+ let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&peer)).await {
+ Ok(Ok(stream)) => stream,
+ Ok(Err(error)) => {
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_error(&peer, format!("connecting to peer {peer}: {error}"));
+ sleep(reconnect_delay).await;
+ reconnect_delay = next_reconnect_delay(reconnect_delay);
+ continue;
+ }
+ Err(_) => {
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_error(&peer, format!("connecting to peer {peer}: timeout"));
+ sleep(reconnect_delay).await;
+ reconnect_delay = next_reconnect_delay(reconnect_delay);
+ continue;
+ }
+ };
+
+ reconnect_delay = INITIAL_RECONNECT_DELAY;
+ let remote_addr = stream.peer_addr().unwrap_or_else(|_| {
+ peer.parse()
+ .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)))
+ });
+ let result = session_loop(
+ network.clone(),
+ stream,
+ remote_addr,
+ Some(peer.clone()),
+ receiver,
+ )
+ .await;
+ match result {
+ Ok(()) => {}
+ Err(error) if is_quiet_disconnect(&error) => {}
+ Err(error) => {
+ let message = format!("{error:#}");
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_error(&peer, message.clone());
+ eprintln!("p2p session with {peer} failed: {message}");
+ }
+ }
+
+ let (sender, next_receiver) = mpsc::channel(PEER_QUEUE_SIZE);
+ receiver = next_receiver;
+ network
+ .inner
+ .sessions
+ .lock()
+ .await
+ .insert(peer.clone(), sender);
+ sleep(reconnect_delay).await;
+ reconnect_delay = next_reconnect_delay(reconnect_delay);
+ }
+}
+
+async fn session_loop(
+ network: GossipNetwork,
+ stream: TcpStream,
+ remote_addr: SocketAddr,
+ stable_peer: Option<String>,
+ mut outbound: mpsc::Receiver<OutboundBatch>,
+) -> Result<()> {
+ let (reader, mut writer) = stream.into_split();
+ let hello = network
+ .inner
+ .node
+ .lock()
+ .await
+ .hello(Some(network.inner.listen_addr.to_string()));
+ write_envelope(&mut writer, &hello).await?;
+ let mut reader = BufReader::new(reader);
+ let mut sync_tick = interval_at(
+ Instant::now() + SESSION_SYNC_INTERVAL,
+ SESSION_SYNC_INTERVAL,
+ );
+ let mut outbound_closed = false;
+ let mut peer_status: Option<PeerStatus> = None;
+ let mut known_peer = stable_peer;
+
+ if known_peer.is_some() {
+ if let Ok(Ok(Some(line))) = timeout(HANDSHAKE_TIMEOUT, read_limited_line(&mut reader)).await
+ {
+ let envelope = parse_envelope(&line)?;
+ if let GossipEnvelope::Hello(hello) = envelope {
+ peer_status =
+ Some(process_hello(&network, remote_addr, &mut known_peer, hello).await?);
+ maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?;
+ } else if let GossipEnvelope::PeerStatus { height, tip_hash } = envelope {
+ peer_status = Some((height, tip_hash.clone()));
+ record_peer_status(&network, &known_peer, remote_addr, height, tip_hash).await;
+ maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?;
+ } else {
+ process_envelope(
+ &network,
+ &mut writer,
+ remote_addr,
+ &mut known_peer,
+ envelope,
+ )
+ .await?;
+ }
+ }
+ }
+
+ loop {
+ tokio::select! {
+ maybe_batch = outbound.recv(), if !outbound_closed => {
+ match maybe_batch {
+ Some(batch) => {
+ let payload = envelopes_for_peer(
+ Some(&network.inner.node),
+ peer_status.clone(),
+ &batch,
+ ).await;
+ write_payload(&mut writer, &payload).await?;
+ if let Some(peer) = &known_peer {
+ network.inner.peers.lock().await.record_sent(peer, payload.len() as u64);
+ }
+ }
+ None => outbound_closed = true,
+ }
+ }
+ _ = sync_tick.tick() => {
+ let status = network.inner.node.lock().await.peer_status();
+ write_envelope(&mut writer, &status).await?;
+ if let Some(status) = peer_status.as_mut() {
+ if let Some(updated_status) = push_catchup_to_peer(&network, &mut writer, status).await? {
+ *status = updated_status;
+ }
+ }
+ }
+ line = read_limited_line(&mut reader) => {
+ let Some(line) = line? else {
+ return Ok(());
+ };
+ let envelope = parse_envelope(&line)?;
+ if let GossipEnvelope::Hello(hello) = envelope {
+ peer_status = Some(process_hello(&network, remote_addr, &mut known_peer, hello).await?);
+ maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?;
+ continue;
+ }
+ if let GossipEnvelope::PeerStatus { height, tip_hash } = &envelope {
+ peer_status = Some((*height, tip_hash.clone()));
+ record_peer_status(&network, &known_peer, remote_addr, *height, tip_hash.clone()).await;
+ maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?;
+ continue;
+ }
+
+ process_envelope(
+ &network,
+ &mut writer,
+ remote_addr,
+ &mut known_peer,
+ envelope,
+ ).await?;
+ }
+ }
+ }
+}
+
+async fn process_envelope(
+ network: &GossipNetwork,
+ writer: &mut OwnedWriteHalf,
+ remote_addr: SocketAddr,
+ known_peer: &mut Option<String>,
+ envelope: GossipEnvelope,
+) -> Result<()> {
+ match envelope {
+ GossipEnvelope::Hello(hello) => {
+ let _ = process_hello(network, remote_addr, known_peer, hello).await?;
+ }
+ GossipEnvelope::ChainSnapshotRequest => {
+ let snapshot = network.inner.node.lock().await.chain_snapshot();
+ write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?;
+ }
+ GossipEnvelope::BlockRangeRequest { from_height, limit } => {
+ let blocks = network
+ .inner
+ .node
+ .lock()
+ .await
+ .blocks_from(from_height, limit.min(MAX_BLOCK_BATCH));
+ write_envelope(writer, &GossipEnvelope::Blocks { blocks }).await?;
+ }
+ GossipEnvelope::TransactionRequest { signatures } => {
+ let transactions = network
+ .inner
+ .node
+ .lock()
+ .await
+ .transactions_by_signature(&signatures);
+ if !transactions.is_empty() {
+ write_envelope(writer, &GossipEnvelope::Transactions { transactions }).await?;
+ }
+ }
+ GossipEnvelope::BlockRequest { hashes } => {
+ let blocks = network.inner.node.lock().await.blocks_by_hash(&hashes);
+ if !blocks.is_empty() {
+ write_envelope(writer, &GossipEnvelope::Blocks { blocks }).await?;
+ }
+ }
+ GossipEnvelope::Inventory { txs, blocks } => {
+ let requests = network
+ .inner
+ .node
+ .lock()
+ .await
+ .missing_inventory_requests(&txs, &blocks);
+ write_payload(writer, &requests).await?;
+ }
+ GossipEnvelope::PeerAnnouncement { address } => {
+ let peer = normalize_advertised_peer(&address, remote_addr)?;
+ *known_peer = Some(peer.clone());
+ {
+ let mut peers = network.inner.peers.lock().await;
+ peers.add_peer(peer.clone());
+ peers.record_received(&peer, 1);
+ }
+ let snapshot = network.inner.node.lock().await.chain_snapshot();
+ write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?;
+ }
+ GossipEnvelope::PeerList { peers } => {
+ apply_peer_list(network, remote_addr, peers).await?;
+ }
+ GossipEnvelope::Block(block) => {
+ let needs_vdf = {
+ let node = network.inner.node.lock().await;
+ node.block_requires_vdf_verification(&block)
+ };
+ let result = match needs_vdf {
+ Ok(false) => Ok(()),
+ Ok(true) => match verify_block_vdf(block).await {
+ Ok(block) => network
+ .inner
+ .node
+ .lock()
+ .await
+ .receive_preverified_block(block),
+ Err(error) => Err(error),
+ },
+ Err(error) => Err(error),
+ };
+ record_inbound_result(network, known_peer, remote_addr, result).await;
+ network.forward_outbox().await;
+ }
+ GossipEnvelope::Blocks { blocks } => {
+ let local_ledger = network.inner.node.lock().await.clone_ledger();
+ let result = match validate_blocks_extension(local_ledger, blocks).await {
+ Ok(ledger) => network
+ .inner
+ .node
+ .lock()
+ .await
+ .import_verified_ledger(ledger)
+ .map(|_| ()),
+ Err(error) => Err(error),
+ };
+ let request_snapshot = result.as_ref().err().is_some_and(is_possible_fork_error);
+ record_inbound_result(network, known_peer, remote_addr, result).await;
+ if request_snapshot {
+ write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?;
+ }
+ network.forward_outbox().await;
+ }
+ GossipEnvelope::ChainSnapshot(snapshot) => {
+ let local_ledger = network.inner.node.lock().await.clone_ledger();
+ let result = match validate_snapshot_extension(local_ledger, snapshot).await {
+ Ok(ledger) => network
+ .inner
+ .node
+ .lock()
+ .await
+ .import_verified_ledger(ledger)
+ .map(|_| ()),
+ Err(error) => Err(error),
+ };
+ record_inbound_result(network, known_peer, remote_addr, result).await;
+ network.forward_outbox().await;
+ }
+ other => {
+ let result = network.inner.node.lock().await.receive(other);
+ record_inbound_result(network, known_peer, remote_addr, result).await;
+ network.forward_outbox().await;
+ }
+ }
+ Ok(())
+}
+
+async fn maybe_request_catchup(
+ network: &GossipNetwork,
+ writer: &mut OwnedWriteHalf,
+ peer_status: &PeerStatus,
+) -> Result<()> {
+ let (local_height, local_tip_hash) = {
+ let node = network.inner.node.lock().await;
+ let status = node.ledger().status();
+ (status.height, status.tip_hash)
+ };
+ let (peer_height, peer_tip_hash) = peer_status;
+ if *peer_height > local_height {
+ write_envelope(
+ writer,
+ &GossipEnvelope::BlockRangeRequest {
+ from_height: local_height + 1,
+ limit: MAX_BLOCK_BATCH,
+ },
+ )
+ .await?;
+ } else if *peer_height == local_height && peer_tip_hash != &local_tip_hash {
+ write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?;
+ }
+ Ok(())
+}
+
+async fn push_catchup_to_peer(
+ network: &GossipNetwork,
+ writer: &mut OwnedWriteHalf,
+ peer_status: &PeerStatus,
+) -> Result<Option<PeerStatus>> {
+ let payload = catchup_payload_for_peer(&network.inner.node, peer_status).await;
+ if payload.is_empty() {
+ return Ok(None);
+ }
+
+ let updated_status = payload.iter().find_map(|envelope| match envelope {
+ GossipEnvelope::Blocks { blocks } => blocks
+ .last()
+ .map(|block| (block.height, block.hash.clone())),
+ GossipEnvelope::ChainSnapshot(snapshot) => snapshot
+ .blocks
+ .last()
+ .map(|block| (block.height, block.hash.clone())),
+ _ => None,
+ });
+ write_payload(writer, &payload).await?;
+ Ok(updated_status)
+}
+
+async fn catchup_payload_for_peer(
+ node: &SharedNode,
+ peer_status: &PeerStatus,
+) -> Vec<GossipEnvelope> {
+ let (peer_height, peer_tip_hash) = peer_status;
+ let node = node.lock().await;
+ let local_status = node.ledger().status();
+ if *peer_height < local_status.height {
+ let blocks = node.blocks_from(peer_height + 1, MAX_BLOCK_BATCH);
+ if blocks.is_empty() {
+ Vec::new()
+ } else {
+ vec![GossipEnvelope::Blocks { blocks }]
+ }
+ } else if *peer_height == local_status.height && peer_tip_hash != &local_status.tip_hash {
+ vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]
+ } else {
+ Vec::new()
+ }
+}
+
+async fn apply_peer_list(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ peers: Vec<String>,
+) -> Result<()> {
+ let self_addr = network.inner.listen_addr.to_string();
+ let mut peerbook = network.inner.peers.lock().await;
+ for address in peers {
+ let peer = normalize_advertised_peer(&address, remote_addr)?;
+ if peer != self_addr {
+ peerbook.add_peer(peer);
+ }
+ }
+ Ok(())
+}
+
+async fn write_payload(writer: &mut OwnedWriteHalf, payload: &[GossipEnvelope]) -> Result<()> {
+ for envelope in payload {
+ write_envelope(writer, envelope).await?;
+ }
+ Ok(())
+}
+
+async fn write_envelope(writer: &mut OwnedWriteHalf, envelope: &GossipEnvelope) -> Result<()> {
+ let line = serde_json::to_string(envelope)?;
+ if line.len() > MAX_GOSSIP_LINE_BYTES {
+ anyhow::bail!(
+ "p2p message is {} bytes, exceeding {} byte limit",
+ line.len(),
+ MAX_GOSSIP_LINE_BYTES
+ );
+ }
+ writer.write_all(line.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ Ok(())
+}
+
+async fn read_limited_line(reader: &mut BufReader<OwnedReadHalf>) -> Result<Option<String>> {
+ let mut bytes = Vec::new();
+ loop {
+ let available = reader.fill_buf().await?;
+ if available.is_empty() {
+ if bytes.is_empty() {
+ return Ok(None);
+ }
+ anyhow::bail!("peer closed before completing a gossip message");
+ }
+
+ if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
+ if bytes.len() + newline > MAX_GOSSIP_LINE_BYTES {
+ anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES);
+ }
+ bytes.extend_from_slice(&available[..newline]);
+ reader.consume(newline + 1);
+ if bytes.ends_with(b"\r") {
+ bytes.pop();
+ }
+ return String::from_utf8(bytes)
+ .context("p2p message is not valid UTF-8")
+ .map(Some);
+ }
+
+ if bytes.len() + available.len() > MAX_GOSSIP_LINE_BYTES {
+ anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES);
+ }
+ let consumed = available.len();
+ bytes.extend_from_slice(available);
+ reader.consume(consumed);
+ }
+}
+
+fn parse_envelope(line: &str) -> Result<GossipEnvelope> {
+ let envelope = serde_json::from_str(line).context("invalid p2p envelope JSON")?;
+ validate_envelope_limits(&envelope)?;
+ Ok(envelope)
+}
+
+fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> {
+ match envelope {
+ GossipEnvelope::BlockRangeRequest { limit, .. } => {
+ ensure_len("block range request", *limit, MAX_BLOCK_BATCH)?;
+ }
+ GossipEnvelope::TransactionRequest { signatures } => {
+ ensure_len("transaction request", signatures.len(), MAX_OBJECT_REQUESTS)?;
+ }
+ GossipEnvelope::BlockRequest { hashes } => {
+ ensure_len("block request", hashes.len(), MAX_OBJECT_REQUESTS)?;
+ }
+ GossipEnvelope::Inventory { txs, blocks } => {
+ ensure_len("transaction inventory", txs.len(), MAX_INVENTORY_ITEMS)?;
+ ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?;
+ }
+ GossipEnvelope::Transactions { transactions } => {
+ ensure_len("transaction batch", transactions.len(), MAX_OBJECT_REQUESTS)?;
+ }
+ GossipEnvelope::Blocks { blocks } => {
+ ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?;
+ }
+ GossipEnvelope::ChainSnapshot(snapshot) => {
+ ensure_len("chain snapshot", snapshot.blocks.len(), MAX_SNAPSHOT_BLOCKS)?;
+ }
+ GossipEnvelope::PeerList { peers } => {
+ ensure_len("peer list", peers.len(), MAX_PEER_LIST)?;
+ }
+ GossipEnvelope::Hello(_)
+ | GossipEnvelope::PeerStatus { .. }
+ | GossipEnvelope::ChainSnapshotRequest
+ | GossipEnvelope::Transaction(_)
+ | GossipEnvelope::Block(_)
+ | GossipEnvelope::PeerAnnouncement { .. } => {}
+ }
+ Ok(())
+}
+
+fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> {
+ if len > max {
+ anyhow::bail!("{label} has {len} items, exceeding limit {max}");
+ }
+ Ok(())
+}
+
+async fn envelopes_for_peer(
+ node: Option<&SharedNode>,
+ peer_status: Option<PeerStatus>,
+ envelopes: &[GossipEnvelope],
+) -> Vec<GossipEnvelope> {
+ let Some(node) = node else {
+ return envelopes.to_vec();
+ };
+ let Some((peer_height, peer_tip_hash)) = peer_status else {
+ return envelopes.to_vec();
+ };
+
+ let node = node.lock().await;
+ let local_status = node.ledger().status();
+ if peer_height < local_status.height {
+ let mut payload = vec![GossipEnvelope::Blocks {
+ blocks: node.blocks_from(peer_height + 1, MAX_BLOCK_BATCH),
+ }];
+ payload.extend(
+ envelopes
+ .iter()
+ .filter(|envelope| !matches!(envelope, GossipEnvelope::Block(_)))
+ .cloned(),
+ );
+ return payload;
+ }
+
+ if peer_height == local_status.height && peer_tip_hash != local_status.tip_hash {
+ return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())];
+ }
+
+ if peer_needs_snapshot(peer_height, envelopes) {
+ return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())];
+ }
+
+ envelopes
+ .iter()
+ .filter(|envelope| match envelope {
+ GossipEnvelope::Block(block) => block.height > peer_height,
+ GossipEnvelope::Inventory { blocks, .. } => {
+ blocks.iter().any(|block| block.height > peer_height)
+ }
+ _ => true,
+ })
+ .map(|envelope| match envelope {
+ GossipEnvelope::Inventory { txs, blocks } => GossipEnvelope::Inventory {
+ txs: txs.clone(),
+ blocks: blocks
+ .iter()
+ .filter(|block| block.height > peer_height)
+ .cloned()
+ .collect(),
+ },
+ other => other.clone(),
+ })
+ .filter(|envelope| match envelope {
+ GossipEnvelope::Inventory { txs, blocks } => !txs.is_empty() || !blocks.is_empty(),
+ _ => true,
+ })
+ .collect()
+}
+
+pub async fn fetch_snapshot(peer: &str) -> Result<ChainSnapshot> {
+ fetch_snapshot_with_announcement(peer, None).await
+}
+
+pub async fn fetch_peer_height(peer: &str) -> Result<u64> {
+ fetch_peer_status(peer).await.map(|(height, _)| height)
+}
+
+async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> {
+ let stream = TcpStream::connect(peer)
+ .await
+ .with_context(|| format!("connecting to peer {peer}"))?;
+ let (reader, _writer) = stream.into_split();
+ let mut reader = BufReader::new(reader);
+ let line = read_limited_line(&mut reader)
+ .await?
+ .with_context(|| format!("peer {peer} closed before sending its peer status"))?;
+ match parse_envelope(&line)? {
+ GossipEnvelope::Hello(hello) => {
+ if hello.protocol_version != PROTOCOL_VERSION {
+ anyhow::bail!(
+ "unsupported protocol version {}; expected {}",
+ hello.protocol_version,
+ PROTOCOL_VERSION
+ );
+ }
+ if hello.network_id != NETWORK_ID {
+ anyhow::bail!(
+ "wrong network {}; expected {}",
+ hello.network_id,
+ NETWORK_ID
+ );
+ }
+ Ok((hello.height, hello.tip_hash))
+ }
+ GossipEnvelope::PeerStatus { height, tip_hash } => Ok((height, tip_hash)),
+ other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"),
+ }
+}
+
+pub async fn fetch_snapshot_with_announcement(
+ peer: &str,
+ advertised_addr: Option<SocketAddr>,
+) -> Result<ChainSnapshot> {
+ let stream = TcpStream::connect(peer)
+ .await
+ .with_context(|| format!("connecting to join peer {peer}"))?;
+ let (reader, mut writer) = stream.into_split();
+ let mut reader = BufReader::new(reader);
+ let line = read_limited_line(&mut reader)
+ .await?
+ .with_context(|| format!("join peer {peer} closed before sending its peer status"))?;
+ match parse_envelope(&line)? {
+ GossipEnvelope::Hello(hello) => {
+ if hello.protocol_version != PROTOCOL_VERSION {
+ anyhow::bail!(
+ "unsupported protocol version {}; expected {}",
+ hello.protocol_version,
+ PROTOCOL_VERSION
+ );
+ }
+ if hello.network_id != NETWORK_ID {
+ anyhow::bail!(
+ "wrong network {}; expected {}",
+ hello.network_id,
+ NETWORK_ID
+ );
+ }
+ }
+ GossipEnvelope::PeerStatus { .. } => {}
+ other => anyhow::bail!("join peer {peer} sent {other:?} instead of peer status"),
+ }
+
+ let line = serde_json::to_string(&GossipEnvelope::ChainSnapshotRequest)?;
+ writer.write_all(line.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ let snapshot = read_join_snapshot_response(peer, &mut reader).await?;
+
+ if let Some(address) = advertised_addr {
+ let line = serde_json::to_string(&GossipEnvelope::PeerAnnouncement {
+ address: address.to_string(),
+ })?;
+ writer.write_all(line.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ if let Ok(Ok(Some(line))) =
+ timeout(Duration::from_secs(2), read_limited_line(&mut reader)).await
+ {
+ if let GossipEnvelope::ChainSnapshot(fresh_snapshot) = parse_envelope(&line)? {
+ if snapshot_height(&fresh_snapshot) >= snapshot_height(&snapshot) {
+ return Ok(fresh_snapshot);
+ }
+ }
+ }
+ }
+
+ Ok(snapshot)
+}
+
+async fn read_join_snapshot_response(
+ peer: &str,
+ reader: &mut BufReader<OwnedReadHalf>,
+) -> Result<ChainSnapshot> {
+ for _ in 0..MAX_JOIN_RESPONSE_ENVELOPES {
+ let line = timeout(JOIN_RESPONSE_TIMEOUT, read_limited_line(reader))
+ .await
+ .with_context(|| format!("join peer {peer} timed out waiting for a chain snapshot"))??
+ .with_context(|| format!("join peer {peer} closed before sending a chain snapshot"))?;
+ match join_snapshot_response(peer, parse_envelope(&line)?)? {
+ Some(snapshot) => return Ok(snapshot),
+ None => continue,
+ }
+ }
+
+ anyhow::bail!("join peer {peer} sent too many non-snapshot envelopes while joining")
+}
+
+fn join_snapshot_response(peer: &str, envelope: GossipEnvelope) -> Result<Option<ChainSnapshot>> {
+ match envelope {
+ GossipEnvelope::ChainSnapshot(snapshot) => Ok(Some(snapshot)),
+ GossipEnvelope::Hello(_)
+ | GossipEnvelope::PeerStatus { .. }
+ | GossipEnvelope::PeerList { .. }
+ | GossipEnvelope::Inventory { .. } => Ok(None),
+ other => anyhow::bail!("join peer {peer} sent {other:?} instead of a chain snapshot"),
+ }
+}
+
+async fn validate_snapshot_extension(
+ mut ledger: Ledger,
+ snapshot: ChainSnapshot,
+) -> Result<Ledger> {
+ let missing_blocks = ledger.missing_snapshot_blocks(&snapshot)?;
+ verify_blocks_vdf(missing_blocks).await?;
+
+ tokio::task::spawn_blocking(move || {
+ ledger.extend_from_preverified_snapshot(snapshot)?;
+ Ok(ledger)
+ })
+ .await
+ .context("chain snapshot extension worker failed")?
+}
+
+async fn validate_blocks_extension(mut ledger: Ledger, blocks: Vec<Block>) -> Result<Ledger> {
+ if blocks.is_empty() {
+ return Ok(ledger);
+ }
+ verify_blocks_vdf(blocks.clone()).await?;
+
+ tokio::task::spawn_blocking(move || {
+ for block in blocks {
+ ledger.apply_preverified_block(block)?;
+ }
+ Ok(ledger)
+ })
+ .await
+ .context("block batch extension worker failed")?
+}
+
+async fn verify_block_vdf(block: Block) -> Result<Block> {
+ let seed = block.vdf_seed();
+ let rounds = block.vdf_rounds;
+ let solution = block.vdf_output.clone();
+ let valid = tokio::task::spawn_blocking(move || verify_vdf(&seed, rounds, &solution))
+ .await
+ .context("VDF verification worker failed")?;
+ if !valid {
+ anyhow::bail!("block VDF output is invalid");
+ }
+
+ Ok(block)
+}
+
+async fn verify_blocks_vdf(blocks: Vec<Block>) -> Result<()> {
+ let mut tasks = tokio::task::JoinSet::new();
+ for block in blocks {
+ tasks.spawn_blocking(move || {
+ if !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) {
+ anyhow::bail!("block {} VDF output is invalid", block.height);
+ }
+ Ok::<(), anyhow::Error>(())
+ });
+ }
+
+ while let Some(result) = tasks.join_next().await {
+ result.context("VDF verification worker failed")??;
+ }
+
+ Ok(())
+}
+
+async fn record_peer_status(
+ network: &GossipNetwork,
+ known_peer: &Option<String>,
+ remote_addr: SocketAddr,
+ height: u64,
+ tip_hash: String,
+) {
+ if let Some(peer) = known_peer {
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_status(peer, height, tip_hash);
+ } else {
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_received(&remote_addr.to_string(), 1);
+ }
+}
+
+async fn process_hello(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ known_peer: &mut Option<String>,
+ hello: ProtocolHello,
+) -> Result<PeerStatus> {
+ if hello.protocol_version != PROTOCOL_VERSION {
+ anyhow::bail!(
+ "unsupported protocol version {}; expected {}",
+ hello.protocol_version,
+ PROTOCOL_VERSION
+ );
+ }
+ if hello.network_id != NETWORK_ID {
+ anyhow::bail!(
+ "wrong network {}; expected {}",
+ hello.network_id,
+ NETWORK_ID
+ );
+ }
+ let local_genesis = network
+ .inner
+ .node
+ .lock()
+ .await
+ .ledger()
+ .genesis_hash()
+ .to_string();
+ if hello.genesis_hash != local_genesis {
+ anyhow::bail!(
+ "wrong genesis {}; expected {local_genesis}",
+ hello.genesis_hash
+ );
+ }
+
+ if let Some(listen_addr) = &hello.listen_addr {
+ let peer = normalize_advertised_peer(listen_addr, remote_addr)?;
+ if peer != network.inner.listen_addr.to_string() {
+ *known_peer = Some(peer.clone());
+ network.inner.peers.lock().await.add_peer(peer);
+ }
+ }
+ record_peer_status(
+ network,
+ known_peer,
+ remote_addr,
+ hello.height,
+ hello.tip_hash.clone(),
+ )
+ .await;
+ Ok((hello.height, hello.tip_hash))
+}
+
+async fn record_inbound_result(
+ network: &GossipNetwork,
+ known_peer: &Option<String>,
+ remote_addr: SocketAddr,
+ result: Result<()>,
+) {
+ let peer = known_peer
+ .clone()
+ .unwrap_or_else(|| remote_addr.to_string());
+ match result {
+ Ok(()) => {
+ if known_peer.is_some() {
+ network.inner.peers.lock().await.record_received(&peer, 1);
+ }
+ }
+ Err(error) => {
+ let message = format!("{error:#}");
+ if known_peer.is_some() {
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_inbound_error(&peer, message.clone());
+ }
+ eprintln!("p2p envelope from {peer} ignored: {message}");
+ }
+ }
+}
+
+fn next_reconnect_delay(current: Duration) -> Duration {
+ (current * 2).min(MAX_RECONNECT_DELAY)
+}
+
+fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
+ snapshot
+ .blocks
+ .last()
+ .map(|block| block.height)
+ .unwrap_or(0)
+}
+
+fn peer_needs_snapshot(peer_height: u64, envelopes: &[GossipEnvelope]) -> bool {
+ envelopes
+ .iter()
+ .filter_map(|envelope| match envelope {
+ GossipEnvelope::Block(block) => Some(block.height),
+ GossipEnvelope::Inventory { blocks, .. } => {
+ blocks.iter().map(|block| block.height).min()
+ }
+ _ => None,
+ })
+ .min()
+ .is_some_and(|first_block_height| peer_height + 1 < first_block_height)
+}
+
+fn reachable_advertised_addr(advertised_addr: SocketAddr, remote_addr: SocketAddr) -> SocketAddr {
+ let mut reachable_addr = advertised_addr;
+ if reachable_addr.ip().is_unspecified() {
+ reachable_addr.set_ip(remote_addr.ip());
+ }
+ reachable_addr
+}
+
+fn normalize_advertised_peer(address: &str, remote_addr: SocketAddr) -> Result<String> {
+ let advertised_addr = address
+ .parse::<SocketAddr>()
+ .with_context(|| format!("invalid announced peer address {address}"))?;
+ Ok(reachable_advertised_addr(advertised_addr, remote_addr).to_string())
+}
+
+fn is_quiet_disconnect(error: &anyhow::Error) -> bool {
+ error.chain().any(|cause| {
+ cause.downcast_ref::<std::io::Error>().is_some_and(|error| {
+ matches!(
+ error.kind(),
+ ErrorKind::ConnectionReset
+ | ErrorKind::BrokenPipe
+ | ErrorKind::UnexpectedEof
+ | ErrorKind::ConnectionAborted
+ )
+ })
+ })
+}
+
+fn is_possible_fork_error(error: &anyhow::Error) -> bool {
+ let message = format!("{error:#}");
+ message.contains("does not extend local tip")
+ || message.contains("conflicts with local chain")
+ || message.contains("expected block height")
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{collections::BTreeMap, net::SocketAddr, sync::Arc};
+
+ use crate::{
+ app::{
+ BlockInventory, GossipEnvelope, NETWORK_ID, NodeConfig, NodeCore, PROTOCOL_VERSION,
+ PeerBook, PeerDirection, ProtocolHello,
+ },
+ domain::{Amount, Wallet},
+ };
+
+ use super::{
+ MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, next_reconnect_delay, parse_envelope,
+ reachable_advertised_addr, validate_envelope_limits,
+ };
+
+ #[test]
+ fn unspecified_announced_ip_uses_remote_ip_with_announced_port() {
+ let advertised: SocketAddr = "0.0.0.0:9445".parse().unwrap();
+ let remote: SocketAddr = "203.0.113.10:52144".parse().unwrap();
+
+ assert_eq!(
+ reachable_advertised_addr(advertised, remote).to_string(),
+ "203.0.113.10:9445"
+ );
+ }
+
+ #[test]
+ fn explicit_announced_ip_is_kept() {
+ let advertised: SocketAddr = "127.0.0.1:9445".parse().unwrap();
+ let remote: SocketAddr = "127.0.0.1:52144".parse().unwrap();
+
+ assert_eq!(
+ reachable_advertised_addr(advertised, remote).to_string(),
+ "127.0.0.1:9445"
+ );
+ }
+
+ #[test]
+ fn oversized_object_requests_are_rejected_before_processing() {
+ let envelope = GossipEnvelope::TransactionRequest {
+ signatures: vec!["sig".to_string(); MAX_OBJECT_REQUESTS + 1],
+ };
+
+ let error = validate_envelope_limits(&envelope).unwrap_err();
+
+ assert!(error.to_string().contains("transaction request"));
+ }
+
+ #[test]
+ fn oversized_inventory_is_rejected_before_processing() {
+ let envelope = GossipEnvelope::Inventory {
+ txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1],
+ blocks: Vec::new(),
+ };
+
+ let error = validate_envelope_limits(&envelope).unwrap_err();
+
+ assert!(error.to_string().contains("transaction inventory"));
+ }
+
+ #[test]
+ fn parser_applies_envelope_limits() {
+ let line = serde_json::to_string(&GossipEnvelope::BlockRequest {
+ hashes: vec!["hash".to_string(); MAX_OBJECT_REQUESTS + 1],
+ })
+ .unwrap();
+
+ let error = parse_envelope(&line).unwrap_err();
+
+ assert!(error.to_string().contains("block request"));
+ }
+
+ #[test]
+ fn peer_needs_snapshot_when_block_gossip_skips_a_height() {
+ let block = crate::domain::Block {
+ height: 10,
+ prev_hash: "prev".to_string(),
+ timestamp_ms: 1,
+ miner: "miner".to_string(),
+ reward: 100,
+ vdf_rounds: 1,
+ vdf_output: "vdf".to_string(),
+ transactions: Vec::new(),
+ hash: "hash".to_string(),
+ };
+
+ assert!(super::peer_needs_snapshot(
+ 8,
+ &[GossipEnvelope::Block(block.clone())]
+ ));
+ assert!(!super::peer_needs_snapshot(
+ 9,
+ &[GossipEnvelope::Block(block)]
+ ));
+ assert!(!super::peer_needs_snapshot(
+ 8,
+ &[GossipEnvelope::PeerAnnouncement {
+ address: "127.0.0.1:9444".to_string()
+ }]
+ ));
+ }
+
+ #[test]
+ fn reconnect_backoff_is_capped() {
+ assert_eq!(
+ next_reconnect_delay(super::INITIAL_RECONNECT_DELAY),
+ std::time::Duration::from_secs(2)
+ );
+ assert_eq!(
+ next_reconnect_delay(super::MAX_RECONNECT_DELAY),
+ super::MAX_RECONNECT_DELAY
+ );
+ }
+
+ #[tokio::test]
+ async fn peer_payload_repairs_lagging_peer_without_networking() {
+ let alice = Wallet::from_seed("p2p-alice");
+ let bob = Wallet::from_seed("p2p-bob");
+ let allocations = allocations(&[alice.clone(), bob.clone()], 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let block = {
+ let mut node = node.lock().await;
+ node.burn(1).unwrap();
+ node.drain_outbox();
+ let block = node.mine_one_at(1).unwrap();
+ node.drain_outbox();
+ block
+ };
+
+ let payload = super::envelopes_for_peer(
+ Some(&node),
+ Some((0, "genesis".to_string())),
+ &[GossipEnvelope::Block(block)],
+ )
+ .await;
+
+ assert!(matches!(payload[0], GossipEnvelope::Blocks { .. }));
+ match &payload[0] {
+ GossipEnvelope::Blocks { blocks } => {
+ assert_eq!(blocks.len(), 1);
+ assert_eq!(blocks[0].height, 1);
+ }
+ _ => unreachable!(),
+ }
+ }
+
+ #[tokio::test]
+ async fn tx_and_block_gossip_is_announced_as_inventory() {
+ let alice = Wallet::from_seed("inventory-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node: Arc::clone(&node),
+ peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())),
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ let (tx_signature, block_hash) = {
+ let mut node = node.lock().await;
+ let tx = node.burn(1).unwrap();
+ node.drain_outbox();
+ let block = node.mine_one_at(1).unwrap();
+ (tx.signature().to_string(), block.hash)
+ };
+ let (tx, block) = {
+ let node = node.lock().await;
+ (
+ node.transactions_by_signature(std::slice::from_ref(&tx_signature))
+ .remove(0),
+ node.blocks_by_hash(std::slice::from_ref(&block_hash))
+ .remove(0),
+ )
+ };
+
+ let prepared = network
+ .prepare_gossip(vec![
+ GossipEnvelope::Transaction(tx),
+ GossipEnvelope::Block(block),
+ ])
+ .await;
+
+ assert_eq!(prepared.len(), 1);
+ match &prepared[0] {
+ GossipEnvelope::Inventory { txs, blocks } => {
+ assert_eq!(txs, &[tx_signature]);
+ assert_eq!(blocks.len(), 1);
+ assert_eq!(blocks[0].hash, block_hash);
+ }
+ other => panic!("expected inventory, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn inventory_requests_only_missing_objects() {
+ let alice = Wallet::from_seed("missing-inv-alice");
+ let bob = Wallet::from_seed("missing-inv-bob");
+ let allocations = allocations(&[alice.clone(), bob.clone()], 1_000);
+ let mut local = node("local", alice.clone(), allocations.clone());
+ let mut remote = node("remote", bob, allocations);
+ let tx = local.burn(1).unwrap();
+ let block = local.mine_one_at(1).unwrap();
+
+ let requests = remote.missing_inventory_requests(
+ &[tx.signature().to_string()],
+ &[BlockInventory {
+ height: block.height,
+ hash: block.hash.clone(),
+ }],
+ );
+ assert!(matches!(
+ requests[0],
+ GossipEnvelope::TransactionRequest { .. }
+ ));
+ assert!(matches!(requests[1], GossipEnvelope::BlockRequest { .. }));
+
+ remote.receive(GossipEnvelope::Transaction(tx)).unwrap();
+ let requests = remote.missing_inventory_requests(
+ &[],
+ &[BlockInventory {
+ height: block.height,
+ hash: block.hash,
+ }],
+ );
+ assert_eq!(requests.len(), 1);
+ assert!(matches!(requests[0], GossipEnvelope::BlockRequest { .. }));
+ }
+
+ #[tokio::test]
+ async fn inventory_gap_requests_range_instead_of_orphan_block() {
+ let alice = Wallet::from_seed("gap-inv-alice");
+ let bob = Wallet::from_seed("gap-inv-bob");
+ let allocations = allocations(&[alice.clone(), bob.clone()], 1_000);
+ let mut local = node("local", alice, allocations.clone());
+ let remote = node("remote", bob, allocations);
+
+ let mut latest = None;
+ for height in 1..=3 {
+ local.burn(1).unwrap();
+ latest = Some(local.mine_one_at(height).unwrap());
+ }
+ let latest = latest.unwrap();
+
+ let requests = remote.missing_inventory_requests(
+ &[],
+ &[BlockInventory {
+ height: latest.height,
+ hash: latest.hash,
+ }],
+ );
+
+ assert_eq!(requests.len(), 1);
+ match &requests[0] {
+ GossipEnvelope::BlockRangeRequest { from_height, limit } => {
+ assert_eq!(*from_height, 1);
+ assert_eq!(*limit, crate::app::BLOCK_REQUEST_LIMIT);
+ }
+ other => panic!("expected block range request, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn session_catchup_payload_pushes_missing_blocks_to_lagging_peer() {
+ let alice = Wallet::from_seed("catchup-alice");
+ let bob = Wallet::from_seed("catchup-bob");
+ let allocations = allocations(&[alice.clone(), bob], 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ {
+ let mut node = node.lock().await;
+ for height in 1..=3 {
+ node.burn(1).unwrap();
+ node.drain_outbox();
+ node.mine_one_at(height).unwrap();
+ node.drain_outbox();
+ }
+ }
+
+ let payload = super::catchup_payload_for_peer(&node, &(1, "old-tip".to_string())).await;
+
+ assert_eq!(payload.len(), 1);
+ match &payload[0] {
+ GossipEnvelope::Blocks { blocks } => {
+ assert_eq!(
+ blocks.iter().map(|block| block.height).collect::<Vec<_>>(),
+ vec![2, 3]
+ );
+ }
+ other => panic!("expected missing block payload, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn hello_rejects_wrong_network_or_genesis() {
+ let alice = Wallet::from_seed("hello-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())),
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ let wrong_network = ProtocolHello {
+ protocol_version: PROTOCOL_VERSION,
+ network_id: "other-network".to_string(),
+ genesis_hash: network
+ .inner
+ .node
+ .lock()
+ .await
+ .ledger()
+ .genesis_hash()
+ .to_string(),
+ listen_addr: Some("127.0.0.1:9545".to_string()),
+ height: 0,
+ tip_hash: "tip".to_string(),
+ };
+ assert!(
+ super::process_hello(
+ &network,
+ "127.0.0.1:9545".parse().unwrap(),
+ &mut None,
+ wrong_network,
+ )
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("wrong network")
+ );
+
+ let wrong_genesis = ProtocolHello {
+ protocol_version: PROTOCOL_VERSION,
+ network_id: NETWORK_ID.to_string(),
+ genesis_hash: "not-local-genesis".to_string(),
+ listen_addr: Some("127.0.0.1:9545".to_string()),
+ height: 0,
+ tip_hash: "tip".to_string(),
+ };
+ assert!(
+ super::process_hello(
+ &network,
+ "127.0.0.1:9545".parse().unwrap(),
+ &mut None,
+ wrong_genesis,
+ )
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("wrong genesis")
+ );
+ }
+
+ #[tokio::test]
+ async fn inbound_status_does_not_create_outbound_ephemeral_peer() {
+ let alice = Wallet::from_seed("inbound-status-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default()));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers: Arc::clone(&peers),
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ super::record_peer_status(
+ &network,
+ &None,
+ "127.0.0.1:51729".parse().unwrap(),
+ 4,
+ "tip".to_string(),
+ )
+ .await;
+
+ let peers = peers.lock().await;
+ assert!(peers.addresses().is_empty());
+ let listed = peers.list();
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].direction, PeerDirection::Inbound);
+ }
+
+ #[test]
+ fn join_snapshot_response_ignores_status_noise_before_snapshot() {
+ assert!(
+ super::join_snapshot_response(
+ "127.0.0.1:9544",
+ GossipEnvelope::PeerStatus {
+ height: 0,
+ tip_hash: "tip".to_string()
+ }
+ )
+ .unwrap()
+ .is_none()
+ );
+
+ let alice = Wallet::from_seed("join-noise-alice");
+ let snapshot = node(
+ "alice",
+ alice.clone(),
+ allocations(std::slice::from_ref(&alice), 1_000),
+ )
+ .chain_snapshot();
+ let parsed = super::join_snapshot_response(
+ "127.0.0.1:9544",
+ GossipEnvelope::ChainSnapshot(snapshot.clone()),
+ )
+ .unwrap();
+
+ assert_eq!(parsed, Some(snapshot));
+ }
+
+ #[tokio::test]
+ async fn peer_exchange_advertises_stable_listen_and_known_peers() {
+ let alice = Wallet::from_seed("px-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![
+ "127.0.0.1:9545".to_string(),
+ ])));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers,
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ match network.peer_exchange().await {
+ GossipEnvelope::PeerList { peers } => {
+ assert!(peers.contains(&"127.0.0.1:9544".to_string()));
+ assert!(peers.contains(&"127.0.0.1:9545".to_string()));
+ }
+ other => panic!("expected peer list, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn peer_list_adds_stable_outbound_peers() {
+ let alice = Wallet::from_seed("px-recv-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default()));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers: Arc::clone(&peers),
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ }),
+ };
+
+ super::apply_peer_list(
+ &network,
+ "127.0.0.1:9545".parse().unwrap(),
+ vec!["127.0.0.1:9544".to_string(), "127.0.0.1:9546".to_string()],
+ )
+ .await
+ .unwrap();
+
+ let addresses = peers.lock().await.addresses();
+ assert!(!addresses.contains(&"127.0.0.1:9544".to_string()));
+ assert!(addresses.contains(&"127.0.0.1:9546".to_string()));
+ }
+
+ fn node(name: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore {
+ NodeCore::new(NodeConfig {
+ name: name.to_string(),
+ wallet,
+ genesis_allocations: allocations,
+ vdf_rounds: 25,
+ burn_per_block: 0,
+ })
+ }
+
+ fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
+ wallets
+ .iter()
+ .map(|wallet| (wallet.address().to_string(), amount))
+ .collect()
+ }
+}
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -0,0 +1,163 @@
+use std::{
+ fs::{self, File, OpenOptions},
+ io::Write,
+ path::Path,
+};
+
+use anyhow::{Context, Result, anyhow, bail};
+use serde::{Deserialize, Serialize};
+
+use crate::domain::Wallet;
+
+const WALLET_FILE_VERSION: u32 = 2;
+
+#[derive(Debug, Serialize, Deserialize)]
+struct WalletFile {
+ version: u32,
+ seed: String,
+ address: String,
+}
+
+pub fn load_or_create(path: &Path) -> Result<Wallet> {
+ if path.exists() {
+ return load(path);
+ }
+
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("failed to create wallet directory {}", parent.display()))?;
+ }
+
+ let seed = random_seed()?;
+ let wallet = Wallet::from_seed(&seed);
+ let mut file = create_wallet_file(path)?;
+ write_wallet_file(&mut file, seed, wallet.address())
+ .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+
+ Ok(wallet)
+}
+
+fn load(path: &Path) -> Result<Wallet> {
+ let bytes =
+ fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
+ let stored: WalletFile = serde_json::from_slice(&bytes)
+ .with_context(|| format!("failed to parse wallet file {}", path.display()))?;
+
+ let wallet = Wallet::from_seed(&stored.seed);
+ if stored.version == 1 {
+ let mut file = OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(path)
+ .with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
+ write_wallet_file(&mut file, stored.seed, wallet.address())
+ .with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
+ return Ok(wallet);
+ }
+ if stored.version != WALLET_FILE_VERSION {
+ bail!(
+ "unsupported wallet file version {} in {}",
+ stored.version,
+ path.display()
+ );
+ }
+ if wallet.address() != stored.address {
+ bail!(
+ "wallet file {} has address {}, but its seed derives {}",
+ path.display(),
+ stored.address,
+ wallet.address()
+ );
+ }
+
+ Ok(wallet)
+}
+
+fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> {
+ let stored = WalletFile {
+ version: WALLET_FILE_VERSION,
+ seed,
+ address: address.to_string(),
+ };
+ let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
+ file.write_all(&bytes)?;
+ file.write_all(b"\n")?;
+ Ok(())
+}
+
+fn random_seed() -> Result<String> {
+ let mut bytes = [0_u8; 32];
+ getrandom::getrandom(&mut bytes)
+ .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
+ Ok(hex_encode(&bytes))
+}
+
+fn create_wallet_file(path: &Path) -> Result<File> {
+ let mut options = OpenOptions::new();
+ options.write(true).create_new(true);
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+
+ options
+ .open(path)
+ .with_context(|| format!("failed to create wallet file {}", path.display()))
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use tempfile::tempdir;
+
+ use super::load_or_create;
+
+ #[test]
+ fn creates_and_reuses_wallet_file() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let first = load_or_create(&path).unwrap();
+ let second = load_or_create(&path).unwrap();
+
+ assert_eq!(first.address(), second.address());
+ let stored = fs::read_to_string(path).unwrap();
+ assert!(stored.contains(first.address()));
+ assert!(!stored.contains("dev-wallet"));
+ }
+
+ #[test]
+ fn migrates_v1_wallet_file_to_current_address() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+ fs::write(&path, r#"{"version":1,"seed":"alice","address":"old"}"#).unwrap();
+
+ let wallet = load_or_create(&path).unwrap();
+ let stored = fs::read_to_string(path).unwrap();
+
+ assert!(stored.contains("\"version\": 2"));
+ assert!(stored.contains(wallet.address()));
+ }
+
+ #[test]
+ fn rejects_seed_address_mismatch() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+ fs::write(
+ &path,
+ r#"{"version":2,"seed":"alice","address":"mv_wrong"}"#,
+ )
+ .unwrap();
+
+ let error = load_or_create(&path).unwrap_err();
+
+ assert!(error.to_string().contains("seed derives"));
+ }
+}
diff --git a/src/app.rs b/src/app.rs
@@ -0,0 +1,771 @@
+use std::{
+ collections::BTreeMap,
+ sync::Arc,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+use tokio::sync::Mutex;
+
+use crate::domain::{
+ Amount, Block, ChainSnapshot, ChainStatus, Ledger, PreparedBlock, Transaction,
+ VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+};
+
+pub type SharedNode = Arc<Mutex<NodeCore>>;
+pub type SharedPeerBook = Arc<Mutex<PeerBook>>;
+
+pub const DEFAULT_BURN_PER_BLOCK: Amount = 0;
+pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000;
+pub const PROTOCOL_VERSION: u32 = 1;
+pub const NETWORK_ID: &str = "mivora-devnet-v0";
+pub const BLOCK_REQUEST_LIMIT: usize = 128;
+const IMPORT_REBROADCAST_LIMIT: usize = 128;
+
+#[derive(Clone, Debug)]
+pub struct NodeConfig {
+ pub name: String,
+ pub wallet: Wallet,
+ pub genesis_allocations: BTreeMap<String, Amount>,
+ pub vdf_rounds: u32,
+ pub burn_per_block: Amount,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum GossipEnvelope {
+ Hello(ProtocolHello),
+ PeerStatus {
+ height: u64,
+ tip_hash: String,
+ },
+ ChainSnapshotRequest,
+ BlockRangeRequest {
+ from_height: u64,
+ limit: usize,
+ },
+ TransactionRequest {
+ signatures: Vec<String>,
+ },
+ BlockRequest {
+ hashes: Vec<String>,
+ },
+ Inventory {
+ txs: Vec<String>,
+ blocks: Vec<BlockInventory>,
+ },
+ Transaction(Transaction),
+ Transactions {
+ transactions: Vec<Transaction>,
+ },
+ Block(Block),
+ Blocks {
+ blocks: Vec<Block>,
+ },
+ ChainSnapshot(ChainSnapshot),
+ PeerAnnouncement {
+ address: String,
+ },
+ PeerList {
+ peers: Vec<String>,
+ },
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ProtocolHello {
+ pub protocol_version: u32,
+ pub network_id: String,
+ pub genesis_hash: String,
+ pub listen_addr: Option<String>,
+ pub height: u64,
+ pub tip_hash: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct BlockInventory {
+ pub height: u64,
+ pub hash: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct NodeStatus {
+ pub name: String,
+ pub wallet_address: String,
+ pub wallet_balance: Amount,
+ pub mining: MiningStatus,
+ pub chain: ChainStatus,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct MiningStatus {
+ pub automatic: bool,
+ pub burn_per_block: Amount,
+ pub vdf_rounds: u32,
+ pub vdf_target_block_ms: u64,
+ pub current_leader: Option<String>,
+ pub wallet_is_current_leader: bool,
+ pub last_auto_burn_height: Option<u64>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct AutoMineOutcome {
+ pub burned: Option<Transaction>,
+ pub block: Option<Block>,
+ pub skipped_reason: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AutoMinePlan {
+ pub burned: Option<Transaction>,
+ pub work: Option<PreparedBlock>,
+ pub skipped_reason: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub struct NodeCore {
+ name: String,
+ wallet: Wallet,
+ ledger: Ledger,
+ burn_per_block: Amount,
+ last_auto_burn_height: Option<u64>,
+ outbox: Vec<GossipEnvelope>,
+}
+
+impl NodeCore {
+ pub fn new(config: NodeConfig) -> Self {
+ let ledger = Ledger::new(config.genesis_allocations, config.vdf_rounds);
+ Self::from_ledger(config.name, config.wallet, ledger, config.burn_per_block)
+ }
+
+ pub fn from_ledger(
+ name: String,
+ wallet: Wallet,
+ ledger: Ledger,
+ burn_per_block: Amount,
+ ) -> Self {
+ Self {
+ name,
+ wallet,
+ ledger,
+ burn_per_block,
+ last_auto_burn_height: None,
+ outbox: Vec::new(),
+ }
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn wallet_address(&self) -> &str {
+ self.wallet.address()
+ }
+
+ pub fn ledger(&self) -> &Ledger {
+ &self.ledger
+ }
+
+ pub(crate) fn clone_ledger(&self) -> Ledger {
+ self.ledger.clone()
+ }
+
+ pub fn chain(&self) -> &[Block] {
+ self.ledger.chain()
+ }
+
+ pub fn chain_height(&self) -> u64 {
+ self.ledger.height()
+ }
+
+ pub fn recent_blocks(&self, limit: usize) -> Vec<Block> {
+ self.ledger.recent_blocks(limit)
+ }
+
+ pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> {
+ self.ledger.blocks_before(before_height, limit)
+ }
+
+ pub fn pending_transactions(&self) -> Vec<Transaction> {
+ self.ledger.pending().to_vec()
+ }
+
+ pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> {
+ let txs = self
+ .ledger
+ .pending()
+ .iter()
+ .map(|tx| tx.signature().to_string())
+ .collect::<Vec<_>>();
+ if txs.is_empty() {
+ Vec::new()
+ } else {
+ vec![GossipEnvelope::Inventory {
+ txs,
+ blocks: Vec::new(),
+ }]
+ }
+ }
+
+ pub fn chain_snapshot(&self) -> ChainSnapshot {
+ self.ledger.snapshot()
+ }
+
+ pub fn hello(&self, listen_addr: Option<String>) -> GossipEnvelope {
+ let status = self.ledger.status();
+ GossipEnvelope::Hello(ProtocolHello {
+ protocol_version: PROTOCOL_VERSION,
+ network_id: NETWORK_ID.to_string(),
+ genesis_hash: self.ledger.genesis_hash().to_string(),
+ listen_addr,
+ height: status.height,
+ tip_hash: status.tip_hash,
+ })
+ }
+
+ pub fn peer_status(&self) -> GossipEnvelope {
+ let status = self.ledger.status();
+ GossipEnvelope::PeerStatus {
+ height: status.height,
+ tip_hash: status.tip_hash,
+ }
+ }
+
+ pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> {
+ self.ledger.blocks_from(from_height, limit)
+ }
+
+ pub fn transactions_by_signature(&self, signatures: &[String]) -> Vec<Transaction> {
+ signatures
+ .iter()
+ .filter_map(|signature| self.ledger.transaction_by_signature(signature))
+ .collect()
+ }
+
+ pub fn blocks_by_hash(&self, hashes: &[String]) -> Vec<Block> {
+ hashes
+ .iter()
+ .filter_map(|hash| self.ledger.block_by_hash(hash))
+ .collect()
+ }
+
+ pub fn missing_inventory_requests(
+ &self,
+ txs: &[String],
+ blocks: &[BlockInventory],
+ ) -> Vec<GossipEnvelope> {
+ let missing_txs = txs
+ .iter()
+ .filter(|signature| !self.ledger.has_transaction(signature))
+ .cloned()
+ .collect::<Vec<_>>();
+ let local_height = self.ledger.height();
+ let first_height_gap = blocks
+ .iter()
+ .filter(|block| !self.ledger.has_block(&block.hash))
+ .filter(|block| block.height > local_height + 1)
+ .map(|block| block.height)
+ .min();
+ let missing_blocks = blocks
+ .iter()
+ .filter(|block| !self.ledger.has_block(&block.hash))
+ .filter(|block| first_height_gap.is_none_or(|gap| block.height < gap))
+ .map(|block| block.hash.clone())
+ .collect::<Vec<_>>();
+
+ let mut requests = Vec::new();
+ if !missing_txs.is_empty() {
+ requests.push(GossipEnvelope::TransactionRequest {
+ signatures: missing_txs,
+ });
+ }
+ if !missing_blocks.is_empty() {
+ requests.push(GossipEnvelope::BlockRequest {
+ hashes: missing_blocks,
+ });
+ }
+ if first_height_gap.is_some() {
+ requests.push(GossipEnvelope::BlockRangeRequest {
+ from_height: local_height + 1,
+ limit: BLOCK_REQUEST_LIMIT,
+ });
+ }
+ requests
+ }
+
+ pub fn status(&self) -> NodeStatus {
+ let current_leader = self.ledger.expected_leader_for_next_block();
+ let wallet_is_current_leader = current_leader
+ .as_deref()
+ .is_none_or(|leader| leader == self.wallet.address());
+
+ NodeStatus {
+ name: self.name.clone(),
+ wallet_address: self.wallet.address().to_string(),
+ wallet_balance: self.ledger.balance_of(self.wallet.address()),
+ mining: MiningStatus {
+ automatic: true,
+ burn_per_block: self.burn_per_block,
+ vdf_rounds: self.ledger.vdf_rounds(),
+ vdf_target_block_ms: VDF_TARGET_BLOCK_MS,
+ current_leader,
+ wallet_is_current_leader,
+ last_auto_burn_height: self.last_auto_burn_height,
+ },
+ chain: self.ledger.status(),
+ }
+ }
+
+ pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> {
+ let was_disabled = self.burn_per_block == 0;
+ self.burn_per_block = amount;
+ if was_disabled && amount > 0 {
+ self.last_auto_burn_height = None;
+ }
+ self.prepare_automatic_burn()
+ }
+
+ pub fn burn(&mut self, amount: Amount) -> Result<Transaction> {
+ let tx = self
+ .wallet
+ .burn(amount, self.ledger.next_nonce(self.wallet.address()));
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
+ }
+ Ok(tx)
+ }
+
+ pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> {
+ let tx = self
+ .wallet
+ .transfer(to, amount, self.ledger.next_nonce(self.wallet.address()));
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
+ }
+ Ok(tx)
+ }
+
+ pub fn mine_one(&mut self) -> Result<Block> {
+ self.mine_one_at(now_ms())
+ }
+
+ pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome {
+ let plan = self.prepare_automatic_mining(timestamp_ms);
+ let mut outcome = AutoMineOutcome {
+ burned: plan.burned,
+ block: None,
+ skipped_reason: plan.skipped_reason,
+ };
+
+ let Some(work) = plan.work else {
+ return outcome;
+ };
+ let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
+ match self.complete_prepared_block(work, vdf_output) {
+ Ok(block) => {
+ outcome.block = Some(block);
+ outcome.skipped_reason = None;
+ }
+ Err(error) => {
+ outcome.skipped_reason = Some(format!("{error:#}"));
+ }
+ }
+
+ outcome
+ }
+
+ pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan {
+ let mut plan = AutoMinePlan {
+ burned: None,
+ work: None,
+ skipped_reason: None,
+ };
+
+ match self.prepare_automatic_burn() {
+ Ok(tx) => plan.burned = tx,
+ Err(error) => {
+ plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
+ return plan;
+ }
+ }
+
+ let selected_leader = self.ledger.expected_leader_for_next_block();
+ if selected_leader
+ .as_deref()
+ .is_some_and(|leader| leader != self.wallet.address())
+ {
+ plan.skipped_reason = selected_leader.map(|leader| {
+ format!("wallet is waiting for selected leader {leader} to finish the VDF")
+ });
+ return plan;
+ }
+
+ match self
+ .ledger
+ .prepare_next_block(self.wallet.address(), timestamp_ms)
+ {
+ Ok(work) => {
+ plan.work = Some(work);
+ }
+ Err(error) => {
+ plan.skipped_reason = Some(format!("{error:#}"));
+ }
+ }
+
+ plan
+ }
+
+ fn prepare_automatic_burn(&mut self) -> Result<Option<Transaction>> {
+ let current_height = self.ledger.status().height;
+ if self.burn_per_block == 0 {
+ self.last_auto_burn_height = Some(current_height);
+ return Ok(None);
+ }
+ if self.last_auto_burn_height == Some(current_height) {
+ return Ok(None);
+ }
+
+ let tx = self.burn(self.burn_per_block)?;
+ self.last_auto_burn_height = Some(current_height);
+ Ok(Some(tx))
+ }
+
+ pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> {
+ let block = self
+ .ledger
+ .mine_next_block(self.wallet.address(), timestamp_ms)?;
+ self.ledger.apply_locally_mined_block(block.clone())?;
+ self.outbox.push(GossipEnvelope::Block(block.clone()));
+ Ok(block)
+ }
+
+ pub fn complete_prepared_block(
+ &mut self,
+ work: PreparedBlock,
+ vdf_output: String,
+ ) -> Result<Block> {
+ let block = work.finish(vdf_output);
+ self.ledger.apply_locally_mined_block(block.clone())?;
+ self.outbox.push(GossipEnvelope::Block(block.clone()));
+ Ok(block)
+ }
+
+ pub fn receive(&mut self, envelope: GossipEnvelope) -> Result<()> {
+ match envelope {
+ GossipEnvelope::Hello(_)
+ | GossipEnvelope::PeerStatus { .. }
+ | GossipEnvelope::ChainSnapshotRequest
+ | GossipEnvelope::BlockRangeRequest { .. }
+ | GossipEnvelope::TransactionRequest { .. }
+ | GossipEnvelope::BlockRequest { .. }
+ | GossipEnvelope::Inventory { .. } => Ok(()),
+ GossipEnvelope::Transaction(tx) => {
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx));
+ }
+ Ok(())
+ }
+ GossipEnvelope::Transactions { transactions } => {
+ for tx in transactions {
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx));
+ }
+ }
+ Ok(())
+ }
+ GossipEnvelope::Block(block) => {
+ let previous_height = self.ledger.height();
+ self.ledger.apply_block(block.clone())?;
+ if self.ledger.height() > previous_height {
+ self.outbox.push(GossipEnvelope::Block(block));
+ }
+ Ok(())
+ }
+ GossipEnvelope::Blocks { blocks } => {
+ let mut imported = Vec::new();
+ for block in blocks {
+ let previous_height = self.ledger.height();
+ self.ledger.apply_block(block.clone())?;
+ if self.ledger.height() > previous_height {
+ imported.push(block);
+ }
+ }
+ for block in imported {
+ self.outbox.push(GossipEnvelope::Block(block));
+ }
+ Ok(())
+ }
+ GossipEnvelope::ChainSnapshot(snapshot) => self.import_chain_snapshot(snapshot),
+ GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerList { .. } => Ok(()),
+ }
+ }
+
+ pub(crate) fn receive_preverified_block(&mut self, block: Block) -> Result<()> {
+ let previous_height = self.ledger.height();
+ self.ledger.apply_preverified_block(block.clone())?;
+ if self.ledger.height() > previous_height {
+ self.outbox.push(GossipEnvelope::Block(block));
+ }
+ Ok(())
+ }
+
+ pub(crate) fn block_requires_vdf_verification(&self, block: &Block) -> Result<bool> {
+ self.ledger.block_requires_vdf_verification(block)
+ }
+
+ pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> {
+ let previous_height = self.ledger.height();
+ let imported = self.ledger.extend_from_snapshot(snapshot)?;
+ if imported {
+ self.last_auto_burn_height = None;
+ self.enqueue_imported_blocks(previous_height);
+ }
+ Ok(())
+ }
+
+ pub(crate) fn import_verified_ledger(&mut self, ledger: Ledger) -> Result<bool> {
+ if ledger.genesis_hash() != self.ledger.genesis_hash() {
+ anyhow::bail!("chain snapshot genesis does not match local chain");
+ }
+ let previous_height = self.ledger.height();
+ if ledger.height() <= previous_height {
+ return Ok(false);
+ }
+
+ self.ledger = ledger;
+ self.last_auto_burn_height = None;
+ self.enqueue_imported_blocks(previous_height);
+ Ok(true)
+ }
+
+ pub fn drain_outbox(&mut self) -> Vec<GossipEnvelope> {
+ std::mem::take(&mut self.outbox)
+ }
+
+ fn enqueue_imported_blocks(&mut self, previous_height: u64) {
+ if self.ledger.height() <= previous_height {
+ return;
+ }
+ let blocks = self
+ .ledger
+ .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT);
+ if !blocks.is_empty() {
+ self.outbox.push(GossipEnvelope::Blocks { blocks });
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+pub struct PeerBook {
+ peers: BTreeMap<String, PeerInfo>,
+}
+
+impl PeerBook {
+ pub fn from_addresses(addresses: Vec<String>) -> Self {
+ let mut book = Self::default();
+ for address in addresses {
+ book.add_peer(address);
+ }
+ book
+ }
+
+ pub fn add_peer(&mut self, address: impl Into<String>) {
+ let address = address.into();
+ let peer = self
+ .peers
+ .entry(address.clone())
+ .or_insert_with(|| PeerInfo::new(address, PeerDirection::Outbound));
+ if peer.direction == PeerDirection::Inbound {
+ peer.direction = PeerDirection::Outbound;
+ }
+ }
+
+ pub fn addresses(&self) -> Vec<String> {
+ self.peers
+ .values()
+ .filter(|peer| peer.direction != PeerDirection::Inbound)
+ .map(|peer| peer.address.clone())
+ .collect()
+ }
+
+ pub fn addresses_except(&self, excluded: &str) -> Vec<String> {
+ self.addresses()
+ .into_iter()
+ .filter(|address| address != excluded)
+ .collect()
+ }
+
+ pub fn list(&self) -> Vec<PeerInfo> {
+ self.peers.values().cloned().collect()
+ }
+
+ pub fn record_sent(&mut self, address: &str, count: u64) {
+ let peer = self.ensure(address, PeerDirection::Outbound);
+ peer.messages_sent += count;
+ peer.last_error = None;
+ }
+
+ pub fn record_status(&mut self, address: &str, height: u64, tip_hash: String) {
+ let peer = self.ensure(address, PeerDirection::Outbound);
+ peer.last_known_height = Some(height);
+ peer.last_known_tip_hash = Some(tip_hash);
+ }
+
+ pub fn record_error(&mut self, address: &str, error: impl Into<String>) {
+ let peer = self.ensure(address, PeerDirection::Outbound);
+ peer.last_error = Some(error.into());
+ }
+
+ pub fn record_inbound_error(&mut self, address: &str, error: impl Into<String>) {
+ let peer = self.ensure(address, PeerDirection::Inbound);
+ peer.last_error = Some(error.into());
+ }
+
+ pub fn record_received(&mut self, address: &str, count: u64) {
+ let peer = self.ensure(address, PeerDirection::Inbound);
+ peer.messages_received += count;
+ }
+
+ fn ensure(&mut self, address: &str, direction: PeerDirection) -> &mut PeerInfo {
+ self.peers
+ .entry(address.to_string())
+ .or_insert_with(|| PeerInfo::new(address.to_string(), direction))
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct PeerInfo {
+ pub address: String,
+ pub direction: PeerDirection,
+ pub messages_sent: u64,
+ pub messages_received: u64,
+ pub last_known_height: Option<u64>,
+ pub last_known_tip_hash: Option<String>,
+ pub last_error: Option<String>,
+}
+
+impl PeerInfo {
+ fn new(address: String, direction: PeerDirection) -> Self {
+ Self {
+ address,
+ direction,
+ messages_sent: 0,
+ messages_received: 0,
+ last_known_height: None,
+ last_known_tip_hash: None,
+ last_error: None,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PeerDirection {
+ Outbound,
+ Inbound,
+}
+
+pub fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time is before unix epoch")
+ .as_millis() as u64
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeMap;
+
+ use crate::domain::Wallet;
+
+ use super::{NodeConfig, NodeCore};
+
+ #[test]
+ fn same_height_verified_import_does_not_reset_auto_burn_guard() {
+ let alice = Wallet::from_seed("same-height-import-alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ let mut node = NodeCore::new(NodeConfig {
+ name: "alice".to_string(),
+ wallet: alice,
+ genesis_allocations: allocations,
+ vdf_rounds: 10,
+ burn_per_block: 1,
+ });
+
+ let first = node.prepare_automatic_mining(1);
+ assert!(first.burned.is_some());
+ assert_eq!(node.last_auto_burn_height, Some(0));
+
+ let same_height_ledger = node.clone_ledger();
+ assert!(!node.import_verified_ledger(same_height_ledger).unwrap());
+ assert_eq!(node.last_auto_burn_height, Some(0));
+
+ let second = node.prepare_automatic_mining(2);
+ assert!(second.burned.is_none());
+ }
+}
+
+#[derive(Debug, Default)]
+pub struct InMemoryNetwork {
+ nodes: BTreeMap<String, NodeCore>,
+}
+
+impl InMemoryNetwork {
+ pub fn insert(&mut self, id: impl Into<String>, node: NodeCore) {
+ self.nodes.insert(id.into(), node);
+ }
+
+ pub fn node(&self, id: &str) -> Option<&NodeCore> {
+ self.nodes.get(id)
+ }
+
+ pub fn node_mut(&mut self, id: &str) -> Option<&mut NodeCore> {
+ self.nodes.get_mut(id)
+ }
+
+ pub fn deliver_until_idle(&mut self) -> Result<()> {
+ loop {
+ let mut outbound = Vec::new();
+ for (id, node) in &mut self.nodes {
+ for envelope in node.drain_outbox() {
+ outbound.push((id.clone(), envelope));
+ }
+ }
+
+ if outbound.is_empty() {
+ return Ok(());
+ }
+
+ for (from, envelope) in outbound {
+ for (id, node) in &mut self.nodes {
+ if *id != from {
+ node.receive(envelope.clone())?;
+ }
+ }
+ }
+ }
+ }
+
+ pub fn sync_node_from_peer(&mut self, from: &str, to: &str, limit: usize) -> Result<bool> {
+ let from_height = self
+ .nodes
+ .get(to)
+ .map(|node| node.chain_height() + 1)
+ .ok_or_else(|| anyhow::anyhow!("missing sync target node {to}"))?;
+ let blocks = self
+ .nodes
+ .get(from)
+ .map(|node| node.blocks_from(from_height, limit))
+ .ok_or_else(|| anyhow::anyhow!("missing sync source node {from}"))?;
+ if blocks.is_empty() {
+ return Ok(false);
+ }
+
+ self.nodes
+ .get_mut(to)
+ .expect("sync target exists")
+ .receive(GossipEnvelope::Blocks { blocks })?;
+ Ok(true)
+ }
+}
diff --git a/src/domain.rs b/src/domain.rs
@@ -0,0 +1,1425 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use anyhow::{Context, Result, anyhow, bail};
+use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+pub type Amount = u64;
+pub const BLOCK_REWARD: Amount = 100;
+pub const VDF_TARGET_BLOCK_MS: u64 = 60_000;
+const MAX_PENDING_TRANSACTIONS: usize = 10_000;
+const MAX_BLOCK_TRANSACTIONS: usize = 1_000;
+const MIN_VDF_ROUNDS: u32 = 1;
+const VDF_RETARGET_WINDOW_BLOCKS: usize = 10;
+const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 10;
+const FORK_FINALITY_DEPTH: u64 = 6;
+const BETTER_VRF_MAX_SHORTER_BY: u64 = 2;
+const VDF_MODULUS: u128 = 4_611_685_975_477_714_963;
+const VDF_CHALLENGE_MIN: u64 = 1_073_741_827;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Wallet {
+ address: String,
+ secret: String,
+}
+
+impl Wallet {
+ pub fn from_seed(seed: &str) -> Self {
+ let seed_hash = Sha256::digest(format!("mivora-wallet-seed:{seed}").as_bytes());
+ let mut signing_seed = [0_u8; 32];
+ signing_seed.copy_from_slice(&seed_hash);
+ let signing_key = SigningKey::from_bytes(&signing_seed);
+ let secret = hex_encode(signing_seed);
+ let address = hex_encode(signing_key.verifying_key().to_bytes());
+ Self { address, secret }
+ }
+
+ pub fn address(&self) -> &str {
+ &self.address
+ }
+
+ pub fn burn(&self, amount: Amount, nonce: u64) -> Transaction {
+ let unsigned = UnsignedTransaction::Burn {
+ from: self.address.clone(),
+ amount,
+ nonce,
+ };
+ unsigned.sign(self)
+ }
+
+ pub fn transfer(&self, to: impl Into<String>, amount: Amount, nonce: u64) -> Transaction {
+ let unsigned = UnsignedTransaction::Transfer {
+ from: self.address.clone(),
+ to: to.into(),
+ amount,
+ nonce,
+ };
+ unsigned.sign(self)
+ }
+
+ fn sign_payload(&self, payload: &str) -> String {
+ let seed = decode_hex_array::<32>(&self.secret).expect("wallet secret is valid hex");
+ let signing_key = SigningKey::from_bytes(&seed);
+ let signature: Signature = signing_key.sign(payload.as_bytes());
+ hex_encode(signature.to_bytes())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum UnsignedTransaction {
+ Transfer {
+ from: String,
+ to: String,
+ amount: Amount,
+ nonce: u64,
+ },
+ Burn {
+ from: String,
+ amount: Amount,
+ nonce: u64,
+ },
+}
+
+impl UnsignedTransaction {
+ fn sign(self, wallet: &Wallet) -> Transaction {
+ let signature = wallet.sign_payload(&self.canonical());
+ match self {
+ Self::Transfer {
+ from,
+ to,
+ amount,
+ nonce,
+ } => Transaction::Transfer {
+ from,
+ to,
+ amount,
+ nonce,
+ signature,
+ },
+ Self::Burn {
+ from,
+ amount,
+ nonce,
+ } => Transaction::Burn {
+ from,
+ amount,
+ nonce,
+ signature,
+ },
+ }
+ }
+
+ fn canonical(&self) -> String {
+ match self {
+ Self::Transfer {
+ from,
+ to,
+ amount,
+ nonce,
+ } => format!("transfer:{from}:{to}:{amount}:{nonce}"),
+ Self::Burn {
+ from,
+ amount,
+ nonce,
+ } => format!("burn:{from}:{amount}:{nonce}"),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Transaction {
+ Transfer {
+ from: String,
+ to: String,
+ amount: Amount,
+ nonce: u64,
+ signature: String,
+ },
+ Burn {
+ from: String,
+ amount: Amount,
+ nonce: u64,
+ signature: String,
+ },
+}
+
+impl Transaction {
+ pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self {
+ let from = from.into();
+ let signature = hex_hash(format!("mivora-genesis-burn:{from}:{amount}"));
+ Self::Burn {
+ from,
+ amount,
+ nonce: 0,
+ signature,
+ }
+ }
+
+ pub fn sender(&self) -> &str {
+ match self {
+ Self::Transfer { from, .. } | Self::Burn { from, .. } => from,
+ }
+ }
+
+ pub fn nonce(&self) -> u64 {
+ match self {
+ Self::Transfer { nonce, .. } | Self::Burn { nonce, .. } => *nonce,
+ }
+ }
+
+ pub fn amount(&self) -> Amount {
+ match self {
+ Self::Transfer { amount, .. } | Self::Burn { amount, .. } => *amount,
+ }
+ }
+
+ pub fn signature(&self) -> &str {
+ match self {
+ Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature,
+ }
+ }
+
+ pub fn is_burn(&self) -> bool {
+ matches!(self, Self::Burn { .. })
+ }
+
+ pub fn canonical(&self) -> String {
+ format!("{}:{}", self.signing_payload(), self.signature())
+ }
+
+ fn signing_payload(&self) -> String {
+ match self {
+ Self::Transfer {
+ from,
+ to,
+ amount,
+ nonce,
+ ..
+ } => format!("transfer:{from}:{to}:{amount}:{nonce}"),
+ Self::Burn {
+ from,
+ amount,
+ nonce,
+ ..
+ } => format!("burn:{from}:{amount}:{nonce}"),
+ }
+ }
+
+ fn verify_signature(&self) -> Result<()> {
+ let public_key = decode_hex_array::<32>(self.sender())
+ .with_context(|| format!("invalid public key for {}", self.sender()))?;
+ let signature =
+ decode_hex_array::<64>(self.signature()).context("invalid signature hex")?;
+ let verifying_key =
+ VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?;
+ let signature = Signature::from_bytes(&signature);
+ verifying_key
+ .verify(self.signing_payload().as_bytes(), &signature)
+ .context("transaction signature is invalid")
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct Block {
+ pub height: u64,
+ pub prev_hash: String,
+ pub timestamp_ms: u64,
+ pub miner: String,
+ pub reward: Amount,
+ pub vdf_rounds: u32,
+ pub vdf_output: String,
+ pub transactions: Vec<Transaction>,
+ pub hash: String,
+}
+
+impl Block {
+ fn new(draft: BlockDraft) -> Self {
+ let mut block = Self {
+ height: draft.height,
+ prev_hash: draft.prev_hash,
+ timestamp_ms: draft.timestamp_ms,
+ miner: draft.miner,
+ reward: draft.reward,
+ vdf_rounds: draft.vdf_rounds,
+ vdf_output: draft.vdf_output,
+ transactions: draft.transactions,
+ hash: String::new(),
+ };
+ block.hash = block.compute_hash();
+ block
+ }
+
+ pub fn compute_hash(&self) -> String {
+ hex_hash(format!("block:{}:{}", self.vdf_seed(), self.vdf_output,))
+ }
+
+ pub fn vdf_seed(&self) -> String {
+ block_content_hash(
+ self.height,
+ &self.prev_hash,
+ self.timestamp_ms,
+ &self.miner,
+ self.reward,
+ self.vdf_rounds,
+ &self.transactions,
+ )
+ }
+
+ pub fn burn_tickets(&self) -> Vec<(&str, Amount)> {
+ self.transactions
+ .iter()
+ .filter_map(|tx| match tx {
+ Transaction::Burn { from, amount, .. } if *amount > 0 => {
+ Some((from.as_str(), *amount))
+ }
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn leader_score(&self) -> LeaderScore<'_> {
+ LeaderScore(&self.hash)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct PreparedBlock {
+ height: u64,
+ prev_hash: String,
+ timestamp_ms: u64,
+ miner: String,
+ reward: Amount,
+ vdf_rounds: u32,
+ vdf_seed: String,
+ transactions: Vec<Transaction>,
+}
+
+impl PreparedBlock {
+ pub fn vdf_seed(&self) -> &str {
+ &self.vdf_seed
+ }
+
+ pub fn vdf_rounds(&self) -> u32 {
+ self.vdf_rounds
+ }
+
+ pub fn height(&self) -> u64 {
+ self.height
+ }
+
+ pub fn finish(self, vdf_output: String) -> Block {
+ Block::new(BlockDraft {
+ height: self.height,
+ prev_hash: self.prev_hash,
+ timestamp_ms: self.timestamp_ms,
+ miner: self.miner,
+ reward: self.reward,
+ vdf_rounds: self.vdf_rounds,
+ vdf_output,
+ transactions: self.transactions,
+ })
+ }
+}
+
+#[derive(Clone, Debug)]
+struct BlockDraft {
+ height: u64,
+ prev_hash: String,
+ timestamp_ms: u64,
+ miner: String,
+ reward: Amount,
+ vdf_rounds: u32,
+ vdf_output: String,
+ transactions: Vec<Transaction>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ChainStatus {
+ pub height: u64,
+ pub tip_hash: String,
+ pub next_leader: Option<String>,
+ pub block_reward: Amount,
+ pub balances: BTreeMap<String, Amount>,
+ pub pending_transactions: usize,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ChainSnapshot {
+ pub genesis_allocations: BTreeMap<String, Amount>,
+ pub vdf_rounds: u32,
+ pub blocks: Vec<Block>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct GenesisBurn {
+ pub from: String,
+ pub amount: Amount,
+}
+
+impl GenesisBurn {
+ pub fn new(from: impl Into<String>, amount: Amount) -> Self {
+ Self {
+ from: from.into(),
+ amount,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct ForkPoint {
+ common_ancestor_height: u64,
+}
+
+impl ForkPoint {
+ fn first_diverging_height(self) -> u64 {
+ self.common_ancestor_height + 1
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct LeaderScore<'a>(&'a str);
+
+impl Ord for LeaderScore<'_> {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.0.cmp(other.0)
+ }
+}
+
+impl PartialOrd for LeaderScore<'_> {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ForkQuality {
+ LocalBetter,
+ RemoteBetter,
+ Equal,
+}
+
+impl From<std::cmp::Ordering> for ForkQuality {
+ fn from(ordering: std::cmp::Ordering) -> Self {
+ match ordering {
+ std::cmp::Ordering::Less => Self::LocalBetter,
+ std::cmp::Ordering::Equal => Self::Equal,
+ std::cmp::Ordering::Greater => Self::RemoteBetter,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ForkChoice {
+ KeepLocal,
+ SwitchToCandidate,
+}
+
+#[derive(Clone, Debug)]
+pub struct Ledger {
+ chain: Vec<Block>,
+ genesis_allocations: BTreeMap<String, Amount>,
+ balances: BTreeMap<String, Amount>,
+ nonces: BTreeMap<String, u64>,
+ pending: Vec<Transaction>,
+ block_reward: Amount,
+ initial_vdf_rounds: u32,
+ vdf_rounds: u32,
+}
+
+impl Ledger {
+ pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u32) -> Self {
+ Self::new_with_genesis_transactions(genesis_allocations, Vec::new(), vdf_rounds)
+ .expect("empty genesis transactions are valid")
+ }
+
+ pub fn new_with_genesis_burns(
+ genesis_allocations: BTreeMap<String, Amount>,
+ genesis_burns: Vec<GenesisBurn>,
+ vdf_rounds: u32,
+ ) -> Result<Self> {
+ let transactions = genesis_burns
+ .into_iter()
+ .map(|burn| Transaction::genesis_burn(burn.from, burn.amount))
+ .collect();
+ Self::new_with_genesis_transactions(genesis_allocations, transactions, vdf_rounds)
+ }
+
+ fn new_with_genesis_transactions(
+ genesis_allocations: BTreeMap<String, Amount>,
+ genesis_transactions: Vec<Transaction>,
+ vdf_rounds: u32,
+ ) -> Result<Self> {
+ let balances = balances_after_genesis(&genesis_allocations, &genesis_transactions)?;
+ let genesis = build_genesis_block(&genesis_allocations, genesis_transactions);
+ Ok(Self {
+ chain: vec![genesis],
+ genesis_allocations: genesis_allocations.clone(),
+ balances,
+ nonces: BTreeMap::new(),
+ pending: Vec::new(),
+ block_reward: BLOCK_REWARD,
+ initial_vdf_rounds: vdf_rounds,
+ vdf_rounds,
+ })
+ }
+
+ pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
+ Self::from_snapshot_with_vdf_policy(snapshot, true)
+ }
+
+ fn from_snapshot_with_vdf_policy(snapshot: ChainSnapshot, verify_vdf: bool) -> Result<Self> {
+ let ChainSnapshot {
+ genesis_allocations,
+ vdf_rounds,
+ blocks,
+ } = snapshot;
+
+ if blocks.is_empty() {
+ bail!("chain snapshot is empty");
+ }
+
+ let genesis = blocks[0].clone();
+ validate_genesis_block(&genesis)?;
+ let balances = balances_after_genesis(&genesis_allocations, &genesis.transactions)?;
+ let expected_genesis =
+ build_genesis_block(&genesis_allocations, genesis.transactions.clone());
+ if genesis != expected_genesis {
+ bail!("chain snapshot genesis does not match its allocations and transactions");
+ }
+
+ let mut ledger = Self {
+ chain: vec![genesis],
+ genesis_allocations,
+ balances,
+ nonces: BTreeMap::new(),
+ pending: Vec::new(),
+ block_reward: BLOCK_REWARD,
+ initial_vdf_rounds: vdf_rounds,
+ vdf_rounds,
+ };
+
+ for block in blocks.into_iter().skip(1) {
+ if verify_vdf {
+ ledger.apply_block(block)?;
+ } else {
+ ledger.apply_preverified_block(block)?;
+ }
+ }
+ Ok(ledger)
+ }
+
+ pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> {
+ self.extend_from_snapshot_with_vdf_policy(snapshot, true)
+ }
+
+ pub(crate) fn extend_from_preverified_snapshot(
+ &mut self,
+ snapshot: ChainSnapshot,
+ ) -> Result<bool> {
+ self.extend_from_snapshot_with_vdf_policy(snapshot, false)
+ }
+
+ pub(crate) fn missing_snapshot_blocks(&self, snapshot: &ChainSnapshot) -> Result<Vec<Block>> {
+ let remote_height = self.validate_snapshot_identity(snapshot)?;
+ if remote_height <= self.height() {
+ return Ok(Vec::new());
+ }
+ let common_ancestor_height = self.common_ancestor_height(snapshot)?;
+
+ Ok(snapshot
+ .blocks
+ .iter()
+ .skip(common_ancestor_height as usize + 1)
+ .cloned()
+ .collect())
+ }
+
+ fn extend_from_snapshot_with_vdf_policy(
+ &mut self,
+ snapshot: ChainSnapshot,
+ verify_vdf: bool,
+ ) -> Result<bool> {
+ self.validate_snapshot_identity(&snapshot)?;
+ let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf)?;
+ let fork_point = self.fork_point_with_candidate(&candidate)?;
+
+ if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal {
+ return Ok(false);
+ }
+
+ self.replace_with_better_chain(candidate, fork_point);
+
+ Ok(true)
+ }
+
+ fn validate_snapshot_identity(&self, snapshot: &ChainSnapshot) -> Result<u64> {
+ if snapshot.blocks.is_empty() {
+ bail!("chain snapshot is empty");
+ }
+ if snapshot.vdf_rounds != self.initial_vdf_rounds {
+ bail!("chain snapshot initial VDF rounds do not match local chain");
+ }
+ if snapshot.genesis_allocations != self.genesis_allocations {
+ bail!("chain snapshot genesis allocations do not match local chain");
+ }
+ if snapshot.blocks[0].hash != self.genesis_hash() {
+ bail!("chain snapshot genesis does not match local chain");
+ }
+
+ let remote_height = snapshot
+ .blocks
+ .last()
+ .map(|block| block.height)
+ .unwrap_or(0);
+
+ Ok(remote_height)
+ }
+
+ fn common_ancestor_height(&self, snapshot: &ChainSnapshot) -> Result<u64> {
+ self.validate_snapshot_identity(snapshot)?;
+ let max_common_index = self.chain.len().min(snapshot.blocks.len()) - 1;
+ for index in 0..=max_common_index {
+ if self.chain[index] != snapshot.blocks[index] {
+ if index == 0 {
+ bail!("chain snapshot has no common genesis block");
+ }
+ return Ok(index as u64 - 1);
+ }
+ }
+ Ok(max_common_index as u64)
+ }
+
+ fn fork_point_with_candidate(&self, candidate: &Ledger) -> Result<ForkPoint> {
+ if candidate.genesis_hash() != self.genesis_hash() {
+ bail!("candidate chain has no common genesis block");
+ }
+ let max_common_index = self.chain.len().min(candidate.chain.len()) - 1;
+ for index in 0..=max_common_index {
+ if self.chain[index] != candidate.chain[index] {
+ if index == 0 {
+ bail!("candidate chain has no common genesis block");
+ }
+ return Ok(ForkPoint {
+ common_ancestor_height: index as u64 - 1,
+ });
+ }
+ }
+ Ok(ForkPoint {
+ common_ancestor_height: max_common_index as u64,
+ })
+ }
+
+ fn choose_fork(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkChoice {
+ let local_height = self.height();
+ let remote_height = candidate.height();
+ if remote_height == local_height && candidate.tip().hash == self.tip().hash {
+ return ForkChoice::KeepLocal;
+ }
+
+ let finalized_floor = local_height.saturating_sub(FORK_FINALITY_DEPTH);
+ if fork_point.common_ancestor_height < finalized_floor {
+ return ForkChoice::KeepLocal;
+ }
+
+ match self.fork_quality(candidate, fork_point) {
+ ForkQuality::RemoteBetter => {
+ if remote_height + BETTER_VRF_MAX_SHORTER_BY >= local_height {
+ return ForkChoice::SwitchToCandidate;
+ }
+ }
+ ForkQuality::LocalBetter => {
+ if local_height + BETTER_VRF_MAX_SHORTER_BY >= remote_height {
+ return ForkChoice::KeepLocal;
+ }
+ }
+ ForkQuality::Equal => {}
+ }
+
+ if remote_height > local_height {
+ ForkChoice::SwitchToCandidate
+ } else {
+ ForkChoice::KeepLocal
+ }
+ }
+
+ fn fork_quality(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkQuality {
+ let local_fork = self
+ .chain
+ .iter()
+ .skip(fork_point.first_diverging_height() as usize);
+ let remote_fork = candidate
+ .chain
+ .iter()
+ .skip(fork_point.first_diverging_height() as usize);
+ for (local, remote) in local_fork.zip(remote_fork) {
+ match local.leader_score().cmp(&remote.leader_score()) {
+ std::cmp::Ordering::Equal => continue,
+ ordering => return ForkQuality::from(ordering),
+ }
+ }
+ ForkQuality::Equal
+ }
+
+ fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) {
+ let mut carry_forward = self.pending.clone();
+ for block in self
+ .chain
+ .iter()
+ .skip(fork_point.first_diverging_height() as usize)
+ {
+ carry_forward.extend(block.transactions.clone());
+ }
+
+ let mined_signatures = candidate
+ .chain
+ .iter()
+ .flat_map(|block| block.transactions.iter())
+ .map(|tx| tx.signature().to_string())
+ .collect::<BTreeSet<_>>();
+
+ for transaction in carry_forward {
+ if !mined_signatures.contains(transaction.signature()) {
+ let _ = candidate.submit_transaction(transaction);
+ }
+ }
+
+ *self = candidate;
+ }
+
+ pub fn snapshot(&self) -> ChainSnapshot {
+ ChainSnapshot {
+ genesis_allocations: self.genesis_allocations.clone(),
+ vdf_rounds: self.initial_vdf_rounds,
+ blocks: self.chain.clone(),
+ }
+ }
+
+ pub fn status(&self) -> ChainStatus {
+ ChainStatus {
+ height: self.tip().height,
+ tip_hash: self.tip().hash.clone(),
+ next_leader: self.expected_leader_for_next_block(),
+ block_reward: self.block_reward,
+ balances: self.balances.clone(),
+ pending_transactions: self.pending.len(),
+ }
+ }
+
+ pub fn chain(&self) -> &[Block] {
+ &self.chain
+ }
+
+ pub fn genesis_hash(&self) -> &str {
+ &self.chain[0].hash
+ }
+
+ pub fn height(&self) -> u64 {
+ self.tip().height
+ }
+
+ pub fn recent_blocks(&self, limit: usize) -> Vec<Block> {
+ self.chain.iter().rev().take(limit).cloned().collect()
+ }
+
+ pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> {
+ self.chain
+ .iter()
+ .rev()
+ .filter(|block| block.height < before_height)
+ .take(limit)
+ .cloned()
+ .collect()
+ }
+
+ pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> {
+ if limit == 0 {
+ return Vec::new();
+ }
+ self.chain
+ .iter()
+ .filter(|block| block.height >= from_height)
+ .take(limit)
+ .cloned()
+ .collect()
+ }
+
+ pub fn block_by_hash(&self, hash: &str) -> Option<Block> {
+ self.chain.iter().find(|block| block.hash == hash).cloned()
+ }
+
+ pub fn has_block(&self, hash: &str) -> bool {
+ self.chain.iter().any(|block| block.hash == hash)
+ }
+
+ pub fn pending(&self) -> &[Transaction] {
+ &self.pending
+ }
+
+ pub fn transaction_by_signature(&self, signature: &str) -> Option<Transaction> {
+ self.pending
+ .iter()
+ .chain(
+ self.chain
+ .iter()
+ .flat_map(|block| block.transactions.iter()),
+ )
+ .find(|tx| tx.signature() == signature)
+ .cloned()
+ }
+
+ pub fn has_transaction(&self, signature: &str) -> bool {
+ self.transaction_by_signature(signature).is_some()
+ }
+
+ pub fn vdf_rounds(&self) -> u32 {
+ self.vdf_rounds
+ }
+
+ pub fn balance_of(&self, address: &str) -> Amount {
+ self.balances.get(address).copied().unwrap_or(0)
+ }
+
+ pub fn next_nonce(&self, address: &str) -> u64 {
+ let base = self.nonces.get(address).copied().unwrap_or(0);
+ let Some(mut next) = base.checked_add(1) else {
+ return u64::MAX;
+ };
+ while self
+ .pending
+ .iter()
+ .any(|tx| tx.sender() == address && tx.nonce() == next)
+ {
+ let Some(candidate) = next.checked_add(1) else {
+ return u64::MAX;
+ };
+ next = candidate;
+ }
+ next
+ }
+
+ pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> {
+ if self
+ .pending
+ .iter()
+ .any(|tx| tx.signature() == transaction.signature())
+ {
+ return Ok(false);
+ }
+
+ transaction.verify_signature()?;
+
+ if self
+ .pending
+ .iter()
+ .any(|tx| tx.sender() == transaction.sender() && tx.nonce() == transaction.nonce())
+ {
+ return Ok(false);
+ }
+
+ if self.pending.len() >= MAX_PENDING_TRANSACTIONS {
+ bail!("mempool is full");
+ }
+
+ let mut balances = self.balances.clone();
+ let mut nonces = self.nonces.clone();
+ for pending in self.valid_pending_transactions() {
+ apply_transaction(&pending, &mut balances, &mut nonces)?;
+ }
+
+ let expected_nonce = next_expected_nonce(&nonces, transaction.sender())?;
+ if transaction.nonce() < expected_nonce {
+ return Ok(false);
+ }
+ if transaction.nonce() > expected_nonce {
+ self.pending.push(transaction);
+ return Ok(true);
+ }
+
+ apply_transaction(&transaction, &mut balances, &mut nonces)?;
+ self.pending.push(transaction);
+ Ok(true)
+ }
+
+ pub fn mine_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<Block> {
+ let prepared = self.prepare_next_block(miner, timestamp_ms)?;
+ let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
+ Ok(prepared.finish(vdf_output))
+ }
+
+ pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> {
+ if let Some(leader) = self.expected_leader_for_next_block() {
+ if leader != miner {
+ bail!("wallet {miner} is not the selected leader; expected {leader}");
+ }
+ }
+
+ let transactions = self
+ .valid_pending_transactions()
+ .into_iter()
+ .take(MAX_BLOCK_TRANSACTIONS)
+ .collect::<Vec<_>>();
+ if !contains_positive_burn(&transactions) {
+ bail!("cannot mine block without burned coins");
+ }
+
+ let tip = self.tip();
+ let prev_hash = tip.hash.clone();
+ let height = tip.height + 1;
+ let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1);
+ let vdf_seed = block_content_hash(
+ height,
+ &prev_hash,
+ timestamp_ms,
+ miner,
+ self.block_reward,
+ self.vdf_rounds,
+ &transactions,
+ );
+ Ok(PreparedBlock {
+ height,
+ prev_hash,
+ timestamp_ms,
+ miner: miner.to_string(),
+ reward: self.block_reward,
+ vdf_rounds: self.vdf_rounds,
+ vdf_seed,
+ transactions,
+ })
+ }
+
+ pub fn apply_block(&mut self, block: Block) -> Result<()> {
+ self.apply_block_with_vdf_policy(block, true)
+ }
+
+ pub(crate) fn block_requires_vdf_verification(&self, block: &Block) -> Result<bool> {
+ self.precheck_block_without_vdf(block)
+ }
+
+ pub fn apply_locally_mined_block(&mut self, block: Block) -> Result<()> {
+ self.apply_preverified_block(block)
+ }
+
+ pub(crate) fn apply_preverified_block(&mut self, block: Block) -> Result<()> {
+ self.apply_block_with_vdf_policy(block, false)
+ }
+
+ fn apply_block_with_vdf_policy(&mut self, block: Block, should_verify_vdf: bool) -> Result<()> {
+ if !self.precheck_block_without_vdf(&block)? {
+ return Ok(());
+ }
+
+ if should_verify_vdf && !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output)
+ {
+ bail!("block VDF output is invalid");
+ }
+
+ let mut balances = self.balances.clone();
+ let mut nonces = self.nonces.clone();
+ let mut signatures = BTreeSet::new();
+ for tx in &block.transactions {
+ if !signatures.insert(tx.signature()) {
+ bail!("duplicate transaction in block");
+ }
+ apply_transaction(tx, &mut balances, &mut nonces)?;
+ }
+ credit_balance(&mut balances, &block.miner, block.reward)?;
+
+ let mined_signatures = block
+ .transactions
+ .iter()
+ .map(|tx| tx.signature().to_string())
+ .collect::<BTreeSet<_>>();
+ self.balances = balances;
+ self.nonces = nonces;
+ self.pending.retain(|tx| {
+ !mined_signatures.contains(tx.signature())
+ && tx.nonce() > self.nonces.get(tx.sender()).copied().unwrap_or(0)
+ });
+ self.chain.push(block);
+ self.vdf_rounds = self.next_vdf_rounds_after_tip();
+ Ok(())
+ }
+
+ fn precheck_block_without_vdf(&self, block: &Block) -> Result<bool> {
+ if block.height <= self.tip().height {
+ let existing = self
+ .chain
+ .get(block.height as usize)
+ .with_context(|| format!("local chain has no block at height {}", block.height))?;
+ if existing.hash == block.hash {
+ return Ok(false);
+ }
+ bail!(
+ "block at height {} conflicts with local chain",
+ block.height
+ );
+ }
+
+ let expected_height = self.tip().height + 1;
+ if block.height != expected_height {
+ bail!(
+ "expected block height {expected_height}, got {}",
+ block.height
+ );
+ }
+ if block.prev_hash != self.tip().hash {
+ bail!("block does not extend local tip");
+ }
+ if block.compute_hash() != block.hash {
+ bail!("block hash is invalid");
+ }
+ if block.reward != self.block_reward {
+ bail!("block reward is invalid");
+ }
+ if block.vdf_rounds != self.vdf_rounds {
+ bail!("block VDF rounds are invalid");
+ }
+ if block.timestamp_ms <= self.tip().timestamp_ms {
+ bail!("block timestamp must increase");
+ }
+ if block.transactions.len() > MAX_BLOCK_TRANSACTIONS {
+ bail!("block has too many transactions");
+ }
+ if !contains_positive_burn(&block.transactions) {
+ bail!("block must include burned coins");
+ }
+ if let Some(leader) = self.expected_leader_for_next_block() {
+ if leader != block.miner {
+ bail!(
+ "block miner {} is not selected leader {leader}",
+ block.miner
+ );
+ }
+ }
+
+ Ok(true)
+ }
+
+ fn next_vdf_rounds_after_tip(&self) -> u32 {
+ let Some(tip) = self.chain.last() else {
+ return self.vdf_rounds;
+ };
+ if tip.height < 2 {
+ return self.vdf_rounds;
+ }
+
+ let mut total_observed_ms = 0_u128;
+ let mut observed_blocks = 0_u128;
+ for pair in self.chain.windows(2).rev().take(VDF_RETARGET_WINDOW_BLOCKS) {
+ total_observed_ms += u128::from(pair[1].timestamp_ms - pair[0].timestamp_ms);
+ observed_blocks += 1;
+ }
+ if observed_blocks == 0 {
+ return self.vdf_rounds;
+ }
+
+ let average_observed_ms = (total_observed_ms / observed_blocks) as u64;
+ retarget_vdf_rounds(tip.vdf_rounds, average_observed_ms)
+ }
+
+ pub fn expected_leader_for_next_block(&self) -> Option<String> {
+ let tip = self.tip();
+ let tickets = tip.burn_tickets();
+ if tickets.is_empty() {
+ return None;
+ }
+ let total_burned = tickets
+ .iter()
+ .map(|(_, amount)| u128::from(*amount))
+ .sum::<u128>();
+ let seed = hash_to_u64(format!("leader:{}:{}", tip.hash, tip.vdf_output));
+ let winning_ticket = u128::from(seed) % total_burned;
+ let mut cumulative = 0_u128;
+ for (address, amount) in tickets {
+ cumulative += u128::from(amount);
+ if winning_ticket < cumulative {
+ return Some(address.to_string());
+ }
+ }
+ None
+ }
+
+ fn valid_pending_transactions(&self) -> Vec<Transaction> {
+ let mut balances = self.balances.clone();
+ let mut nonces = self.nonces.clone();
+ let mut valid = Vec::new();
+ let mut remaining = self.pending.iter().collect::<Vec<_>>();
+
+ while !remaining.is_empty() {
+ let mut progressed = false;
+ let mut still_pending = Vec::new();
+
+ for tx in remaining {
+ if apply_transaction(tx, &mut balances, &mut nonces).is_ok() {
+ valid.push(tx.clone());
+ progressed = true;
+ } else {
+ still_pending.push(tx);
+ }
+ }
+
+ if !progressed {
+ break;
+ }
+
+ remaining = still_pending;
+ }
+
+ valid
+ }
+
+ fn tip(&self) -> &Block {
+ self.chain
+ .last()
+ .expect("ledger is always initialized with genesis")
+ }
+}
+
+fn contains_positive_burn(transactions: &[Transaction]) -> bool {
+ transactions
+ .iter()
+ .any(|tx| matches!(tx, Transaction::Burn { amount, .. } if *amount > 0))
+}
+
+fn block_content_hash(
+ height: u64,
+ prev_hash: &str,
+ timestamp_ms: u64,
+ miner: &str,
+ reward: Amount,
+ vdf_rounds: u32,
+ transactions: &[Transaction],
+) -> String {
+ let txs = transactions
+ .iter()
+ .map(Transaction::canonical)
+ .collect::<Vec<_>>()
+ .join("|");
+ hex_hash(format!(
+ "block-content:{height}:{prev_hash}:{timestamp_ms}:{miner}:{reward}:{vdf_rounds}:{txs}"
+ ))
+}
+
+fn apply_transaction(
+ transaction: &Transaction,
+ balances: &mut BTreeMap<String, Amount>,
+ nonces: &mut BTreeMap<String, u64>,
+) -> Result<()> {
+ transaction.verify_signature()?;
+
+ let from = transaction.sender();
+ let expected_nonce = next_expected_nonce(nonces, from)?;
+ if transaction.nonce() != expected_nonce {
+ bail!(
+ "invalid nonce for {from}: expected {expected_nonce}, got {}",
+ transaction.nonce()
+ );
+ }
+ debit_balance(balances, from, transaction.amount())?;
+ match transaction {
+ Transaction::Transfer { to, amount, .. } => {
+ credit_balance(balances, to, *amount)?;
+ }
+ Transaction::Burn { .. } => {}
+ }
+ nonces.insert(from.to_string(), transaction.nonce());
+ Ok(())
+}
+
+fn next_expected_nonce(nonces: &BTreeMap<String, u64>, address: &str) -> Result<u64> {
+ nonces
+ .get(address)
+ .copied()
+ .unwrap_or(0)
+ .checked_add(1)
+ .with_context(|| format!("nonce space exhausted for {address}"))
+}
+
+fn debit_balance(
+ balances: &mut BTreeMap<String, Amount>,
+ address: &str,
+ amount: Amount,
+) -> Result<()> {
+ let balance = balances.entry(address.to_string()).or_insert(0);
+ if *balance < amount {
+ bail!("insufficient funds for {address}");
+ }
+ *balance -= amount;
+ Ok(())
+}
+
+fn credit_balance(
+ balances: &mut BTreeMap<String, Amount>,
+ address: &str,
+ amount: Amount,
+) -> Result<()> {
+ let balance = balances.entry(address.to_string()).or_insert(0);
+ *balance = balance
+ .checked_add(amount)
+ .with_context(|| format!("balance overflow for {address}"))?;
+ Ok(())
+}
+
+fn build_genesis_block(
+ genesis_allocations: &BTreeMap<String, Amount>,
+ transactions: Vec<Transaction>,
+) -> Block {
+ let txs = transactions
+ .iter()
+ .map(Transaction::canonical)
+ .collect::<Vec<_>>()
+ .join("|");
+ let vdf_output = hex_hash(format!("mivora-genesis-vdf:{genesis_allocations:?}:{txs}"));
+ let mut genesis = Block {
+ height: 0,
+ prev_hash: "0".repeat(64),
+ timestamp_ms: 0,
+ miner: "genesis".to_string(),
+ reward: 0,
+ vdf_rounds: 0,
+ vdf_output,
+ transactions,
+ hash: String::new(),
+ };
+ genesis.hash = genesis.compute_hash();
+ genesis
+}
+
+fn validate_genesis_block(block: &Block) -> Result<()> {
+ if block.height != 0 {
+ bail!("genesis block height must be 0");
+ }
+ if block.prev_hash != "0".repeat(64) {
+ bail!("genesis block prev_hash must be all zeroes");
+ }
+ if block.timestamp_ms != 0 {
+ bail!("genesis block timestamp must be 0");
+ }
+ if block.miner != "genesis" {
+ bail!("genesis block miner must be genesis");
+ }
+ if block.reward != 0 {
+ bail!("genesis block reward must be 0");
+ }
+ if block.vdf_rounds != 0 {
+ bail!("genesis block VDF rounds must be 0");
+ }
+ if block.compute_hash() != block.hash {
+ bail!("genesis block hash is invalid");
+ }
+ Ok(())
+}
+
+fn balances_after_genesis(
+ genesis_allocations: &BTreeMap<String, Amount>,
+ transactions: &[Transaction],
+) -> Result<BTreeMap<String, Amount>> {
+ let mut balances = genesis_allocations.clone();
+ for transaction in transactions {
+ match transaction {
+ Transaction::Burn { from, amount, .. } => {
+ let balance = balances.entry(from.clone()).or_insert(0);
+ if *balance < *amount {
+ bail!("genesis burn exceeds allocation for {from}");
+ }
+ *balance -= *amount;
+ }
+ Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"),
+ }
+ }
+ Ok(balances)
+}
+
+pub fn run_vdf(seed: &str, rounds: u32) -> String {
+ let x = vdf_seed_element(seed);
+ let mut y = x;
+ for _ in 0..rounds {
+ y = mul_mod(y, y);
+ }
+
+ let challenge = vdf_challenge_prime(seed, rounds, y);
+ let proof = vdf_proof(x, rounds, challenge);
+ encode_vdf_solution(y, proof)
+}
+
+pub fn verify_vdf(seed: &str, rounds: u32, solution: &str) -> bool {
+ let Some((y, proof)) = decode_vdf_solution(solution) else {
+ return false;
+ };
+ if y == 0 || y >= VDF_MODULUS || proof >= VDF_MODULUS {
+ return false;
+ }
+
+ let x = vdf_seed_element(seed);
+ let challenge = vdf_challenge_prime(seed, rounds, y);
+ let remainder = pow_mod_small(2, rounds, challenge) as u128;
+ let verified = mul_mod(mod_pow(proof, challenge as u128), mod_pow(x, remainder));
+ verified == y
+}
+
+fn vdf_seed_element(seed: &str) -> u128 {
+ let digest = Sha256::digest(format!("mivora-vdf-seed:{seed}").as_bytes());
+ let mut bytes = [0_u8; 16];
+ bytes.copy_from_slice(&digest[..16]);
+ 2 + (u128::from_be_bytes(bytes) % (VDF_MODULUS - 3))
+}
+
+fn vdf_challenge_prime(seed: &str, rounds: u32, output: u128) -> u64 {
+ let digest = Sha256::digest(format!("mivora-vdf-challenge:{seed}:{rounds}:{output:x}"));
+ let mut bytes = [0_u8; 8];
+ bytes.copy_from_slice(&digest[..8]);
+ let candidate = VDF_CHALLENGE_MIN + (u64::from_be_bytes(bytes) % VDF_CHALLENGE_MIN);
+ next_odd_prime(candidate | 1)
+}
+
+fn vdf_proof(x: u128, rounds: u32, challenge: u64) -> u128 {
+ let mut proof = 1_u128;
+ let mut remainder = 1_u64 % challenge;
+ for _ in 0..rounds {
+ let doubled = remainder * 2;
+ let carry = doubled >= challenge;
+ proof = mul_mod(proof, proof);
+ if carry {
+ proof = mul_mod(proof, x);
+ }
+ remainder = doubled % challenge;
+ }
+ proof
+}
+
+fn encode_vdf_solution(output: u128, proof: u128) -> String {
+ format!("{output:032x}:{proof:032x}")
+}
+
+fn decode_vdf_solution(solution: &str) -> Option<(u128, u128)> {
+ let (output, proof) = solution.split_once(':')?;
+ if output.len() != 32 || proof.len() != 32 {
+ return None;
+ }
+ Some((
+ u128::from_str_radix(output, 16).ok()?,
+ u128::from_str_radix(proof, 16).ok()?,
+ ))
+}
+
+fn mul_mod(left: u128, right: u128) -> u128 {
+ (left * right) % VDF_MODULUS
+}
+
+fn mod_pow(mut base: u128, mut exponent: u128) -> u128 {
+ let mut result = 1_u128;
+ while exponent > 0 {
+ if exponent & 1 == 1 {
+ result = mul_mod(result, base);
+ }
+ base = mul_mod(base, base);
+ exponent >>= 1;
+ }
+ result
+}
+
+fn pow_mod_small(base: u64, exponent: u32, modulus: u64) -> u64 {
+ let mut result = 1_u128;
+ let mut base = u128::from(base % modulus);
+ let mut exponent = exponent;
+ let modulus = u128::from(modulus);
+ while exponent > 0 {
+ if exponent & 1 == 1 {
+ result = (result * base) % modulus;
+ }
+ base = (base * base) % modulus;
+ exponent >>= 1;
+ }
+ result as u64
+}
+
+fn next_odd_prime(mut candidate: u64) -> u64 {
+ while !is_odd_prime(candidate) {
+ candidate = candidate.saturating_add(2);
+ }
+ candidate
+}
+
+fn is_odd_prime(candidate: u64) -> bool {
+ if candidate < 3 || candidate % 2 == 0 {
+ return false;
+ }
+ let mut divisor = 3_u64;
+ while divisor * divisor <= candidate {
+ if candidate % divisor == 0 {
+ return false;
+ }
+ divisor += 2;
+ }
+ true
+}
+
+fn retarget_vdf_rounds(current_rounds: u32, observed_block_ms: u64) -> u32 {
+ let current = u128::from(current_rounds);
+ let observed = u128::from(observed_block_ms.max(1));
+ let raw_adjusted = current * u128::from(VDF_TARGET_BLOCK_MS) / observed;
+ let max_step = (current * MAX_VDF_RETARGET_STEP_PERCENT / 100).max(1);
+ let min_next = current
+ .saturating_sub(max_step)
+ .max(u128::from(MIN_VDF_ROUNDS));
+ let max_next = current.saturating_add(max_step).min(u128::from(u32::MAX));
+ raw_adjusted.clamp(min_next, max_next) as u32
+}
+
+pub fn hex_hash(input: impl AsRef<[u8]>) -> String {
+ hex_encode(Sha256::digest(input.as_ref()))
+}
+
+fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> {
+ let bytes = decode_hex(input)?;
+ let len = bytes.len();
+ bytes
+ .try_into()
+ .map_err(|_| anyhow!("expected {} hex bytes, got {len}", N))
+}
+
+fn decode_hex(input: &str) -> Result<Vec<u8>> {
+ if input.len() % 2 != 0 {
+ bail!("hex string has odd length");
+ }
+
+ let mut bytes = Vec::with_capacity(input.len() / 2);
+ for pair in input.as_bytes().chunks_exact(2) {
+ let high = hex_value(pair[0])?;
+ let low = hex_value(pair[1])?;
+ bytes.push((high << 4) | low);
+ }
+ Ok(bytes)
+}
+
+fn hex_value(byte: u8) -> Result<u8> {
+ match byte {
+ b'0'..=b'9' => Ok(byte - b'0'),
+ b'a'..=b'f' => Ok(byte - b'a' + 10),
+ b'A'..=b'F' => Ok(byte - b'A' + 10),
+ _ => bail!("invalid hex character"),
+ }
+}
+
+fn hash_to_u64(input: impl AsRef<[u8]>) -> u64 {
+ let digest = Sha256::digest(input.as_ref());
+ u64::from_be_bytes(
+ digest[..8]
+ .try_into()
+ .map_err(|_| anyhow!("sha256 digest had unexpected length"))
+ .expect("sha256 digest is at least eight bytes"),
+ )
+}
+
+fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let bytes = bytes.as_ref();
+ let mut encoded = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ encoded.push(HEX[(byte >> 4) as usize] as char);
+ encoded.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ encoded
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -0,0 +1,3 @@
+pub mod adapters;
+pub mod app;
+pub mod domain;
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,662 @@
+use std::{
+ collections::BTreeMap, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc, time::Duration,
+};
+
+use anyhow::{Context, Result, bail};
+use mivora::{
+ adapters::{chain_store::SqliteChainStore, http, p2p, wallet_store},
+ app::{
+ DEFAULT_BURN_PER_BLOCK, DEFAULT_VDF_ROUNDS, NodeCore, PeerBook, SharedNode, SharedPeerBook,
+ now_ms,
+ },
+ domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, run_vdf},
+};
+use tokio::sync::Mutex;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let Some(opts) = CliOptions::parse()? else {
+ return Ok(());
+ };
+ let wallet_path = opts.wallet_path();
+ let wallet = wallet_store::load_or_create(&wallet_path)?;
+ let chain_store = SqliteChainStore::open(opts.chain_db_path())?;
+ let ledger = initialize_ledger(&opts, wallet.address(), &chain_store).await?;
+
+ let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger(
+ opts.node_name,
+ wallet,
+ ledger,
+ opts.burn_per_block,
+ )));
+ let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(opts.peers)));
+ let initial_snapshot = { node.lock().await.chain_snapshot() };
+ persist_chain_snapshot(&chain_store, initial_snapshot).await?;
+
+ println!("mivora wallet: {}", node.lock().await.wallet_address());
+ println!("wallet file: {}", wallet_path.display());
+ println!("chain database: {}", chain_store.path().display());
+ println!("management UI: http://{}", opts.http_addr);
+ println!("p2p listener: {}", opts.p2p_addr);
+ println!(
+ "automatic mining: VDF-driven, burning {} coins per block",
+ opts.burn_per_block
+ );
+
+ let persistence_node = Arc::clone(&node);
+ let persistence_store = chain_store.clone();
+ tokio::spawn(async move {
+ run_chain_persistence(persistence_node, persistence_store).await;
+ });
+
+ let gossip =
+ p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?;
+
+ let miner_node = Arc::clone(&node);
+ let miner_gossip = gossip.clone();
+ tokio::spawn(async move {
+ run_automatic_miner(miner_node, miner_gossip).await;
+ });
+
+ let sync_node = Arc::clone(&node);
+ let sync_gossip = gossip.clone();
+ tokio::spawn(async move {
+ run_peer_sync(sync_node, sync_gossip).await;
+ });
+
+ http::serve(node, peers, gossip, opts.http_addr).await
+}
+
+async fn initialize_ledger(
+ opts: &CliOptions,
+ wallet_address: &str,
+ chain_store: &SqliteChainStore,
+) -> Result<Ledger> {
+ if let Some(snapshot) = chain_store.load()? {
+ let height = snapshot_height(&snapshot);
+ let ledger = Ledger::from_snapshot(snapshot).with_context(|| {
+ format!(
+ "failed to load chain database {}",
+ chain_store.path().display()
+ )
+ })?;
+ println!(
+ "resumed chain from {} at height {height}",
+ chain_store.path().display()
+ );
+ Ok(ledger)
+ } else if opts.start_new_chain {
+ start_new_chain_ledger(wallet_address, opts.genesis_amount, opts.vdf_rounds)
+ } else {
+ join_chain_ledger(&opts.join_peers, opts.p2p_addr).await
+ }
+}
+
+#[derive(Debug)]
+struct CliOptions {
+ node_name: String,
+ wallet_path: Option<PathBuf>,
+ chain_db_path: Option<PathBuf>,
+ http_addr: SocketAddr,
+ p2p_addr: SocketAddr,
+ peers: Vec<String>,
+ join_peers: Vec<String>,
+ start_new_chain: bool,
+ genesis_amount: Amount,
+ vdf_rounds: u32,
+ burn_per_block: Amount,
+ data_dir: PathBuf,
+}
+
+impl CliOptions {
+ fn parse() -> Result<Option<Self>> {
+ Self::parse_from(std::env::args().skip(1))
+ }
+
+ fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Option<Self>> {
+ let mut opts = Self {
+ node_name: "mivora-dev".to_string(),
+ wallet_path: None,
+ chain_db_path: None,
+ http_addr: SocketAddr::from_str("127.0.0.1:8443")?,
+ p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?,
+ peers: Vec::new(),
+ join_peers: Vec::new(),
+ start_new_chain: false,
+ genesis_amount: 1,
+ vdf_rounds: DEFAULT_VDF_ROUNDS,
+ burn_per_block: DEFAULT_BURN_PER_BLOCK,
+ data_dir: PathBuf::from(".mivora"),
+ };
+
+ let raw_args = args.into_iter().collect::<Vec<_>>();
+ if raw_args.is_empty() {
+ print_help();
+ return Ok(None);
+ }
+
+ let mut args = raw_args.into_iter();
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "--start" => opts.start_new_chain = true,
+ "--name" => opts.node_name = next_value(&mut args, "--name")?,
+ "--wallet" => {
+ opts.wallet_path = Some(PathBuf::from(next_value(&mut args, "--wallet")?))
+ }
+ "--chain-db" => {
+ opts.chain_db_path = Some(PathBuf::from(next_value(&mut args, "--chain-db")?))
+ }
+ "--wallet-seed" => {
+ bail!(
+ "--wallet-seed was removed; wallets are stored in --wallet <path> or .mivora/wallet.json"
+ )
+ }
+ "--http" => {
+ opts.http_addr = next_value(&mut args, "--http")?
+ .parse()
+ .context("invalid --http address")?;
+ }
+ "--p2p" => {
+ opts.p2p_addr = next_value(&mut args, "--p2p")?
+ .parse()
+ .context("invalid --p2p address")?;
+ }
+ "--peer" => opts.peers.push(next_value(&mut args, "--peer")?),
+ "--join" => {
+ let peer = next_value(&mut args, "--join")?;
+ opts.peers.push(peer.clone());
+ opts.join_peers.push(peer);
+ }
+ "--genesis-amount" => {
+ opts.genesis_amount = next_value(&mut args, "--genesis-amount")?
+ .parse()
+ .context("invalid --genesis-amount")?;
+ if opts.genesis_amount < 1 {
+ bail!("--genesis-amount must be at least 1 for the genesis burn");
+ }
+ }
+ "--vdf-rounds" => {
+ opts.vdf_rounds = next_value(&mut args, "--vdf-rounds")?
+ .parse()
+ .context("invalid --vdf-rounds")?;
+ }
+ "--burn-per-block" => {
+ opts.burn_per_block = next_value(&mut args, "--burn-per-block")?
+ .parse()
+ .context("invalid --burn-per-block")?;
+ }
+ "--data-dir" => opts.data_dir = PathBuf::from(next_value(&mut args, "--data-dir")?),
+ "--help" | "-h" => {
+ print_help();
+ std::process::exit(0);
+ }
+ other => bail!("unknown argument {other}; pass --help for usage"),
+ }
+ }
+
+ if opts.start_new_chain && !opts.join_peers.is_empty() {
+ bail!("choose either --start or --join, not both");
+ }
+ if !opts.start_new_chain && opts.join_peers.is_empty() {
+ bail!("pass --start to create a new chain, or --join <addr:port> to join one");
+ }
+
+ Ok(Some(opts))
+ }
+
+ fn wallet_path(&self) -> PathBuf {
+ self.wallet_path
+ .clone()
+ .unwrap_or_else(|| self.data_dir.join("wallet.json"))
+ }
+
+ fn chain_db_path(&self) -> PathBuf {
+ self.chain_db_path
+ .clone()
+ .unwrap_or_else(|| self.data_dir.join("chain.sqlite3"))
+ }
+}
+
+fn next_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
+ args.next()
+ .with_context(|| format!("missing value after {name}"))
+}
+
+fn print_help() {
+ println!(
+ "mivora\n\n\
+ Usage:\n\
+ mivora --start [options]\n\
+ mivora --join <addr:port> [options]\n\n\
+ Options:\n\
+ --start Create a new chain with a genesis burn\n\
+ --name <name> Node display name\n\
+ --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\
+ --chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\
+ --http <addr:port> HTTP management UI address (default 127.0.0.1:8443)\n\
+ --p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\
+ --peer <addr:port> P2P peer to gossip to; may be repeated\n\
+ --join <addr:port> Fetch chain snapshot from this peer before mining\n\
+ --genesis-amount <amount> Pre-burn genesis amount for this wallet (default 1)\n\
+ --vdf-rounds <rounds> Initial VDF delay rounds; protocol retargets toward 60s blocks\n\
+ --burn-per-block <amount> Fixed automatic burn before each block attempt\n\
+ --data-dir <path> Local wallet directory\n"
+ );
+}
+
+fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
+ snapshot
+ .blocks
+ .last()
+ .map(|block| block.height)
+ .unwrap_or(0)
+}
+
+fn start_new_chain_ledger(
+ wallet_address: &str,
+ genesis_amount: Amount,
+ vdf_rounds: u32,
+) -> Result<Ledger> {
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet_address.to_string(), genesis_amount);
+ Ledger::new_with_genesis_burns(
+ genesis,
+ vec![GenesisBurn::new(wallet_address, 1)],
+ vdf_rounds,
+ )
+}
+
+async fn join_chain_ledger(join_peers: &[String], advertised_addr: SocketAddr) -> Result<Ledger> {
+ let mut errors = Vec::new();
+ for peer in join_peers {
+ match p2p::fetch_snapshot_with_announcement(peer, Some(advertised_addr)).await {
+ Ok(snapshot) => {
+ let height = snapshot
+ .blocks
+ .last()
+ .map(|block| block.height)
+ .unwrap_or(0);
+ println!("joined chain from {peer} at height {height}");
+ return Ledger::from_snapshot(snapshot);
+ }
+ Err(error) => {
+ errors.push(format!("{peer}: {error:#}"));
+ }
+ }
+ }
+
+ bail!(
+ "could not join any requested peer; refusing to start a separate chain: {}",
+ errors.join("; ")
+ )
+}
+
+async fn run_automatic_miner(node: SharedNode, gossip: p2p::GossipNetwork) {
+ let mut last_logged_skip: Option<(u64, String)> = None;
+ loop {
+ let (height, plan, outbox) = {
+ let mut node = node.lock().await;
+ let height = node.chain_height();
+ let plan = node.prepare_automatic_mining(now_ms());
+ let outbox = node.drain_outbox();
+ (height, plan, outbox)
+ };
+
+ if let Err(error) = gossip.broadcast(outbox).await {
+ eprintln!("p2p broadcast failed after automatic burn: {error:#}");
+ }
+
+ let Some(work) = plan.work else {
+ if let Some(reason) = &plan.skipped_reason {
+ let skip = (height, reason.clone());
+ if last_logged_skip.as_ref() != Some(&skip) {
+ println!("auto-mining skipped at height {height}: {reason}");
+ last_logged_skip = Some(skip);
+ }
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ continue;
+ };
+
+ last_logged_skip = None;
+ println!(
+ "leader selected locally for candidate block {}; running VDF for {} rounds",
+ work.height(),
+ work.vdf_rounds()
+ );
+
+ let seed = work.vdf_seed().to_string();
+ let rounds = work.vdf_rounds();
+ let vdf_output = match tokio::task::spawn_blocking(move || run_vdf(&seed, rounds)).await {
+ Ok(output) => output,
+ Err(error) => {
+ eprintln!("VDF worker failed: {error:#}");
+ continue;
+ }
+ };
+
+ let (mined, outbox) = {
+ let mut node = node.lock().await;
+ let mined = node.complete_prepared_block(work, vdf_output);
+ let outbox = node.drain_outbox();
+ (mined, outbox)
+ };
+
+ match mined {
+ Ok(block) => {
+ println!("auto-mined block {} ({})", block.height, block.hash);
+ }
+ Err(error) => println!("auto-mining skipped after VDF: {error:#}"),
+ }
+
+ if let Err(error) = gossip.broadcast(outbox).await {
+ eprintln!("p2p broadcast failed after automatic block: {error:#}");
+ }
+
+ tokio::task::yield_now().await;
+ }
+}
+
+async fn run_peer_sync(node: SharedNode, gossip: p2p::GossipNetwork) {
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+ let envelopes = {
+ let mut node = node.lock().await;
+ let mut envelopes = vec![node.peer_status()];
+ envelopes.extend(node.drain_outbox());
+ envelopes.extend(node.mempool_gossip());
+ envelopes
+ };
+ let mut envelopes = envelopes;
+ envelopes.push(gossip.peer_exchange().await);
+ if let Err(error) = gossip.broadcast(envelopes).await {
+ eprintln!("p2p sync gossip failed: {error:#}");
+ }
+ }
+}
+
+async fn run_chain_persistence(node: SharedNode, store: SqliteChainStore) {
+ run_chain_persistence_with_interval(node, store, Duration::from_secs(2)).await;
+}
+
+async fn run_chain_persistence_with_interval(
+ node: SharedNode,
+ store: SqliteChainStore,
+ interval: Duration,
+) {
+ let mut last_saved_tip: Option<String> = None;
+ loop {
+ tokio::time::sleep(interval).await;
+ let snapshot = { node.lock().await.chain_snapshot() };
+ let Some(tip_hash) = snapshot.blocks.last().map(|block| block.hash.clone()) else {
+ continue;
+ };
+ if last_saved_tip.as_deref() == Some(tip_hash.as_str()) {
+ continue;
+ }
+
+ match persist_chain_snapshot(&store, snapshot).await {
+ Ok(()) => last_saved_tip = Some(tip_hash),
+ Err(error) => eprintln!("chain persistence failed: {error:#}"),
+ }
+ }
+}
+
+async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapshot) -> Result<()> {
+ let store = store.clone();
+ tokio::task::spawn_blocking(move || store.save(&snapshot))
+ .await
+ .context("chain persistence worker failed")??;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{collections::BTreeMap, sync::Arc, time::Duration};
+
+ use mivora::{
+ adapters::chain_store::SqliteChainStore,
+ app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
+ domain::{GenesisBurn, Ledger, Wallet},
+ };
+ use rusqlite::Connection;
+ use tempfile::tempdir;
+ use tokio::sync::Mutex;
+
+ use super::{
+ CliOptions, initialize_ledger, persist_chain_snapshot, run_chain_persistence_with_interval,
+ };
+
+ fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
+ CliOptions::parse_from(args.iter().map(|arg| arg.to_string()))
+ }
+
+ fn ledger_with_one_spendable_coin(wallet: &Wallet) -> Ledger {
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 2);
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap()
+ }
+
+ fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger {
+ let mut ledger = ledger_with_one_spendable_coin(wallet);
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+ let block = ledger.mine_next_block(wallet.address(), 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ ledger
+ }
+
+ #[test]
+ fn no_args_prints_help_without_starting() {
+ assert!(parse(&[]).unwrap().is_none());
+ }
+
+ #[test]
+ fn removed_wallet_seed_is_rejected() {
+ let error = parse(&["--wallet-seed", "alice", "--start"]).unwrap_err();
+ assert!(error.to_string().contains("--wallet-seed was removed"));
+ }
+
+ #[test]
+ fn start_mode_is_explicit() {
+ let opts = parse(&["--start"]).unwrap().unwrap();
+ assert!(opts.start_new_chain);
+ assert!(opts.join_peers.is_empty());
+ }
+
+ #[test]
+ fn join_mode_does_not_start_new_chain() {
+ let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
+ assert!(!opts.start_new_chain);
+ assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]);
+ }
+
+ #[test]
+ fn http_management_port_can_be_configured() {
+ let opts = parse(&["--start", "--http", "127.0.0.1:18443"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443");
+ }
+
+ #[test]
+ fn wallet_defaults_under_data_dir() {
+ let opts = parse(&["--start", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ opts.wallet_path(),
+ std::path::PathBuf::from("tmp-node/wallet.json")
+ );
+ }
+
+ #[test]
+ fn chain_db_defaults_under_data_dir() {
+ let opts = parse(&["--start", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ opts.chain_db_path(),
+ std::path::PathBuf::from("tmp-node/chain.sqlite3")
+ );
+ }
+
+ #[test]
+ fn wallet_path_can_be_explicit() {
+ let opts = parse(&["--start", "--wallet", "alice-wallet.json"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ opts.wallet_path(),
+ std::path::PathBuf::from("alice-wallet.json")
+ );
+ }
+
+ #[test]
+ fn chain_db_path_can_be_explicit() {
+ let opts = parse(&["--start", "--chain-db", "alice-chain.sqlite3"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ opts.chain_db_path(),
+ std::path::PathBuf::from("alice-chain.sqlite3")
+ );
+ }
+
+ #[tokio::test]
+ async fn startup_resumes_persisted_chain_instead_of_creating_new_genesis() {
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let persisted_wallet = Wallet::from_seed("persisted-chain-owner");
+ let persisted = ledger_with_one_mined_block(&persisted_wallet);
+ store.save(&persisted.snapshot()).unwrap();
+ let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
+ let opts = parse(&[
+ "--start",
+ "--genesis-amount",
+ "1",
+ "--chain-db",
+ chain_path.to_str().unwrap(),
+ ])
+ .unwrap()
+ .unwrap();
+
+ let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ .await
+ .unwrap();
+
+ assert_eq!(resumed.status().height, 1);
+ assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
+ assert_eq!(resumed.genesis_hash(), persisted.genesis_hash());
+ assert_eq!(resumed.balance_of(fresh_wallet.address()), 0);
+ }
+
+ #[tokio::test]
+ async fn persisted_chain_satisfies_join_mode_without_contacting_peer() {
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let alice = Wallet::from_seed("offline-join-alice");
+ let persisted = ledger_with_one_mined_block(&alice);
+ store.save(&persisted.snapshot()).unwrap();
+ let bob = Wallet::from_seed("offline-join-bob");
+ let opts = parse(&[
+ "--join",
+ "127.0.0.1:1",
+ "--chain-db",
+ chain_path.to_str().unwrap(),
+ ])
+ .unwrap()
+ .unwrap();
+
+ let resumed = initialize_ledger(&opts, bob.address(), &store)
+ .await
+ .unwrap();
+
+ assert_eq!(resumed.status().height, 1);
+ assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
+ }
+
+ #[tokio::test]
+ async fn invalid_persisted_chain_is_reported_and_never_replaced() {
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let connection = Connection::open(&chain_path).unwrap();
+ connection
+ .execute(
+ r#"
+INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms)
+VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0)
+"#,
+ [],
+ )
+ .unwrap();
+ let wallet = Wallet::from_seed("bad-db-wallet");
+ let opts = parse(&[
+ "--start",
+ "--genesis-amount",
+ "2",
+ "--chain-db",
+ chain_path.to_str().unwrap(),
+ ])
+ .unwrap()
+ .unwrap();
+
+ let error = initialize_ledger(&opts, wallet.address(), &store)
+ .await
+ .unwrap_err();
+
+ assert!(
+ format!("{error:#}").contains("failed to parse chain snapshot from database"),
+ "{error:#}"
+ );
+ }
+
+ #[tokio::test]
+ async fn persistence_loop_saves_new_tip_after_node_changes() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let wallet = Wallet::from_seed("background-persistence");
+ let ledger = ledger_with_one_spendable_coin(&wallet);
+ let node = Arc::new(Mutex::new(NodeCore::from_ledger(
+ "persistence".to_string(),
+ wallet.clone(),
+ ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ )));
+ let initial_snapshot = { node.lock().await.chain_snapshot() };
+ persist_chain_snapshot(&store, initial_snapshot)
+ .await
+ .unwrap();
+
+ let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
+ Arc::clone(&node),
+ store.clone(),
+ Duration::from_millis(10),
+ ));
+ {
+ let mut node = node.lock().await;
+ node.burn(1).unwrap();
+ node.mine_one_at(1_000).unwrap();
+ }
+
+ let expected_tip = node.lock().await.ledger().status().tip_hash;
+ let mut restored_tip = None;
+ for _ in 0..50 {
+ if let Some(snapshot) = store.load().unwrap() {
+ restored_tip = snapshot.blocks.last().map(|block| block.hash.clone());
+ if restored_tip.as_deref() == Some(expected_tip.as_str()) {
+ break;
+ }
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ persistence_task.abort();
+
+ assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str()));
+ }
+}
diff --git a/tests/coin.rs b/tests/coin.rs
@@ -0,0 +1,1588 @@
+use std::collections::BTreeMap;
+
+use mivora::{
+ adapters::chain_store::SqliteChainStore,
+ app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection},
+ domain::{
+ Amount, BLOCK_REWARD, GenesisBurn, Ledger, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf,
+ },
+};
+use tempfile::tempdir;
+
+fn node(name: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore {
+ NodeCore::new(NodeConfig {
+ name: name.to_string(),
+ wallet,
+ genesis_allocations: allocations,
+ vdf_rounds: 25,
+ burn_per_block: DEFAULT_BURN_PER_BLOCK,
+ })
+}
+
+fn wallets(names: &[&str]) -> Vec<Wallet> {
+ names.iter().map(|name| Wallet::from_seed(name)).collect()
+}
+
+fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
+ wallets
+ .iter()
+ .map(|wallet| (wallet.address().to_string(), amount))
+ .collect()
+}
+
+fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String {
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+ let block = ledger
+ .mine_next_block(wallet.address(), timestamp_ms)
+ .unwrap();
+ let hash = block.hash.clone();
+ ledger.apply_block(block).unwrap();
+ hash
+}
+
+fn fork_with_better_vrf_block(
+ base: &Ledger,
+ wallet: &Wallet,
+ local_fork_block_hash: &str,
+ first_timestamp_ms: u64,
+) -> Option<Ledger> {
+ for offset in 0..10_000 {
+ let mut candidate = base.clone();
+ let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset);
+ if hash.as_str() < local_fork_block_hash {
+ return Some(candidate);
+ }
+ }
+ None
+}
+
+fn fork_with_worse_vrf_block(
+ base: &Ledger,
+ wallet: &Wallet,
+ local_fork_block_hash: &str,
+ first_timestamp_ms: u64,
+) -> Option<Ledger> {
+ for offset in 0..10_000 {
+ let mut candidate = base.clone();
+ let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset);
+ if hash.as_str() > local_fork_block_hash {
+ return Some(candidate);
+ }
+ }
+ None
+}
+
+fn starter_node(name: &str, wallet: Wallet) -> NodeCore {
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1);
+ let ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25)
+ .unwrap();
+ NodeCore::from_ledger(name.to_string(), wallet, ledger, DEFAULT_BURN_PER_BLOCK)
+}
+
+#[test]
+fn genesis_burn_starts_chain_with_zero_balance_and_first_leader() {
+ let alice = Wallet::from_seed("alice");
+ let node = starter_node("alice", alice.clone());
+
+ let genesis = &node.ledger().chain()[0];
+ assert_eq!(node.ledger().balance_of(alice.address()), 0);
+ assert_eq!(genesis.height, 0);
+ assert_eq!(genesis.transactions.len(), 1);
+ assert!(genesis.transactions[0].is_burn());
+ assert_eq!(genesis.transactions[0].amount(), 1);
+ assert_eq!(
+ node.ledger().expected_leader_for_next_block().as_deref(),
+ Some(alice.address())
+ );
+}
+
+#[test]
+fn starter_node_waits_for_burn_before_first_reward() {
+ let alice = Wallet::from_seed("alice");
+ let mut node = starter_node("alice", alice.clone());
+
+ let outcome = node.automatic_mine_once(1);
+ assert!(outcome.burned.is_none());
+ assert!(outcome.block.is_none());
+ assert!(
+ outcome
+ .skipped_reason
+ .unwrap()
+ .contains("without burned coins")
+ );
+ assert_eq!(node.ledger().status().height, 0);
+ assert_eq!(node.ledger().balance_of(alice.address()), 0);
+}
+
+#[test]
+fn burn_in_latest_block_selects_next_leader() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(20, ledger.next_nonce(alice.address())))
+ .unwrap();
+ ledger
+ .submit_transaction(bob.burn(80, ledger.next_nonce(bob.address())))
+ .unwrap();
+
+ let first = ledger.mine_next_block(alice.address(), 1).unwrap();
+ ledger.apply_block(first).unwrap();
+
+ let expected = ledger.expected_leader_for_next_block().unwrap();
+ assert!(expected == alice.address() || expected == bob.address());
+
+ let non_leader = if expected == alice.address() {
+ bob.address()
+ } else {
+ alice.address()
+ };
+ assert!(ledger.mine_next_block(non_leader, 2).is_err());
+
+ let leader_wallet = if expected == alice.address() {
+ &alice
+ } else {
+ &bob
+ };
+ ledger
+ .submit_transaction(leader_wallet.burn(1, ledger.next_nonce(leader_wallet.address())))
+ .unwrap();
+ assert!(ledger.mine_next_block(&expected, 2).is_ok());
+}
+
+#[test]
+fn transfer_and_burn_update_balances_when_block_is_applied() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 100);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.transfer(bob.address(), 125, ledger.next_nonce(alice.address())))
+ .unwrap();
+ ledger
+ .submit_transaction(alice.burn(25, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ ledger.apply_block(block).unwrap();
+
+ assert_eq!(ledger.balance_of(alice.address()), 950);
+ assert_eq!(ledger.balance_of(bob.address()), 225);
+}
+
+#[test]
+fn forged_transaction_is_rejected() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(allocations, 10);
+
+ let mut forged = bob.burn(10, 1);
+ if let mivora::domain::Transaction::Burn { from, .. } = &mut forged {
+ *from = alice.address().to_string();
+ }
+
+ let error = ledger.submit_transaction(forged).unwrap_err();
+ assert!(error.to_string().contains("signature"));
+}
+
+#[test]
+fn block_with_forged_transaction_is_rejected() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+
+ let mut block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ if let mivora::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] {
+ signature.push_str("00");
+ }
+ block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
+ block.hash = block.compute_hash();
+
+ let error = ledger.apply_block(block).unwrap_err();
+ assert!(error.to_string().contains("signature"));
+}
+
+#[test]
+fn block_reward_is_fixed_at_one_hundred_coins() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ assert_eq!(block.reward, BLOCK_REWARD);
+
+ ledger.apply_block(block).unwrap();
+ assert_eq!(ledger.balance_of(alice.address()), 1_099);
+}
+
+#[test]
+fn transfer_that_would_overflow_recipient_balance_is_rejected() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1);
+ allocations.insert(bob.address().to_string(), Amount::MAX);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ let error = ledger
+ .submit_transaction(alice.transfer(bob.address(), 1, ledger.next_nonce(alice.address())))
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("balance overflow"));
+}
+
+#[test]
+fn block_reward_that_would_overflow_miner_balance_is_rejected() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), Amount::MAX);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let block = ledger.mine_next_block(alice.address(), 1).unwrap();
+
+ let error = ledger.apply_block(block).unwrap_err();
+
+ assert!(format!("{error:#}").contains("balance overflow"));
+}
+
+#[test]
+fn block_without_burned_coins_cannot_be_mined_or_applied() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ let error = ledger.mine_next_block(alice.address(), 1).unwrap_err();
+ assert!(error.to_string().contains("without burned coins"));
+
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let mut block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ block.transactions.clear();
+ block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
+ block.hash = block.compute_hash();
+
+ let error = ledger.apply_block(block).unwrap_err();
+ assert!(error.to_string().contains("must include burned coins"));
+}
+
+#[test]
+fn vdf_is_bound_to_block_contents() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let mut block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ block.timestamp_ms += 1;
+ block.hash = block.compute_hash();
+
+ let error = ledger.apply_block(block).unwrap_err();
+ assert!(error.to_string().contains("VDF output is invalid"));
+}
+
+#[test]
+fn automatic_mining_burns_configured_amount_once_per_height() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let mut node = NodeCore::new(NodeConfig {
+ name: "alice".to_string(),
+ wallet: alice.clone(),
+ genesis_allocations: allocations,
+ vdf_rounds: 10,
+ burn_per_block: 25,
+ });
+
+ let first = node.automatic_mine_once(1);
+ assert!(first.burned.is_some());
+ assert!(first.block.is_some());
+ assert_eq!(node.ledger().chain().len(), 2);
+ assert_eq!(node.ledger().balance_of(alice.address()), 1_075);
+
+ let second = node.automatic_mine_once(2);
+ assert!(second.burned.is_some());
+ assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(25));
+}
+
+#[test]
+fn default_automatic_mining_does_not_burn() {
+ assert_eq!(DEFAULT_BURN_PER_BLOCK, 0);
+
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ let mut node = node("alice", alice.clone(), allocations);
+
+ let outcome = node.automatic_mine_once(1);
+ assert!(outcome.burned.is_none());
+ assert!(outcome.block.is_none());
+ assert!(
+ outcome
+ .skipped_reason
+ .unwrap()
+ .contains("without burned coins")
+ );
+ assert_eq!(node.ledger().balance_of(alice.address()), 1_000);
+}
+
+#[test]
+fn burn_per_block_can_be_set_to_zero() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ let mut node = node("alice", alice, allocations);
+
+ let burned = node.set_burn_per_block(25).unwrap();
+ assert!(burned.is_some());
+ assert_eq!(node.status().mining.burn_per_block, 25);
+ let burned = node.set_burn_per_block(0).unwrap();
+ assert!(burned.is_none());
+ assert_eq!(node.status().mining.burn_per_block, 0);
+}
+
+#[test]
+fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 100);
+
+ let mut ledger = Ledger::new(allocations.clone(), 25);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let first = ledger.mine_next_block(alice.address(), 1).unwrap();
+ ledger.apply_block(first).unwrap();
+
+ let mut bob_node = node("bob", bob.clone(), allocations);
+ bob_node
+ .receive(mivora::app::GossipEnvelope::ChainSnapshot(
+ ledger.snapshot(),
+ ))
+ .unwrap();
+
+ let skipped = bob_node.automatic_mine_once(2);
+ assert!(skipped.burned.is_none());
+ assert!(skipped.block.is_none());
+
+ let burned = bob_node.set_burn_per_block(1).unwrap();
+
+ assert!(burned.is_some());
+ assert_eq!(bob_node.ledger().pending().len(), 1);
+ assert_eq!(bob_node.ledger().pending()[0].sender(), bob.address());
+ assert_eq!(bob_node.ledger().pending()[0].amount(), 1);
+}
+
+#[test]
+fn automatic_mining_waits_when_wallet_is_not_selected_leader() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations.clone(), 25);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let first = ledger.mine_next_block(alice.address(), 1).unwrap();
+ ledger.apply_block(first).unwrap();
+
+ let mut bob_node = node("bob", bob.clone(), allocations);
+ bob_node.set_burn_per_block(10).unwrap();
+ bob_node
+ .receive(mivora::app::GossipEnvelope::Block(
+ ledger.chain()[1].clone(),
+ ))
+ .unwrap();
+
+ let outcome = bob_node.automatic_mine_once(2);
+ assert!(outcome.burned.is_some());
+ assert!(outcome.block.is_none());
+ assert!(outcome.skipped_reason.unwrap().contains(alice.address()));
+ assert_eq!(bob_node.ledger().chain().len(), 2);
+}
+
+#[test]
+fn block_with_wrong_vdf_rounds_is_rejected() {
+ let wallet = Wallet::from_seed("alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(genesis, 25);
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+
+ let mut block = ledger.mine_next_block(wallet.address(), 1).unwrap();
+ block.vdf_rounds = 1;
+ block.hash = block.compute_hash();
+ block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
+
+ assert!(ledger.apply_block(block).is_err());
+}
+
+#[test]
+fn vdf_solution_verifies_without_rerunning_delay() {
+ let solution = run_vdf("test-seed", 128);
+
+ assert!(verify_vdf("test-seed", 128, &solution));
+ assert!(!verify_vdf("other-seed", 128, &solution));
+ assert!(!verify_vdf("test-seed", 129, &solution));
+ assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution"));
+}
+
+#[test]
+fn vdf_rounds_retarget_toward_one_minute_blocks() {
+ let wallet = Wallet::from_seed("alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(genesis, 100);
+
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+ let block1 = ledger
+ .mine_next_block(wallet.address(), VDF_TARGET_BLOCK_MS)
+ .unwrap();
+ assert_eq!(block1.vdf_rounds, 100);
+ ledger.apply_block(block1).unwrap();
+ assert_eq!(ledger.vdf_rounds(), 100);
+
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+ let block2 = ledger
+ .mine_next_block(
+ wallet.address(),
+ VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2,
+ )
+ .unwrap();
+ assert_eq!(block2.vdf_rounds, 100);
+ ledger.apply_block(block2).unwrap();
+ assert_eq!(ledger.vdf_rounds(), 110);
+
+ ledger
+ .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address())))
+ .unwrap();
+ let block3 = ledger
+ .mine_next_block(
+ wallet.address(),
+ VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2,
+ )
+ .unwrap();
+ assert_eq!(block3.vdf_rounds, 110);
+ ledger.apply_block(block3).unwrap();
+ assert_eq!(ledger.vdf_rounds(), 99);
+}
+
+#[test]
+fn future_nonce_transactions_wait_for_missing_gap() {
+ let wallet = Wallet::from_seed("alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(genesis, 25);
+
+ let tx3 = wallet.burn(3, 3);
+ ledger.submit_transaction(tx3.clone()).unwrap();
+ assert_eq!(ledger.next_nonce(wallet.address()), 1);
+ assert_eq!(ledger.pending().len(), 1);
+
+ ledger.submit_transaction(wallet.burn(1, 1)).unwrap();
+ assert_eq!(ledger.next_nonce(wallet.address()), 2);
+ ledger.submit_transaction(wallet.burn(2, 2)).unwrap();
+ assert_eq!(ledger.next_nonce(wallet.address()), 4);
+
+ let block = ledger
+ .prepare_next_block(wallet.address(), 1)
+ .unwrap()
+ .finish("test-vdf".to_string());
+ assert_eq!(block.transactions.len(), 3);
+ assert!(block.transactions.contains(&tx3));
+}
+
+#[test]
+fn in_memory_network_syncs_nodes_without_tcp() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+
+ let mut network = InMemoryNetwork::default();
+ network.insert("alice", node("alice", alice.clone(), allocations.clone()));
+ network.insert("bob", node("bob", bob.clone(), allocations));
+
+ network.node_mut("alice").unwrap().burn(10).unwrap();
+ network.deliver_until_idle().unwrap();
+ assert_eq!(network.node("bob").unwrap().ledger().pending().len(), 1);
+
+ network.node_mut("alice").unwrap().mine_one().unwrap();
+ network.deliver_until_idle().unwrap();
+
+ let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash;
+ let bob_tip = network.node("bob").unwrap().ledger().status().tip_hash;
+ assert_eq!(alice_tip, bob_tip);
+ assert_eq!(network.node("bob").unwrap().ledger().chain().len(), 2);
+}
+
+#[test]
+fn in_memory_network_delivers_transaction_to_multiple_peers() {
+ let wallets = wallets(&["alice", "bob", "carol", "dave"]);
+ let allocations = allocations(&wallets, 1_000);
+ let mut network = InMemoryNetwork::default();
+
+ for (name, wallet) in ["alice", "bob", "carol", "dave"]
+ .iter()
+ .zip(wallets.clone())
+ {
+ network.insert(*name, node(name, wallet, allocations.clone()));
+ }
+
+ network.node_mut("alice").unwrap().burn(15).unwrap();
+ network.deliver_until_idle().unwrap();
+
+ for name in ["bob", "carol", "dave"] {
+ let pending = network.node(name).unwrap().ledger().pending();
+ assert_eq!(pending.len(), 1, "{name} did not receive alice's burn");
+ assert_eq!(pending[0].amount(), 15);
+ }
+}
+
+#[test]
+fn in_memory_network_syncs_mined_block_to_multiple_peers() {
+ let wallets = wallets(&["alice", "bob", "carol", "dave"]);
+ let allocations = allocations(&wallets, 1_000);
+ let mut network = InMemoryNetwork::default();
+
+ for (name, wallet) in ["alice", "bob", "carol", "dave"]
+ .iter()
+ .zip(wallets.clone())
+ {
+ network.insert(*name, node(name, wallet, allocations.clone()));
+ }
+
+ network.node_mut("alice").unwrap().burn(10).unwrap();
+ network.deliver_until_idle().unwrap();
+ network.node_mut("alice").unwrap().mine_one().unwrap();
+ network.deliver_until_idle().unwrap();
+
+ let tip = network.node("alice").unwrap().ledger().status().tip_hash;
+ for name in ["alice", "bob", "carol", "dave"] {
+ let ledger = network.node(name).unwrap().ledger();
+ assert_eq!(ledger.status().height, 1, "{name} is at the wrong height");
+ assert_eq!(ledger.status().tip_hash, tip, "{name} has a different tip");
+ assert!(
+ ledger.pending().is_empty(),
+ "{name} kept mined transactions"
+ );
+ }
+}
+
+#[test]
+fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob];
+ let allocations = allocations(&wallets, 1_000);
+ let mut network = InMemoryNetwork::default();
+
+ network.insert("alice", node("alice", alice.clone(), allocations.clone()));
+ network.insert("bob", node("bob", wallets[1].clone(), allocations));
+
+ for height in 1..=5 {
+ network.node_mut("alice").unwrap().burn(1).unwrap();
+ network
+ .node_mut("alice")
+ .unwrap()
+ .mine_one_at(height * VDF_TARGET_BLOCK_MS)
+ .unwrap();
+ }
+ assert_eq!(network.node("alice").unwrap().ledger().status().height, 5);
+ assert_eq!(network.node("bob").unwrap().ledger().status().height, 0);
+
+ assert!(network.sync_node_from_peer("alice", "bob", 2).unwrap());
+ assert_eq!(network.node("bob").unwrap().ledger().status().height, 2);
+
+ assert!(network.sync_node_from_peer("alice", "bob", 10).unwrap());
+ let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash;
+ let bob_status = network.node("bob").unwrap().ledger().status();
+ assert_eq!(bob_status.height, 5);
+ assert_eq!(bob_status.tip_hash, alice_tip);
+}
+
+#[test]
+fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
+ let alice = Wallet::from_seed("flow-alice");
+ let bob = Wallet::from_seed("flow-bob");
+ let carol = Wallet::from_seed("flow-carol");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let alice_ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5)
+ .unwrap();
+
+ let mut network = InMemoryNetwork::default();
+ network.insert(
+ "a",
+ NodeCore::from_ledger(
+ "a".to_string(),
+ alice.clone(),
+ alice_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+ let mut mined_by = Vec::new();
+
+ for height in 1..=2 {
+ network.node_mut("a").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap();
+ mined_by.push(block.miner.clone());
+ network.deliver_until_idle().unwrap();
+ }
+
+ let bob_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap();
+ network.insert(
+ "b",
+ NodeCore::from_ledger(
+ "b".to_string(),
+ bob.clone(),
+ bob_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+
+ for height in 3..=4 {
+ network.node_mut("a").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap();
+ mined_by.push(block.miner.clone());
+ network.deliver_until_idle().unwrap();
+ }
+
+ let carol_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap();
+ network.insert(
+ "c",
+ NodeCore::from_ledger(
+ "c".to_string(),
+ carol.clone(),
+ carol_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+
+ network
+ .node_mut("a")
+ .unwrap()
+ .transfer(bob.address(), 30)
+ .unwrap();
+ network.node_mut("a").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap();
+ assert!(
+ block5
+ .transactions
+ .iter()
+ .any(|tx| matches!(tx, mivora::domain::Transaction::Transfer { to, amount, .. } if to == bob.address() && *amount == 30))
+ );
+ mined_by.push(block5.miner.clone());
+ let block5_outbox = network.node_mut("a").unwrap().drain_outbox();
+ for envelope in &block5_outbox {
+ network
+ .node_mut("b")
+ .unwrap()
+ .receive(envelope.clone())
+ .unwrap();
+ }
+ assert_eq!(network.node("b").unwrap().ledger().status().height, 5);
+ assert_eq!(network.node("c").unwrap().ledger().status().height, 4);
+ let catchup_snapshot = network.node("a").unwrap().chain_snapshot();
+ network
+ .node_mut("c")
+ .unwrap()
+ .import_chain_snapshot(catchup_snapshot)
+ .unwrap();
+
+ for id in ["a", "b", "c"] {
+ assert_eq!(
+ network.node(id).unwrap().ledger().status().height,
+ 5,
+ "{id} did not import block 5"
+ );
+ assert_eq!(
+ network.node(id).unwrap().ledger().balance_of(bob.address()),
+ 30,
+ "{id} did not apply A -> B transfer"
+ );
+ }
+
+ network.node_mut("b").unwrap().burn(10).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block6 = network.node_mut("a").unwrap().mine_one_at(6).unwrap();
+ mined_by.push(block6.miner.clone());
+ network.deliver_until_idle().unwrap();
+
+ assert_eq!(
+ network
+ .node("a")
+ .unwrap()
+ .ledger()
+ .expected_leader_for_next_block(),
+ Some(bob.address().to_string())
+ );
+ network
+ .node_mut("b")
+ .unwrap()
+ .transfer(carol.address(), 10)
+ .unwrap();
+ network.node_mut("b").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block7 = network.node_mut("b").unwrap().mine_one_at(7).unwrap();
+ mined_by.push(block7.miner.clone());
+ network.deliver_until_idle().unwrap();
+
+ network.node_mut("c").unwrap().burn(5).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block8 = network.node_mut("b").unwrap().mine_one_at(8).unwrap();
+ mined_by.push(block8.miner.clone());
+ network.deliver_until_idle().unwrap();
+
+ assert_eq!(
+ network
+ .node("a")
+ .unwrap()
+ .ledger()
+ .expected_leader_for_next_block(),
+ Some(carol.address().to_string())
+ );
+ network.node_mut("c").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let block9 = network.node_mut("c").unwrap().mine_one_at(9).unwrap();
+ mined_by.push(block9.miner.clone());
+ network.deliver_until_idle().unwrap();
+
+ let final_tip = network.node("a").unwrap().ledger().status().tip_hash;
+ for id in ["a", "b", "c"] {
+ assert_eq!(network.node(id).unwrap().ledger().status().height, 9);
+ assert_eq!(
+ network.node(id).unwrap().ledger().status().tip_hash,
+ final_tip
+ );
+ }
+ for wallet in [&alice, &bob, &carol] {
+ assert!(
+ mined_by.iter().any(|miner| miner == wallet.address()),
+ "{} never mined",
+ wallet.address()
+ );
+ }
+}
+
+#[test]
+fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() {
+ let temp = tempdir().unwrap();
+ let alice = Wallet::from_seed("persistent-flow-alice");
+ let bob = Wallet::from_seed("persistent-flow-bob");
+ let carol = Wallet::from_seed("persistent-flow-carol");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 50);
+ let alice_ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5)
+ .unwrap();
+
+ let mut network = InMemoryNetwork::default();
+ network.insert(
+ "a",
+ NodeCore::from_ledger(
+ "a".to_string(),
+ alice.clone(),
+ alice_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+
+ network.node_mut("a").unwrap().burn(1).unwrap();
+ network.node_mut("a").unwrap().mine_one_at(1).unwrap();
+ network.deliver_until_idle().unwrap();
+
+ let bob_store = SqliteChainStore::open(temp.path().join("bob.sqlite3")).unwrap();
+ bob_store
+ .save(&network.node("a").unwrap().chain_snapshot())
+ .unwrap();
+ let bob_joined_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap();
+ network.insert(
+ "b",
+ NodeCore::from_ledger(
+ "b".to_string(),
+ bob.clone(),
+ bob_joined_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+ assert_eq!(
+ network.node("b").unwrap().ledger().status().tip_hash,
+ network.node("a").unwrap().ledger().status().tip_hash
+ );
+
+ network
+ .node_mut("a")
+ .unwrap()
+ .transfer(bob.address(), 10)
+ .unwrap();
+ network.node_mut("a").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ network.node_mut("a").unwrap().mine_one_at(2).unwrap();
+ network.deliver_until_idle().unwrap();
+ assert_eq!(
+ network
+ .node("b")
+ .unwrap()
+ .ledger()
+ .balance_of(bob.address()),
+ 10
+ );
+
+ bob_store
+ .save(&network.node("b").unwrap().chain_snapshot())
+ .unwrap();
+ let bob_restarted_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap();
+ network.insert(
+ "b",
+ NodeCore::from_ledger(
+ "b".to_string(),
+ bob.clone(),
+ bob_restarted_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+ assert_eq!(
+ network.node("b").unwrap().ledger().status().tip_hash,
+ network.node("a").unwrap().ledger().status().tip_hash,
+ "restarted Bob should resume the persisted chain tip"
+ );
+
+ let carol_store = SqliteChainStore::open(temp.path().join("carol.sqlite3")).unwrap();
+ carol_store
+ .save(&network.node("a").unwrap().chain_snapshot())
+ .unwrap();
+ let carol_joined_ledger = Ledger::from_snapshot(carol_store.load().unwrap().unwrap()).unwrap();
+ network.insert(
+ "c",
+ NodeCore::from_ledger(
+ "c".to_string(),
+ carol,
+ carol_joined_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ ),
+ );
+
+ network.node_mut("b").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ network.node_mut("a").unwrap().mine_one_at(3).unwrap();
+ network.deliver_until_idle().unwrap();
+ assert_eq!(
+ network
+ .node("a")
+ .unwrap()
+ .ledger()
+ .expected_leader_for_next_block()
+ .as_deref(),
+ Some(bob.address())
+ );
+
+ network.node_mut("b").unwrap().burn(1).unwrap();
+ network.deliver_until_idle().unwrap();
+ let bob_block = network.node_mut("b").unwrap().mine_one_at(4).unwrap();
+ assert_eq!(bob_block.miner, bob.address());
+ network.deliver_until_idle().unwrap();
+
+ let final_status = network.node("a").unwrap().ledger().status();
+ for id in ["b", "c"] {
+ assert_eq!(
+ network.node(id).unwrap().ledger().status().height,
+ final_status.height,
+ "{id} did not catch up after Bob restarted"
+ );
+ assert_eq!(
+ network.node(id).unwrap().ledger().status().tip_hash,
+ final_status.tip_hash,
+ "{id} ended on a different tip after Bob restarted"
+ );
+ }
+
+ bob_store
+ .save(&network.node("b").unwrap().chain_snapshot())
+ .unwrap();
+ assert_eq!(
+ bob_store
+ .load()
+ .unwrap()
+ .unwrap()
+ .blocks
+ .last()
+ .unwrap()
+ .height,
+ final_status.height
+ );
+}
+
+#[test]
+fn mined_block_gossip_does_not_include_full_chain_snapshot() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob.clone()];
+ let allocations = allocations(&wallets, 1_000);
+
+ let mut alice_node = NodeCore::new(NodeConfig {
+ name: "alice".to_string(),
+ wallet: alice,
+ genesis_allocations: allocations.clone(),
+ vdf_rounds: 10,
+ burn_per_block: 1,
+ });
+
+ let plan = alice_node.prepare_automatic_mining(1);
+ let burn_outbox = alice_node.drain_outbox();
+ assert_eq!(burn_outbox.len(), 1);
+ assert!(matches!(
+ burn_outbox[0],
+ mivora::app::GossipEnvelope::Transaction(_)
+ ));
+
+ let work = plan.work.unwrap();
+ let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
+ alice_node
+ .complete_prepared_block(work, vdf_output)
+ .unwrap();
+ let block_outbox = alice_node.drain_outbox();
+
+ assert_eq!(block_outbox.len(), 1);
+ assert!(matches!(
+ block_outbox[0],
+ mivora::app::GossipEnvelope::Block(_)
+ ));
+}
+
+#[test]
+fn received_transaction_is_rebroadcast_to_other_peers_without_networking() {
+ let names = ["alice", "bob", "carol"];
+ let wallets = wallets(&names);
+ let allocations = allocations(&wallets, 1_000);
+ let alice = wallets[0].clone();
+ let bob = wallets[1].clone();
+ let carol = wallets[2].clone();
+
+ let mut carol_node = node("carol", carol, allocations.clone());
+ let mut hub = node("alice", alice, allocations.clone());
+ let mut bob_node = node("bob", bob, allocations);
+
+ let tx = carol_node.burn(25).unwrap();
+ carol_node.drain_outbox();
+
+ hub.receive(mivora::app::GossipEnvelope::Transaction(tx.clone()))
+ .unwrap();
+ let forwarded = hub.drain_outbox();
+ assert_eq!(forwarded.len(), 1);
+ assert!(matches!(
+ forwarded[0],
+ mivora::app::GossipEnvelope::Transaction(_)
+ ));
+
+ for envelope in forwarded {
+ bob_node.receive(envelope).unwrap();
+ }
+ assert!(
+ bob_node
+ .ledger()
+ .pending()
+ .iter()
+ .any(|pending| pending.signature() == tx.signature())
+ );
+
+ hub.receive(mivora::app::GossipEnvelope::Transaction(tx))
+ .unwrap();
+ assert!(hub.drain_outbox().is_empty());
+}
+
+#[test]
+fn mempool_gossip_repairs_future_nonce_gap_without_networking() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob.clone()];
+ let allocations = allocations(&wallets, 1_000);
+ let mut alice_node = node("alice", alice, allocations.clone());
+ let mut bob_node = node("bob", bob, allocations);
+
+ let first = alice_node.burn(1).unwrap();
+ let second = alice_node.burn(1).unwrap();
+ alice_node.drain_outbox();
+
+ bob_node
+ .receive(mivora::app::GossipEnvelope::Transaction(second.clone()))
+ .unwrap();
+ assert_eq!(bob_node.ledger().pending().len(), 1);
+
+ let mut requests = Vec::new();
+ for envelope in alice_node.mempool_gossip() {
+ match envelope {
+ mivora::app::GossipEnvelope::Inventory { txs, blocks } => {
+ requests.extend(bob_node.missing_inventory_requests(&txs, &blocks));
+ }
+ other => bob_node.receive(other).unwrap(),
+ }
+ }
+ for request in requests {
+ match request {
+ mivora::app::GossipEnvelope::TransactionRequest { signatures } => {
+ bob_node
+ .receive(mivora::app::GossipEnvelope::Transactions {
+ transactions: alice_node.transactions_by_signature(&signatures),
+ })
+ .unwrap();
+ }
+ other => bob_node.receive(other).unwrap(),
+ }
+ }
+ let block = bob_node.mine_one_at(1).unwrap();
+ let signatures = block
+ .transactions
+ .iter()
+ .map(|tx| tx.signature())
+ .collect::<Vec<_>>();
+
+ assert!(signatures.contains(&first.signature()));
+ assert!(signatures.contains(&second.signature()));
+}
+
+#[test]
+fn received_block_is_rebroadcast_to_other_peers_without_networking() {
+ let names = ["alice", "bob", "carol"];
+ let wallets = wallets(&names);
+ let allocations = allocations(&wallets, 1_000);
+ let alice = wallets[0].clone();
+ let bob = wallets[1].clone();
+ let carol = wallets[2].clone();
+
+ let mut miner = node("alice", alice, allocations.clone());
+ let mut hub = node("bob", bob, allocations.clone());
+ let mut carol_node = node("carol", carol, allocations);
+
+ miner.burn(10).unwrap();
+ miner.drain_outbox();
+ let block = miner.mine_one_at(1).unwrap();
+ miner.drain_outbox();
+
+ hub.receive(mivora::app::GossipEnvelope::Block(block.clone()))
+ .unwrap();
+ let forwarded = hub.drain_outbox();
+ assert_eq!(forwarded.len(), 1);
+ assert!(matches!(
+ forwarded[0],
+ mivora::app::GossipEnvelope::Block(_)
+ ));
+
+ for envelope in forwarded {
+ carol_node.receive(envelope).unwrap();
+ }
+ assert_eq!(carol_node.ledger().height(), 1);
+ assert_eq!(
+ carol_node.ledger().status().tip_hash,
+ miner.ledger().status().tip_hash
+ );
+
+ hub.receive(mivora::app::GossipEnvelope::Block(block))
+ .unwrap();
+ assert!(hub.drain_outbox().is_empty());
+}
+
+#[test]
+fn imported_snapshot_blocks_are_rebroadcast_without_networking() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob.clone()];
+ let allocations = allocations(&wallets, 1_000);
+ let mut miner = node("alice", alice, allocations.clone());
+ let mut hub = node("bob", bob, allocations);
+
+ miner.burn(1).unwrap();
+ miner.drain_outbox();
+ miner.mine_one_at(1).unwrap();
+ miner.drain_outbox();
+
+ miner.burn(1).unwrap();
+ miner.drain_outbox();
+ miner.mine_one_at(2).unwrap();
+ miner.drain_outbox();
+
+ hub.import_chain_snapshot(miner.chain_snapshot()).unwrap();
+ let outbox = hub.drain_outbox();
+ assert_eq!(outbox.len(), 1);
+ match &outbox[0] {
+ mivora::app::GossipEnvelope::Blocks { blocks } => {
+ assert_eq!(blocks.len(), 2);
+ assert_eq!(blocks[0].height, 1);
+ assert_eq!(blocks[1].height, 2);
+ }
+ other => panic!("expected imported blocks gossip, got {other:?}"),
+ }
+}
+
+#[test]
+fn multiple_peers_can_contribute_burns_to_the_same_lottery_block() {
+ let names = ["alice", "bob", "carol", "dave"];
+ let wallets = wallets(&names);
+ let allocations = allocations(&wallets, 1_000);
+ let mut network = InMemoryNetwork::default();
+
+ for (name, wallet) in names.iter().zip(wallets.clone()) {
+ network.insert(*name, node(name, wallet, allocations.clone()));
+ }
+
+ for (name, amount) in names.iter().zip([10, 20, 30, 40]) {
+ network.node_mut(name).unwrap().burn(amount).unwrap();
+ }
+ network.deliver_until_idle().unwrap();
+ network.node_mut("alice").unwrap().mine_one().unwrap();
+ network.deliver_until_idle().unwrap();
+
+ for name in names {
+ let ledger = network.node(name).unwrap().ledger();
+ let block = &ledger.chain()[1];
+ let burned = block
+ .transactions
+ .iter()
+ .filter(|tx| tx.is_burn())
+ .map(|tx| tx.amount())
+ .sum::<Amount>();
+
+ assert_eq!(block.transactions.len(), 4);
+ assert_eq!(burned, 100);
+ assert!(ledger.expected_leader_for_next_block().is_some());
+ }
+}
+
+#[test]
+fn peer_book_tracks_multiple_peers_without_networking() {
+ let mut peers = PeerBook::from_addresses(vec![
+ "127.0.0.1:9444".to_string(),
+ "127.0.0.1:9445".to_string(),
+ "127.0.0.1:9444".to_string(),
+ ]);
+
+ peers.record_sent("127.0.0.1:9444", 2);
+ peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string());
+ peers.record_error("127.0.0.1:9445", "connection refused");
+ peers.record_received("127.0.0.1:9555", 1);
+ peers.record_inbound_error("127.0.0.1:56666", "invalid nonce");
+
+ let mut list = peers.list();
+ list.sort_by(|left, right| left.address.cmp(&right.address));
+ assert_eq!(list.len(), 4);
+
+ let outbound_addresses = peers.addresses();
+ assert_eq!(outbound_addresses.len(), 2);
+ assert!(outbound_addresses.contains(&"127.0.0.1:9444".to_string()));
+ assert!(outbound_addresses.contains(&"127.0.0.1:9445".to_string()));
+ assert!(!outbound_addresses.contains(&"127.0.0.1:56666".to_string()));
+
+ let sent_peer = list
+ .iter()
+ .find(|peer| peer.address == "127.0.0.1:9444")
+ .unwrap();
+ assert_eq!(sent_peer.messages_sent, 2);
+ assert_eq!(sent_peer.last_known_height, Some(12));
+ assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash"));
+ assert_eq!(sent_peer.last_error, None);
+
+ let failed_peer = list
+ .iter()
+ .find(|peer| peer.address == "127.0.0.1:9445")
+ .unwrap();
+ assert_eq!(
+ failed_peer.last_error.as_deref(),
+ Some("connection refused")
+ );
+
+ let inbound_peer = list
+ .iter()
+ .find(|peer| peer.address == "127.0.0.1:9555")
+ .unwrap();
+ assert_eq!(inbound_peer.direction, PeerDirection::Inbound);
+ assert_eq!(inbound_peer.messages_received, 1);
+
+ let inbound_error = list
+ .iter()
+ .find(|peer| peer.address == "127.0.0.1:56666")
+ .unwrap();
+ assert_eq!(inbound_error.direction, PeerDirection::Inbound);
+ assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce"));
+}
+
+#[test]
+fn chain_snapshot_round_trips_ledger_state() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(10, ledger.next_nonce(alice.address())))
+ .unwrap();
+ let block = ledger.mine_next_block(alice.address(), 1).unwrap();
+ ledger.apply_block(block).unwrap();
+
+ let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap();
+ assert_eq!(restored.status().height, ledger.status().height);
+ assert_eq!(restored.status().tip_hash, ledger.status().tip_hash);
+ assert_eq!(
+ restored.balance_of(alice.address()),
+ ledger.balance_of(alice.address())
+ );
+}
+
+#[test]
+fn friend_node_can_join_snapshot_from_started_chain() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+
+ let mut alice_genesis = BTreeMap::new();
+ alice_genesis.insert(alice.address().to_string(), 1_000);
+ let mut alice_node = node("alice", alice.clone(), alice_genesis);
+ alice_node.burn(1).unwrap();
+ alice_node.automatic_mine_once(1);
+
+ let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap();
+ let mut bob_node = NodeCore::from_ledger(
+ "bob".to_string(),
+ bob.clone(),
+ joined_ledger,
+ DEFAULT_BURN_PER_BLOCK,
+ );
+
+ assert_eq!(
+ bob_node.ledger().status().tip_hash,
+ alice_node.ledger().status().tip_hash
+ );
+ assert_eq!(bob_node.ledger().status().height, 1);
+ assert_eq!(bob_node.ledger().balance_of(bob.address()), 0);
+
+ let outcome = bob_node.automatic_mine_once(2);
+ assert!(outcome.burned.is_none());
+}
+
+#[test]
+fn running_node_rejects_snapshot_from_different_genesis() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+
+ let mut alice_genesis = BTreeMap::new();
+ alice_genesis.insert(alice.address().to_string(), 1_000);
+ let alice_node = node("alice", alice, alice_genesis);
+
+ let mut bob_genesis = BTreeMap::new();
+ bob_genesis.insert(bob.address().to_string(), 1_000);
+ let mut bob_node = node("bob", bob, bob_genesis);
+
+ let error = bob_node
+ .import_chain_snapshot(alice_node.chain_snapshot())
+ .unwrap_err();
+
+ assert!(error.to_string().contains("genesis"));
+}
+
+#[test]
+fn same_height_fork_snapshot_does_not_reorg() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob.clone()];
+ let shared_genesis = allocations(&wallets, 1_000);
+ let base = Ledger::new(shared_genesis, 1);
+ let mut local = base.clone();
+
+ let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 1);
+ let remote = fork_with_worse_vrf_block(&base, &bob, &local_first_fork_hash, 1).unwrap();
+
+ let local_tip = local.status().tip_hash;
+ assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap());
+ assert_eq!(local.status().tip_hash, local_tip);
+}
+
+#[test]
+fn fork_choice_preflight_rejects_non_matching_genesis_before_scoring() {
+ let alice = Wallet::from_seed("preflight-genesis-alice");
+ let bob = Wallet::from_seed("preflight-genesis-bob");
+ let mut local_genesis = BTreeMap::new();
+ local_genesis.insert(alice.address().to_string(), 1_000);
+ let mut remote_genesis = BTreeMap::new();
+ remote_genesis.insert(bob.address().to_string(), 1_000);
+ let mut local = Ledger::new(local_genesis, 1);
+ let mut remote = Ledger::new(remote_genesis, 1);
+
+ mine_wallet_burn_block(&mut local, &alice, 1);
+ for timestamp in 1..=3 {
+ mine_wallet_burn_block(&mut remote, &bob, timestamp);
+ }
+
+ let local_tip = local.status().tip_hash;
+ let error = local.extend_from_snapshot(remote.snapshot()).unwrap_err();
+
+ assert!(error.to_string().contains("genesis"));
+ assert_eq!(local.status().tip_hash, local_tip);
+}
+
+#[test]
+fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() {
+ let alice = Wallet::from_seed("preflight-invalid-alice");
+ let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
+ let mut common = Ledger::new(shared_genesis, 1);
+ for timestamp in 1..=5 {
+ mine_wallet_burn_block(&mut common, &alice, timestamp);
+ }
+
+ let mut local = common.clone();
+ let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
+ mine_wallet_burn_block(&mut local, &alice, 7);
+ mine_wallet_burn_block(&mut local, &alice, 8);
+
+ let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
+ assert!(remote.chain()[6].hash < local.chain()[6].hash);
+ let mut invalid_snapshot = remote.snapshot();
+ if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() {
+ match transaction {
+ mivora::domain::Transaction::Burn { signature, .. }
+ | mivora::domain::Transaction::Transfer { signature, .. } => signature.push_str("00"),
+ }
+ }
+
+ let local_tip = local.status().tip_hash;
+ let error = local.extend_from_snapshot(invalid_snapshot).unwrap_err();
+
+ assert!(error.to_string().contains("invalid"));
+ assert_eq!(local.status().height, 8);
+ assert_eq!(local.status().tip_hash, local_tip);
+}
+
+#[test]
+fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() {
+ let alice = Wallet::from_seed("finality-alice");
+ let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
+ let mut common = Ledger::new(shared_genesis, 1);
+ mine_wallet_burn_block(&mut common, &alice, 1);
+
+ let mut local = common.clone();
+ for timestamp in 2..=8 {
+ mine_wallet_burn_block(&mut local, &alice, timestamp);
+ }
+
+ let mut remote = common;
+ for timestamp in 20..=29 {
+ mine_wallet_burn_block(&mut remote, &alice, timestamp);
+ }
+
+ assert_eq!(local.status().height, 8);
+ assert_eq!(remote.status().height, 11);
+ let finalized_local_tip = local.status().tip_hash;
+
+ assert!(
+ !local.extend_from_snapshot(remote.snapshot()).unwrap(),
+ "forks that rewrite blocks before the last six should not be accepted"
+ );
+ assert_eq!(local.status().height, 8);
+ assert_eq!(local.status().tip_hash, finalized_local_tip);
+}
+
+#[test]
+fn better_vrf_fork_inside_last_six_wins_when_no_more_than_two_blocks_shorter() {
+ let alice = Wallet::from_seed("better-vrf-alice");
+ let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
+ let mut common = Ledger::new(shared_genesis, 1);
+ for timestamp in 1..=5 {
+ mine_wallet_burn_block(&mut common, &alice, timestamp);
+ }
+
+ let mut local = common.clone();
+ let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
+ mine_wallet_burn_block(&mut local, &alice, 7);
+ mine_wallet_burn_block(&mut local, &alice, 8);
+
+ let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
+
+ assert_eq!(local.status().height, 8);
+ assert_eq!(remote.status().height, 6);
+ assert!(remote.status().height + 2 >= local.status().height);
+ assert!(
+ remote.chain()[6].hash < local.chain()[6].hash,
+ "test setup should give the remote fork the better VRF leader score"
+ );
+ let remote_tip = remote.status().tip_hash;
+
+ assert!(
+ local.extend_from_snapshot(remote.snapshot()).unwrap(),
+ "a better VRF fork inside the last six should win while at most two blocks shorter"
+ );
+ assert_eq!(local.status().height, 6);
+ assert_eq!(local.status().tip_hash, remote_tip);
+}
+
+#[test]
+fn better_vrf_fork_inside_last_six_loses_when_more_than_two_blocks_shorter() {
+ let alice = Wallet::from_seed("too-short-vrf-alice");
+ let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
+ let mut common = Ledger::new(shared_genesis, 1);
+ for timestamp in 1..=5 {
+ mine_wallet_burn_block(&mut common, &alice, timestamp);
+ }
+
+ let mut local = common.clone();
+ let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
+ mine_wallet_burn_block(&mut local, &alice, 7);
+ mine_wallet_burn_block(&mut local, &alice, 8);
+ mine_wallet_burn_block(&mut local, &alice, 9);
+
+ let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
+
+ assert_eq!(local.status().height, 9);
+ assert_eq!(remote.status().height, 6);
+ assert!(remote.chain()[6].hash < local.chain()[6].hash);
+ let local_tip = local.status().tip_hash;
+
+ assert!(
+ !local.extend_from_snapshot(remote.snapshot()).unwrap(),
+ "even a better VRF fork should not win when more than two blocks shorter"
+ );
+ assert_eq!(local.status().height, 9);
+ assert_eq!(local.status().tip_hash, local_tip);
+}
+
+#[test]
+fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() {
+ let alice = Wallet::from_seed("reorg-alice");
+ let bob = Wallet::from_seed("reorg-bob");
+ let carol = Wallet::from_seed("reorg-carol");
+ let wallets = vec![alice.clone(), bob.clone(), carol.clone()];
+ let shared_genesis = allocations(&wallets, 10_000);
+ let mut common = Ledger::new(shared_genesis, 1);
+ mine_wallet_burn_block(&mut common, &alice, 1);
+
+ let mut local = common.clone();
+ let abandoned_transfer = bob.transfer(carol.address(), 7, local.next_nonce(bob.address()));
+ local
+ .submit_transaction(abandoned_transfer.clone())
+ .unwrap();
+ mine_wallet_burn_block(&mut local, &alice, 2);
+
+ let mut remote = common;
+ for timestamp in 20..=23 {
+ mine_wallet_burn_block(&mut remote, &alice, timestamp);
+ }
+
+ assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
+ assert!(
+ local
+ .pending()
+ .iter()
+ .any(|tx| tx.signature() == abandoned_transfer.signature()),
+ "transactions mined only on the abandoned fork should return to the mempool"
+ );
+}
+
+#[test]
+fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+ let wallets = vec![alice.clone(), bob.clone()];
+ let shared_genesis = allocations(&wallets, 1_000);
+ let mut local = Ledger::new(shared_genesis.clone(), 1);
+ let mut remote = Ledger::new(shared_genesis, 1);
+
+ let local_burn = alice.burn(1, local.next_nonce(alice.address()));
+ local.submit_transaction(local_burn.clone()).unwrap();
+ let local_block = local.mine_next_block(alice.address(), 1).unwrap();
+ local.apply_block(local_block).unwrap();
+ let local_transfer = alice.transfer(bob.address(), 5, local.next_nonce(alice.address()));
+ local.submit_transaction(local_transfer.clone()).unwrap();
+
+ remote
+ .submit_transaction(bob.burn(1, remote.next_nonce(bob.address())))
+ .unwrap();
+ let remote_block_1 = remote.mine_next_block(bob.address(), 1).unwrap();
+ remote.apply_block(remote_block_1).unwrap();
+ remote
+ .submit_transaction(bob.burn(1, remote.next_nonce(bob.address())))
+ .unwrap();
+ let remote_block_2 = remote.mine_next_block(bob.address(), 2).unwrap();
+ remote.apply_block(remote_block_2).unwrap();
+
+ let remote_tip = remote.status().tip_hash;
+ assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
+ assert_eq!(local.status().height, 2);
+ assert_eq!(local.status().tip_hash, remote_tip);
+ assert!(
+ local
+ .pending()
+ .iter()
+ .any(|tx| tx.signature() == local_burn.signature())
+ );
+ assert!(
+ local
+ .pending()
+ .iter()
+ .any(|tx| tx.signature() == local_transfer.signature())
+ );
+}
+
+#[test]
+fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() {
+ let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("bob");
+
+ let wallets = vec![alice.clone(), bob.clone()];
+ let shared_genesis = allocations(&wallets, 1_000);
+ let mut alice_node = node("alice", alice, shared_genesis.clone());
+ alice_node.burn(1).unwrap();
+ alice_node.mine_one().unwrap();
+
+ let mut bob_node = node("bob", bob, shared_genesis);
+
+ bob_node
+ .receive(mivora::app::GossipEnvelope::ChainSnapshot(
+ alice_node.chain_snapshot(),
+ ))
+ .unwrap();
+
+ assert_eq!(
+ bob_node.ledger().status().tip_hash,
+ alice_node.ledger().status().tip_hash
+ );
+}