cert: the weight clause Go runs last, the set admitted whole, and one workspace

THE TOTAL. `Register` in `validator/registration.go` runs seven clauses, and
this port had six. The seventh is `math.Add64` on the running weight: a set
whose weights sum past what a `uint64` holds is refused entire, before any of
it is admitted. Without it `total_stake` folded with `saturating_add` and
`voted_stake` summed with `saturating_add`, so a set of four validators at
2^63 reported `u64::MAX` staked AND `u64::MAX` voted — and
`u64::MAX > floor(2·u64::MAX/3)`. A certificate carrying HALF the stake
cleared the two-thirds export floor, which is the rung a bridge reads.

Clamping is what makes the two sides agree on a number neither can represent,
so neither clamps any more. The set carries its total as a fact established at
admission with `checked_add` — one source of truth, decided at the one place
that still has a registration to refuse — and `voted_stake` is checked too:
the voters are a subset of the members, so against a set this crate admitted
the overflow is unreachable, and reaching it is evidence about the stake
source rather than about the votes. `weighted_quasar` takes the same
treatment: a vector with no representable total leaves by the same door as a
zero total, since a plain sum WRAPS to a small total and sizes the count gate
below the stake quorum it exists to anticipate.

THE SET. `insert` admits one validator and reports on that one, which is a
property no set can be built out of: a caller that inserts in a loop and logs
what failed is left holding a set missing a member, with `n` short by one and
the total short by that member's stake — the two numbers every threshold is
read against, wrong, with nothing to say so. `ValidatorSet::register` is the
port of Go's whole-set door: sorted by node id so which duplicate a set is
refused on cannot depend on the order a caller built its input, and
all-or-nothing because the set under construction is a local. `insert` stays
for a membership change to a set already live, and now says so.

THE BINDING. `pkg/typescript` path-depends on `pkg/rust` and could not
compile: narrowing the node id from 32 bytes to 20 left two `NodeID::from([u8;
32])` calls with no impl. It is a 20-byte API now, and the 20 bytes come from
20-byte input — `arr[..20]` would reintroduce exactly the collision the
narrowing exists to prevent, so a hex string of the wrong length is refused,
never trimmed. The width is `size_of::<cert::NodeId>()`, taken from the core
rather than restated, so it cannot drift again. `addValidator` also stopped
discarding the core's answer, which reported a validator as added when the
set had refused it.

And the two crates are one workspace, because that is what they already were.
Built separately they can disagree and did — nothing built both, so nothing
noticed. `cargo test` at the root now builds both, which turns this class of
break from a review finding into a compile error. It also surfaced two tests
in the binding that had been failing unnoticed: they voted with unregistered
ids, from before only members were counted.

No signed byte moves. `canonical_vote_message` is untouched, the node id
enters no signed message, and the Go corpus and the frozen `pop` vectors pass
unchanged.

Verified: 97 tests green (92 rust, 5 napi), clippy clean at the workspace
root, the binding's 29 JS tests green against a linux build of it. Red's PoC
— four validators of 2^63, two voting, read at QUASAR — returned `Ok(())`
before and now dies at registration with `WeightOverflow`.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-01 11:53:54 -07:00
co-authored by Hanzo Dev
parent ab41ba6b1c
commit c22f13f63e
14 changed files with 912 additions and 546 deletions
+4 -1
View File
@@ -86,7 +86,10 @@ node_modules
pkg/rust/target
.dev*
# Language binding build artifacts
# Language binding build artifacts. The Rust crates are one workspace, so cargo
# writes to /target at the root; the per-crate paths predate that and are kept
# for a crate built on its own.
/target/
pkg/rust/target/
pkg/python/.venv/
pkg/python/__pycache__/
+332 -144
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
@@ -19,27 +19,21 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.11"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arrayvec"
version = "0.7.6"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
@@ -57,34 +51,34 @@ dependencies = [
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake3"
version = "1.8.2"
version = "1.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0"
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures",
]
[[package]]
name = "blst"
version = "0.3.16"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45"
checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe"
dependencies = [
"cc",
"glob",
@@ -94,9 +88,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cast"
@@ -106,12 +100,12 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.36"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
"shlex 2.0.1",
]
[[package]]
@@ -125,9 +119,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
@@ -158,9 +152,9 @@ dependencies = [
[[package]]
name = "clang-sys"
version = "1.8.1"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
@@ -169,18 +163,18 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.47"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.47"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstyle",
"clap_lex",
@@ -188,24 +182,42 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.5"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.54"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "criterion"
@@ -242,9 +254,9 @@ dependencies = [
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -252,18 +264,18 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
@@ -272,38 +284,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.15.0"
name = "ctor"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "find-msvc-tools"
version = "0.1.1"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "half"
version = "2.6.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
@@ -322,25 +369,26 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.78"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"once_cell",
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.175"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
@@ -354,13 +402,13 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lux-consensus"
version = "1.22.3"
version = "1.22.4"
dependencies = [
"blake3",
"blst",
@@ -372,11 +420,24 @@ dependencies = [
"serde_json",
]
[[package]]
name = "lux-consensus-napi"
version = "1.22.1"
dependencies = [
"hex",
"lux-consensus",
"napi",
"napi-build",
"napi-derive",
"serde",
"serde_json",
]
[[package]]
name = "memchr"
version = "2.7.5"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
@@ -395,6 +456,66 @@ dependencies = [
"cmake",
]
[[package]]
name = "napi"
version = "2.16.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags",
"ctor",
"napi-derive",
"napi-sys",
"once_cell",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
[[package]]
name = "napi-derive"
version = "2.16.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
dependencies = [
"cfg-if",
"convert_case",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "napi-derive-backend"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
dependencies = [
"convert_case",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn 2.0.119",
]
[[package]]
name = "napi-sys"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
dependencies = [
"libloading",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -426,9 +547,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
@@ -436,6 +557,12 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
@@ -471,32 +598,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -514,9 +641,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.2"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
@@ -526,9 +653,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.10"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -537,9 +664,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.6"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
@@ -549,15 +676,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustversion"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
@@ -569,35 +690,52 @@ dependencies = [
]
[[package]]
name = "serde"
version = "1.0.219"
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
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.219"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.143"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -607,10 +745,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.106"
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[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.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -637,10 +798,25 @@ dependencies = [
]
[[package]]
name = "unicode-ident"
version = "1.0.19"
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "walkdir"
@@ -654,9 +830,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.101"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
@@ -665,25 +841,11 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.101"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -691,31 +853,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.101"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.101"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.78"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -732,35 +894,61 @@ dependencies = [
[[package]]
name = "windows-link"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.0"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zeroize"
version = "1.8.2"
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
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"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+16
View File
@@ -0,0 +1,16 @@
# The Rust side of this repository, as one workspace.
#
# `pkg/typescript` path-depends on `pkg/rust`, so they are already one unit; the
# workspace is that fact written down. Built separately they could disagree —
# and did: narrowing the node id from 32 bytes to 20 in `pkg/rust` left
# `pkg/typescript` unable to compile at all, and nothing said so, because
# nothing built both. One `cargo test` here builds both.
[workspace]
resolver = "2"
members = ["pkg/rust", "pkg/typescript"]
# Profiles apply to a whole workspace, so they live at its root. These are the
# settings the napi binding shipped with; a member's own `[profile]` is ignored.
[profile.release]
lto = true
strip = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lux-consensus"
version = "1.22.3"
version = "1.22.4"
edition = "2021"
description = "Lux Consensus SDK — pure-Rust Quasar post-quantum probabilistic consensus (Wave / FPC / Photon / Focus / Quasar PQ finality via hybrid BLS12-381 + Pulsar = P3Q)"
license-file = "LICENSE"
+131 -13
View File
@@ -128,6 +128,18 @@ pub enum CertError {
/// A proof of possession that is not a valid signature by this key over its
/// own (node, key) message under [`crate::pop::POP_DST`].
PopInvalid,
/// The set's weights sum past what a `u64` can hold. Go's
/// `ErrWeightOverflow`, and refused for the reason Go refuses it: every
/// threshold in this crate is read against the total, so a total that
/// cannot be represented is a total two thirds of which means nothing.
///
/// It is the clause that keeps the two sides of the weighted predicate
/// honest. Were the sum merely clamped, a set summing past `u64::MAX`
/// would report `u64::MAX` staked AND `u64::MAX` voted, and
/// `u64::MAX > floor(2·u64::MAX/3)` — half the stake would read as an
/// export supermajority. So the set is refused at the door instead, and
/// the predicate never sees a number that is not the sum it claims to be.
WeightOverflow,
}
impl std::fmt::Display for CertError {
@@ -167,6 +179,7 @@ impl std::fmt::Display for CertError {
CertError::DuplicateKey => write!(f, "public key is registered to more than one node"),
CertError::DuplicateNode => write!(f, "node is registered more than once"),
CertError::PopInvalid => write!(f, "proof of possession does not verify for this key"),
CertError::WeightOverflow => write!(f, "weight overflowed"),
}
}
}
@@ -393,7 +406,7 @@ impl QuorumCert {
if total == 0 {
return Err(CertError::StakeZero { epoch_height });
}
let voted = self.voted_stake(stake, epoch_height);
let voted = self.voted_stake(stake, epoch_height)?;
let half = half_stake_floor(total);
if voted <= half {
return Err(CertError::StakeBelowMajority {
@@ -417,7 +430,7 @@ impl QuorumCert {
if total == 0 {
return Err(CertError::StakeZero { epoch_height });
}
let voted = self.voted_stake(stake, epoch_height);
let voted = self.voted_stake(stake, epoch_height)?;
let floor = two_thirds_stake_floor(total);
if voted <= floor {
return Err(CertError::StakeBelowSupermajority {
@@ -429,19 +442,46 @@ impl QuorumCert {
Ok(())
}
/// Summed stake of the certificate's voters, saturating.
/// Summed stake of the certificate's voters — checked, and refused rather
/// than clamped.
///
/// Saturating and not wrapping: a set whose weights sum past `u64::MAX`
/// must not wrap to a small number and read as below-threshold, nor wrap
/// past a floor. It is only reached on a set that cannot exist, and it
/// stays monotone if it ever is.
fn voted_stake(&self, stake: &dyn StakeSource, epoch_height: u64) -> u64 {
self.votes.iter().fold(0u64, |acc, v| {
acc.saturating_add(stake.weight(&v.node_id, epoch_height))
/// The voters are a subset of the members, so against any set this crate
/// admitted this sum is bounded by a total [`ValidatorSet::insert`] already
/// proved representable, and the overflow is unreachable. Reaching it is
/// therefore evidence about the [`StakeSource`], not about the votes: it
/// says the source is reporting weights that no admitted set could hold, and
/// a source that cannot state its own total is not one a threshold can be
/// read against.
///
/// It must not clamp. Clamping pins BOTH sides of the predicate to
/// `u64::MAX`, and `u64::MAX > floor(2·u64::MAX/3)` — so a certificate
/// carrying half the stake would clear the export floor. Monotone is not
/// the property that matters here; being the sum it claims to be is.
fn voted_stake(&self, stake: &dyn StakeSource, epoch_height: u64) -> Result<u64, CertError> {
self.votes.iter().try_fold(0u64, |acc, v| {
acc.checked_add(stake.weight(&v.node_id, epoch_height))
.ok_or(CertError::WeightOverflow)
})
}
}
/// One validator asking to be counted: the identity it claims, the key it will
/// sign under, the proof binding the two, and the weight staked behind it.
///
/// The field-for-field twin of Go's `validators.Registration`, and the input to
/// [`ValidatorSet::register`] — the door for a set that arrives whole.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Registration {
/// The identity claimed, by the 20-byte NodeID the proof is bound to.
pub node: NodeId,
/// The BLS12-381 min_pk public key: compressed G1, 48 bytes.
pub public_key: Vec<u8>,
/// The node-bound proof of possession over `node ‖ key`: compressed G2, 96
/// bytes. See [`crate::pop`] for the preimage and the domain.
pub proof: Vec<u8>,
pub weight: u64,
}
/// A validator set: who is a member, what stake each carries, and the keys
/// those members sign with.
///
@@ -489,6 +529,15 @@ pub struct ValidatorSet {
/// Keyed on the canonical bytes re-derived from the DECODED point, never on
/// the caller's input, so two spellings of one key cannot occupy two slots.
owner: HashMap<[u8; PUBLIC_KEY_LEN], NodeId>,
/// The summed weight of every member, keyed or not.
///
/// Established one admission at a time and never recomputed, because it is
/// the admission that decides whether it can be represented at all — see
/// the WEIGHT clause of [`ValidatorSet::insert`]. A total folded on demand
/// has to decide what to do about a sum it cannot hold, at a place with no
/// registration left to refuse; a total accumulated at the door already
/// knows, and the set that would have overflowed does not exist.
total: u64,
}
impl ValidatorSet {
@@ -496,6 +545,45 @@ impl ValidatorSet {
Self::default()
}
/// Admit a whole set of registrations, or none of them. This is the port of
/// Go's `validators.Register`, and the way to build a set from input this
/// node did not produce — a genesis file, a peer, a chain read.
///
/// [`ValidatorSet::insert`] admits ONE validator and reports on that one, so
/// it cannot give a set any property. A caller that inserts in a loop and
/// logs what failed is left holding a set that is missing a member: `n` is
/// short by one and the total is short by that member's stake, and those two
/// numbers are what every threshold in this crate is read against. Nothing
/// announces the loss — the set is well formed, just not the set that was
/// registered — so two nodes fed the same registrations disagree about the
/// floor while both believe they agree.
///
/// So the set is admitted whole or not at all. One inadmissible registration
/// fails the call and no partial set escapes: the set under construction is
/// a local, and the caller receives either all of it or the first clause
/// that failed.
///
/// Registrations are sorted by node id before any is checked, exactly as Go
/// sorts them and with the same stable sort, so WHICH duplicate a set is
/// refused on does not depend on the order a caller happened to build its
/// input. One bad set is one error, everywhere.
///
/// The input is taken by value. Go clones its slice so the sort cannot be
/// seen by the caller; here the caller has given the registrations up, so
/// there is no caller-visible slice to protect and no copy to make.
///
/// Every registration must carry a key: this is the proof path, and Go's
/// `Register` refuses a keyless one for the same reason. A member this node
/// holds no key for comes in through [`ValidatorSet::insert_unkeyed`].
pub fn register(mut registrations: Vec<Registration>) -> Result<Self, CertError> {
registrations.sort_by_key(|r| r.node);
let mut set = Self::new();
for r in &registrations {
set.insert(r.node, r.weight, &r.public_key, &r.proof)?;
}
Ok(set)
}
/// Admit a validator: the identity it claims, the key it will sign under,
/// the proof binding the two, and the weight staked behind it.
///
@@ -532,7 +620,13 @@ impl ValidatorSet {
/// or weight behind a caller that thought it was adding one.
///
/// Nothing is written unless every clause passes: a refused registration
/// leaves no membership, no key and no ownership behind.
/// leaves no membership, no key and no ownership behind. That is a promise
/// about THIS registration and only this one — the set is left exactly as it
/// was, which for a caller admitting several in a loop means it is left
/// holding the ones that already went in. A set built from input this node
/// did not produce wants [`ValidatorSet::register`], which is all-or-nothing
/// over the whole set; this door is the incremental one, for a membership
/// change to a set that is already live.
pub fn insert(
&mut self,
node: NodeId,
@@ -576,10 +670,20 @@ impl ValidatorSet {
if self.weights.contains_key(&node) {
return Err(CertError::DuplicateNode);
}
// WEIGHT, last — the clause Go runs last, for the reason it runs it at
// all. `checked_add`, so a set whose weights sum past `u64::MAX` is
// refused here rather than clamped downstream, where clamping both the
// total and the votes pins them equal and lets half the stake read as
// two thirds of it.
let total = self
.total
.checked_add(weight)
.ok_or(CertError::WeightOverflow)?;
self.keys.insert(node, pk);
self.weights.insert(node, weight);
self.owner.insert(canonical, node);
self.total = total;
Ok(())
}
@@ -597,7 +701,16 @@ impl ValidatorSet {
if self.weights.contains_key(&node) {
return Err(CertError::DuplicateNode);
}
// The same WEIGHT clause. An unkeyed member cannot sign, but its stake
// is counted in the total every threshold is read against — Go's
// `FlattenValidatorSet` adds it and checks the same overflow — so it can
// overflow the total exactly as a keyed one can.
let total = self
.total
.checked_add(weight)
.ok_or(CertError::WeightOverflow)?;
self.weights.insert(node, weight);
self.total = total;
Ok(())
}
@@ -611,7 +724,12 @@ impl ValidatorSet {
if let Some(old) = self.keys.remove(node) {
self.owner.remove(&old.compress());
}
self.weights.remove(node);
// The total is the sum of the weights still present, so it loses exactly
// what the map loses. It was added to the total when the member was
// admitted, so the subtraction is exact and cannot go below zero.
if let Some(weight) = self.weights.remove(node) {
self.total -= weight;
}
}
/// The number of members, keyed or not — the `n` every threshold is read
@@ -672,7 +790,7 @@ impl StakeSource for ValidatorSet {
}
fn total_stake(&self, _epoch_height: u64) -> u64 {
self.weights.values().fold(0u64, |a, w| a.saturating_add(*w))
self.total
}
fn validator_count(&self, _epoch_height: u64) -> i64 {
+39 -3
View File
@@ -202,10 +202,19 @@ pub fn equal_stake_quasar(n: i64) -> i64 {
/// The minimum vote count that CAN reach the two-thirds-by-stake predicate for a
/// weight vector: order heaviest first and count until the running stake first
/// exceeds the floor. Returns 0 for an empty set or zero total — no stake model,
/// fail closed.
/// exceeds the floor. Returns 0 for an empty set, a zero total, or a vector with
/// no representable total — no stake model, fail closed.
pub fn weighted_quasar(weights: &[u64]) -> usize {
let total: u64 = weights.iter().copied().sum();
// Checked, and out the same door as a zero total. A vector summing past
// `u64::MAX` has no total, so it has no two thirds of one to size a count
// against. A plain sum would wrap to a SMALL total and hand back a count
// below the real stake quorum — a count gate sized under the predicate it
// exists to anticipate, which is the fail-open direction. No admitted set
// can be such a vector: `ValidatorSet::insert` refuses the sum at the door.
let total = match weights.iter().try_fold(0u64, |acc, &w| acc.checked_add(w)) {
Some(total) => total,
None => return 0,
};
if total == 0 || weights.is_empty() {
return 0;
}
@@ -216,6 +225,7 @@ pub fn weighted_quasar(weights: &[u64]) -> usize {
let mut count = 0usize;
for w in sorted {
count += 1;
// Bounded by `total`, which is representable, so the running sum is too.
cum += w;
if cum > floor {
break;
@@ -269,3 +279,29 @@ impl Finality {
}
}
}
#[cfg(test)]
mod tests {
use super::weighted_quasar;
/// A weight vector with no representable total takes the fail-closed exit,
/// not the wrapped one. Two validators of 2^63 sum to 2^64: a plain sum
/// wraps that to 0 in release and panics in debug, and a wrapped total of 0
/// would hand `two_thirds_stake_floor` a floor the first voter clears — a
/// count gate of 1 for a set no count can finalize. One less than that pair
/// is a real total, and still counted exactly.
#[test]
fn a_weight_vector_with_no_total_is_fail_closed() {
assert_eq!(weighted_quasar(&[1 << 63, 1 << 63]), 0);
assert_eq!(weighted_quasar(&[u64::MAX, 1]), 0);
assert_eq!(weighted_quasar(&[u64::MAX / 2, u64::MAX / 2]), 2);
assert_eq!(weighted_quasar(&[u64::MAX]), 1);
}
/// The exits that were already there stay where they were.
#[test]
fn no_stake_model_is_zero() {
assert_eq!(weighted_quasar(&[]), 0);
assert_eq!(weighted_quasar(&[0, 0, 0]), 0);
}
}
+251 -2
View File
@@ -15,8 +15,8 @@
use blst::min_pk::SecretKey;
use lux_consensus::cert::{
CertError, NodeId, QuorumCert, StakeSource, ValidatorSet, Vote, VoteVerifier, DST,
SIGNATURE_LEN,
CertError, NodeId, QuorumCert, Registration, StakeSource, ValidatorSet, Vote, VoteVerifier,
DST, SIGNATURE_LEN,
};
use lux_consensus::finality::{canonical_vote_message, Finality, Position};
use lux_consensus::pop;
@@ -927,3 +927,252 @@ fn the_issue_path_applies_the_stake_floor_not_only_the_count() {
"nothing below the stake floor is finalized"
);
}
// ------------------------------------------------------------- the weight total
/// Go's `Register` runs one clause after uniqueness that this port had dropped:
/// `math.Add64` on the running total, refusing the WHOLE set if it overflows.
/// Four validators of 2^63 sum to 2^65. Without the clause both sides of the
/// weighted predicate clamp to `u64::MAX` — total AND voted — and
/// `u64::MAX > floor(2·u64::MAX/3)`, so a certificate carrying half the stake
/// reads as an export supermajority. The set that would do it is refused here,
/// at the member whose weight the total cannot hold.
#[test]
fn a_set_whose_weights_overflow_is_refused_at_admission() {
let huge = 1u64 << 63;
let signers: Vec<Signer> = (1..=4).map(|i| Signer::new(i, huge)).collect();
let mut set = ValidatorSet::new();
let first = &signers[0];
set.insert(first.id, first.weight, &first.public(), &first.pop())
.expect("the first 2^63 fits");
assert_eq!(set.total_stake(0), huge);
// 2^63 + 2^63 is 2^64. Refused, and nothing of it is kept.
let second = &signers[1];
assert_eq!(
set.insert(second.id, second.weight, &second.public(), &second.pop()),
Err(CertError::WeightOverflow)
);
assert_eq!(set.len(), 1, "a refused registration is not a member");
assert_eq!(set.total_stake(0), huge, "nor does its weight count");
assert!(!set.can_verify(&second.id), "nor is its key registered");
// And through the whole-set door, for the same reason.
let registrations: Vec<Registration> = signers
.iter()
.map(|s| Registration {
node: s.id,
public_key: s.public().to_vec(),
proof: s.pop(),
weight: s.weight,
})
.collect();
assert_eq!(
ValidatorSet::register(registrations).unwrap_err(),
CertError::WeightOverflow
);
}
/// The unkeyed door counts weight toward the same total, so it carries the same
/// clause — as Go's `FlattenValidatorSet` does, which checks the overflow before
/// it even looks at the key.
#[test]
fn an_unkeyed_member_cannot_overflow_the_total_either() {
let mut set = ValidatorSet::new();
set.insert_unkeyed(node(1), u64::MAX).expect("the first fits");
assert_eq!(
set.insert_unkeyed(node(2), 1),
Err(CertError::WeightOverflow)
);
assert_eq!(set.len(), 1);
assert_eq!(set.total_stake(0), u64::MAX);
}
/// The largest representable set is admissible, and the total is exact at the
/// boundary rather than clamped to it — a set that sums to `u64::MAX` is a real
/// set, and only the member past it is refused. Retracting one frees exactly its
/// weight, so the total stays the sum of what is actually there.
#[test]
fn the_total_is_exact_at_the_boundary_and_after_a_retraction() {
let mut set = ValidatorSet::new();
set.insert_unkeyed(node(1), u64::MAX - 10).expect("insert");
set.insert_unkeyed(node(2), 10).expect("insert");
assert_eq!(set.total_stake(0), u64::MAX);
assert_eq!(set.insert_unkeyed(node(3), 1), Err(CertError::WeightOverflow));
set.remove(&node(2));
assert_eq!(set.total_stake(0), u64::MAX - 10);
set.insert_unkeyed(node(3), 10).expect("the freed room is real");
assert_eq!(set.total_stake(0), u64::MAX);
}
/// The other half of the same clause, on the predicate side.
///
/// This is the exact certificate the missing clause admitted: four validators of
/// 2^63, two of them voting — half the stake — read against a stake source that
/// clamps, which is what the set itself used to do. The floor is computed from
/// the total, so when both the total and the voted sum clamp to `u64::MAX` the
/// comparison is `u64::MAX > floor(2·u64::MAX/3)` and QUASAR returns `Ok` on 50%.
///
/// It now returns the overflow instead. A sum that cannot be represented is not
/// a sum, and the predicate says so rather than comparing two saturated numbers:
/// no admitted set can reach it, so reaching it is evidence about the source.
#[test]
fn a_clamping_stake_source_is_refused_rather_than_read() {
/// The pre-fix arithmetic, kept whole so the difference is the fix and
/// nothing else: a total folded on demand with `saturating_add`.
struct Clamping {
weights: Vec<(NodeId, u64)>,
}
impl StakeSource for Clamping {
fn weight(&self, node: &NodeId, _epoch_height: u64) -> u64 {
self.weights
.iter()
.find(|(id, _)| id == node)
.map(|(_, w)| *w)
.unwrap_or(0)
}
fn total_stake(&self, _epoch_height: u64) -> u64 {
self.weights.iter().fold(0u64, |a, (_, w)| a.saturating_add(*w))
}
fn validator_count(&self, _epoch_height: u64) -> i64 {
self.weights.len() as i64
}
}
let huge = 1u64 << 63;
// Registered at a weight the set can hold, so the keys resolve and the
// signatures verify — the stake is the only thing under test.
let (signers, set) = committee(4, 1);
let clamping = Clamping {
weights: signers.iter().map(|s| (s.id, huge)).collect(),
};
assert_eq!(
clamping.total_stake(0),
u64::MAX,
"the source clamps, as the set used to"
);
// Two of four: half the stake, and every signature genuine.
let cert = valid_cert(&signers, 2, 2);
assert_eq!(cert.verify(&set, 0), Ok(()));
assert_eq!(
cert.verify_weighted(&set, &clamping, 0),
Err(CertError::WeightOverflow),
"half the stake is not two thirds of it, whatever the arithmetic clamps to"
);
// The same refusal one rung down, so neither tier reads a clamped total.
// Three of four, because Nova's signer floor is read before its stake and
// two would stop there — the stake clause has to be reached to be tested.
let pos = position();
let message = canonical_vote_message(&pos, true);
let votes: Vec<Vote> = signers[..3].iter().map(|s| s.vote(&message)).collect();
let nova = QuorumCert::assemble(Finality::Nova, pos, 3, &votes).expect("assemble");
assert_eq!(
nova.verify_weighted(&set, &clamping, 0),
Err(CertError::WeightOverflow)
);
}
// ------------------------------------------------------------ the whole-set door
/// `register` admits the set or none of it. An inadmissible member fails the
/// call, and the members that were already good do not survive it — which is the
/// whole difference from a loop of `insert`, where they do, leaving a set whose
/// `n` and total describe a set nobody registered.
#[test]
fn one_bad_registration_refuses_the_whole_set() {
let signers: Vec<Signer> = (1..=4).map(|i| Signer::new(i, 100)).collect();
let mut registrations: Vec<Registration> = signers
.iter()
.map(|s| Registration {
node: s.id,
public_key: s.public().to_vec(),
proof: s.pop(),
weight: s.weight,
})
.collect();
// The last one presents a proof bound to another node.
registrations[3].proof = signers[0].pop();
assert_eq!(
ValidatorSet::register(registrations.clone()).unwrap_err(),
CertError::PopInvalid
);
// The same registrations one at a time: three go in, and the caller is left
// holding a set of three where four were registered — n and the total both
// short, with nothing to say so. That is what the whole-set door removes.
let mut partial = ValidatorSet::new();
let mut refused = 0;
for r in &registrations {
if partial
.insert(r.node, r.weight, &r.public_key, &r.proof)
.is_err()
{
refused += 1;
}
}
assert_eq!((partial.len(), partial.total_stake(0), refused), (3, 300, 1));
// Repaired, the set is admitted whole.
registrations[3].proof = signers[3].pop();
let set = ValidatorSet::register(registrations).expect("register");
assert_eq!((set.len(), set.total_stake(0)), (4, 400));
for s in &signers {
assert!(set.can_verify(&s.id));
}
}
/// Which member a bad set is refused on is decided by node id, not by the order a
/// caller happened to build its input — Go sorts before it checks for exactly
/// this reason. Two nodes handed the same registrations in different orders must
/// refuse the same set for the same reason, or they disagree about a set they
/// both rejected.
#[test]
fn the_refusal_does_not_depend_on_the_input_order() {
let signers: Vec<Signer> = (1..=4).map(|i| Signer::new(i, 100)).collect();
let registration = |s: &Signer, weight: u64, proof: Vec<u8>| Registration {
node: s.id,
public_key: s.public().to_vec(),
proof,
weight,
};
// Two faults, at two node ids: node 2 stakes nothing, node 4 proves nothing.
// Sorted, node 2 is reached first, so ZeroWeight is the answer either way.
let low = registration(&signers[1], 0, signers[1].pop());
let high = registration(&signers[3], 100, signers[0].pop());
let good = registration(&signers[0], 100, signers[0].pop());
assert_eq!(
ValidatorSet::register(vec![good.clone(), low.clone(), high.clone()]).unwrap_err(),
CertError::ZeroWeight
);
assert_eq!(
ValidatorSet::register(vec![high, good, low]).unwrap_err(),
CertError::ZeroWeight,
"the same set refused on the same clause, whatever order it arrived in"
);
}
/// The proof path needs a key. Go's `Register` refuses a keyless registration
/// rather than admitting a member that can never sign; a member this node holds
/// no key for comes in through the unkeyed door instead.
#[test]
fn the_whole_set_door_refuses_a_keyless_registration() {
let s = Signer::new(1, 100);
assert_eq!(
ValidatorSet::register(vec![Registration {
node: s.id,
public_key: Vec::new(),
proof: s.pop(),
weight: 100,
}])
.unwrap_err(),
CertError::NoKey
);
}
-333
View File
@@ -1,333 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "cc"
version = "1.2.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a"
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 = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "lux-consensus"
version = "1.22.0"
dependencies = [
"cc",
"hex",
"libc",
]
[[package]]
name = "lux-consensus-napi"
version = "1.22.0"
dependencies = [
"hex",
"lux-consensus",
"napi",
"napi-build",
"napi-derive",
"serde",
"serde_json",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "napi"
version = "2.16.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags",
"ctor",
"napi-derive",
"napi-sys",
"once_cell",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
[[package]]
name = "napi-derive"
version = "2.16.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
dependencies = [
"cfg-if",
"convert_case",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
dependencies = [
"convert_case",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
dependencies = [
"libloading",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+1 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "lux-consensus-napi"
version = "1.22.0"
version = "1.22.1"
edition = "2021"
description = "TypeScript/Node.js bindings for Lux Consensus SDK"
license = "MIT"
@@ -23,9 +23,6 @@ lux-consensus = { path = "../rust" }
[build-dependencies]
napi-build = "2"
[profile.release]
lto = true
strip = true
[features]
default = []
+7 -4
View File
@@ -47,13 +47,16 @@ engine.addBlock({
timestamp: Date.now(),
});
// Record votes (need alpha votes for acceptance)
// Register the validators, then vote. Only members are counted, and a node id
// is 20 bytes — 40 hex characters.
const config = testnetConfig();
for (let i = 0; i < config.alpha; i++) {
for (let i = 0; i < 9; i++) {
const voter = i.toString(16).padStart(40, '0');
engine.addValidator(voter, 100);
engine.recordVote({
blockId: blockId,
voteType: VoteType.Preference,
voter: i.toString(16).padStart(64, '0'),
voter: voter,
});
}
@@ -142,7 +145,7 @@ interface Block {
interface Vote {
blockId: string;
voteType: VoteType;
voter: string; // 32-byte hex
voter: string; // 20-byte hex — a node id, not a block id
signature?: string;
}
+3 -3
View File
@@ -79,7 +79,7 @@ export interface Vote {
blockId: string
/** Vote type (0=Preference, 1=Commit, 2=Cancel) */
voteType: number
/** Voter node ID (hex string) */
/** Voter node ID (20-byte hex string) */
voter: string
/** Optional signature (hex string) */
signature?: string
@@ -105,7 +105,7 @@ export declare class BlockId {
toBytes(): Array<number>
isZero(): boolean
}
/** Node identifier (32-byte hex string) */
/** Node identifier (20-byte hex string) */
export declare class NodeId {
constructor(hexString: string)
toHex(): string
@@ -153,7 +153,7 @@ export declare class ConsensusEngine {
getStatus(blockId: string): BlockStatus
/** Process multiple votes in batch (optimized) */
recordVotesBatch(votes: Array<Vote>): number
/** Add a validator to the engine */
/** Add a validator to the engine, by its 20-byte node id */
addValidator(nodeId: string, weight: number): void
/** Get the current configuration */
getConfig(): QuasarConfig
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/consensus",
"version": "1.22.0",
"version": "1.22.1",
"description": "Lux Consensus SDK for TypeScript/Node.js - Native performance with type safety",
"main": "index.js",
"types": "index.d.ts",
+85 -20
View File
@@ -22,6 +22,20 @@ use std::sync::{Arc, Mutex};
// Import the Engine trait to access its methods
use lux_consensus::Engine;
/// The width of a node id, in bytes, taken from the core type rather than
/// restated here.
///
/// A validator is named by the 20-byte NodeID the network names it by, and the
/// proof of possession it registers with signs `node ‖ key` over exactly those
/// 20 — so a binding that spelled its own width is a binding that can disagree
/// with the standard it wraps. This is derived, so it cannot.
///
/// It must never be reached by truncating a wider id: cutting 32 bytes down to
/// 20 maps distinct validators onto one identity, and one identity is one signer
/// slot and one share of the weight. A hex string of the wrong length is
/// refused, never trimmed.
const NODE_ID_LEN: usize = std::mem::size_of::<lux_consensus::cert::NodeId>();
// Re-use core types from lux-consensus
use lux_consensus::{
Block as CoreBlock,
@@ -66,7 +80,7 @@ impl BlockId {
#[napi]
pub fn to_hex(&self) -> String {
hex::encode(&self.inner)
hex::encode(self.inner)
}
#[napi]
@@ -80,10 +94,10 @@ impl BlockId {
}
}
/// Node identifier (32-byte hex string)
/// Node identifier (20-byte hex string)
#[napi]
pub struct NodeId {
inner: [u8; 32],
inner: [u8; NODE_ID_LEN],
}
#[napi]
@@ -92,17 +106,17 @@ impl NodeId {
pub fn new(hex_string: String) -> Result<Self> {
let bytes = hex::decode(&hex_string)
.map_err(|e| Error::from_reason(format!("Invalid hex: {}", e)))?;
if bytes.len() != 32 {
return Err(Error::from_reason("Node ID must be 32 bytes"));
if bytes.len() != NODE_ID_LEN {
return Err(Error::from_reason("Node ID must be 20 bytes"));
}
let mut arr = [0u8; 32];
let mut arr = [0u8; NODE_ID_LEN];
arr.copy_from_slice(&bytes);
Ok(NodeId { inner: arr })
}
#[napi]
pub fn to_hex(&self) -> String {
hex::encode(&self.inner)
hex::encode(self.inner)
}
#[napi]
@@ -335,7 +349,7 @@ pub struct Vote {
pub block_id: String,
/// Vote type (0=Preference, 1=Commit, 2=Cancel)
pub vote_type: u32,
/// Voter node ID (hex string)
/// Voter node ID (20-byte hex string)
pub voter: String,
/// Optional signature (hex string)
pub signature: Option<String>,
@@ -348,8 +362,11 @@ impl Vote {
let voter = hex::decode(&self.voter)
.map_err(|e| Error::from_reason(format!("Invalid voter ID: {}", e)))?;
if block_id.len() != 32 || voter.len() != 32 {
return Err(Error::from_reason("IDs must be 32 bytes"));
if block_id.len() != 32 {
return Err(Error::from_reason("Block ID must be 32 bytes"));
}
if voter.len() != NODE_ID_LEN {
return Err(Error::from_reason("Voter node ID must be 20 bytes"));
}
let vote_type = match self.vote_type {
@@ -360,7 +377,7 @@ impl Vote {
};
let mut block_arr = [0u8; 32];
let mut voter_arr = [0u8; 32];
let mut voter_arr = [0u8; NODE_ID_LEN];
block_arr.copy_from_slice(&block_id);
voter_arr.copy_from_slice(&voter);
@@ -533,20 +550,25 @@ impl ConsensusEngine {
Ok(engine.record_votes_batch(core_votes) as u32)
}
/// Add a validator to the engine
/// Add a validator to the engine, by its 20-byte node id
#[napi]
pub fn add_validator(&self, node_id: String, weight: u32) -> Result<()> {
let id_bytes = hex::decode(&node_id)
.map_err(|e| Error::from_reason(format!("Invalid node ID: {}", e)))?;
if id_bytes.len() != 32 {
return Err(Error::from_reason("Node ID must be 32 bytes"));
if id_bytes.len() != NODE_ID_LEN {
return Err(Error::from_reason("Node ID must be 20 bytes"));
}
let mut arr = [0u8; 32];
let mut arr = [0u8; NODE_ID_LEN];
arr.copy_from_slice(&id_bytes);
let mut engine = self.inner.lock()
.map_err(|e| Error::from_reason(format!("Lock error: {}", e)))?;
engine.add_validator(CoreNodeID::from(arr), weight as u64);
// The core refuses a node already admitted, and a weight the set's total
// cannot hold. Dropping that answer would report a validator as added
// that is not in the set, so it is raised to the caller.
engine
.add_validator(CoreNodeID::from(arr), weight as u64)
.map_err(|e| Error::from_reason(e.to_string()))?;
Ok(())
}
@@ -679,12 +701,24 @@ mod tests {
};
engine.add_block(block.clone()).unwrap();
// Record votes (need ceil(0.6 * 5) = 3 votes for testnet)
for i in 0..5 {
// Nine distinct voters, and every one of them a registered member.
//
// Members, because only members are counted: an engine holding no
// validators counts nobody, which is the direction to fail in. Nine,
// because testnet is k=5, beta=5: the count opens at k votes, and the
// decision needs beta consecutive affirmative rounds after that — and a
// round in this engine is a vote, so 4 + 5. A repeat voter is ignored,
// so they have to be nine different validators.
for i in 0..9 {
engine
.add_validator(format!("{:02x}", i).repeat(NODE_ID_LEN), 100)
.unwrap();
}
for i in 0..9 {
let vote = Vote {
block_id: "01".repeat(32),
vote_type: 0, // Preference
voter: format!("{:02x}", i).repeat(32),
voter: format!("{:02x}", i).repeat(NODE_ID_LEN),
signature: None,
};
engine.record_vote(vote).unwrap();
@@ -702,11 +736,42 @@ mod tests {
assert!(id.is_zero());
}
/// A node id is 20 bytes here because it is 20 bytes in the core, and a
/// wider one is REFUSED rather than trimmed. Trimming is the failure this
/// guards: 32 bytes cut to 20 maps distinct validators onto one identity,
/// which is one signer slot and one share of the weight for two operators —
/// so every door that takes a node id refuses the wrong width instead.
#[test]
fn a_node_id_is_twenty_bytes_and_a_wider_one_is_refused() {
assert_eq!(NODE_ID_LEN, 20);
let id = NodeId::new("ab".repeat(NODE_ID_LEN)).unwrap();
assert_eq!(id.to_hex(), "ab".repeat(NODE_ID_LEN));
assert_eq!(id.to_bytes().len(), NODE_ID_LEN);
assert!(NodeId::new("ab".repeat(32)).is_err(), "not truncated");
assert!(NodeId::new("ab".repeat(16)).is_err());
// The same width, at the other two doors a node id enters by.
let engine = ConsensusEngine::new(testnet_config()).unwrap();
assert!(engine.add_validator("aa".repeat(32), 100).is_err());
engine.add_validator("aa".repeat(NODE_ID_LEN), 100).unwrap();
let wide = Vote {
block_id: "01".repeat(32),
vote_type: 0,
voter: "bb".repeat(32),
signature: None,
};
assert!(wide.to_core().is_err());
assert!(Vote { voter: "bb".repeat(NODE_ID_LEN), ..wide }.to_core().is_ok());
}
#[test]
fn test_fpc_selector() {
let selector = FpcSelector::new(0.5, 0.8, None).unwrap();
let threshold = selector.threshold(0, 20);
assert!(threshold >= 10 && threshold <= 16);
assert!((10..=16).contains(&threshold));
}
#[test]
+41 -17
View File
@@ -148,13 +148,17 @@ describe('Lux Quasar Consensus SDK', () => {
describe('NodeId', () => {
it('should create from hex string', () => {
const hexId = 'ab'.repeat(32);
const hexId = 'ab'.repeat(20);
const nodeId = new NodeId(hexId);
assert.strictEqual(nodeId.toHex(), hexId);
});
// A node id is 20 bytes, and a wider one is refused rather than trimmed:
// 32 cut down to 20 maps two validators onto one identity, which is one
// signer slot and one share of the weight for both of them.
it('should reject wrong length', () => {
assert.throws(() => new NodeId('ab'.repeat(16)), /32 bytes/);
assert.throws(() => new NodeId('ab'.repeat(16)), /20 bytes/);
assert.throws(() => new NodeId('ab'.repeat(32)), /20 bytes/);
});
});
@@ -192,12 +196,18 @@ describe('Lux Quasar Consensus SDK', () => {
it('should record votes and reach consensus', () => {
const blockId = '01'.repeat(32);
// Record enough votes to reach quorum (testnet alpha=0.6, k=5, need ceil(0.6*5)=3 votes)
for (let i = 0; i < 5; i++) {
// Only members are counted, so the voters are registered first. Nine of
// them: testnet is k=5, beta=5, the count opens at k votes and the
// decision needs beta affirmative rounds after that, and a round here is
// a vote. A node id is 20 bytes = 40 hex characters.
for (let i = 0; i < 9; i++) {
engine.addValidator(i.toString(16).padStart(40, '0'), 100);
}
for (let i = 0; i < 9; i++) {
const vote = {
blockId: blockId,
voteType: VoteType.Preference,
voter: i.toString(16).padStart(64, '0'),
voter: i.toString(16).padStart(40, '0'),
};
engine.recordVote(vote);
}
@@ -222,13 +232,15 @@ describe('Lux Quasar Consensus SDK', () => {
timestamp: Date.now(),
});
// Create batch of votes
// Create batch of votes, from ten registered members
const votes = [];
for (let i = 0; i < 10; i++) {
const voter = (100 + i).toString(16).padStart(40, '0');
engine.addValidator(voter, 100);
votes.push({
blockId: blockId,
voteType: VoteType.Preference,
voter: (100 + i).toString(16).padStart(64, '0'),
voter: voter,
});
}
@@ -237,9 +249,13 @@ describe('Lux Quasar Consensus SDK', () => {
});
it('should add validators', () => {
const nodeId = 'aa'.repeat(32);
const nodeId = 'aa'.repeat(20);
// Should not throw
engine.addValidator(nodeId, 100);
// The same node twice is refused, and a 32-byte id is not a node id.
assert.throws(() => engine.addValidator(nodeId, 100));
assert.throws(() => engine.addValidator('aa'.repeat(32), 100), /20 bytes/);
});
it('should get height', () => {
@@ -251,7 +267,7 @@ describe('Lux Quasar Consensus SDK', () => {
const vote = {
blockId: 'ff'.repeat(32),
voteType: VoteType.Preference,
voter: '00'.repeat(32),
voter: '00'.repeat(20),
};
assert.throws(() => engine.recordVote(vote), /Block not found/);
});
@@ -294,13 +310,16 @@ describe('Lux Quasar Consensus SDK', () => {
assert.strictEqual(engine.getStatus(block.id), BlockStatus.Processing);
}
// Vote on all blocks (need at least ceil(0.6*5)=3 votes for testnet)
// Vote on all blocks, from registered members — an engine holding no
// validators counts nobody
for (const block of blocks) {
for (let i = 0; i < 5; i++) {
const voter = (block.height * 10 + i).toString(16).padStart(40, '0');
engine.addValidator(voter, 100);
engine.recordVote({
blockId: block.id,
voteType: VoteType.Preference,
voter: (block.height * 10 + i).toString(16).padStart(64, '0'),
voter: voter,
});
}
}
@@ -333,12 +352,14 @@ describe('Lux Quasar Consensus SDK', () => {
timestamp: Date.now(),
});
// Need ceil(0.69*21) = 15 votes for mainnet
// Need ceil(0.69*21) = 15 votes for mainnet, all from members
for (let i = 0; i < 21; i++) {
const voter = i.toString(16).padStart(40, '0');
engine.addValidator(voter, 100);
engine.recordVote({
blockId: blockId,
voteType: VoteType.Preference,
voter: i.toString(16).padStart(64, '0'),
voter: voter,
});
}
@@ -377,12 +398,14 @@ describe('Lux Quasar Consensus SDK', () => {
timestamp: Date.now(),
});
// Vote to reach consensus
// Vote to reach consensus, from members
for (let i = 0; i < 5; i++) {
const voter = i.toString(16).padStart(40, '0');
engine.addValidator(voter, 100);
engine.recordVote({
blockId: blockId,
voteType: VoteType.Preference,
voter: i.toString(16).padStart(64, '0'),
voter: voter,
});
}
@@ -399,8 +422,9 @@ describe('Lux Quasar Consensus SDK', () => {
});
describe('Cross-Language Compatibility', () => {
it('should use 32-byte IDs for cross-language consensus', () => {
// Verify ID format matches Rust SDK
it('should use 32-byte block IDs for cross-language consensus', () => {
// Verify ID format matches Rust SDK. A block id is 32 bytes; a node id is
// 20 — see the NodeId suite — and the two are not interchangeable.
const rustStyleId = 'deadbeef'.repeat(8); // 32 bytes
const blockId = new BlockId(rustStyleId);
assert.strictEqual(blockId.toHex(), rustStyleId);