From 0f60ca5305f29fa36cfa6a5222ccab86b51dc12e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 09:45:54 -0700 Subject: [PATCH 01/16] feat: add the canonical singleton lineage walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChainSource::resolve_singleton_lineage` is the only trait method with no default body, and it is the most trust-critical one: its result IS the authority set consumers test membership against. A source backed only by primitive reads therefore had to hand-roll money-critical singleton authentication, and a second hand-rolled copy is a byte-drift bug waiting to happen. Add the composition once, behind the non-default `lineage-walk` feature: walk_singleton_lineage / walk_singleton_lineage_bounded resolve_singleton_lineage_via_walk (the one-line delegation body) LineageWalkError, MAX_LINEAGE_DEPTH ChainSourceError::LineageTooDeep The walk never recognises the next coin, it derives it: each hop reads the current coin's own spend, requires the spend to be that coin's, requires the reveal to hash to that coin's puzzle hash, parses the reveal as a singleton curried to the launcher under resolution, runs the inner puzzle, and reconstructs the odd-amount successor's full puzzle hash. A coin's puzzle hash is attacker-chosen, so recognition by puzzle hash, by curried launcher id, or by selection from `coin_records_by_parent` is spoofable; admission by construction is not. Each derived successor is additionally confirmed to exist via `coin_record`, because a CLVM solution is not committed to by a coin's puzzle hash. The `CREATE_COIN` amount is decoded as SIGNED: CLVM atoms carry no sign, so the singleton melt marker -113 decodes into a u64 as 143 — an odd, positive amount indistinguishable from an ordinary recreation — and a walk that made that mistake would invent a phantom successor for every melted singleton. Bounded at MAX_LINEAGE_DEPTH spends, which refuses rather than truncating: a partial member set would make `contains` answer false for genuine members, which is a fail-open membership answer on a money path. The feature is non-default because the walk needs a CLVM evaluator; consumers that only depend on the trait gain no new transitive dependency. Closes DIG-Network/dig_ecosystem#2572 Co-Authored-By: Claude --- Cargo.lock | 1704 ++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 34 +- README.md | 27 + SPEC.md | 56 +- src/error.rs | 13 + src/lib.rs | 24 + src/walk.rs | 482 ++++++++++++ tests/lineage_walk.rs | 615 +++++++++++++++ 8 files changed, 2837 insertions(+), 118 deletions(-) create mode 100644 src/walk.rs create mode 100644 tests/lineage_walk.rs diff --git a/Cargo.lock b/Cargo.lock index 7e54acb..b4512e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,33 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -20,6 +47,51 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "bitvec" version = "1.1.1" @@ -41,6 +113,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blst" version = "0.3.16" @@ -87,7 +168,7 @@ dependencies = [ "hex", "hkdf", "linked-hash-map", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -103,7 +184,44 @@ dependencies = [ "hex", "hkdf", "linked-hash-map", - "sha2", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "chia-bls" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f85777c396aa0ed9837add53ef22fe6b153d861b8a344dd57a4d493dd6c5c4" +dependencies = [ + "blst", + "chia-sha2 0.42.1", + "chia-traits 0.42.1", + "hex", + "hkdf", + "linked-hash-map", + "sha2 0.10.9", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-consensus" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d942bcb20b243213becbd94630dd7122c18dea7450beab38cbb4a6fcac993cae" +dependencies = [ + "chia-bls 0.36.1", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sha2 0.36.1", + "chia-traits 0.36.1", + "chia_streamable_macro 0.36.1", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "hex", + "hex-literal", "thiserror 1.0.69", ] @@ -117,10 +235,165 @@ dependencies = [ "chia-sha2 0.36.1", "chia-traits 0.36.1", "chia_streamable_macro 0.36.1", - "clvm-traits", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "hex", +] + +[[package]] +name = "chia-puzzle-types" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37af8b2e82a898e35eea5e2766d18d616ab266343fa567042b6217bd96ef4819" +dependencies = [ + "chia-bls 0.36.1", + "chia-protocol", + "chia-puzzles", + "chia-sha2 0.36.1", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "hex-literal", + "num-bigint", +] + +[[package]] +name = "chia-puzzles" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553363ce9550cfde0ae1306a0a79bf9afd36ea09e0222b874b6287b44ad9d178" +dependencies = [ + "hex", + "hex-literal", +] + +[[package]] +name = "chia-sdk-derive" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9e366cbc576221da519a08544b546cbf0441a4d3e8bbd8e69f3a06480a6af4" +dependencies = [ + "convert_case 0.8.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chia-sdk-driver" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2677cf560410db034c8bb5de1e07dec2021b12fd986981390654e260a2b856" +dependencies = [ + "bigdecimal", + "bip39", + "chia-bls 0.36.1", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-signer", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.36.1", + "chia-traits 0.36.1", + "chia_streamable_macro 0.36.1", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "getrandom 0.3.4", + "hex", + "hex-literal", + "indexmap", + "num-bigint", + "rand 0.9.5", + "rand_chacha 0.9.0", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-signer" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f8553345d14f9eb89f07df0d3854162d818d21acf27140997ae77256dc7883" +dependencies = [ + "chia-bls 0.36.1", + "chia-consensus", + "chia-protocol", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.36.1", + "clvm-traits 0.36.1", + "clvmr", + "colored", + "k256", + "rue-lir", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-test" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de32a1cc7124f151401d553413d45b88b607dbfa69cf1721c7daab75b717d1a2" +dependencies = [ + "anyhow", + "bip39", + "chia-bls 0.36.1", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-sdk-signer", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.36.1", + "chia-traits 0.36.1", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "hex", + "indexmap", + "prettytable-rs", + "rand 0.9.5", + "rand_chacha 0.9.0", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0891df37e7b053682b7bec452321d0b2205c20de8792e96470d571390cfd5b48" +dependencies = [ + "chia-bls 0.36.1", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-derive", + "chia-secp", + "chia-sha2 0.36.1", + "chialisp", + "clvm-traits 0.36.1", "clvm-utils", "clvmr", + "hex-literal", + "rue-compiler", + "rue-lir", + "rue-options", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-secp" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fc00f8ad264eb260fd69fe2c7cb10c45b0a0699765e8ed8eac9be064ba3b55" +dependencies = [ + "chia-sha2 0.36.1", "hex", + "k256", + "p256", ] [[package]] @@ -129,7 +402,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f03c71bc63599a711caad15dafca1d2a948c26b4c8a021709a338149632769" dependencies = [ - "sha2", + "sha2 0.10.9", ] [[package]] @@ -138,7 +411,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f06500dc809a916fa0c70f5219b8b79a598462382a4ddca91ef1a3cb32b826ce" dependencies = [ - "sha2", + "sha2 0.10.9", ] [[package]] @@ -147,7 +420,16 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0934b0d6b878f29ba6c958e56e4b7158f9e687c200ffdca141dbc408a5cce42e" dependencies = [ - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "chia-sha2" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eabeed0e07a8656ff9e7597988eb21b48af6a1997dfca47164dd1544c80fcba" +dependencies = [ + "sha2 0.10.9", ] [[package]] @@ -172,13 +454,24 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "chia-traits" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012b4d5336fe5457be5157a655208a9671a41bd41c3fc72ac59c56073c21c18" +dependencies = [ + "chia-sha2 0.42.1", + "chia_streamable_macro 0.42.1", + "thiserror 2.0.19", +] + [[package]] name = "chia_streamable_macro" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6b807a9c04440d3f30b5534b84b462b996b34eee7fe0b7f8737ae6dfc6b9d4" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.119", @@ -190,12 +483,54 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b60cefc5fe39f695816d42a327cbefad3d6d6a8ecadad1b58d7507067c25da8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chia_streamable_macro" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9567c7e8fe38b640aeeb4af3c0e04f4f6938a438a2506b39bb93d39e0a23e1a9" +dependencies = [ + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "chialisp" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c991ea839bb6e2299bd3600b786381c4c1b08f6780800719a24c5d33157cb3e2" +dependencies = [ + "binascii", + "chia-bls 0.42.1", + "clvmr", + "do-notation", + "getrandom 0.2.17", + "hashlink", + "hex", + "indoc", + "js-sys", + "lazy_static", + "num", + "num-bigint", + "num-traits", + "pyo3-build-config", + "regex", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "unicode-segmentation", + "wasm-bindgen", + "yaml-rust2", +] + [[package]] name = "clvm-derive" version = "0.36.1" @@ -207,12 +542,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "clvm-traits" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e74489df9233fdf7605d79032fe18f808ff33d7e69a9932db4c96ffd0e50e9" +dependencies = [ + "clvmr", + "num-bigint", + "thiserror 1.0.69", +] + [[package]] name = "clvm-traits" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96380e11b921106bd3d2e24f8b85b6de2f99a760a6d5f0bd6d68ec096695b34b" dependencies = [ + "chia-bls 0.36.1", + "chia-secp", "clvm-derive", "clvmr", "num-bigint", @@ -226,7 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65adea81c7ab1659180a4ffb10a0df63eb049b5b8de2caa8bf2d762f55c7c4db" dependencies = [ "chia-sha2 0.36.1", - "clvm-traits", + "clvm-traits 0.36.1", "clvmr", "hex", "hex-literal", @@ -250,18 +598,57 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", + "rand 0.8.7", "sha1", "sha3", "thiserror 1.0.69", ] +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -271,6 +658,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -278,7 +674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -294,23 +690,86 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.7.10" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "hybrid-array", ] [[package]] -name = "dig-chainsource-interface" -version = "0.3.0" +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ - "chia-protocol", - "chia-traits 0.36.1", - "hex", + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "dig-chainsource-interface" +version = "0.4.0" +dependencies = [ + "anyhow", + "chia-bls 0.36.1", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-driver", + "chia-sdk-test", + "chia-sdk-types", + "chia-traits 0.36.1", + "clvm-traits 0.36.1", + "clvm-utils", + "clvmr", + "hex", "thiserror 2.0.19", ] @@ -320,12 +779,50 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "do-notation" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e16a80c1dda2cf52fa07106427d3d798b6331dca8155fcb8c39f7fc78f6dd2" + [[package]] name = "ecdsa" version = "0.16.9" @@ -333,13 +830,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", "spki", ] +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -348,31 +851,62 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -382,12 +916,60 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -406,8 +988,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", ] [[package]] @@ -423,16 +1030,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -445,6 +1076,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -466,9 +1106,24 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -476,7 +1131,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -489,7 +1190,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", "signature", ] @@ -499,7 +1200,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -514,18 +1215,59 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -534,6 +1276,27 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -545,6 +1308,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -571,140 +1355,542 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "p256" -version = "0.13.2" +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[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 = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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 = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rue-ast" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8feffcdd1f81db8d83980714cdd15d53608f7cc49e4d4a1c52d0fe1d523c4b" +dependencies = [ + "paste", + "rue-parser", +] + +[[package]] +name = "rue-compiler" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b58c388afe6e7fb78e63f43b455c57b6f788f1e2b65ae895e9ff38039ab2fdc" +dependencies = [ + "clvmr", + "hex", + "id-arena", + "indexmap", + "log", + "num-bigint", + "num-traits", + "rowan", + "rue-ast", + "rue-diagnostic", + "rue-hir", + "rue-lexer", + "rue-lir", + "rue-options", + "rue-parser", + "rue-types", + "thiserror 2.0.19", +] + +[[package]] +name = "rue-diagnostic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953cccb3041f21c77e80ea8fdb03e7214b91195d939f3146f054e27020ea1a9" +dependencies = [ + "derive_more", + "thiserror 2.0.19", +] + +[[package]] +name = "rue-hir" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d73674bfcd164e453dbeabfa99cb952827abf7bc0540ed5bf6d2ccea97d06a" +dependencies = [ + "derive_more", + "hex", + "id-arena", + "indexmap", + "log", + "num-bigint", + "rue-diagnostic", + "rue-lir", + "rue-options", + "rue-types", +] + +[[package]] +name = "rue-lexer" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68345f23ba80d548d4a671de14e455de74ae58fccfa436db105b35308a1780ed" + +[[package]] +name = "rue-lir" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099f3c3d6b432bb94cf8876b7f71b650d6887ae9193e8063c529feca37276b6f" +dependencies = [ + "chialisp", + "clvm-traits 0.28.1", + "clvmr", + "colored", + "id-arena", + "num-bigint", + "num-integer", + "sha2 0.10.9", + "sha3", + "thiserror 2.0.19", +] + +[[package]] +name = "rue-options" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "12f94c541b1397b7fbc4aaba36440c0bc11ddb703b024a424dd2fe193ec413b7" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", + "serde", + "thiserror 2.0.19", + "toml", ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "rue-parser" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "7d23577d98f535c02d2898e0a0e9cdf04b82d1ea91b15cd31e66141bce73c4c5" dependencies = [ - "base64ct", + "derive_more", + "indexmap", + "itertools", + "num-derive", + "num-traits", + "rowan", + "rue-diagnostic", + "rue-lexer", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "rue-types" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "4a6dc5f584d1ff7cf562737aa990d746def396ca4211c8f56c2d28d6dc00718b" dependencies = [ - "der", - "spki", + "clvmr", + "derive_more", + "hex", + "id-arena", + "indexmap", + "log", + "rstest", + "rue-diagnostic", + "thiserror 2.0.19", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "rustc-hash" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] -name = "primeorder" -version = "0.13.6" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "elliptic-curve", + "semver", ] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "once_cell", - "toml_edit", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "quote" -version = "1.0.47" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "proc-macro2", + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", ] [[package]] -name = "radium" -version = "0.7.0" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "rand" -version = "0.8.7" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "libc", - "rand_chacha", - "rand_core", + "serde_core", + "serde_derive", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "ppv-lite86", - "rand_core", + "serde_derive", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "serde_derive" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ - "getrandom", + "proc-macro2", + "quote", + "syn 3.0.0", ] [[package]] -name = "rfc6979" -version = "0.4.0" +name = "serde_json" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "hmac", - "subtle", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "sec1" -version = "0.7.3" +name = "serde_spanned" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", + "serde_core", ] [[package]] @@ -714,8 +1900,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -725,8 +1911,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -735,7 +1932,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -751,10 +1948,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "spki" version = "0.7.3" @@ -799,6 +2002,42 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[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 = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + [[package]] name = "thiserror" version = "1.0.69" @@ -848,12 +2087,60 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -861,10 +2148,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typenum" version = "1.20.1" @@ -877,6 +2191,33 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version_check" version = "0.9.5" @@ -889,6 +2230,99 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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 = "winnow" version = "0.5.40" @@ -898,6 +2332,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wyz" version = "0.5.1" @@ -907,6 +2362,17 @@ dependencies = [ "tap", ] +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -946,3 +2412,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[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 index 5e84c75..6900ee4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ # aggregating canonical source). See SPEC.md for the normative contract. [package] name = "dig-chainsource-interface" -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" @@ -22,10 +22,42 @@ categories = ["cryptography::cryptocurrencies", "api-bindings"] chia-protocol = "0.36.1" thiserror = "2" +# --- `lineage-walk` only (see [features]) ------------------------------------------------------ +# The canonical launcher -> tip singleton walk DERIVES each successor by running the parent's inner +# puzzle, so it needs a CLVM evaluator and the vetted singleton layer/puzzle types. Every version is +# pinned to the same coherent chia-* set the rest of the DIG on-chain line rides (chia-protocol +# 0.36 / chia-wallet-sdk 0.34); the SDK's own SingletonLayer is reused rather than re-implemented so +# the walk cannot byte-drift from the puzzle it authenticates against. +chia-puzzle-types = { version = "0.36.1", optional = true } +chia-puzzles = { version = "0.20", optional = true } +chia-sdk-driver = { version = "0.34", optional = true } +chia-sdk-types = { version = "0.34", optional = true } +clvm-traits = { version = "0.36.1", optional = true } +clvm-utils = { version = "0.36.1", optional = true } +clvmr = { version = "0.16", optional = true } + [features] default = [] testing = [] +# The canonical singleton lineage walk (`walk_singleton_lineage`). NON-DEFAULT: it pulls in a CLVM +# evaluator, and a consumer that only depends on the trait should not pay for one. Enabling it is +# purely additive — no existing item changes shape. +lineage-walk = [ + "dep:chia-puzzle-types", + "dep:chia-puzzles", + "dep:chia-sdk-driver", + "dep:chia-sdk-types", + "dep:clvm-traits", + "dep:clvm-utils", + "dep:clvmr", +] [dev-dependencies] chia-traits = "0.36.1" hex = "0.4" +# The in-process Chia Simulator: the lineage-walk tests authenticate against REAL singleton spends +# (launcher, eve, recreation, melt) rather than hand-built fixtures, so the adversarial cases are +# genuine chain data. +chia-sdk-test = "0.34" +chia-bls = "0.36.1" +anyhow = "1" diff --git a/README.md b/README.md index e4644d8..fb7fb76 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,33 @@ walking `parent_spend` back toward the real launcher — a spoofed curried-puzzl recreation parent-spend, so the walk fails closed. `SingletonLineage` follows suit: authority is **membership** (`contains`), never tip-equality. +## The canonical lineage walk (feature `lineage-walk`) + +`resolve_singleton_lineage` is the one method with no default body, and it is the most +trust-critical: its result IS the authority set consumers test membership against. A source backed +only by primitive reads can borrow the whole walk instead of hand-rolling it: + +```toml +dig-chainsource-interface = { version = "0.4", features = ["lineage-walk"] } +``` + +```rust +fn resolve_singleton_lineage( + &self, + launcher_id: Bytes32, +) -> Result, Self::Error> { + resolve_singleton_lineage_via_walk(self, launcher_id) +} +``` + +The walk starts at the launcher coin and **derives** each successive coin by running the previous +coin's own spend — it never recognises a coin by its puzzle hash, its curried launcher id, or its +presence in a child list, because all three are attacker-chosen. It is bounded at +`MAX_LINEAGE_DEPTH` spends and refuses rather than truncating. See SPEC.md §4a. + +The feature is off by default: the walk needs a CLVM evaluator, and a consumer that only depends on +the trait should not pay for one. + ## Implementing a provider Implement `ChainSource` over your backend, choosing `type Error` (`ChainSourceError` is recommended diff --git a/SPEC.md b/SPEC.md index e6fed02..79cf484 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,4 +1,4 @@ -# dig-chainsource-interface — normative specification (v0.1.0) +# dig-chainsource-interface — normative specification (v0.4.0) This is the authoritative contract for the DIG Network canonical `ChainSource` interface. An independent reimplementation of this crate, of a provider, or of a consumer MUST conform to this @@ -56,6 +56,9 @@ Every fallible method distinguishes: - `TooManyRecords { count, limit }` — the backend returned more records than the consumer's hostile-input bound allows; distinct from `Malformed` (each record may be well-formed, but the count exceeds the cap) — the consumer fails closed the same as every other variant. +- `LineageTooDeep { limit }` — a singleton lineage walk exceeded its hop bound (§4a). The lineage it + could build is INCOMPLETE, so it is refused rather than truncated: a partial member set would make + `contains` answer `false` for genuine members, which is a fail-OPEN membership answer. Absence MUST NOT be encoded as an error; an error MUST NOT be degraded to a value. @@ -72,6 +75,56 @@ echo a caller-supplied coin into the lineage. Echoing would make `contains` mean foreign coin to claim authority. Consumers authenticate coins by walking `parent_spend` toward the real launcher and testing lineage membership — never by puzzle-hash equality. +## 4a. The canonical lineage walk (feature `lineage-walk`) + +`ChainSource::resolve_singleton_lineage` has no default body, so a source backed only by primitive +reads would have to hand-roll the §4 money-critical requirement. The optional, NON-DEFAULT +`lineage-walk` feature supplies that walk once, as free functions: + +```rust +pub const MAX_LINEAGE_DEPTH: usize = 100_000; + +pub enum LineageWalkError { Source(E), Malformed(String), NotASingleton { coin_id }, TooDeep { limit } } + +pub fn walk_singleton_lineage(source: &S, launcher_id: Bytes32) + -> Result, LineageWalkError>; + +pub fn walk_singleton_lineage_bounded(source: &S, launcher_id: Bytes32, max_hops: usize) + -> Result, LineageWalkError>; + +pub fn resolve_singleton_lineage_via_walk>( + source: &S, launcher_id: Bytes32) -> Result, ChainSourceError>; +``` + +A conforming walk MUST: + +1. **Derive, never recognise.** At each hop it reads the current coin's own spend, requires the + returned spend to BE that coin's spend, requires the puzzle reveal to hash to that coin's puzzle + hash, parses the reveal as a singleton curried to the launcher under resolution, runs the inner + puzzle, and RECONSTRUCTS the odd-amount successor's full puzzle hash from the launcher id and the + successor's inner puzzle hash. It MUST NOT select the successor by puzzle-hash equality, by + curried launcher id alone, or from `coin_records_by_parent` — every one of those is spoofable, + because a coin's `puzzle_hash` is attacker-chosen. +2. **Bind each derived coin to chain state.** A CLVM solution is not committed to by a coin's puzzle + hash, so a dishonest source could pair a genuine reveal with a fabricated solution. Each derived + successor MUST be confirmed to exist via `coin_record` before it enters the lineage. +3. **Decode the `CREATE_COIN` amount as SIGNED.** CLVM atoms carry no sign, so the singleton melt + marker `-113` decodes into a `u64` as `143` — an odd, positive amount indistinguishable from an + ordinary recreation. A walk that made that mistake would invent a phantom successor for every + melted singleton instead of reporting the melt. +4. **Refuse, never truncate, past its bound** (`MAX_LINEAGE_DEPTH` spends by default) and reject a + repeated coin id as a cycle. +5. **Preserve the three-valued discipline of §3.** `Ok(None)` means the launcher id names no coin, + names a coin that is not wearing `SINGLETON_LAUNCHER_HASH`, was never spent into an eve, or the + singleton was melted. Every read failure surfaces as `LineageWalkError::Source(_)` carrying the + source's OWN error unchanged, so *unsupported* stays distinguishable from *unreadable* and + neither is ever collapsed into an absence. + +**Stated limit.** An eve coin that has never been spent is admitted on the evidence of the launcher's +own spend, which is the strongest evidence that exists: the eve is by definition the coin the +launcher created. Its inner structure is constrained only once it is itself spent, at which point the +curried launcher id is checked. + ## 5. `CoinRecord` and `CoinState` conversion `CoinRecord { coin: Coin, confirmed_height: Option, spent_height: Option, @@ -114,5 +167,6 @@ A conforming provider MUST: `chia-protocol`). 3. Map `CoinState` per §5. 4. Return a genuine forward-walked lineage from `resolve_singleton_lineage` per §4 — never an echo. + A source backed only by primitive reads SHOULD delegate to the §4a walk rather than hand-roll one. 5. Report a `ProviderInfo` per §6. 6. Remain reads-only: expose no broadcast/spend path through this interface. diff --git a/src/error.rs b/src/error.rs index 077f319..4d21310 100644 --- a/src/error.rs +++ b/src/error.rs @@ -60,6 +60,19 @@ pub enum ChainSourceError { /// The maximum number of records the consumer will accept. limit: usize, }, + + /// A singleton lineage walk exceeded its hop bound before reaching the tip. + /// + /// Distinct from every other variant, and deliberately NOT a silent truncation: the walk found + /// more hops than it will follow, so the lineage it could build is INCOMPLETE and must never be + /// presented as the whole lineage (a partial member set would make + /// [`SingletonLineage::contains`](crate::SingletonLineage::contains) answer `false` for genuine + /// members — a fail-OPEN membership answer on a money path). The answer is unknown → fail closed. + #[error("singleton lineage walk exceeded its {limit}-hop bound")] + LineageTooDeep { + /// The hop bound the walk refused to exceed. + limit: usize, + }, } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index d2739e2..8195c17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,21 @@ //! actual reveal+solution — a spoofed curried-puzzle coin has no genuine recreation parent-spend, //! so the walk fails closed. This crate supplies that primitive (and [`SingletonLineage`], whose //! authority is MEMBERSHIP, not tip-equality); consumers supply the trust logic on top. +//! +//! ## The canonical lineage walk (feature `lineage-walk`) +//! +//! [`ChainSource::resolve_singleton_lineage`] is the one method with no default body, so a source +//! backed only by primitive reads would have to hand-roll that money-critical authentication. Enable +//! the non-default `lineage-walk` feature and the whole walk is supplied — the method body becomes a +//! one-line delegation to [`resolve_singleton_lineage_via_walk`]: +//! +//! ```toml +//! dig-chainsource-interface = { version = "0.4", features = ["lineage-walk"] } +//! ``` +//! +//! The feature is OFF by default because the walk needs a CLVM evaluator (it runs each parent's +//! inner puzzle to DERIVE its successor), and a consumer that only depends on the trait should not +//! pay for one. mod error; mod lineage; @@ -42,6 +57,9 @@ mod provider; mod record; mod source; +#[cfg(feature = "lineage-walk")] +mod walk; + #[cfg(feature = "testing")] mod testing; @@ -51,6 +69,12 @@ pub use provider::{ProviderId, ProviderInfo, ProviderKind}; pub use record::CoinRecord; pub use source::{ChainSource, ChainSourceProvider}; +#[cfg(feature = "lineage-walk")] +pub use walk::{ + resolve_singleton_lineage_via_walk, walk_singleton_lineage, walk_singleton_lineage_bounded, + LineageWalkError, MAX_LINEAGE_DEPTH, +}; + #[cfg(feature = "testing")] pub use testing::MockChainSource; diff --git a/src/walk.rs b/src/walk.rs new file mode 100644 index 0000000..b25ccab --- /dev/null +++ b/src/walk.rs @@ -0,0 +1,482 @@ +//! [`walk_singleton_lineage`] — the ONE canonical launcher → tip singleton walk, composed purely +//! from the [`ChainSource`] primitives (feature `lineage-walk`). +//! +//! ## Why this exists +//! +//! [`ChainSource::resolve_singleton_lineage`] is the only trait method with no default body, yet it +//! is the most trust-critical one: its result IS the authority set consumers test membership +//! against. A source backed only by primitive reads (`coin_record`, `coin_spend`, …) would +//! otherwise have to hand-roll money-critical singleton authentication, and a second hand-rolled +//! copy is a byte-drift bug waiting to happen. This module supplies the composition once, so such a +//! source's method body is a one-line delegation to +//! [`resolve_singleton_lineage_via_walk`]. +//! +//! ## Why the walk, and why puzzle-hash equality is NOT enough (the soundness crux) +//! +//! A Chia coin's `puzzle_hash` is attacker-chosen: anyone can pay to a coin whose puzzle hash +//! equals a victim singleton's outer hash for a victim launcher. Such a coin is not a singleton — +//! it has no genuine recreation history — so a `launcher_id ==` or `puzzle_hash ==` check is +//! spoofable, and so is picking a "child that looks right" out of +//! [`ChainSource::coin_records_by_parent`]. +//! +//! This walk therefore never *recognises* the next coin; it **derives** it. At each hop it reads +//! the current coin's own spend, proves the puzzle reveal hashes to that coin's puzzle hash, parses +//! the reveal as a singleton (reading the *curried* launcher id and re-checking it against the +//! launcher under resolution), RUNS the inner puzzle, and reconstructs the odd-amount successor's +//! full puzzle hash from the launcher id and the successor's inner puzzle hash. Only a coin the +//! chain provably created that way enters the lineage; a look-alike coin can never be admitted, +//! because admission is by construction rather than by comparison. +//! +//! ## Three-valued discipline +//! +//! - `Ok(None)` — no singleton state exists: the launcher id names no coin, names a coin that is +//! not a launcher, was never spent into an eve, or the singleton has been fully melted. +//! - `Ok(Some(_))` — an authenticated lineage, launcher → tip inclusive. +//! - `Err(_)` — the walk could NOT answer (a source read failed, the chain data is inconsistent, or +//! the hop bound was exceeded). NEVER collapsed into "no lineage": a caller that reads a +//! transport failure as an absence is the class of bug that spends money twice. + +use std::collections::BTreeSet; +use std::fmt; + +use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; +use chia_puzzle_types::singleton::SingletonArgs; +use chia_puzzles::SINGLETON_LAUNCHER_HASH; +use chia_sdk_driver::{Layer, Puzzle, SingletonLayer}; +use chia_sdk_types::run_puzzle; +use clvm_traits::{FromClvm, ToClvm}; +use clvm_utils::{tree_hash, TreeHash}; +use clvmr::{Allocator, NodePtr}; + +use crate::error::ChainSourceError; +use crate::lineage::SingletonLineage; +use crate::source::ChainSource; + +/// The maximum number of SPENDS [`walk_singleton_lineage`] follows before failing closed with +/// [`LineageWalkError::TooDeep`]. +/// +/// A genuine singleton advances exactly one coin per spend, so the bound is exactly the number of +/// times the singleton may ever have been spent (a lineage of `n` coins has `n - 1` spends). A DID or DataStore under heavy use might accumulate +/// thousands of states over its lifetime, so the bound is deliberately generous — it is a DoS guard, +/// not a policy limit. Its purpose is that a hostile or malformed source cannot make the walk loop +/// forever or allocate without end: the member set is capped at this many `Bytes32`, i.e. ~3.2 MB. +/// +/// The value matches `dig_did::resolve::MAX_LINEAGE_DEPTH`, so the two walks in the ecosystem refuse +/// at exactly the same depth rather than disagreeing about what "too deep" means. +pub const MAX_LINEAGE_DEPTH: usize = 100_000; + +/// Why a singleton lineage walk could not answer. +/// +/// Every variant means **the walk does not know** — none of them means "there is no lineage", which +/// is `Ok(None)`. The source's own error is preserved verbatim in [`Source`](Self::Source) so a +/// caller can still distinguish *unsupported* from *unreadable* (the distinction the ecosystem's +/// fail-closed contract rests on); it is never flattened into a string by this type. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum LineageWalkError { + /// A [`ChainSource`] read failed. The source's own error, unmodified. + Source(E), + + /// The chain data the source returned is internally inconsistent or undecodable — a spend that + /// is not the spend of the coin it was asked for, a reveal that does not hash to the coin's + /// puzzle hash, an unparseable puzzle, a derived successor the source does not know, or a + /// repeated coin id (a cycle). The read is untrustworthy → fail closed. + Malformed(String), + + /// A coin on the walk is not a genuine singleton for the launcher under resolution: its reveal + /// does not parse as a singleton layer, or its curried launcher id names a different singleton. + NotASingleton { + /// The coin whose singleton structure could not be proven. + coin_id: Bytes32, + }, + + /// The walk exceeded its hop bound. The lineage found so far is INCOMPLETE and is deliberately + /// discarded rather than returned as a truncated member set. + TooDeep { + /// The hop bound the walk refused to exceed. + limit: usize, + }, +} + +impl fmt::Display for LineageWalkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Source(error) => write!(f, "chain source read failed: {error}"), + Self::Malformed(detail) => write!(f, "inconsistent chain data: {detail}"), + Self::NotASingleton { coin_id } => { + write!( + f, + "coin {coin_id} is not a genuine singleton of this launcher" + ) + } + Self::TooDeep { limit } => { + write!(f, "singleton lineage walk exceeded its {limit}-hop bound") + } + } + } +} + +impl std::error::Error for LineageWalkError {} + +impl From> for ChainSourceError { + /// Projects a walk failure onto the shared error type, PRESERVING the source's own variant. + /// + /// A [`LineageWalkError::Source`] passes through unchanged, so an `Unsupported`/`Timeout`/ + /// `RateLimited` read stays distinguishable from a genuine data problem — flattening it to + /// `Malformed` would erase exactly the distinction the fail-closed contract depends on. + fn from(error: LineageWalkError) -> Self { + match error { + LineageWalkError::Source(error) => error, + LineageWalkError::Malformed(detail) => ChainSourceError::Malformed(detail), + LineageWalkError::NotASingleton { coin_id } => ChainSourceError::Malformed(format!( + "coin {coin_id} is not a genuine singleton of this launcher" + )), + LineageWalkError::TooDeep { limit } => ChainSourceError::LineageTooDeep { limit }, + } + } +} + +/// Resolves `launcher_id`'s authenticated lineage for a source whose `Error` is the shared +/// [`ChainSourceError`] — the drop-in body for [`ChainSource::resolve_singleton_lineage`]. +/// +/// ```ignore +/// fn resolve_singleton_lineage( +/// &self, +/// launcher_id: Bytes32, +/// ) -> Result, Self::Error> { +/// resolve_singleton_lineage_via_walk(self, launcher_id) +/// } +/// ``` +/// +/// Semantics are exactly [`walk_singleton_lineage`]'s; only the error is projected (see the +/// [`From`] impl above, which preserves the source's own variant). +pub fn resolve_singleton_lineage_via_walk( + source: &S, + launcher_id: Bytes32, +) -> Result, ChainSourceError> +where + S: ChainSource, +{ + walk_singleton_lineage(source, launcher_id).map_err(Into::into) +} + +/// Walks the singleton launched at `launcher_id` forward to its current unspent tip, returning +/// every coin id on the walk as an authenticated [`SingletonLineage`]. +/// +/// This is a genuine forward walk: it starts at the launcher coin and derives each successive coin +/// from the previous coin's actual spend (see the module docs for why derivation, not recognition, +/// is the only sound construction). It never echoes a caller-supplied coin, and the caller supplies +/// nothing but the launcher id. +/// +/// Bounded at [`MAX_LINEAGE_DEPTH`] hops; use [`walk_singleton_lineage_bounded`] to choose another +/// bound. +/// +/// # Returns +/// +/// | Case | Result | +/// |---|---| +/// | `launcher_id` names no coin | `Ok(None)` | +/// | it names a coin that is not a singleton launcher | `Ok(None)` | +/// | the launcher was never spent (no eve minted) | `Ok(None)` | +/// | the singleton was melted (a spend with no odd successor) | `Ok(None)` | +/// | a live singleton | `Ok(Some(lineage))`, launcher → tip inclusive | +/// | a source read failed | `Err(LineageWalkError::Source(_))` | +/// | the chain data is inconsistent | `Err(LineageWalkError::Malformed(_))` | +/// | a coin is not a genuine singleton of this launcher | `Err(LineageWalkError::NotASingleton { .. })` | +/// | the hop bound was exceeded | `Err(LineageWalkError::TooDeep { .. })` | +pub fn walk_singleton_lineage( + source: &S, + launcher_id: Bytes32, +) -> Result, LineageWalkError> { + walk_singleton_lineage_bounded(source, launcher_id, MAX_LINEAGE_DEPTH) +} + +/// [`walk_singleton_lineage`] with an explicit hop bound. +/// +/// Factored out so the [`LineageWalkError::TooDeep`] behaviour can be exercised over a short real +/// chain with a tiny bound, rather than only by a 100,000-hop fixture that no test would build. +pub fn walk_singleton_lineage_bounded( + source: &S, + launcher_id: Bytes32, + max_hops: usize, +) -> Result, LineageWalkError> { + let Some(launcher) = read_launcher_coin(source, launcher_id)? else { + return Ok(None); + }; + + let mut allocator = Allocator::new(); + let mut members = BTreeSet::from([launcher_id]); + let mut current = launcher; + // The launcher's own spend is structurally different from a singleton spend (its CREATE_COIN + // already carries the eve's FULL puzzle hash), so the first hop is handled separately. + let mut at_launcher = true; + + // `max_hops` counts SPENDS followed, so the loop runs one extra time: the final read is the one + // that discovers the tip is unspent, and it follows no spend. + for _hop in 0..=max_hops { + let Some(spend) = read_spend_of(source, current)? else { + // An unspent coin is the tip — unless it is the launcher itself, in which case no + // singleton state was ever minted. + return Ok((!at_launcher).then(|| SingletonLineage::new(current.coin_id(), members))); + }; + + let (puzzle, solution) = parse_spend(&mut allocator, &spend)?; + let successor = if at_launcher { + eve_created_by_launcher(&mut allocator, current, puzzle, solution)? + } else { + singleton_successor(&mut allocator, current, launcher_id, puzzle, solution)? + }; + let Some(successor) = successor else { + // The spend emitted no odd-amount successor: the singleton was melted, so it has no + // current coin. A melt is a genuine absence, not a failure. + return Ok(None); + }; + + // A solution is NOT committed to by a coin's puzzle hash, so a dishonest source could pair a + // genuine reveal with a fabricated solution and steer the walk onto a coin the chain never + // created. Requiring the derived successor to exist on chain binds every hop to real state. + require_coin_exists(source, successor)?; + + if !members.insert(successor.coin_id()) { + return Err(LineageWalkError::Malformed(format!( + "coin {} repeats in the lineage (a cycle)", + successor.coin_id() + ))); + } + current = successor; + at_launcher = false; + } + + Err(LineageWalkError::TooDeep { limit: max_hops }) +} + +/// Reads the launcher coin named by `launcher_id`, or `None` when no singleton was launched there. +/// +/// A coin id that names nothing, or names a coin that is not wearing the well-known singleton +/// launcher puzzle, means the singleton genuinely does not exist — not that the read failed. +fn read_launcher_coin( + source: &S, + launcher_id: Bytes32, +) -> Result, LineageWalkError> { + let Some(record) = source + .coin_record(launcher_id) + .map_err(LineageWalkError::Source)? + else { + return Ok(None); + }; + if record.coin.coin_id() != launcher_id { + return Err(LineageWalkError::Malformed(format!( + "source returned coin {} for id {launcher_id}", + record.coin.coin_id() + ))); + } + if record.coin.puzzle_hash != Bytes32::new(SINGLETON_LAUNCHER_HASH) { + return Ok(None); + } + Ok(Some(record.coin)) +} + +/// Reads the spend of `coin`, proving the returned spend really is that coin's and that its puzzle +/// reveal hashes to the coin's own puzzle hash. +/// +/// Both checks defend against a lying source: without them, an attacker-supplied reveal could be +/// run in place of the coin's real puzzle and emit any successor it liked. +fn read_spend_of( + source: &S, + coin: Coin, +) -> Result, LineageWalkError> { + let Some(spend) = source + .coin_spend(coin.coin_id()) + .map_err(LineageWalkError::Source)? + else { + return Ok(None); + }; + if spend.coin != coin { + return Err(LineageWalkError::Malformed(format!( + "source returned a spend of coin {} when asked for {}", + spend.coin.coin_id(), + coin.coin_id() + ))); + } + let revealed = program_tree_hash(&spend.puzzle_reveal)?; + if Bytes32::from(revealed) != coin.puzzle_hash { + return Err(LineageWalkError::Malformed(format!( + "puzzle reveal does not hash to the puzzle hash of coin {}", + coin.coin_id() + ))); + } + Ok(Some(spend)) +} + +/// Requires `coin` to be known to the source, binding a derived successor to real chain state. +fn require_coin_exists( + source: &S, + coin: Coin, +) -> Result<(), LineageWalkError> { + let known = source + .coin_record(coin.coin_id()) + .map_err(LineageWalkError::Source)? + .is_some_and(|record| record.coin == coin); + if !known { + return Err(LineageWalkError::Malformed(format!( + "the spend claims to create coin {}, which the source does not know", + coin.coin_id() + ))); + } + Ok(()) +} + +/// Reconstructs the eve singleton a launcher spend creates. +/// +/// A launcher's `CREATE_COIN` puzzle hash is already the eve's FULL (singleton-wrapped) puzzle hash, +/// so the eve is built directly from the condition. The eve's curried launcher id is not verifiable +/// here — it is proven at the NEXT hop, where the eve's own reveal is parsed as a singleton layer +/// and its curried launcher id is checked against the launcher under resolution. +fn eve_created_by_launcher( + allocator: &mut Allocator, + launcher: Coin, + puzzle: Puzzle, + solution: NodePtr, +) -> Result, LineageWalkError> { + Ok( + match run_for_continuation(allocator, puzzle.ptr(), solution)? { + Continuation::Ends => None, + Continuation::Recreates(puzzle_hash, amount) => { + Some(Coin::new(launcher.coin_id(), puzzle_hash, amount)) + } + }, + ) +} + +/// Reconstructs the exact singleton successor `parent` creates, or `None` when the spend melts the +/// singleton (no odd-amount child). +/// +/// The successor's puzzle hash is COMPUTED from `launcher_id` and the successor's inner puzzle hash +/// — never read from an untrusted field — which is what makes the hop an authentication rather than +/// a comparison. `parent` must itself parse as a singleton curried to `launcher_id`, so a coin that +/// merely wears a matching puzzle hash cannot extend the lineage. +fn singleton_successor( + allocator: &mut Allocator, + parent: Coin, + launcher_id: Bytes32, + puzzle: Puzzle, + solution: NodePtr, +) -> Result, LineageWalkError> { + let layer = SingletonLayer::::parse_puzzle(allocator, puzzle) + .map_err(|error| LineageWalkError::Malformed(format!("undecodable puzzle: {error}")))? + .filter(|layer| layer.launcher_id == launcher_id) + .ok_or(LineageWalkError::NotASingleton { + coin_id: parent.coin_id(), + })?; + + let solution = SingletonLayer::::parse_solution(allocator, solution) + .map_err(|error| LineageWalkError::Malformed(format!("undecodable solution: {error}")))?; + Ok( + match run_for_continuation(allocator, layer.inner_puzzle.ptr(), solution.inner_solution)? { + Continuation::Ends => None, + Continuation::Recreates(inner_puzzle_hash, amount) => { + let full = + SingletonArgs::curry_tree_hash(launcher_id, TreeHash::from(inner_puzzle_hash)); + Some(Coin::new(parent.coin_id(), full.into(), amount)) + } + }, + ) +} + +/// The `CREATE_COIN` amount a singleton's inner puzzle emits to MELT the singleton: the top layer +/// turns that output into an ordinary coin instead of a singleton recreation, ending the lineage. +const SINGLETON_MELT_AMOUNT: i64 = -113; + +/// What running a spend's puzzle says about the singleton's continuation. +enum Continuation { + /// The spend recreates the singleton as `(inner_or_full_puzzle_hash, amount)`. + Recreates(Bytes32, u64), + /// The spend melts the singleton (or emits no odd-amount child at all): the lineage ends here. + Ends, +} + +/// Runs `puzzle` against `solution` and reports whether the spend continues or ends the singleton. +/// +/// Odd amount is the singleton's continuation marker: a singleton spend emits at most one +/// odd-amount child and that child is the recreated singleton. Even-amount children are ordinary +/// payments — a singleton spend may pay anyone — and are deliberately ignored. +/// +/// The amount is decoded as a SIGNED integer on purpose. CLVM atoms carry no sign, so decoding the +/// melt marker `-113` into a `u64` silently yields `143`: an odd, positive amount that reads as a +/// perfectly ordinary recreation. A walk that made that mistake would invent a phantom successor +/// for every melted singleton instead of reporting the melt. +fn run_for_continuation( + allocator: &mut Allocator, + puzzle: NodePtr, + solution: NodePtr, +) -> Result> { + let output = run_puzzle(allocator, puzzle, solution) + .map_err(|error| LineageWalkError::Malformed(format!("puzzle did not run: {error}")))?; + let conditions = Vec::::from_clvm(allocator, output).map_err(|error| { + LineageWalkError::Malformed(format!("undecodable condition list: {error}")) + })?; + + let mut recreation: Option<(Bytes32, u64)> = None; + for condition in conditions { + let Ok((opcode, (puzzle_hash, (signed_amount, _memos)))) = + CreateCoin::from_clvm(allocator, condition) + else { + continue; + }; + if opcode != CREATE_COIN { + continue; + } + if signed_amount == SINGLETON_MELT_AMOUNT { + return Ok(Continuation::Ends); + } + let Ok(amount) = u64::try_from(signed_amount) else { + continue; + }; + if amount % 2 == 0 { + continue; + } + if recreation.is_some() { + return Err(LineageWalkError::Malformed( + "a singleton spend emitted more than one odd-amount child".to_string(), + )); + } + recreation = Some((puzzle_hash, amount)); + } + + Ok(match recreation { + Some((puzzle_hash, amount)) => Continuation::Recreates(puzzle_hash, amount), + None => Continuation::Ends, + }) +} + +/// The CLVM opcode for `CREATE_COIN`. +const CREATE_COIN: i64 = 51; + +/// A `CREATE_COIN` condition decoded with a SIGNED amount, so the melt marker survives (see +/// [`run_for_continuation`]): `(opcode, (puzzle_hash, (amount, memos)))`. Any condition with a +/// different shape simply fails to decode and is skipped. +type CreateCoin = (i64, (Bytes32, (i64, NodePtr))); + +/// Deserializes a spend's puzzle reveal and solution into `allocator`. +fn parse_spend( + allocator: &mut Allocator, + spend: &CoinSpend, +) -> Result<(Puzzle, NodePtr), LineageWalkError> { + let puzzle = alloc(allocator, &spend.puzzle_reveal)?; + let solution = alloc(allocator, &spend.solution)?; + Ok((Puzzle::parse(allocator, puzzle), solution)) +} + +/// Deserializes a [`Program`] into an allocated [`NodePtr`]. +fn alloc(allocator: &mut Allocator, program: &Program) -> Result> { + program + .to_clvm(allocator) + .map_err(|error| LineageWalkError::Malformed(format!("undecodable program: {error}"))) +} + +/// The CLVM tree hash of a serialized [`Program`], without disturbing the walk's allocator. +fn program_tree_hash(program: &Program) -> Result> { + let mut allocator = Allocator::new(); + let ptr = alloc(&mut allocator, program)?; + Ok(tree_hash(&allocator, ptr)) +} diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs new file mode 100644 index 0000000..e697269 --- /dev/null +++ b/tests/lineage_walk.rs @@ -0,0 +1,615 @@ +//! Adversarial tests for the canonical singleton lineage walk (needs +//! `--features lineage-walk,testing`). +//! +//! The authentic cases run against REAL singleton spends produced by the in-process Chia simulator — +//! a genuine launcher, its eve, a recreation, and a melt — so the walk is exercised on chain data it +//! did not invent. The fail-closed cases use [`MockChainSource`], which can lie in ways a simulator +//! cannot. +//! +//! The load-bearing test is [`a_lookalike_coin_wearing_the_singleton_puzzle_hash_is_not_a_member`]: +//! a coin that genuinely exists on chain, genuinely wears the victim singleton's outer puzzle hash, +//! and genuinely has the same amount — but was created by an ordinary spend rather than by a +//! singleton recreation. Nothing about the coin itself distinguishes it from the real tip; only the +//! derivation does. A walk that recognised coins instead of deriving them would admit it. + +#![cfg(all(feature = "lineage-walk", feature = "testing"))] + +use std::cell::Cell; + +use anyhow::Result; +use chia_bls::{PublicKey, SecretKey}; +use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; +use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; +use chia_puzzle_types::{EveProof, LineageProof, Memos, Proof}; +use chia_sdk_driver::{ + Launcher, Layer, SingletonLayer, Spend, SpendContext, SpendWithConditions, StandardLayer, +}; +use chia_sdk_test::Simulator; +use chia_sdk_types::{Condition, Conditions}; +use clvm_utils::TreeHash; +use dig_chainsource_interface::{ + walk_singleton_lineage, walk_singleton_lineage_bounded, ChainSource, ChainSourceError, + CoinRecord, LineageWalkError, MockChainSource, SingletonLineage, +}; + +// --------------------------------------------------------------------------------------------- +// A chain view backed entirely by the real simulator. +// --------------------------------------------------------------------------------------------- + +/// An honest [`ChainSource`] over the in-process simulator: every read is answered from real +/// simulated chain state, so nothing in these fixtures is hand-forged. +struct SimSource<'a> { + sim: &'a Simulator, + /// How many times the walk consulted [`ChainSource::coin_records_by_parent`]. A sound walk + /// DERIVES its successor from the parent's own spend, so this must stay zero: any reliance on + /// the child list hands a source the power to steer the lineage (see + /// [`a_genuine_sibling_of_the_successor_is_not_selected_as_the_successor`]). + children_reads: Cell, +} + +impl ChainSource for SimSource<'_> { + type Error = ChainSourceError; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.sim.coin_state(coin_id).map(CoinRecord::from)) + } + + fn coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + include_spent: bool, + ) -> Result, Self::Error> { + Ok(self + .sim + .unspent_coins(puzzle_hash, false) + .into_iter() + .filter_map(|coin| self.sim.coin_state(coin.coin_id())) + .map(CoinRecord::from) + .filter(|record| include_spent || !record.is_spent()) + .collect()) + } + + fn coin_records_by_parent( + &self, + parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + self.children_reads.set(self.children_reads.get() + 1); + Ok(self + .sim + .children(parent_coin_id) + .into_iter() + .map(CoinRecord::from) + .collect()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.sim.coin_spend(coin_id)) + } + + fn resolve_singleton_lineage( + &self, + launcher_id: Bytes32, + ) -> Result, Self::Error> { + // The helper under test is the whole point; delegating here proves the one-line body works. + dig_chainsource_interface::resolve_singleton_lineage_via_walk(self, launcher_id) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } +} + +// --------------------------------------------------------------------------------------------- +// Fixture construction: a real singleton, advanced by real spends. +// --------------------------------------------------------------------------------------------- + +/// A live singleton in the simulator, tracked as the test advances it. +struct Singleton { + launcher_id: Bytes32, + /// Launcher -> ... -> tip, in walk order. + trail: Vec, + proof: Proof, + inner_puzzle_hash: Bytes32, + pk: PublicKey, + sk: SecretKey, +} + +impl Singleton { + fn tip(&self) -> Coin { + *self.trail.last().expect("a launched singleton has a tip") + } + + /// The full (singleton-wrapped) puzzle hash the singleton's coins wear. + fn outer_puzzle_hash(&self) -> Bytes32 { + SingletonArgs::curry_tree_hash(self.launcher_id, TreeHash::from(self.inner_puzzle_hash)) + .into() + } +} + +/// Launches a real singleton with a standard p2 inner puzzle and settles it, returning the launcher +/// coin, the eve coin, and everything needed to advance it. +fn launch(sim: &mut Simulator, ctx: &mut SpendContext) -> Result { + launch_with_amount(sim, ctx, 1) +} + +/// [`launch`] with a chosen singleton amount, so a spend can both recreate the singleton (an odd +/// amount) AND pay an even-amount decoy out of the same coin. +fn launch_with_amount( + sim: &mut Simulator, + ctx: &mut SpendContext, + amount: u64, +) -> Result { + let owner = sim.bls(amount); + let launcher = Launcher::new(owner.coin.coin_id(), amount); + let launcher_coin = launcher.coin(); + let (conditions, eve) = launcher.spend(ctx, owner.puzzle_hash, ())?; + StandardLayer::new(owner.pk).spend(ctx, owner.coin, conditions)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + + Ok(Singleton { + launcher_id: launcher_coin.coin_id(), + trail: vec![launcher_coin, eve], + proof: Proof::Eve(EveProof { + parent_parent_coin_info: launcher_coin.parent_coin_info, + parent_amount: launcher_coin.amount, + }), + inner_puzzle_hash: owner.puzzle_hash, + pk: owner.pk, + sk: owner.sk, + }) +} + +/// Advances the singleton by one genuine recreation spend, appending the new tip to the trail. +fn advance(sim: &mut Simulator, ctx: &mut SpendContext, singleton: &mut Singleton) -> Result<()> { + advance_paying(sim, ctx, singleton, singleton.tip().amount, None) +} + +/// Advances the singleton, recreating it with `recreate_amount` and optionally paying an extra +/// even-amount coin to `decoy` out of the SAME spend — so the recreation gets a genuine sibling. +fn advance_paying( + sim: &mut Simulator, + ctx: &mut SpendContext, + singleton: &mut Singleton, + recreate_amount: u64, + decoy: Option<(Bytes32, u64)>, +) -> Result<()> { + let tip = singleton.tip(); + let sk = singleton.sk.clone(); + + let mut conditions = + Conditions::new().create_coin(singleton.inner_puzzle_hash, recreate_amount, Memos::None); + if let Some((puzzle_hash, amount)) = decoy { + conditions = conditions.create_coin(puzzle_hash, amount, Memos::None); + } + let inner = StandardLayer::new(singleton.pk).spend_with_conditions(ctx, conditions)?; + let layer = SingletonLayer::new(singleton.launcher_id, StandardLayer::new(singleton.pk)); + let solution = SingletonSolution { + lineage_proof: singleton.proof, + amount: tip.amount, + inner_solution: inner.solution, + }; + let puzzle = layer.construct_puzzle(ctx)?; + let solution = ctx.alloc(&solution)?; + ctx.spend(tip, Spend::new(puzzle, solution))?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&sk))?; + + singleton.proof = Proof::Lineage(LineageProof { + parent_parent_coin_info: tip.parent_coin_info, + parent_inner_puzzle_hash: singleton.inner_puzzle_hash, + parent_amount: tip.amount, + }); + singleton.trail.push(Coin::new( + tip.coin_id(), + singleton.outer_puzzle_hash(), + recreate_amount, + )); + Ok(()) +} + +fn source(sim: &Simulator) -> SimSource<'_> { + SimSource { + sim, + children_reads: Cell::new(0), + } +} + +// --------------------------------------------------------------------------------------------- +// The authentic walk. +// --------------------------------------------------------------------------------------------- + +#[test] +fn walk_returns_every_coin_from_the_launcher_to_the_tip() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + advance(&mut sim, ctx, &mut singleton)?; + + let src = source(&sim); + let lineage = walk_singleton_lineage(&src, singleton.launcher_id)? + .expect("a live singleton has a lineage"); + + assert_eq!(lineage.tip(), singleton.tip().coin_id()); + assert_eq!(lineage.len(), singleton.trail.len()); + for coin in &singleton.trail { + assert!( + lineage.contains(coin.coin_id()), + "genuine lineage coin {} is missing", + coin.coin_id() + ); + } + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// THE ADVERSARIAL TEST. +// --------------------------------------------------------------------------------------------- + +/// A coin that exists on chain, wears the victim singleton's exact outer puzzle hash, and carries +/// the same amount — but was created by an ordinary payment, not by a singleton recreation. +/// +/// Nothing observable about the coin distinguishes it from the genuine tip, so this is precisely the +/// fixture a walk that RECOGNISES coins (by puzzle hash, by curried launcher id, or by picking a +/// plausible child) cannot survive. It must be neither a member nor the tip. +#[test] +fn a_lookalike_coin_wearing_the_singleton_puzzle_hash_is_not_a_member() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + + // An unrelated party pays 1 mojo to the victim singleton's outer puzzle hash. + let attacker = sim.bls(1); + let spoofed_puzzle_hash = singleton.outer_puzzle_hash(); + StandardLayer::new(attacker.pk).spend( + ctx, + attacker.coin, + Conditions::new().create_coin(spoofed_puzzle_hash, 1, Memos::None), + )?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&attacker.sk))?; + let spoof = Coin::new(attacker.coin.coin_id(), spoofed_puzzle_hash, 1); + + // The spoof really is on chain and really does wear the singleton's puzzle hash. + assert!(sim.coin_state(spoof.coin_id()).is_some()); + assert_eq!(spoof.puzzle_hash, singleton.tip().puzzle_hash); + assert_ne!(spoof.coin_id(), singleton.tip().coin_id()); + + let src = source(&sim); + let lineage = walk_singleton_lineage(&src, singleton.launcher_id)?.expect("live singleton"); + + assert!( + !lineage.contains(spoof.coin_id()), + "a look-alike coin with no genuine recreation parent-spend was admitted" + ); + assert_eq!(lineage.tip(), singleton.tip().coin_id()); + Ok(()) +} + +/// The walk must not be steerable by a look-alike that is a GENUINE SIBLING of the real successor +/// — the nearest wrong implementation picks the successor out of +/// [`ChainSource::coin_records_by_parent`]. +/// +/// The singleton spend here recreates itself (odd amount 3) and, out of the SAME coin, pays a decoy +/// (even amount 2) wearing the successor's EXACT full puzzle hash. So the parent has two children +/// with identical puzzle hashes, and a child-selecting walk has nothing to choose between them. A +/// derivation has: only one of the two is the coin the parent's own solution creates as its odd +/// continuation. +#[test] +fn a_genuine_sibling_of_the_successor_is_not_selected_as_the_successor() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch_with_amount(&mut sim, ctx, 5)?; + + let successor_puzzle_hash = singleton.outer_puzzle_hash(); + let eve = singleton.tip(); + advance_paying( + &mut sim, + ctx, + &mut singleton, + 3, + Some((successor_puzzle_hash, 2)), + )?; + let decoy = Coin::new(eve.coin_id(), successor_puzzle_hash, 2); + + // The fixture is only distinguishing if the decoy really is a sibling wearing the same hash. + let src = source(&sim); + let children = src.coin_records_by_parent(eve.coin_id())?; + assert_eq!(children.len(), 2, "the successor must have a real sibling"); + assert!(children + .iter() + .all(|child| child.coin.puzzle_hash == successor_puzzle_hash)); + assert!(sim.coin_state(decoy.coin_id()).is_some()); + + src.children_reads.set(0); + let lineage = walk_singleton_lineage(&src, singleton.launcher_id)?.expect("live singleton"); + assert_eq!(lineage.tip(), singleton.tip().coin_id()); + assert!( + !lineage.contains(decoy.coin_id()), + "an even-amount sibling wearing the successor's puzzle hash was admitted" + ); + // The outcome above is order-dependent for a child-selecting walk — it could pick the genuine + // successor by luck. This is the assertion that is NOT: a sound walk never asks for the child + // list at all, so no ordering, and no source, can steer it. + assert_eq!( + src.children_reads.get(), + 0, + "the walk consulted the child list, so a source could choose its successor" + ); + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Three-valued discipline: absence vs unreadable vs unsupported. +// --------------------------------------------------------------------------------------------- + +#[test] +fn an_unknown_launcher_is_a_genuine_absence() -> Result<()> { + let sim = Simulator::new(); + let src = source(&sim); + assert_eq!( + walk_singleton_lineage(&src, Bytes32::new([0x11; 32]))?, + None + ); + Ok(()) +} + +#[test] +fn a_coin_that_is_not_a_launcher_is_a_genuine_absence() -> Result<()> { + let mut sim = Simulator::new(); + let ordinary = sim.bls(1); + let src = source(&sim); + assert_eq!( + walk_singleton_lineage(&src, ordinary.coin.coin_id())?, + None, + "an ordinary coin's id names no singleton" + ); + Ok(()) +} + +#[test] +fn a_transport_failure_is_never_reported_as_an_absent_lineage() { + let source = MockChainSource::new().fail_with(ChainSourceError::Transport("socket".into())); + let error = walk_singleton_lineage(&source, Bytes32::new([0x22; 32])) + .expect_err("a read failure must not resolve"); + assert_eq!( + error, + LineageWalkError::Source(ChainSourceError::Transport("socket".into())), + "the source's own error must survive the walk verbatim" + ); +} + +#[test] +fn an_unsupported_read_stays_distinguishable_from_unreadable_and_from_absent() { + let source = MockChainSource::new().fail_with(ChainSourceError::Unsupported("coin_record")); + let projected: ChainSourceError = walk_singleton_lineage(&source, Bytes32::new([0x33; 32])) + .expect_err("unsupported is not an absence") + .into(); + assert_eq!(projected, ChainSourceError::Unsupported("coin_record")); + assert_ne!(projected, ChainSourceError::Malformed("coin_record".into())); +} + +#[test] +fn a_melted_singleton_has_no_lineage() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + + // Melt: spend the eve emitting NO odd-amount successor. + let tip = singleton.tip(); + let sk = singleton.sk.clone(); + // A melt is an odd-amount CREATE_COIN with the singleton melt marker (-113), which the top + // layer turns into an ORDINARY coin — so the singleton emits no successor and ceases to exist. + let melt = ctx.alloc(&(51, (singleton.inner_puzzle_hash, (-113, ()))))?; + let inner = StandardLayer::new(singleton.pk) + .spend_with_conditions(ctx, Conditions::new().with(Condition::Other(melt)))?; + let layer = SingletonLayer::new(singleton.launcher_id, StandardLayer::new(singleton.pk)); + let puzzle = layer.construct_puzzle(ctx)?; + let solution = ctx.alloc(&SingletonSolution { + lineage_proof: singleton.proof, + amount: tip.amount, + inner_solution: inner.solution, + })?; + ctx.spend(tip, Spend::new(puzzle, solution))?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&sk))?; + + singleton.trail.push(tip); + let src = source(&sim); + assert_eq!(walk_singleton_lineage(&src, singleton.launcher_id)?, None); + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Bounds and lying sources. +// --------------------------------------------------------------------------------------------- + +/// The bound must be a REFUSAL, not a truncation: a partial member set would answer `false` for +/// genuine members, which is a fail-open membership answer on a money path. +#[test] +fn exceeding_the_hop_bound_refuses_rather_than_truncating() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + advance(&mut sim, ctx, &mut singleton)?; + + let src = source(&sim); + + // The chain is launcher -> eve -> C2 -> C3, i.e. exactly THREE spends. One under the bound must + // refuse rather than return the first three coins as if they were the whole lineage. + assert_eq!(singleton.trail.len(), 4); + let error = walk_singleton_lineage_bounded(&src, singleton.launcher_id, 2) + .expect_err("an over-deep walk must not resolve"); + assert_eq!(error, LineageWalkError::TooDeep { limit: 2 }); + assert_eq!( + ChainSourceError::from(error), + ChainSourceError::LineageTooDeep { limit: 2 }, + "the over-deep refusal must stay distinguishable from every other failure" + ); + + // AT the bound it resolves — a limit pinned only from below could only confirm itself. + let lineage = walk_singleton_lineage_bounded(&src, singleton.launcher_id, 3)? + .expect("the walk completes at exactly the bound"); + assert_eq!(lineage.tip(), singleton.tip().coin_id()); + Ok(()) +} + +#[test] +fn a_spend_of_the_wrong_coin_fails_closed() { + let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); + let launcher = Coin::new(Bytes32::new([0x01; 32]), launcher_ph, 1); + let other = Coin::new(Bytes32::new([0x02; 32]), launcher_ph, 1); + + let source = MockChainSource::new() + .with_coin(launcher.coin_id(), record(launcher)) + .with_spend( + launcher.coin_id(), + CoinSpend::new(other, Program::from(vec![0x01]), Program::from(vec![0x80])), + ); + + let error = walk_singleton_lineage(&source, launcher.coin_id()) + .expect_err("a mismatched spend must fail closed"); + assert!(matches!(error, LineageWalkError::Malformed(_))); +} + +#[test] +fn a_reveal_that_does_not_hash_to_the_coin_fails_closed() { + let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); + let launcher = Coin::new(Bytes32::new([0x03; 32]), launcher_ph, 1); + + // `(q . ())` is a valid program, but it is not the launcher puzzle, so it cannot hash to the + // launcher puzzle hash — the source is passing off someone else's reveal. + let source = MockChainSource::new() + .with_coin(launcher.coin_id(), record(launcher)) + .with_spend( + launcher.coin_id(), + CoinSpend::new( + launcher, + Program::from(vec![0x01, 0x80]), + Program::from(vec![0x80]), + ), + ); + + let error = walk_singleton_lineage(&source, launcher.coin_id()) + .expect_err("a foreign reveal must fail closed"); + assert!(matches!(error, LineageWalkError::Malformed(_))); +} + +/// A launcher coin that was never spent has minted no eve, so there is no singleton state yet. +#[test] +fn an_unspent_launcher_has_no_singleton_state() { + let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); + let launcher = Coin::new(Bytes32::new([0x04; 32]), launcher_ph, 1); + let source = MockChainSource::new().with_coin(launcher.coin_id(), record(launcher)); + + assert_eq!( + walk_singleton_lineage(&source, launcher.coin_id()), + Ok(None) + ); +} + +/// A launcher whose spend creates a NON-singleton eve: the launcher puzzle emits whatever +/// `CREATE_COIN` its solution names, so a launcher can perfectly well create an ordinary coin. +/// +/// The eve then exists, is genuinely the launcher's child, and is genuinely spendable — and is still +/// not a singleton. Only parsing the eve's own reveal as a singleton layer can tell, which is why +/// the walk does exactly that rather than trusting the launcher's word. +#[test] +fn an_eve_that_is_not_a_singleton_fails_closed() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let owner = sim.bls(1); + + let launcher_coin = Coin::new( + owner.coin.coin_id(), + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH), + 1, + ); + StandardLayer::new(owner.pk).spend( + ctx, + owner.coin, + Conditions::new().create_coin(launcher_coin.puzzle_hash, 1, Memos::None), + )?; + + // Spend the launcher so it creates an ORDINARY standard coin rather than a singleton. + let launcher_puzzle = ctx.alloc(&Program::from(chia_puzzles::SINGLETON_LAUNCHER.to_vec()))?; + let launcher_solution = ctx.alloc(&(owner.puzzle_hash, (1, (Vec::::new(), ()))))?; + ctx.spend( + launcher_coin, + Spend::new(launcher_puzzle, launcher_solution), + )?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + + // Spend the fake eve so the walk reaches its reveal (an unspent coin would end the walk first). + let fake_eve = Coin::new(launcher_coin.coin_id(), owner.puzzle_hash, 1); + StandardLayer::new(owner.pk).spend(ctx, fake_eve, Conditions::new())?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + + let src = source(&sim); + let error = walk_singleton_lineage(&src, launcher_coin.coin_id()) + .expect_err("a non-singleton eve must not resolve to a lineage"); + assert_eq!( + error, + LineageWalkError::NotASingleton { + coin_id: fake_eve.coin_id() + } + ); + Ok(()) +} + +#[test] +fn a_launcher_record_for_the_wrong_coin_fails_closed() { + let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); + let asked_for = Coin::new(Bytes32::new([0x05; 32]), launcher_ph, 1); + let returned = Coin::new(Bytes32::new([0x06; 32]), launcher_ph, 1); + + let source = MockChainSource::new().with_coin(asked_for.coin_id(), record(returned)); + + let error = walk_singleton_lineage(&source, asked_for.coin_id()) + .expect_err("a record for a different coin must fail closed"); + assert!(matches!(error, LineageWalkError::Malformed(_))); +} + +#[test] +fn every_failure_reports_itself_distinguishably() { + let coin_id = Bytes32::new([0x07; 32]); + let messages = [ + LineageWalkError::Source(ChainSourceError::Timeout).to_string(), + LineageWalkError::::Malformed("bad".into()).to_string(), + LineageWalkError::::NotASingleton { coin_id }.to_string(), + LineageWalkError::::TooDeep { limit: 9 }.to_string(), + ]; + assert!(messages.iter().all(|message| !message.is_empty())); + assert_eq!( + messages + .iter() + .collect::>() + .len(), + messages.len(), + "each failure must read differently in a log" + ); + + assert_eq!( + ChainSourceError::from(LineageWalkError::::NotASingleton { coin_id }), + ChainSourceError::Malformed(format!( + "coin {coin_id} is not a genuine singleton of this launcher" + )) + ); + assert_eq!( + ChainSourceError::from(LineageWalkError::::Malformed("x".into())), + ChainSourceError::Malformed("x".into()) + ); +} + +fn record(coin: Coin) -> CoinRecord { + CoinRecord { + coin, + confirmed_height: Some(1), + spent_height: None, + timestamp: None, + coinbase: false, + } +} From 7eb47c1abaad6b7b875920e55dca101bfdcf240c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:12:40 -0700 Subject: [PATCH 02/16] =?UTF-8?q?test(walk):=20red=20=E2=80=94=20an=20unre?= =?UTF-8?q?adable=20spend=20is=20reported=20as=20the=20tip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite could not express the attack: the `record()` helper hardcoded `spent_height: None`, so no fixture ever had a coin that was spent while its spend was unreadable. Adds that fixture at both the launcher and mid-lineage. Refs #2572 Co-Authored-By: Claude --- tests/lineage_walk.rs | 174 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs index e697269..1e5bff2 100644 --- a/tests/lineage_walk.rs +++ b/tests/lineage_walk.rs @@ -45,12 +45,21 @@ struct SimSource<'a> { /// the child list hands a source the power to steer the lineage (see /// [`a_genuine_sibling_of_the_successor_is_not_selected_as_the_successor`]). children_reads: Cell, + /// A coin whose SPEND this source withholds while still reporting the coin as spent — an + /// otherwise honest source that has simply lost one spend (a pruned node, a partial index). + withheld_spend: Option, + /// A coin whose RECORD this source withholds, so a derived successor cannot be bound to real + /// chain state. Drives [`require_coin_exists`]'s guard. + withheld_record: Option, } impl ChainSource for SimSource<'_> { type Error = ChainSourceError; fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + if self.withheld_record == Some(coin_id) { + return Ok(None); + } Ok(self.sim.coin_state(coin_id).map(CoinRecord::from)) } @@ -83,6 +92,9 @@ impl ChainSource for SimSource<'_> { } fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + if self.withheld_spend == Some(coin_id) { + return Ok(None); + } Ok(self.sim.coin_spend(coin_id)) } @@ -214,6 +226,8 @@ fn source(sim: &Simulator) -> SimSource<'_> { SimSource { sim, children_reads: Cell::new(0), + withheld_spend: None, + withheld_record: None, } } @@ -604,6 +618,157 @@ fn every_failure_reports_itself_distinguishably() { ); } +// --------------------------------------------------------------------------------------------- +// A spend the source cannot serve is UNKNOWN, never a tip (the `Ok(None)` ambiguity). +// --------------------------------------------------------------------------------------------- + +/// `ChainSource::coin_spend` returns `Ok(None)` for "unspent OR unknown", and only the coin's own +/// `spent_height` tells the two apart. A walk that conflates them reports the last coin it could +/// read as the unspent tip. +/// +/// Here the chain is launcher -> eve -> C2 -> C3 and the source is honest about every coin's +/// record — it has simply lost C2's spend, as a pruned or partially-indexed node would. C2 is +/// therefore recorded as SPENT while its spend reads as absent. Answering `C2` as the tip would +/// assert that a superseded state is current; the walk must refuse instead. +#[test] +fn a_spent_coin_whose_spend_the_source_cannot_serve_is_never_reported_as_the_tip() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + advance(&mut sim, ctx, &mut singleton)?; + + let stale = singleton.trail[2]; + let tip = singleton.tip(); + + // The fixture only distinguishes anything if C2 really is spent and really is NOT the tip. + let spent_state = sim.coin_state(stale.coin_id()).expect("C2 is on chain"); + assert!( + spent_state.spent_height.is_some(), + "C2 must be spent for the ambiguity to exist" + ); + assert_ne!(stale.coin_id(), tip.coin_id()); + + let mut src = source(&sim); + src.withheld_spend = Some(stale.coin_id()); + + let error = walk_singleton_lineage(&src, singleton.launcher_id) + .expect_err("a spend the source cannot serve is an unknown, not a tip"); + assert!( + matches!(error, LineageWalkError::Malformed(_)), + "expected a refusal, got {error:?}" + ); + + // And the honest control: with the same source telling the whole truth, the walk resolves. + let honest = source(&sim); + assert_eq!( + walk_singleton_lineage(&honest, singleton.launcher_id)? + .expect("the honest chain resolves") + .tip(), + tip.coin_id(), + ); + Ok(()) +} + +/// The same ambiguity at the LAUNCHER degrades an unknown into "this singleton never existed" — +/// the SPEC §3 violation, and the one that reads as a genuine absence rather than a stale tip. +#[test] +fn a_spent_launcher_whose_spend_the_source_cannot_serve_is_not_an_absence() { + let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); + let launcher = Coin::new(Bytes32::new([0x08; 32]), launcher_ph, 1); + + let source = MockChainSource::new().with_coin(launcher.coin_id(), spent_record(launcher, 12)); + + let error = walk_singleton_lineage(&source, launcher.coin_id()) + .expect_err("a spent launcher with no readable spend is unknown, not unlaunched"); + assert!( + matches!(error, LineageWalkError::Malformed(_)), + "expected a refusal, got {error:?}" + ); + + // The control: the SAME launcher recorded as UNSPENT is a genuine absence, so the refusal above + // is driven by `spent_height`, not merely by the missing spend. + let unspent = MockChainSource::new().with_coin(launcher.coin_id(), record(launcher)); + assert_eq!(walk_singleton_lineage(&unspent, launcher.coin_id()), Ok(None)); +} + +// --------------------------------------------------------------------------------------------- +// The anti-lying-source guards, each pinned by a test that dies with it. +// --------------------------------------------------------------------------------------------- + +/// SPEC §4a requirement 2: a derived successor must be bound to a real `coin_record`. +/// +/// A solution is not committed to by the coin's puzzle hash, so a source that pairs a genuine +/// reveal with a fabricated solution can name any successor it likes. This fixture is the readable +/// half of that: the successor C2 is derived from a genuine spend, but the source does not admit +/// the coin exists. Deleting the existence check makes the walk sail past C2 to the real tip and +/// answer `Ok(Some(..))`, so this test is what keeps the check alive. +#[test] +fn a_derived_successor_the_source_does_not_know_fails_closed() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + advance(&mut sim, ctx, &mut singleton)?; + + let unknown = singleton.trail[2]; + let mut src = source(&sim); + src.withheld_record = Some(unknown.coin_id()); + + let error = walk_singleton_lineage(&src, singleton.launcher_id) + .expect_err("a successor the source does not know must fail closed"); + match error { + LineageWalkError::Malformed(detail) => assert!( + detail.contains(&unknown.coin_id().to_string()), + "the refusal must name the unknown coin: {detail}" + ), + other => panic!("expected a refusal, got {other:?}"), + } + + // The control: without the veil, the very same chain resolves to the real tip — so the failure + // above is the guard biting, not a broken fixture. + let honest = source(&sim); + assert_eq!( + walk_singleton_lineage(&honest, singleton.launcher_id)? + .expect("the honest chain resolves") + .tip(), + singleton.tip().coin_id(), + ); + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// The documented return table, pinned. +// --------------------------------------------------------------------------------------------- + +/// A launcher spent into an eve that is still UNSPENT resolves to a two-coin lineage whose tip is +/// the eve — even though the eve's own singleton structure cannot be proven until it is spent. +/// +/// This is a deliberate, documented limitation, not an oversight: the launcher's `CREATE_COIN` +/// carries the eve's FULL puzzle hash, which is non-invertible, so nothing but the launcher's own +/// spender chose it. It fails closed with `NotASingleton` the moment the eve is spent. The test +/// exists so the documented behaviour is enforced rather than merely described. +#[test] +fn an_unspent_eve_is_the_tip_of_a_two_coin_lineage() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let singleton = launch(&mut sim, ctx)?; + + let eve = singleton.tip(); + assert!( + sim.coin_spend(eve.coin_id()).is_none(), + "the eve must be unspent for this to be the documented case" + ); + + let src = source(&sim); + let lineage = + walk_singleton_lineage(&src, singleton.launcher_id)?.expect("a freshly launched singleton"); + assert_eq!(lineage.tip(), eve.coin_id()); + assert_eq!(lineage.len(), 2); + assert!(lineage.contains(singleton.launcher_id)); + Ok(()) +} + fn record(coin: Coin) -> CoinRecord { CoinRecord { coin, @@ -613,3 +778,12 @@ fn record(coin: Coin) -> CoinRecord { coinbase: false, } } + +/// [`record`] for a coin the source reports as SPENT — the state no fixture could express while +/// `spent_height` was hardcoded to `None`. +fn spent_record(coin: Coin, spent_height: u32) -> CoinRecord { + CoinRecord { + spent_height: Some(spent_height), + ..record(coin) + } +} From a5fa0bd046398f46ead4debb9ede4253333d0015 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:17:06 -0700 Subject: [PATCH 03/16] fix(walk): refuse a spent coin whose spend the source cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChainSource::coin_spend` returns `Ok(None)` for "unspent OR unknown", and the walk read only that, so an unreadable spend became the tip: mid-lineage it returned a stale tip (a dead singleton authenticating as live, had the lost spend been the melt), and at the launcher it degraded an unknown into "never existed" (SPEC §3). The coin's own `spent_height` separates the two; it is carried from the records the walk already reads, so no extra source call. Also: a CREATE_COIN whose arguments fail to decode is now a refusal rather than a skip, since skipping one turned an unreadable condition into a phantom melt — the same not-known-presenting-as-a-tip shape. The cycle guard is fused with the member insertion so it cannot be weakened without breaking a tested behaviour. Refs #2572 Co-Authored-By: Claude --- src/walk.rs | 185 +++++++++++++++++++++++++++++++++--------- tests/lineage_walk.rs | 5 +- 2 files changed, 151 insertions(+), 39 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index b25ccab..25c8376 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -50,6 +50,7 @@ use clvmr::{Allocator, NodePtr}; use crate::error::ChainSourceError; use crate::lineage::SingletonLineage; +use crate::record::CoinRecord; use crate::source::ChainSource; /// The maximum number of SPENDS [`walk_singleton_lineage`] follows before failing closed with @@ -61,8 +62,16 @@ use crate::source::ChainSource; /// not a policy limit. Its purpose is that a hostile or malformed source cannot make the walk loop /// forever or allocate without end: the member set is capped at this many `Bytes32`, i.e. ~3.2 MB. /// -/// The value matches `dig_did::resolve::MAX_LINEAGE_DEPTH`, so the two walks in the ecosystem refuse -/// at exactly the same depth rather than disagreeing about what "too deep" means. +/// # The canonical bound — depend on this constant, never re-declare the literal +/// +/// This is the ecosystem's SINGLE source of truth for how deep a singleton lineage walk may go. +/// Every DIG crate that bounds such a walk MUST read it from here rather than declaring its own +/// `100_000`: two literals that must agree, with nothing enforcing it, drift the moment one is +/// tuned, and the two walks then disagree about what "too deep" means on a money path. +/// +/// This crate is `00-foundation`, so every consumer sits strictly above it and the dependency is a +/// legal downward edge. Known re-declarations still to adopt it: `dig_did::resolve` and +/// `dig_evidence::MAX_LINEAGE_DEPTH`. pub const MAX_LINEAGE_DEPTH: usize = 100_000; /// Why a singleton lineage walk could not answer. @@ -179,11 +188,25 @@ where /// | it names a coin that is not a singleton launcher | `Ok(None)` | /// | the launcher was never spent (no eve minted) | `Ok(None)` | /// | the singleton was melted (a spend with no odd successor) | `Ok(None)` | +/// | the launcher minted an eve that is still unspent | `Ok(Some(lineage))`, launcher + eve (see below) | /// | a live singleton | `Ok(Some(lineage))`, launcher → tip inclusive | /// | a source read failed | `Err(LineageWalkError::Source(_))` | -/// | the chain data is inconsistent | `Err(LineageWalkError::Malformed(_))` | +/// | the chain data is inconsistent, incl. a spent coin whose spend the source cannot serve | `Err(LineageWalkError::Malformed(_))` | /// | a coin is not a genuine singleton of this launcher | `Err(LineageWalkError::NotASingleton { .. })` | /// | the hop bound was exceeded | `Err(LineageWalkError::TooDeep { .. })` | +/// +/// # The unspent eve (SPEC §4a) +/// +/// An eve that has never been spent is admitted on its launcher's word alone: a launcher's +/// `CREATE_COIN` carries the eve's FULL puzzle hash, and the walk cannot yet parse a reveal to +/// confirm the eve really wears a singleton curried to this launcher. So a launcher spent into an +/// ordinary coin resolves to `Ok(Some(_))` with that coin as the tip, not `Ok(None)`. +/// +/// This is sound rather than merely tolerated. The full hash is non-invertible, so only the +/// launcher's own spender could have chosen it — nothing an attacker supplies reaches this +/// decision. And it fails closed at the very next hop: the moment the eve is spent, its reveal is +/// parsed and a non-singleton yields [`LineageWalkError::NotASingleton`]. A consumer that needs a +/// *proven* singleton, rather than a launched one, must therefore require a tip beyond the eve. pub fn walk_singleton_lineage( source: &S, launcher_id: Bytes32, @@ -206,7 +229,10 @@ pub fn walk_singleton_lineage_bounded( let mut allocator = Allocator::new(); let mut members = BTreeSet::from([launcher_id]); - let mut current = launcher; + let mut current = launcher.coin; + // Carried alongside `current` because `coin_spend` answers `Ok(None)` for "unspent OR unknown"; + // only the coin's OWN record tells those apart (see [`read_spend_of`]). + let mut current_spent_height = launcher.spent_height; // The launcher's own spend is structurally different from a singleton spend (its CREATE_COIN // already carries the eve's FULL puzzle hash), so the first hop is handled separately. let mut at_launcher = true; @@ -214,7 +240,7 @@ pub fn walk_singleton_lineage_bounded( // `max_hops` counts SPENDS followed, so the loop runs one extra time: the final read is the one // that discovers the tip is unspent, and it follows no spend. for _hop in 0..=max_hops { - let Some(spend) = read_spend_of(source, current)? else { + let Some(spend) = read_spend_of(source, current, current_spent_height)? else { // An unspent coin is the tip — unless it is the launcher itself, in which case no // singleton state was ever minted. return Ok((!at_launcher).then(|| SingletonLineage::new(current.coin_id(), members))); @@ -235,29 +261,46 @@ pub fn walk_singleton_lineage_bounded( // A solution is NOT committed to by a coin's puzzle hash, so a dishonest source could pair a // genuine reveal with a fabricated solution and steer the walk onto a coin the chain never // created. Requiring the derived successor to exist on chain binds every hop to real state. - require_coin_exists(source, successor)?; + let record = require_coin_exists(source, successor)?; - if !members.insert(successor.coin_id()) { - return Err(LineageWalkError::Malformed(format!( - "coin {} repeats in the lineage (a cycle)", - successor.coin_id() - ))); - } + admit_member(&mut members, successor.coin_id())?; current = successor; + current_spent_height = record.spent_height; at_launcher = false; } Err(LineageWalkError::TooDeep { limit: max_hops }) } +/// Records `coin_id` as a lineage member, refusing a repeat. +/// +/// Fused with the insertion on purpose: a cycle guard that can be deleted without also dropping the +/// member is a guard nothing protects, and the walk's completeness test only notices the missing +/// member. Written as one operation, weakening the refusal means editing this function, where the +/// unit test below sits. +fn admit_member( + members: &mut BTreeSet, + coin_id: Bytes32, +) -> Result<(), LineageWalkError> { + if members.insert(coin_id) { + return Ok(()); + } + Err(LineageWalkError::Malformed(format!( + "coin {coin_id} repeats in the lineage (a cycle)" + ))) +} + /// Reads the launcher coin named by `launcher_id`, or `None` when no singleton was launched there. /// /// A coin id that names nothing, or names a coin that is not wearing the well-known singleton /// launcher puzzle, means the singleton genuinely does not exist — not that the read failed. +/// +/// The whole record is returned, not just the coin: the walk needs `spent_height` to tell an +/// unspent launcher from one whose spend the source cannot serve. fn read_launcher_coin( source: &S, launcher_id: Bytes32, -) -> Result, LineageWalkError> { +) -> Result, LineageWalkError> { let Some(record) = source .coin_record(launcher_id) .map_err(LineageWalkError::Source)? @@ -273,23 +316,38 @@ fn read_launcher_coin( if record.coin.puzzle_hash != Bytes32::new(SINGLETON_LAUNCHER_HASH) { return Ok(None); } - Ok(Some(record.coin)) + Ok(Some(record)) } /// Reads the spend of `coin`, proving the returned spend really is that coin's and that its puzzle -/// reveal hashes to the coin's own puzzle hash. +/// reveal hashes to the coin's own puzzle hash. `spent_height` is `coin`'s own record field. /// -/// Both checks defend against a lying source: without them, an attacker-supplied reveal could be +/// Both proofs defend against a lying source: without them, an attacker-supplied reveal could be /// run in place of the coin's real puzzle and emit any successor it liked. +/// +/// `Ok(None)` means the coin is genuinely UNSPENT, never merely that no spend came back. +/// [`ChainSource::coin_spend`] returns `Ok(None)` for "unspent **or unknown**", and the coin's own +/// `spent_height` is the only thing that separates them. Conflating the two lets an honest-looking +/// source that has simply lost a spend present a superseded coin as the current tip — and if the +/// lost spend was the MELT, a dead singleton would authenticate as live. A spent coin whose spend +/// cannot be served is a "could not answer", so it fails closed. fn read_spend_of( source: &S, coin: Coin, + spent_height: Option, ) -> Result, LineageWalkError> { let Some(spend) = source .coin_spend(coin.coin_id()) .map_err(LineageWalkError::Source)? else { - return Ok(None); + return match spent_height { + Some(height) => Err(LineageWalkError::Malformed(format!( + "coin {} is recorded as spent at height {height}, but the source served no spend \ + for it", + coin.coin_id() + ))), + None => Ok(None), + }; }; if spend.coin != coin { return Err(LineageWalkError::Malformed(format!( @@ -308,22 +366,27 @@ fn read_spend_of( Ok(Some(spend)) } -/// Requires `coin` to be known to the source, binding a derived successor to real chain state. +/// Requires `coin` to be known to the source, binding a derived successor to real chain state, and +/// returns that coin's record. +/// +/// Equality is over the whole [`Coin`], so a source cannot satisfy the check with a different coin +/// that merely shares an id-shaped field. The record travels back to the caller because the next +/// hop needs its `spent_height` (see [`read_spend_of`]) — this is the same read either way, so +/// nothing extra is asked of the source. fn require_coin_exists( source: &S, coin: Coin, -) -> Result<(), LineageWalkError> { - let known = source +) -> Result> { + source .coin_record(coin.coin_id()) .map_err(LineageWalkError::Source)? - .is_some_and(|record| record.coin == coin); - if !known { - return Err(LineageWalkError::Malformed(format!( - "the spend claims to create coin {}, which the source does not know", - coin.coin_id() - ))); - } - Ok(()) + .filter(|record| record.coin == coin) + .ok_or_else(|| { + LineageWalkError::Malformed(format!( + "the spend claims to create coin {}, which the source does not know", + coin.coin_id() + )) + }) } /// Reconstructs the eve singleton a launcher spend creates. @@ -418,20 +481,31 @@ fn run_for_continuation( let mut recreation: Option<(Bytes32, u64)> = None; for condition in conditions { - let Ok((opcode, (puzzle_hash, (signed_amount, _memos)))) = - CreateCoin::from_clvm(allocator, condition) - else { + // Decode the OPCODE first and the arguments separately. Skipping a condition that fails to + // decode as a whole would turn an unparseable CREATE_COIN — or one whose amount overflows + // `i64` — into a silent "no successor", i.e. a phantom melt: the walk would stop early and + // report a superseded coin as the tip. Once a condition is known to be a CREATE_COIN, an + // argument list the walk cannot read is a refusal, never an omission. + let Ok((opcode, arguments)) = ConditionHead::from_clvm(allocator, condition) else { continue; }; if opcode != CREATE_COIN { continue; } + let (puzzle_hash, (signed_amount, _memos)) = + CreateCoinArguments::from_clvm(allocator, arguments).map_err(|error| { + LineageWalkError::Malformed(format!("undecodable CREATE_COIN condition: {error}")) + })?; + if signed_amount == SINGLETON_MELT_AMOUNT { return Ok(Continuation::Ends); } - let Ok(amount) = u64::try_from(signed_amount) else { - continue; - }; + let amount = u64::try_from(signed_amount).map_err(|_| { + LineageWalkError::Malformed(format!( + "CREATE_COIN with the negative amount {signed_amount}, which is not the singleton \ + melt marker {SINGLETON_MELT_AMOUNT}" + )) + })?; if amount % 2 == 0 { continue; } @@ -452,10 +526,14 @@ fn run_for_continuation( /// The CLVM opcode for `CREATE_COIN`. const CREATE_COIN: i64 = 51; -/// A `CREATE_COIN` condition decoded with a SIGNED amount, so the melt marker survives (see -/// [`run_for_continuation`]): `(opcode, (puzzle_hash, (amount, memos)))`. Any condition with a -/// different shape simply fails to decode and is skipped. -type CreateCoin = (i64, (Bytes32, (i64, NodePtr))); +/// Any condition, split into its opcode and its still-undecoded arguments: `(opcode . arguments)`. +/// A condition whose opcode is not even an integer is not `CREATE_COIN` and is skipped. +type ConditionHead = (i64, NodePtr); + +/// A `CREATE_COIN`'s arguments decoded with a SIGNED amount, so the melt marker survives (see +/// [`run_for_continuation`]): `(puzzle_hash, (amount, memos))`. Decoded only AFTER the opcode is +/// known to be `CREATE_COIN`, so a failure here is a refusal rather than a skip. +type CreateCoinArguments = (Bytes32, (i64, NodePtr)); /// Deserializes a spend's puzzle reveal and solution into `allocator`. fn parse_spend( @@ -480,3 +558,34 @@ fn program_tree_hash(program: &Program) -> Result(&mut members, coin_id), + Ok(()) + ); + + let repeat = admit_member::(&mut members, coin_id) + .expect_err("a repeated coin is a cycle"); + assert_eq!( + repeat, + LineageWalkError::Malformed(format!("coin {coin_id} repeats in the lineage (a cycle)")) + ); + assert_eq!(members.len(), 1); + } +} diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs index 1e5bff2..d179e6d 100644 --- a/tests/lineage_walk.rs +++ b/tests/lineage_walk.rs @@ -689,7 +689,10 @@ fn a_spent_launcher_whose_spend_the_source_cannot_serve_is_not_an_absence() { // The control: the SAME launcher recorded as UNSPENT is a genuine absence, so the refusal above // is driven by `spent_height`, not merely by the missing spend. let unspent = MockChainSource::new().with_coin(launcher.coin_id(), record(launcher)); - assert_eq!(walk_singleton_lineage(&unspent, launcher.coin_id()), Ok(None)); + assert_eq!( + walk_singleton_lineage(&unspent, launcher.coin_id()), + Ok(None) + ); } // --------------------------------------------------------------------------------------------- From c0f86d4d686e2031277128fae718fb3ccf9594c6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:18:53 -0700 Subject: [PATCH 04/16] test(walk): pin the condition decoder against a phantom melt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simulator's validator rejects a malformed condition, so an undecodable CREATE_COIN is only reachable from a lying source — these drive run_for_continuation directly. The third test is the control: well-formed melt and recreation conditions must still decode, or a decoder that refused everything would pass the other two. Refs #2572 Co-Authored-By: Claude --- src/walk.rs | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/walk.rs b/src/walk.rs index 25c8376..3682d4c 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -451,6 +451,7 @@ fn singleton_successor( const SINGLETON_MELT_AMOUNT: i64 = -113; /// What running a spend's puzzle says about the singleton's continuation. +#[derive(Debug)] enum Continuation { /// The spend recreates the singleton as `(inner_or_full_puzzle_hash, amount)`. Recreates(Bytes32, u64), @@ -588,4 +589,100 @@ mod tests { ); assert_eq!(members.len(), 1); } + + /// Quotes `value` so [`run_puzzle`] returns it verbatim, letting a test hand + /// [`run_for_continuation`] a condition list the simulator would never mint — the CLVM + /// validator rejects a malformed condition, so the only place this shape is reachable is a + /// LYING source, which is exactly the case under test. + fn quoting(allocator: &mut Allocator, value: NodePtr) -> NodePtr { + let quote = allocator.one(); + allocator + .new_pair(quote, value) + .expect("a two-node pair always allocates") + } + + /// Allocates the condition list `conditions`. + fn condition_list(allocator: &mut Allocator, conditions: Vec) -> NodePtr { + conditions + .to_clvm(allocator) + .expect("a condition list always allocates") + } + + fn continuation_of( + allocator: &mut Allocator, + conditions: Vec, + ) -> Result> { + let list = condition_list(allocator, conditions); + let puzzle = quoting(allocator, list); + run_for_continuation(allocator, puzzle, NodePtr::NIL) + } + + /// A CREATE_COIN the walk cannot decode must REFUSE, not be skipped. + /// + /// Skipping it leaves the spend looking like it emitted no odd-amount child, which the walk + /// reads as a melt — a phantom one. The lineage would then stop early and report a superseded + /// coin as the tip: the same not-known-presenting-as-a-tip defect as an unreadable spend, + /// arrived at through the condition decoder. + #[test] + fn an_undecodable_create_coin_refuses_rather_than_reading_as_a_melt() { + let allocator = &mut Allocator::new(); + let opcode = CREATE_COIN + .to_clvm(allocator) + .expect("the opcode allocates"); + // `(51)` — a CREATE_COIN with no puzzle hash and no amount at all. + let truncated = allocator + .new_pair(opcode, NodePtr::NIL) + .expect("the condition allocates"); + + let error = continuation_of(allocator, vec![truncated]) + .expect_err("an undecodable CREATE_COIN is not a melt"); + assert!( + matches!(error, LineageWalkError::Malformed(detail) if detail.contains("CREATE_COIN")), + "the refusal must name the condition it could not read" + ); + } + + /// A negative amount that is NOT the melt marker is nonsense the walk must refuse, for the same + /// reason: silently dropping it invents a melt. + #[test] + fn a_negative_non_melt_amount_refuses() { + let allocator = &mut Allocator::new(); + let condition = (CREATE_COIN, (Bytes32::new([0x0C; 32]), (-7i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + + let error = continuation_of(allocator, vec![condition]) + .expect_err("a negative non-melt amount is not a melt"); + assert!(matches!(error, LineageWalkError::Malformed(_))); + } + + /// The control the two refusals above need: WELL-FORMED conditions still decode exactly as + /// before, so the strictness bites only on what the walk genuinely cannot read. + /// + /// Without this, a `run_for_continuation` that refused everything would pass both tests. + #[test] + fn well_formed_conditions_still_decode_as_melt_and_as_recreation() { + let allocator = &mut Allocator::new(); + let puzzle_hash = Bytes32::new([0x0D; 32]); + + let melt = (CREATE_COIN, (puzzle_hash, (SINGLETON_MELT_AMOUNT, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + assert!(matches!( + continuation_of(allocator, vec![melt]), + Ok(Continuation::Ends) + )); + + let payment = (CREATE_COIN, (puzzle_hash, (2i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + let recreate = (CREATE_COIN, (puzzle_hash, (3i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + // The even-amount payment is an ordinary output and must still be ignored, not refused. + assert!(matches!( + continuation_of(allocator, vec![payment, recreate]), + Ok(Continuation::Recreates(hash, 3)) if hash == puzzle_hash + )); + } } From 513bda8629eecc0c6f8502c2dfa39f4a7f677e79 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:23:04 -0700 Subject: [PATCH 05/16] docs(spec): state the unreadable-spend refusal and the unspent-eve case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §4a gains requirements 6 and 7 (an unreadable spend and an undecodable CREATE_COIN are unknowns, never a tip) and states the unspent-eve return explicitly, since consumers read the contract here. MAX_LINEAGE_DEPTH is declared the ecosystem's single source of truth for the bound. Refs #2572 Co-Authored-By: Claude --- SPEC.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 79cf484..8fb6f22 100644 --- a/SPEC.md +++ b/SPEC.md @@ -119,11 +119,31 @@ A conforming walk MUST: singleton was melted. Every read failure surfaces as `LineageWalkError::Source(_)` carrying the source's OWN error unchanged, so *unsupported* stays distinguishable from *unreadable* and neither is ever collapsed into an absence. - -**Stated limit.** An eve coin that has never been spent is admitted on the evidence of the launcher's -own spend, which is the strongest evidence that exists: the eve is by definition the coin the -launcher created. Its inner structure is constrained only once it is itself spent, at which point the -curried launcher id is checked. +6. **Treat an unreadable spend as unknown, never as the tip.** `coin_spend` answers `Ok(None)` for + "unspent **or** unknown" (§3), so a walk MUST consult the coin's own `spent_height` before + concluding it has reached the tip. A coin recorded as SPENT whose spend the source does not serve + MUST fail closed with `LineageWalkError::Malformed`. Reporting it as the tip would present a + superseded state as current — and if the unserved spend was the melt, a dead singleton would + authenticate as live; at the launcher it would degrade an unknown into "never launched", + violating §3. +7. **Refuse an unreadable `CREATE_COIN`.** Once a condition's opcode is known to be `CREATE_COIN`, + arguments the walk cannot decode (including an amount outside `i64`, or a negative amount that is + not the melt marker) MUST be a refusal. Skipping such a condition makes a spend look as though it + emitted no odd-amount child — a phantom melt, i.e. requirement 6's defect reached through the + condition decoder. + +`MAX_LINEAGE_DEPTH` is the ecosystem's SINGLE source of truth for this bound. A DIG crate that +bounds a singleton lineage walk MUST import it from this crate rather than re-declare the literal; +this crate is `00-foundation`, so every such consumer sits strictly above it. + +**Stated limit — the unspent eve.** An eve coin that has never been spent is admitted on the evidence +of the launcher's own spend, which is the strongest evidence that exists: the eve is by definition the +coin the launcher created, and the launcher's `CREATE_COIN` carries the eve's FULL puzzle hash, which +is non-invertible — so nothing an attacker supplies reaches the decision. Consequently a launcher +spent into an ORDINARY coin resolves to `Ok(Some(_))` with that coin as the tip, not `Ok(None)`. The +eve's inner structure is constrained the moment it is itself spent, at which point the curried +launcher id is checked and a non-singleton yields `NotASingleton`. A consumer that requires a *proven* +singleton rather than a *launched* one MUST require a tip beyond the eve. ## 5. `CoinRecord` and `CoinState` conversion From ed80097d657611a6e2b86706ce78f99e85bb3829 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:41:54 -0700 Subject: [PATCH 06/16] test(walk): pin the canonical nil-puzzle-hash melt, and two unpinned decoder guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical chia melt condition carries a NIL puzzle hash — chia_sdk_types declares MeltSingleton with `puzzle_hash: () if ()` — so every melt standard chia-wallet-sdk tooling emits is `(51 () -113)`. The condition decoder forces Bytes32 before testing the melt marker, so it refuses that form: a DID or DataStore melted with standard tooling is permanently unanswerable. RED: the_canonical_nil_puzzle_hash_melt_still_decodes_as_a_melt. Co-Authored-By: Claude --- src/walk.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/walk.rs b/src/walk.rs index 3682d4c..022d131 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -685,4 +685,75 @@ mod tests { Ok(Continuation::Recreates(hash, 3)) if hash == puzzle_hash )); } + + /// The CANONICAL chia melt condition carries a NIL puzzle hash, not a 32-byte one. + /// + /// `chia_sdk_types::Condition::MeltSingleton` declares `puzzle_hash: () if ()`, so every melt + /// built by standard chia-wallet-sdk tooling — which is what `dig-did` and `chip35_dl_coin` + /// emit — is `(51 () -113)`. A decoder that forces `Bytes32` BEFORE testing the melt marker + /// refuses it, and a DID or DataStore melted with standard tooling then becomes permanently + /// unanswerable: the walk reports "the chain data is inconsistent" forever, blaming an honest + /// source, where the truth is a plain, final absence. + /// + /// Both forms are minted in practice, so both must decode. The 32-byte form is covered by + /// [`well_formed_conditions_still_decode_as_melt_and_as_recreation`]; this is the one that + /// fixture cannot express. + #[test] + fn the_canonical_nil_puzzle_hash_melt_still_decodes_as_a_melt() { + let allocator = &mut Allocator::new(); + let canonical_melt = (CREATE_COIN, ((), (SINGLETON_MELT_AMOUNT, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + + assert!( + matches!( + continuation_of(allocator, vec![canonical_melt]), + Ok(Continuation::Ends) + ), + "the canonical `(51 () -113)` melt must end the lineage, not refuse" + ); + } + + /// Reading the melt marker first must NOT relax the refusal on an unreadable RECREATION. + /// + /// The whole point of decoding the puzzle hash late is that the melt no longer needs one. An + /// odd, positive amount is a recreation, and a recreation the walk cannot address is still a + /// refusal — otherwise the strictness this decoder exists for would have been traded away for + /// the melt fix. + #[test] + fn a_recreation_whose_puzzle_hash_is_not_32_bytes_still_refuses() { + let allocator = &mut Allocator::new(); + let short_hash = (CREATE_COIN, ([0x0Eu8; 31], (3i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + + let error = continuation_of(allocator, vec![short_hash]) + .expect_err("a recreation with an unreadable puzzle hash is not a melt"); + assert!( + matches!(error, LineageWalkError::Malformed(detail) if detail.contains("CREATE_COIN")), + "the refusal must name the condition it could not read" + ); + } + + /// A singleton spend emits at most ONE odd-amount child; two is chain data the walk cannot + /// interpret, and picking either would be a guess about which coin is the singleton. + #[test] + fn two_odd_amount_children_refuse_rather_than_choosing_one() { + let allocator = &mut Allocator::new(); + let first = (CREATE_COIN, (Bytes32::new([0x1A; 32]), (1i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + let second = (CREATE_COIN, (Bytes32::new([0x1B; 32]), (3i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + + let error = continuation_of(allocator, vec![first, second]) + .expect_err("two odd-amount children are ambiguous"); + assert_eq!( + error, + LineageWalkError::Malformed( + "a singleton spend emitted more than one odd-amount child".to_string() + ) + ); + } } From 5e38fa9ffd7a7819feae38c9ccdae1f9e3ea3430 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:44:52 -0700 Subject: [PATCH 07/16] fix(walk): decode the melt marker before demanding a 32-byte puzzle hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical chia melt condition is `(51 () -113)` — chia_sdk_types declares `MeltSingleton { puzzle_hash: () if () }` — and it is what every melt built by standard chia-wallet-sdk tooling emits, including dig-did's and chip35_dl_coin's. Forcing Bytes32 as part of the CREATE_COIN argument decode refused that form outright, so a singleton melted with standard tooling was permanently unanswerable: Malformed forever, blaming an honest source, where the truth is a plain final absence. The amount is the discriminant, so it is read first; the puzzle hash is resolved only once the amount proves the condition is an odd-amount recreation. The refuse-on-unreadable property is preserved and now pinned from the other side too. Co-Authored-By: Claude --- src/walk.rs | 21 ++++++++++- tests/lineage_walk.rs | 84 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 022d131..4453792 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -498,6 +498,9 @@ fn run_for_continuation( LineageWalkError::Malformed(format!("undecodable CREATE_COIN condition: {error}")) })?; + // The AMOUNT decides what the puzzle hash has to be, so it is read first. A melt's puzzle + // hash is canonically NIL and an odd recreation's is a 32-byte address; demanding 32 bytes + // up front would refuse every melt standard tooling emits (see `CreateCoinArguments`). if signed_amount == SINGLETON_MELT_AMOUNT { return Ok(Continuation::Ends); } @@ -510,6 +513,13 @@ fn run_for_continuation( if amount % 2 == 0 { continue; } + // Now — and only now — the condition is known to address a coin the walk must follow, so + // an unreadable puzzle hash is a refusal rather than an omission. + let puzzle_hash = Bytes32::from_clvm(allocator, puzzle_hash).map_err(|error| { + LineageWalkError::Malformed(format!( + "undecodable CREATE_COIN condition: recreation puzzle hash: {error}" + )) + })?; if recreation.is_some() { return Err(LineageWalkError::Malformed( "a singleton spend emitted more than one odd-amount child".to_string(), @@ -534,7 +544,16 @@ type ConditionHead = (i64, NodePtr); /// A `CREATE_COIN`'s arguments decoded with a SIGNED amount, so the melt marker survives (see /// [`run_for_continuation`]): `(puzzle_hash, (amount, memos))`. Decoded only AFTER the opcode is /// known to be `CREATE_COIN`, so a failure here is a refusal rather than a skip. -type CreateCoinArguments = (Bytes32, (i64, NodePtr)); +/// +/// # Why the puzzle hash stays a raw [`NodePtr`] here +/// +/// The canonical chia melt condition is `(51 () -113)` — a NIL puzzle hash. `chia_sdk_types` +/// declares it that way (`MeltSingleton { puzzle_hash: () if () }`), and it is what every melt +/// built by standard chia-wallet-sdk tooling carries. Demanding [`Bytes32`] as part of THIS decode +/// would therefore refuse the canonical melt outright, making any singleton melted with standard +/// tooling permanently unanswerable. The amount is the discriminant, so the hash is resolved only +/// once the amount proves the condition is an odd-amount recreation. +type CreateCoinArguments = (NodePtr, (i64, NodePtr)); /// Deserializes a spend's puzzle reveal and solution into `allocator`. fn parse_spend( diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs index d179e6d..e43b852 100644 --- a/tests/lineage_walk.rs +++ b/tests/lineage_walk.rs @@ -406,20 +406,16 @@ fn an_unsupported_read_stays_distinguishable_from_unreadable_and_from_absent() { assert_ne!(projected, ChainSourceError::Malformed("coin_record".into())); } -#[test] -fn a_melted_singleton_has_no_lineage() -> Result<()> { - let mut sim = Simulator::new(); - let ctx = &mut SpendContext::new(); - let mut singleton = launch(&mut sim, ctx)?; - - // Melt: spend the eve emitting NO odd-amount successor. +/// Spends the singleton's tip with `melt`, a condition list that emits no singleton recreation. +fn melt_with( + sim: &mut Simulator, + ctx: &mut SpendContext, + singleton: &Singleton, + melt: Conditions, +) -> Result<()> { let tip = singleton.tip(); let sk = singleton.sk.clone(); - // A melt is an odd-amount CREATE_COIN with the singleton melt marker (-113), which the top - // layer turns into an ORDINARY coin — so the singleton emits no successor and ceases to exist. - let melt = ctx.alloc(&(51, (singleton.inner_puzzle_hash, (-113, ()))))?; - let inner = StandardLayer::new(singleton.pk) - .spend_with_conditions(ctx, Conditions::new().with(Condition::Other(melt)))?; + let inner = StandardLayer::new(singleton.pk).spend_with_conditions(ctx, melt)?; let layer = SingletonLayer::new(singleton.launcher_id, StandardLayer::new(singleton.pk)); let puzzle = layer.construct_puzzle(ctx)?; let solution = ctx.alloc(&SingletonSolution { @@ -429,13 +425,75 @@ fn a_melted_singleton_has_no_lineage() -> Result<()> { })?; ctx.spend(tip, Spend::new(puzzle, solution))?; sim.spend_coins(ctx.take(), std::slice::from_ref(&sk))?; + Ok(()) +} + +/// A melt whose `CREATE_COIN` names a 32-byte puzzle hash alongside the `-113` marker: the top +/// layer turns that output into an ORDINARY coin, so the singleton emits no successor. +#[test] +fn a_melted_singleton_has_no_lineage() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let singleton = launch(&mut sim, ctx)?; + + let melt = ctx.alloc(&(51, (singleton.inner_puzzle_hash, (-113, ()))))?; + melt_with( + &mut sim, + ctx, + &singleton, + Conditions::new().with(Condition::Other(melt)), + )?; - singleton.trail.push(tip); let src = source(&sim); assert_eq!(walk_singleton_lineage(&src, singleton.launcher_id)?, None); Ok(()) } +/// The melt the ECOSYSTEM actually emits: `Conditions::melt_singleton()`, whose `CREATE_COIN` +/// carries a **NIL** puzzle hash (`chia_sdk_types` declares `MeltSingleton { puzzle_hash: () }`). +/// +/// This is the form `dig-did` and `chip35_dl_coin` produce, because they build spends with +/// standard chia-wallet-sdk tooling. A walk that decodes the puzzle hash before testing the melt +/// marker refuses it, and the melted singleton then reports "the chain data is inconsistent" +/// forever — a dead identity that can never be resolved as dead, with an honest source blamed. +/// The 32-byte fixture above cannot express this; that is precisely why both exist. +#[test] +fn a_singleton_melted_with_standard_tooling_has_no_lineage() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let singleton = launch(&mut sim, ctx)?; + + melt_with( + &mut sim, + ctx, + &singleton, + Conditions::new().melt_singleton(), + )?; + + // The fixture is only distinguishing if the melt really carries a NIL puzzle hash: serialized, + // `(51 () -113)` is `ff 33 ff 80 ff 81 8f 80`. A fixture that quietly emitted a 32-byte hash + // would duplicate the test above and prove nothing. + const CANONICAL_MELT: [u8; 8] = [0xff, 0x33, 0xff, 0x80, 0xff, 0x81, 0x8f, 0x80]; + let spend = sim + .coin_spend(singleton.tip().coin_id()) + .expect("the melt spend is on chain"); + assert!( + spend + .solution + .as_ref() + .windows(CANONICAL_MELT.len()) + .any(|window| window == CANONICAL_MELT), + "the standard melt must serialize its CREATE_COIN with a nil puzzle hash" + ); + + assert_eq!( + walk_singleton_lineage(&source(&sim), singleton.launcher_id)?, + None, + "a singleton melted with standard tooling is a genuine absence, not a refusal" + ); + Ok(()) +} + // --------------------------------------------------------------------------------------------- // Bounds and lying sources. // --------------------------------------------------------------------------------------------- From 6b2c2b291cd41e44b5ecea02637a0d280ab1a331 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:48:54 -0700 Subject: [PATCH 08/16] fix(walk): read a puzzle reveal with the back-reference deserializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chia_protocol::Program's ToClvm uses node_from_bytes — the NON-backref reader — so a genuine singleton whose spend was serialized with back-references, the compressed form full nodes accept and block generators emit, was unresolvable and reported as Malformed: the walk blaming an honest source for chain data the chain itself considers valid. A curried singleton reveal compresses substantially (1381 -> 1289 bytes in the fixture), so this is the ordinary case, not an exotic one. Program::run reads back-references for exactly this reason; the walk now matches it. Co-Authored-By: Claude --- src/walk.rs | 17 ++++++--- tests/lineage_walk.rs | 81 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 4453792..86c1570 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -44,8 +44,9 @@ use chia_puzzle_types::singleton::SingletonArgs; use chia_puzzles::SINGLETON_LAUNCHER_HASH; use chia_sdk_driver::{Layer, Puzzle, SingletonLayer}; use chia_sdk_types::run_puzzle; -use clvm_traits::{FromClvm, ToClvm}; +use clvm_traits::FromClvm; use clvm_utils::{tree_hash, TreeHash}; +use clvmr::serde::node_from_bytes_backrefs; use clvmr::{Allocator, NodePtr}; use crate::error::ChainSourceError; @@ -565,10 +566,16 @@ fn parse_spend( Ok((Puzzle::parse(allocator, puzzle), solution)) } -/// Deserializes a [`Program`] into an allocated [`NodePtr`]. +/// Deserializes a [`Program`] into an allocated [`NodePtr`], accepting CLVM **back-references**. +/// +/// Back-references are the compressed serialization full nodes accept and block generators emit: a +/// repeated subtree is written once and pointed at thereafter, which a curried singleton puzzle +/// does heavily. [`Program`]'s own `ToClvm` uses the NON-backref reader, so allocating through it +/// makes a genuine compressed reveal unreadable — reported as [`LineageWalkError::Malformed`], +/// i.e. blaming an honest source for chain data the chain itself considers valid. `Program::run` +/// reads back-references for exactly this reason, and the walk matches it. fn alloc(allocator: &mut Allocator, program: &Program) -> Result> { - program - .to_clvm(allocator) + node_from_bytes_backrefs(allocator, program.as_ref()) .map_err(|error| LineageWalkError::Malformed(format!("undecodable program: {error}"))) } @@ -581,6 +588,8 @@ fn program_tree_hash(program: &Program) -> Result { /// A coin whose RECORD this source withholds, so a derived successor cannot be bound to real /// chain state. Drives [`require_coin_exists`]'s guard. withheld_record: Option, + /// Re-serializes every puzzle reveal in the CLVM **back-reference** form — the compressed + /// encoding full nodes accept and block generators emit. The chain data is byte-for-byte + /// equivalent; only its serialization differs. + backref_reveals: bool, +} + +impl SimSource<'_> { + /// Applies [`SimSource::backref_reveals`] to a spend on its way out of the source. + fn serialized_as_configured(&self, spend: CoinSpend) -> CoinSpend { + if !self.backref_reveals { + return spend; + } + CoinSpend::new( + spend.coin, + backref_serialized(&spend.puzzle_reveal), + spend.solution, + ) + } +} + +/// Re-encodes `program` using CLVM back-references, preserving the tree it denotes exactly. +fn backref_serialized(program: &Program) -> Program { + let mut allocator = Allocator::new(); + let node = node_from_bytes(&mut allocator, program.as_ref()).expect("the program deserializes"); + Program::from(node_to_bytes_backrefs(&allocator, node).expect("the program re-serializes")) } impl ChainSource for SimSource<'_> { @@ -95,7 +122,10 @@ impl ChainSource for SimSource<'_> { if self.withheld_spend == Some(coin_id) { return Ok(None); } - Ok(self.sim.coin_spend(coin_id)) + Ok(self + .sim + .coin_spend(coin_id) + .map(|spend| self.serialized_as_configured(spend))) } fn resolve_singleton_lineage( @@ -228,6 +258,7 @@ fn source(sim: &Simulator) -> SimSource<'_> { children_reads: Cell::new(0), withheld_spend: None, withheld_record: None, + backref_reveals: false, } } @@ -259,6 +290,54 @@ fn walk_returns_every_coin_from_the_launcher_to_the_tip() -> Result<()> { Ok(()) } +/// The SAME honest chain, with every puzzle reveal serialized in the CLVM **back-reference** form. +/// +/// Back-references are the compressed encoding full nodes accept and block generators emit: a +/// repeated subtree is written once and pointed at thereafter. A curried singleton puzzle repeats +/// subtrees heavily, so this is not an exotic case — it is what a real singleton's reveal looks +/// like whenever it travels compressed. +/// +/// `chia_protocol::Program`'s `ToClvm` deserializes with the NON-backref reader, so a walk that +/// allocates a reveal that way cannot read one, and reports `Malformed` — "the chain data is +/// untrustworthy" — about a genuine singleton served by an honest source. `Program::run` in that +/// very same file uses the backref reader, which is the shape the walk must match. +#[test] +fn a_backref_serialized_puzzle_reveal_resolves_exactly_as_the_plain_one() -> Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mut singleton = launch(&mut sim, ctx)?; + advance(&mut sim, ctx, &mut singleton)?; + advance(&mut sim, ctx, &mut singleton)?; + + // The fixture is only distinguishing if some reveal genuinely compresses — a chain whose + // backref encoding happened to equal its plain one would re-run the honest test and prove + // nothing. (The eve's curried singleton reveal is the one that does.) + let eve_reveal = sim + .coin_spend(singleton.trail[1].coin_id()) + .expect("the eve is spent") + .puzzle_reveal; + let compressed = backref_serialized(&eve_reveal); + assert_ne!( + compressed.as_ref(), + eve_reveal.as_ref(), + "the reveal must actually use back-references for this fixture to bite" + ); + let mut allocator = Allocator::new(); + assert!( + node_from_bytes(&mut allocator, compressed.as_ref()).is_err(), + "the non-backref reader must be unable to read the compressed reveal" + ); + + let mut src = source(&sim); + src.backref_reveals = true; + let lineage = walk_singleton_lineage(&src, singleton.launcher_id)? + .expect("a compressed reveal is still a genuine singleton"); + + assert_eq!(lineage.tip(), singleton.tip().coin_id()); + assert_eq!(lineage.len(), singleton.trail.len()); + Ok(()) +} + // --------------------------------------------------------------------------------------------- // THE ADVERSARIAL TEST. // --------------------------------------------------------------------------------------------- From a84c85059c5033989f37dcd7d27c58f000fa376b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:55:22 -0700 Subject: [PATCH 09/16] fix(walk): reset the CLVM allocator per hop and bound the walk in wall-clock time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DoS defenses the walk was missing, both of which chia-query's walk — the implementation this one is meant to replace — already carries. The allocator was hoisted outside the hop loop. clvmr's Allocator is an arena that frees nothing until dropped, so every hop's puzzle, solution and evaluation accumulated for the whole walk: 552 MB over 100,000 hops of a hostile ever-advancing chain, and the arena's own node ceiling was reached at ~74,500 hops — BEFORE MAX_LINEAGE_DEPTH — so the documented TooDeep refusal was unreachable at the default bound and a legitimate 80,000-state singleton would have been reported as malformed chain data. The allocator now lives inside successor_of, where nothing above it can hoist it back out. The hop cap bounds neither elapsed time nor per-hop CLVM cost, and ChainSource is synchronous, so a source that simply keeps answering holds the caller's thread. WalkBounds adds a wall-clock budget, defaulting to 45s to agree with chia-query, checked once per hop. A provider whose resolve_singleton_lineage is a one-line delegation inherits both guards. LineageWalkError::DeadlineExceeded is distinct from TooDeep and from Malformed, and projects to ChainSourceError::Timeout: running out of time is not evidence that the source served bad data. Both existing public signatures are unchanged. Co-Authored-By: Claude --- src/lib.rs | 3 +- src/walk.rs | 170 ++++++++++++++-- tests/hostile_lineage_walk.rs | 369 ++++++++++++++++++++++++++++++++++ 3 files changed, 522 insertions(+), 20 deletions(-) create mode 100644 tests/hostile_lineage_walk.rs diff --git a/src/lib.rs b/src/lib.rs index 8195c17..4774b55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,7 +72,8 @@ pub use source::{ChainSource, ChainSourceProvider}; #[cfg(feature = "lineage-walk")] pub use walk::{ resolve_singleton_lineage_via_walk, walk_singleton_lineage, walk_singleton_lineage_bounded, - LineageWalkError, MAX_LINEAGE_DEPTH, + walk_singleton_lineage_within, LineageWalkError, WalkBounds, DEFAULT_WALK_BUDGET, + MAX_LINEAGE_DEPTH, }; #[cfg(feature = "testing")] diff --git a/src/walk.rs b/src/walk.rs index 86c1570..38c417f 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -38,6 +38,7 @@ use std::collections::BTreeSet; use std::fmt; +use std::time::{Duration, Instant}; use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; use chia_puzzle_types::singleton::SingletonArgs; @@ -75,6 +76,64 @@ use crate::source::ChainSource; /// `dig_evidence::MAX_LINEAGE_DEPTH`. pub const MAX_LINEAGE_DEPTH: usize = 100_000; +/// The wall-clock budget an entire [`walk_singleton_lineage`] may consume. +/// +/// # Why a hop cap is not enough +/// +/// [`MAX_LINEAGE_DEPTH`] bounds how many spends the walk follows; it bounds neither the total time +/// nor the per-hop CLVM cost of following them. A hostile source that answers every hop with a +/// structurally valid, ever-advancing chain of DISTINCT recreations trips no guard — the cycle guard +/// sees no repeat and every hop authenticates — so it holds the walk for as long as it keeps +/// serving. [`ChainSource`] is SYNCHRONOUS, so that is the caller's thread: at the default hop bound +/// and 20 ms per read, an attacker buys the better part of an hour of hang, inside whatever ceremony +/// the caller was performing. +/// +/// This budget is therefore the PRIMARY denial-of-service defense and the hop cap is the +/// belt-and-braces bound beneath it. It is generous enough for any legitimate lineage over a healthy +/// source and matches the equivalent bound in `chia-query`'s walk, so the two agree about how long a +/// lineage resolution may take. +pub const DEFAULT_WALK_BUDGET: Duration = Duration::from_secs(45); + +/// How far, and for how long, a lineage walk may run before failing closed. +/// +/// Both bounds are always present: [`WalkBounds::default`] is what [`walk_singleton_lineage`] uses, +/// so a provider whose `resolve_singleton_lineage` is a one-line delegation INHERITS the +/// denial-of-service guards rather than having to remember them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WalkBounds { + /// The maximum number of spends to follow ([`LineageWalkError::TooDeep`] beyond it). + pub max_hops: usize, + /// The wall-clock budget for the whole walk ([`LineageWalkError::DeadlineExceeded`] beyond it). + pub budget: Duration, +} + +impl Default for WalkBounds { + fn default() -> Self { + Self { + max_hops: MAX_LINEAGE_DEPTH, + budget: DEFAULT_WALK_BUDGET, + } + } +} + +impl WalkBounds { + /// The default bounds with a chosen hop cap — the form tests use to exercise + /// [`LineageWalkError::TooDeep`] over a short chain. + #[must_use] + pub fn hops(max_hops: usize) -> Self { + Self { + max_hops, + ..Self::default() + } + } + + /// These bounds with a chosen wall-clock budget. + #[must_use] + pub fn within(self, budget: Duration) -> Self { + Self { budget, ..self } + } +} + /// Why a singleton lineage walk could not answer. /// /// Every variant means **the walk does not know** — none of them means "there is no lineage", which @@ -106,6 +165,14 @@ pub enum LineageWalkError { /// The hop bound the walk refused to exceed. limit: usize, }, + + /// The walk outlasted its wall-clock budget. Like [`TooDeep`](Self::TooDeep) the partial lineage + /// is discarded; unlike it, nothing about the chain was necessarily wrong — the walk simply ran + /// out of time, which is why it reports as a timeout rather than as inconsistent chain data. + DeadlineExceeded { + /// The budget the walk refused to exceed. + budget: Duration, + }, } impl fmt::Display for LineageWalkError { @@ -122,6 +189,12 @@ impl fmt::Display for LineageWalkError { Self::TooDeep { limit } => { write!(f, "singleton lineage walk exceeded its {limit}-hop bound") } + Self::DeadlineExceeded { budget } => { + write!( + f, + "singleton lineage walk exceeded its {budget:?} wall-clock budget" + ) + } } } } @@ -142,6 +215,10 @@ impl From> for ChainSourceError { "coin {coin_id} is not a genuine singleton of this launcher" )), LineageWalkError::TooDeep { limit } => ChainSourceError::LineageTooDeep { limit }, + // A budget overrun is exactly what `Timeout` means — "the read did not complete within + // the deadline, so whether an answer exists is unknown". Mapping it to `Malformed` would + // accuse an honest source of serving bad data for the crime of being slow. + LineageWalkError::DeadlineExceeded { .. } => ChainSourceError::Timeout, } } } @@ -178,8 +255,8 @@ where /// is the only sound construction). It never echoes a caller-supplied coin, and the caller supplies /// nothing but the launcher id. /// -/// Bounded at [`MAX_LINEAGE_DEPTH`] hops; use [`walk_singleton_lineage_bounded`] to choose another -/// bound. +/// Bounded by [`WalkBounds::default`] — [`MAX_LINEAGE_DEPTH`] hops within [`DEFAULT_WALK_BUDGET`]; +/// use [`walk_singleton_lineage_within`] to choose other bounds. /// /// # Returns /// @@ -195,6 +272,7 @@ where /// | the chain data is inconsistent, incl. a spent coin whose spend the source cannot serve | `Err(LineageWalkError::Malformed(_))` | /// | a coin is not a genuine singleton of this launcher | `Err(LineageWalkError::NotASingleton { .. })` | /// | the hop bound was exceeded | `Err(LineageWalkError::TooDeep { .. })` | +/// | the wall-clock budget was exceeded | `Err(LineageWalkError::DeadlineExceeded { .. })` | /// /// # The unspent eve (SPEC §4a) /// @@ -212,48 +290,60 @@ pub fn walk_singleton_lineage( source: &S, launcher_id: Bytes32, ) -> Result, LineageWalkError> { - walk_singleton_lineage_bounded(source, launcher_id, MAX_LINEAGE_DEPTH) + walk_singleton_lineage_within(source, launcher_id, WalkBounds::default()) } -/// [`walk_singleton_lineage`] with an explicit hop bound. +/// [`walk_singleton_lineage`] with an explicit hop bound, at the default wall-clock budget. /// -/// Factored out so the [`LineageWalkError::TooDeep`] behaviour can be exercised over a short real -/// chain with a tiny bound, rather than only by a 100,000-hop fixture that no test would build. +/// Retained as the narrow, hop-only form; [`walk_singleton_lineage_within`] takes both bounds. pub fn walk_singleton_lineage_bounded( source: &S, launcher_id: Bytes32, max_hops: usize, ) -> Result, LineageWalkError> { + walk_singleton_lineage_within(source, launcher_id, WalkBounds::hops(max_hops)) +} + +/// [`walk_singleton_lineage`] with explicit [`WalkBounds`]. +/// +/// Both bounds are exposed so each can be exercised over a short real chain with a tiny value, +/// rather than only by a fixture no test would build. +pub fn walk_singleton_lineage_within( + source: &S, + launcher_id: Bytes32, + bounds: WalkBounds, +) -> Result, LineageWalkError> { + let started = Instant::now(); let Some(launcher) = read_launcher_coin(source, launcher_id)? else { return Ok(None); }; - let mut allocator = Allocator::new(); let mut members = BTreeSet::from([launcher_id]); let mut current = launcher.coin; // Carried alongside `current` because `coin_spend` answers `Ok(None)` for "unspent OR unknown"; // only the coin's OWN record tells those apart (see [`read_spend_of`]). let mut current_spent_height = launcher.spent_height; // The launcher's own spend is structurally different from a singleton spend (its CREATE_COIN - // already carries the eve's FULL puzzle hash), so the first hop is handled separately. - let mut at_launcher = true; + // already carries the eve's FULL puzzle hash), so the first hop obeys a different rule. + let mut rule = HopRule::Launch; // `max_hops` counts SPENDS followed, so the loop runs one extra time: the final read is the one // that discovers the tip is unspent, and it follows no spend. - for _hop in 0..=max_hops { + for _hop in 0..=bounds.max_hops { + if started.elapsed() > bounds.budget { + return Err(LineageWalkError::DeadlineExceeded { + budget: bounds.budget, + }); + } + let Some(spend) = read_spend_of(source, current, current_spent_height)? else { // An unspent coin is the tip — unless it is the launcher itself, in which case no // singleton state was ever minted. + let at_launcher = matches!(rule, HopRule::Launch); return Ok((!at_launcher).then(|| SingletonLineage::new(current.coin_id(), members))); }; - let (puzzle, solution) = parse_spend(&mut allocator, &spend)?; - let successor = if at_launcher { - eve_created_by_launcher(&mut allocator, current, puzzle, solution)? - } else { - singleton_successor(&mut allocator, current, launcher_id, puzzle, solution)? - }; - let Some(successor) = successor else { + let Some(successor) = successor_of(current, &spend, rule)? else { // The spend emitted no odd-amount successor: the singleton was melted, so it has no // current coin. A melt is a genuine absence, not a failure. return Ok(None); @@ -267,10 +357,52 @@ pub fn walk_singleton_lineage_bounded( admit_member(&mut members, successor.coin_id())?; current = successor; current_spent_height = record.spent_height; - at_launcher = false; + rule = HopRule::Recreate { launcher_id }; } - Err(LineageWalkError::TooDeep { limit: max_hops }) + Err(LineageWalkError::TooDeep { + limit: bounds.max_hops, + }) +} + +/// Which rule authenticates the hop about to be followed. +#[derive(Debug, Clone, Copy)] +enum HopRule { + /// The LAUNCHER's own spend: its `CREATE_COIN` already carries the eve's FULL puzzle hash, and + /// no reveal has yet been parsed as a singleton. + Launch, + /// A singleton spend: the reveal must parse as a singleton layer curried to this launcher, and + /// the successor's puzzle hash is recomputed from it. + Recreate { launcher_id: Bytes32 }, +} + +/// Derives the successor `spend` creates for `coin`, or `None` when the spend ends the lineage. +/// +/// # Why the allocator is created HERE, once per hop +/// +/// A [`clvmr::Allocator`] is an arena: it allocates monotonically and frees nothing until it is +/// dropped. An allocator hoisted outside the walk's loop therefore accumulates every hop's puzzle, +/// solution and evaluation for the whole walk, so a hostile source's ever-advancing chain buys +/// unbounded memory alongside unbounded time — measured at 552 MB over 100,000 hops, against 24 MB +/// when each hop starts clean. Worse, the arena's own node ceiling is reached before +/// [`MAX_LINEAGE_DEPTH`] is, so the walk fails as [`LineageWalkError::Malformed`] — accusing an +/// honest source of serving inconsistent chain data when the truth is that the walk ran out of room. +/// +/// Owning the allocator inside this function is what makes that unhoistable: nothing above it holds +/// one, so the per-hop reset cannot be quietly undone by moving a line. +fn successor_of( + coin: Coin, + spend: &CoinSpend, + rule: HopRule, +) -> Result, LineageWalkError> { + let allocator = &mut Allocator::new(); + let (puzzle, solution) = parse_spend(allocator, spend)?; + match rule { + HopRule::Launch => eve_created_by_launcher(allocator, coin, puzzle, solution), + HopRule::Recreate { launcher_id } => { + singleton_successor(allocator, coin, launcher_id, puzzle, solution) + } + } } /// Records `coin_id` as a lineage member, refusing a repeat. diff --git a/tests/hostile_lineage_walk.rs b/tests/hostile_lineage_walk.rs new file mode 100644 index 0000000..7e48c01 --- /dev/null +++ b/tests/hostile_lineage_walk.rs @@ -0,0 +1,369 @@ +//! The walk under a source that never stops answering (needs `--features lineage-walk,testing`). +//! +//! A cycle guard catches only a REPEATED coin. A hostile source can instead serve an unbounded, +//! ever-advancing chain of DISTINCT recreations — every hop structurally valid, every coin new — and +//! nothing in a hop-count cap bounds the WALL-CLOCK time or the CLVM memory that costs. [`ChainSource`] +//! is synchronous, so a walk that runs long blocks its caller's thread; inside `dig-app` that thread is +//! carrying a real-money mint ceremony. +//! +//! [`HostileSource`] below is that adversary, built from real chia puzzles rather than stubs: a genuine +//! singleton launcher, a genuine curried singleton top layer, and an inner puzzle that recreates the +//! singleton at the SAME outer puzzle hash forever. Each hop therefore produces a coin the walk has +//! never seen, at zero network cost. + +#![cfg(all(feature = "lineage-walk", feature = "testing"))] + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; +use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; +use chia_puzzle_types::{EveProof, Proof}; +use chia_sdk_driver::SpendContext; +use clvm_utils::tree_hash; +use clvmr::NodePtr; +use dig_chainsource_interface::{ + walk_singleton_lineage_within, ChainSource, ChainSourceError, CoinRecord, LineageWalkError, + SingletonLineage, WalkBounds, +}; + +/// The `c` (cons) CLVM operator. +const CONS: i64 = 4; +/// The `q` (quote) CLVM operator. +const QUOTE: i64 = 1; +/// The CLVM opcode for `CREATE_COIN`. +const CREATE_COIN: i64 = 51; +/// The environment path selecting the FIRST element of the solution. +const FIRST_SOLUTION_ARG: i64 = 2; + +/// A singleton inner puzzle that recreates the singleton at whatever inner puzzle hash its solution +/// names: `(c (c (q . 51) (c 2 (c (q . 1) ()))) ())`, i.e. it emits `((51 1))`. +/// +/// Reading the recreation target from the SOLUTION rather than quoting it is what makes an endless +/// chain expressible at all. A puzzle that quoted its own tree hash could not exist — the hash would +/// have to be known before the puzzle containing it was built — so a self-perpetuating singleton +/// needs the hash supplied from outside, exactly as this one does. +fn endless_inner_puzzle(ctx: &mut SpendContext) -> Result { + let quoted_amount = ctx.alloc(&(QUOTE, QUOTE))?; + let quoted_opcode = ctx.alloc(&(QUOTE, CREATE_COIN))?; + let cons = ctx.alloc(&CONS)?; + let target = ctx.alloc(&FIRST_SOLUTION_ARG)?; + + let amount_tail = ctx.alloc(&vec![cons, quoted_amount, NodePtr::NIL])?; + let arguments = ctx.alloc(&vec![cons, target, amount_tail])?; + let condition = ctx.alloc(&vec![cons, quoted_opcode, arguments])?; + Ok(ctx.alloc(&vec![cons, condition, NodePtr::NIL])?) +} + +/// A [`ChainSource`] that serves an infinite, structurally valid singleton lineage. +/// +/// Every coin it reports is genuinely derivable from the previous coin's spend, so no guard in the +/// walk can refuse it on its merits: the chain is not malformed, it is merely endless. Only a bound +/// stops it. +struct HostileSource { + launcher: Coin, + /// The outer puzzle hash every coin after the launcher wears. Constant by construction: the + /// inner puzzle recreates itself, so the curry never changes. + outer_puzzle_hash: Bytes32, + launcher_reveal: Program, + launcher_solution: Program, + singleton_reveal: Program, + singleton_solution: Program, + /// Coins minted so far, extended lazily as the walk asks about them. + known: RefCell>, + /// The deepest coin minted so far — the point the chain grows from. + frontier: RefCell, + /// Every primitive read the walk has performed, so a measurement can report reads alongside + /// elapsed time. + reads: Cell, +} + +impl HostileSource { + fn new(ctx: &mut SpendContext) -> Result { + let launcher = Coin::new( + Bytes32::new([0xA1; 32]), + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH), + 1, + ); + let launcher_id = launcher.coin_id(); + + let inner = endless_inner_puzzle(ctx)?; + let inner_puzzle_hash = Bytes32::from(tree_hash(ctx, inner)); + let singleton = ctx.curry(SingletonArgs::new(launcher_id, inner))?; + let outer_puzzle_hash = Bytes32::from(tree_hash(ctx, singleton)); + + let inner_solution = ctx.alloc(&vec![inner_puzzle_hash])?; + let singleton_solution = ctx.alloc(&SingletonSolution { + // The walk never inspects the lineage proof — it authenticates by DERIVING the successor, + // not by trusting a proof — so any well-formed proof serves here. + lineage_proof: Proof::Eve(EveProof { + parent_parent_coin_info: launcher.parent_coin_info, + parent_amount: 1, + }), + amount: 1, + inner_solution, + })?; + let launcher_reveal = + ctx.alloc(&Program::from(chia_puzzles::SINGLETON_LAUNCHER.to_vec()))?; + let launcher_solution = + ctx.alloc(&(outer_puzzle_hash, (1, (Vec::::new(), ()))))?; + + let eve = Coin::new(launcher_id, outer_puzzle_hash, 1); + Ok(Self { + launcher, + outer_puzzle_hash, + launcher_reveal: ctx.serialize(&launcher_reveal)?, + launcher_solution: ctx.serialize(&launcher_solution)?, + singleton_reveal: ctx.serialize(&singleton)?, + singleton_solution: ctx.serialize(&singleton_solution)?, + known: RefCell::new(HashMap::from([(eve.coin_id(), eve)])), + frontier: RefCell::new(eve), + reads: Cell::new(0), + }) + } + + fn launcher_id(&self) -> Bytes32 { + self.launcher.coin_id() + } + + /// Resolves `coin_id`, minting one more generation if the walk has reached the frontier. + /// + /// The walk asks about coins strictly in order, so one extension always suffices; the loop is a + /// guard against a caller that skips, not an expectation. + fn coin(&self, coin_id: Bytes32) -> Option { + for _ in 0..2 { + if let Some(coin) = self.known.borrow().get(&coin_id) { + return Some(*coin); + } + let mut frontier = self.frontier.borrow_mut(); + let next = Coin::new(frontier.coin_id(), self.outer_puzzle_hash, 1); + *frontier = next; + self.known.borrow_mut().insert(next.coin_id(), next); + } + None + } + + fn record(&self, coin: Coin) -> CoinRecord { + CoinRecord { + coin, + confirmed_height: Some(1), + // Every coin is SPENT, so the walk never finds a tip and never stops of its own accord. + spent_height: Some(2), + timestamp: None, + coinbase: false, + } + } +} + +impl ChainSource for HostileSource { + type Error = ChainSourceError; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + self.reads.set(self.reads.get() + 1); + if coin_id == self.launcher_id() { + return Ok(Some(self.record(self.launcher))); + } + Ok(self.coin(coin_id).map(|coin| self.record(coin))) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + self.reads.set(self.reads.get() + 1); + if coin_id == self.launcher_id() { + return Ok(Some(CoinSpend::new( + self.launcher, + self.launcher_reveal.clone(), + self.launcher_solution.clone(), + ))); + } + Ok(self.coin(coin_id).map(|coin| { + CoinSpend::new( + coin, + self.singleton_reveal.clone(), + self.singleton_solution.clone(), + ) + })) + } + + fn resolve_singleton_lineage( + &self, + launcher_id: Bytes32, + ) -> Result, Self::Error> { + dig_chainsource_interface::resolve_singleton_lineage_via_walk(self, launcher_id) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } +} + +/// The fixture is only adversarial if the chain really is endless and really is well formed — an +/// endless source the walk refuses on the FIRST hop would make every bound below vacuous. +#[test] +fn the_hostile_chain_is_genuinely_endless_and_genuinely_well_formed() -> Result<()> { + let ctx = &mut SpendContext::new(); + let source = HostileSource::new(ctx)?; + + // A hop cap far above any plausible refusal-on-the-first-hop still ends in TooDeep, so the walk + // really did follow 64 valid recreations rather than tripping a guard. + let error = walk_singleton_lineage_within(&source, source.launcher_id(), WalkBounds::hops(64)) + .expect_err("an endless chain never reaches a tip"); + assert_eq!(error, LineageWalkError::TooDeep { limit: 64 }); + Ok(()) +} + +/// The hop cap alone bounds neither elapsed time nor memory, so the walk carries a WALL-CLOCK budget. +/// +/// This is the defense `chia-query`'s walk calls its PRIMARY one, and the reason is arithmetic: at +/// the 100,000-hop default and 20 ms per read, a source that simply keeps answering holds the calling +/// thread for the better part of an hour. `ChainSource` is synchronous, so that is a hang, not a +/// slow query. +#[test] +fn an_endless_chain_is_refused_on_the_wall_clock_budget() -> Result<()> { + let ctx = &mut SpendContext::new(); + let source = HostileSource::new(ctx)?; + + let budget = Duration::from_millis(50); + let started = Instant::now(); + let error = walk_singleton_lineage_within( + &source, + source.launcher_id(), + // The hop cap is left at its default so it CANNOT be what stops the walk: only the budget can. + WalkBounds::default().within(budget), + ) + .expect_err("an endless chain must not resolve"); + let elapsed = started.elapsed(); + + assert_eq!(error, LineageWalkError::DeadlineExceeded { budget }); + assert!( + elapsed < Duration::from_secs(10), + "the budget must actually stop the walk; it ran for {elapsed:?}" + ); + assert_eq!( + ChainSourceError::from(error), + ChainSourceError::Timeout, + "running out of time is a timeout, distinct from malformed chain data and from too-deep" + ); + Ok(()) +} + +/// The budget must not refuse an HONEST short walk — a bound tested only by what it rejects could be +/// satisfied by a walk that refuses everything. +#[test] +fn a_short_honest_walk_finishes_well_inside_its_budget() -> Result<()> { + let ctx = &mut SpendContext::new(); + let source = HostileSource::new(ctx)?; + + // Four hops of the same endless chain, taken with a generous budget: the walk reaches the hop + // cap, which proves it was the CAP and not the clock that stopped it. + let error = walk_singleton_lineage_within( + &source, + source.launcher_id(), + WalkBounds::hops(4).within(Duration::from_secs(30)), + ) + .expect_err("four hops of an endless chain still exhaust the cap"); + assert_eq!( + error, + LineageWalkError::TooDeep { limit: 4 }, + "a generous budget must leave the hop cap in charge" + ); + Ok(()) +} + +/// A measurement, not a gate: run with +/// `cargo test --release --features lineage-walk,testing --test hostile_lineage_walk -- --ignored +/// --nocapture` to re-measure the cost of the hostile chain at the DEFAULT hop bound. +/// +/// It is `#[ignore]`d because exhausting 100,000 hops takes tens of seconds, which does not belong in +/// every CI run — but it compiles on every run, so it cannot silently rot. The number that matters is +/// peak working set: an allocator shared across hops never frees, so it grows with the chain. +#[test] +#[ignore = "a multi-second DoS measurement, not a correctness gate"] +fn measure_the_cost_of_the_default_hop_bound() -> Result<()> { + let ctx = &mut SpendContext::new(); + let source = HostileSource::new(ctx)?; + + let started = Instant::now(); + let outcome = walk_singleton_lineage_within( + &source, + source.launcher_id(), + // A budget large enough that the HOP cap is what ends the walk, so the measurement reports + // the full cost of 100,000 hops rather than the cost of one deadline. + WalkBounds::default().within(Duration::from_secs(3600)), + ); + + println!( + "hops={} elapsed={:.2}s reads={} peak_working_set={:.1} MB outcome={:?}", + dig_chainsource_interface::MAX_LINEAGE_DEPTH, + started.elapsed().as_secs_f64(), + source.reads.get(), + peak_working_set_bytes() as f64 / (1024.0 * 1024.0), + outcome + ); + Ok(()) +} + +/// This process's peak working set, in bytes. +#[cfg(windows)] +fn peak_working_set_bytes() -> u64 { + #[repr(C)] + #[derive(Default)] + struct ProcessMemoryCounters { + cb: u32, + page_fault_count: u32, + peak_working_set_size: usize, + working_set_size: usize, + quota_peak_paged_pool_usage: usize, + quota_paged_pool_usage: usize, + quota_peak_non_paged_pool_usage: usize, + quota_non_paged_pool_usage: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + } + + extern "system" { + fn GetCurrentProcess() -> isize; + fn K32GetProcessMemoryInfo( + process: isize, + counters: *mut ProcessMemoryCounters, + size: u32, + ) -> i32; + } + + let mut counters = ProcessMemoryCounters { + cb: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: `counters` is a live, correctly sized `PROCESS_MEMORY_COUNTERS` and the pseudo-handle + // from `GetCurrentProcess` is always valid. + unsafe { + K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, counters.cb); + } + counters.peak_working_set_size as u64 +} + +/// Peak working set is a Windows measurement; elsewhere the elapsed/reads figures still stand. +#[cfg(not(windows))] +fn peak_working_set_bytes() -> u64 { + 0 +} From a85c4120f6eca517791c87b5826ffe96c057c252 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 10:57:59 -0700 Subject: [PATCH 10/16] docs(walk): cite this crate's own measured allocator-per-hop figures --- src/walk.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 38c417f..f3a24d6 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -383,10 +383,14 @@ enum HopRule { /// A [`clvmr::Allocator`] is an arena: it allocates monotonically and frees nothing until it is /// dropped. An allocator hoisted outside the walk's loop therefore accumulates every hop's puzzle, /// solution and evaluation for the whole walk, so a hostile source's ever-advancing chain buys -/// unbounded memory alongside unbounded time — measured at 552 MB over 100,000 hops, against 24 MB -/// when each hop starts clean. Worse, the arena's own node ceiling is reached before -/// [`MAX_LINEAGE_DEPTH`] is, so the walk fails as [`LineageWalkError::Malformed`] — accusing an -/// honest source of serving inconsistent chain data when the truth is that the walk ran out of room. +/// unbounded memory alongside unbounded time. Measured over the endless chain in +/// `tests/hostile_lineage_walk.rs`, hoisting the allocator costs **276.5 MB** of peak working set +/// against **28.9 MB** when each hop starts clean. Worse, the arena's own node ceiling is reached +/// well BEFORE [`MAX_LINEAGE_DEPTH`] is, so the hoisted walk never reaches its documented +/// [`LineageWalkError::TooDeep`] refusal and fails as [`LineageWalkError::Malformed`] instead — +/// accusing an honest source of serving inconsistent chain data when the truth is that the walk ran +/// out of room. A long-lived singleton with tens of thousands of states would be libelled the same +/// way. /// /// Owning the allocator inside this function is what makes that unhoistable: nothing above it holds /// one, so the per-hop reset cannot be quietly undone by moving a line. From 2c30f40f0747921e383e2d9904d326fc5c6020db Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 11:03:33 -0700 Subject: [PATCH 11/16] test(walk): pin four guards whose deletion left every test green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the existing tests named one guard and were satisfied by another, which is the failure mode a coarse `matches!(_, Malformed(_))` invites when several guards produce the same variant: - a_spend_of_the_wrong_coin_fails_closed used a reveal that could not hash to the coin, so the REVEAL check fired and the coin-identity check was never reached. It now serves the genuine launcher puzzle with a workable solution, asserts the exact refusal, and carries an honest control. - the launcher puzzle-hash check was covered only by an UNSPENT ordinary coin, whose Ok(None) arrives through the at-launcher branch with or without the check. A SPENT non-launcher whose spend would otherwise mint a good eve is the only fixture that distinguishes them. Two guards had no test at all: the curried-launcher-id filter (reached only by a well-formed singleton of a DIFFERENT launcher — every other non-singleton fixture is refused one step earlier at the parse) and the more-than-one-odd-child refusal. Each of the four now goes red when, and only when, its own guard is deleted. Co-Authored-By: Claude --- tests/hostile_lineage_walk.rs | 19 +++- tests/lineage_walk.rs | 200 ++++++++++++++++++++++++++++++++-- 2 files changed, 207 insertions(+), 12 deletions(-) diff --git a/tests/hostile_lineage_walk.rs b/tests/hostile_lineage_walk.rs index 7e48c01..ca255cd 100644 --- a/tests/hostile_lineage_walk.rs +++ b/tests/hostile_lineage_walk.rs @@ -294,9 +294,22 @@ fn a_short_honest_walk_finishes_well_inside_its_budget() -> Result<()> { /// `cargo test --release --features lineage-walk,testing --test hostile_lineage_walk -- --ignored /// --nocapture` to re-measure the cost of the hostile chain at the DEFAULT hop bound. /// -/// It is `#[ignore]`d because exhausting 100,000 hops takes tens of seconds, which does not belong in -/// every CI run — but it compiles on every run, so it cannot silently rot. The number that matters is -/// peak working set: an allocator shared across hops never frees, so it grows with the chain. +/// # Why the per-hop allocator is not pinned by a RUNNING assertion +/// +/// Exhausting the arena costs roughly a fixed number of CLVM pair allocations however the fixture is +/// shaped, so any test that observes the difference must pay it: ~6s in release and far longer in a +/// debug CI run. A peak-working-set assertion would be cheaper and is worse — peak working set is +/// monotonic per process, so a sibling test that had already raised it would make the delta zero and +/// the assertion vacuously green. +/// +/// The reset is therefore pinned STRUCTURALLY instead: `successor_of` owns its allocator as a local, +/// so nothing above it holds one to hoist. Restoring the defect means changing that function's +/// signature and its call site, not deleting a line. This measurement is the empirical backstop, and +/// it compiles on every run so it cannot silently rot. +/// +/// Measured on this fixture: **28.9 MB** peak and `TooDeep { limit: 100000 }` with the per-hop +/// allocator, against **276.5 MB** and `Malformed("too many pairs")` with it hoisted — the documented +/// hop bound unreachable, and the failure misattributed to the source. #[test] #[ignore = "a multi-second DoS measurement, not a correctness gate"] fn measure_the_cost_of_the_default_hop_bound() -> Result<()> { diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs index b2b9e9c..45a61ff 100644 --- a/tests/lineage_walk.rs +++ b/tests/lineage_walk.rs @@ -26,9 +26,9 @@ use chia_sdk_driver::{ }; use chia_sdk_test::Simulator; use chia_sdk_types::{Condition, Conditions}; -use clvm_utils::TreeHash; -use clvmr::serde::{node_from_bytes, node_to_bytes_backrefs}; -use clvmr::Allocator; +use clvm_utils::{tree_hash, TreeHash}; +use clvmr::serde::{node_from_bytes, node_to_bytes, node_to_bytes_backrefs}; +use clvmr::{Allocator, NodePtr}; use dig_chainsource_interface::{ walk_singleton_lineage, walk_singleton_lineage_bounded, ChainSource, ChainSourceError, CoinRecord, LineageWalkError, MockChainSource, SingletonLineage, @@ -608,22 +608,102 @@ fn exceeding_the_hop_bound_refuses_rather_than_truncating() -> Result<()> { Ok(()) } +/// The spend a source serves must be the spend of the coin it was ASKED for. +/// +/// The fixture deliberately hands over a spend that is otherwise beyond reproach: the reveal is the +/// genuine launcher puzzle, so it hashes correctly, and the solution mints an eve the source knows. +/// Every other guard is therefore satisfied, and only the coin-identity check stands between the +/// walk and a lineage assembled from another coin's history. A fixture whose reveal did not hash +/// correctly would be refused by the reveal check instead, and would pin nothing. #[test] fn a_spend_of_the_wrong_coin_fails_closed() { let launcher_ph = Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH); - let launcher = Coin::new(Bytes32::new([0x01; 32]), launcher_ph, 1); + let asked_for = Coin::new(Bytes32::new([0x01; 32]), launcher_ph, 1); let other = Coin::new(Bytes32::new([0x02; 32]), launcher_ph, 1); + let eve_puzzle_hash = Bytes32::new([0x0B; 32]); + let eve = Coin::new(asked_for.coin_id(), eve_puzzle_hash, 1); let source = MockChainSource::new() - .with_coin(launcher.coin_id(), record(launcher)) + .with_coin(asked_for.coin_id(), record(asked_for)) + .with_coin(eve.coin_id(), record(eve)) .with_spend( - launcher.coin_id(), - CoinSpend::new(other, Program::from(vec![0x01]), Program::from(vec![0x80])), + asked_for.coin_id(), + CoinSpend::new( + other, + launcher_reveal(), + launcher_solution(eve_puzzle_hash, 1), + ), ); - let error = walk_singleton_lineage(&source, launcher.coin_id()) + let error = walk_singleton_lineage(&source, asked_for.coin_id()) .expect_err("a mismatched spend must fail closed"); - assert!(matches!(error, LineageWalkError::Malformed(_))); + assert_eq!( + error, + LineageWalkError::Malformed(format!( + "source returned a spend of coin {} when asked for {}", + other.coin_id(), + asked_for.coin_id() + )), + "the refusal must be the coin-identity check, not some other guard that also says Malformed" + ); + + // The control: the SAME spend, correctly attributed, resolves — so the refusal above is the + // identity check biting rather than a fixture that could never have worked. + let honest = MockChainSource::new() + .with_coin(asked_for.coin_id(), record(asked_for)) + .with_coin(eve.coin_id(), record(eve)) + .with_spend( + asked_for.coin_id(), + CoinSpend::new( + asked_for, + launcher_reveal(), + launcher_solution(eve_puzzle_hash, 1), + ), + ); + assert_eq!( + walk_singleton_lineage(&honest, asked_for.coin_id()) + .expect("the honest launcher resolves") + .expect("a launched singleton") + .tip(), + eve.coin_id() + ); +} + +/// A SPENT coin that is not a launcher is still a genuine absence, and this is the only fixture +/// shape that can say so. +/// +/// [`a_coin_that_is_not_a_launcher_is_a_genuine_absence`] uses an UNSPENT ordinary coin, so its +/// `Ok(None)` arrives whether the launcher puzzle-hash check exists or not: without the check the +/// walk simply finds no spend and returns the at-launcher `None`. Only a coin that IS spent — and +/// whose spend would otherwise mint a perfectly good eve — distinguishes the two, and answering +/// `Ok(Some(_))` for it would report an ordinary two-coin payment chain as a singleton lineage. +#[test] +fn a_spent_coin_that_is_not_a_launcher_is_still_a_genuine_absence() { + // `(q . ((51 1)))` — a puzzle that mints one odd-amount child for any solution. + let eve_puzzle_hash = Bytes32::new([0x0A; 32]); + let reveal = quoting_puzzle(&vec![(51, (eve_puzzle_hash, (1, ())))]); + let ordinary = Coin::new(Bytes32::new([0x09; 32]), tree_hash_of(&reveal), 1); + let eve = Coin::new(ordinary.coin_id(), eve_puzzle_hash, 1); + + // The fixture only distinguishes anything if the coin genuinely is not a launcher. + assert_ne!( + ordinary.puzzle_hash, + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH) + ); + + let source = MockChainSource::new() + .with_coin(ordinary.coin_id(), spent_record(ordinary, 7)) + .with_coin(eve.coin_id(), record(eve)) + .with_spend( + ordinary.coin_id(), + CoinSpend::new(ordinary, reveal, Program::from(vec![0x80])), + ); + + assert_eq!( + walk_singleton_lineage(&source, ordinary.coin_id()), + Ok(None), + "an ordinary spent coin names no singleton, however well its spend reads" + ); } #[test] @@ -909,6 +989,108 @@ fn an_unspent_eve_is_the_tip_of_a_two_coin_lineage() -> Result<()> { Ok(()) } +/// A coin on the walk whose reveal parses as a perfectly good singleton — of a DIFFERENT launcher. +/// +/// This is the only shape that reaches the curried-launcher-id check. Every other non-singleton +/// fixture (an ordinary p2 eve, say) is refused one step earlier, when the reveal fails to parse as +/// a singleton layer at all — so `an_eve_that_is_not_a_singleton_fails_closed` fires through a +/// different branch and leaves this guard untouched. +/// +/// Admitting such a coin would let anyone extend a victim's lineage with their own singleton's +/// history: the curried launcher id is the only thing tying a well-formed singleton to the launcher +/// under resolution. +#[test] +fn a_singleton_curried_to_a_different_launcher_is_not_a_member() -> Result<()> { + let ctx = &mut SpendContext::new(); + let victim = Coin::new( + Bytes32::new([0x0E; 32]), + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH), + 1, + ); + let foreign_launcher_id = Bytes32::new([0xFE; 32]); + + // A genuine singleton top layer, curried to somebody else's launcher, wrapping an inner puzzle + // that recreates itself — so with the guard removed the walk has a successor to derive. + let inner = ctx.alloc(&vec![(51, (Bytes32::new([0x0F; 32]), (1, ())))])?; + let quoted_inner = ctx.alloc(&(1, inner))?; + let foreign = ctx.curry(SingletonArgs::new(foreign_launcher_id, quoted_inner))?; + let eve = Coin::new(victim.coin_id(), Bytes32::from(tree_hash(ctx, foreign)), 1); + let eve_solution = ctx.alloc(&SingletonSolution { + lineage_proof: Proof::Eve(EveProof { + parent_parent_coin_info: victim.parent_coin_info, + parent_amount: 1, + }), + amount: 1, + inner_solution: NodePtr::NIL, + })?; + + let source = MockChainSource::new() + .with_coin(victim.coin_id(), record(victim)) + .with_coin(eve.coin_id(), spent_record(eve, 8)) + .with_spend( + victim.coin_id(), + CoinSpend::new( + victim, + launcher_reveal(), + launcher_solution(eve.puzzle_hash, 1), + ), + ) + .with_spend( + eve.coin_id(), + CoinSpend::new(eve, ctx.serialize(&foreign)?, ctx.serialize(&eve_solution)?), + ); + + // The fixture is only distinguishing if the reveal really does parse as a singleton — otherwise + // this would re-test the parse failure that `an_eve_that_is_not_a_singleton_fails_closed` covers. + assert!( + SingletonLayer::::parse_puzzle( + ctx, + chia_sdk_driver::Puzzle::parse(ctx, foreign) + )? + .is_some_and(|layer| layer.launcher_id == foreign_launcher_id), + "the reveal must be a well-formed singleton of the OTHER launcher" + ); + + assert_eq!( + walk_singleton_lineage(&source, victim.coin_id()), + Err(LineageWalkError::NotASingleton { + coin_id: eve.coin_id() + }), + "a singleton of another launcher must be refused as such, not derived from" + ); + Ok(()) +} + +/// The well-known singleton launcher puzzle, serialized. +fn launcher_reveal() -> Program { + Program::from(chia_puzzles::SINGLETON_LAUNCHER.to_vec()) +} + +/// A launcher solution minting an eve at `puzzle_hash` for `amount`, with no key-value list. +fn launcher_solution(puzzle_hash: Bytes32, amount: u64) -> Program { + serialized(&(puzzle_hash, (amount, (Vec::::new(), ())))) +} + +/// `(q . conditions)` — a puzzle that emits `conditions` verbatim whatever its solution. +fn quoting_puzzle>(conditions: &T) -> Program { + serialized(&(1, conditions)) +} + +/// The CLVM tree hash of a serialized program. +fn tree_hash_of(program: &Program) -> Bytes32 { + let mut allocator = Allocator::new(); + let node = node_from_bytes(&mut allocator, program.as_ref()).expect("the program deserializes"); + Bytes32::from(tree_hash(&allocator, node)) +} + +/// Serializes `value` to a [`Program`]. +fn serialized>(value: &T) -> Program { + let mut allocator = Allocator::new(); + let node = + clvm_traits::ToClvm::to_clvm(value, &mut allocator).expect("the value always allocates"); + Program::from(node_to_bytes(&allocator, node).expect("the value always serializes")) +} + fn record(coin: Coin) -> CoinRecord { CoinRecord { coin, From 6325607a3dc483b01e133134500804f190b877e4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 11:06:58 -0700 Subject: [PATCH 12/16] docs(spec): state the melt-marker ordering, the backref reader, and both walk bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §4a gains four normative requirements the walk now satisfies: decode the CREATE_COIN amount before the puzzle hash (so the canonical `(51 () -113)` melt decodes), deserialize programs with the back-reference reader, refuse past a wall-clock budget as well as a hop cap, and start each hop with a fresh CLVM allocator. Other providers are told in §7 to delegate to this walk, so a requirement absent from the SPEC is a requirement a reimplementation will miss. Co-Authored-By: Claude --- README.md | 7 +++++-- SPEC.md | 52 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fb7fb76..7993d42 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,11 @@ fn resolve_singleton_lineage( The walk starts at the launcher coin and **derives** each successive coin by running the previous coin's own spend — it never recognises a coin by its puzzle hash, its curried launcher id, or its -presence in a child list, because all three are attacker-chosen. It is bounded at -`MAX_LINEAGE_DEPTH` spends and refuses rather than truncating. See SPEC.md §4a. +presence in a child list, because all three are attacker-chosen. It refuses rather than truncating +past either of its two bounds — `MAX_LINEAGE_DEPTH` spends and `DEFAULT_WALK_BUDGET` of wall-clock +time — so a hostile source serving an endless chain of valid recreations can neither hang the +calling thread nor grow the walk's memory without limit. Both bounds come with the one-line +delegation above; `walk_singleton_lineage_within` chooses others. See SPEC.md §4a. The feature is off by default: the walk needs a CLVM evaluator, and a consumer that only depends on the trait should not pay for one. diff --git a/SPEC.md b/SPEC.md index 8fb6f22..4b02197 100644 --- a/SPEC.md +++ b/SPEC.md @@ -56,6 +56,7 @@ Every fallible method distinguishes: - `TooManyRecords { count, limit }` — the backend returned more records than the consumer's hostile-input bound allows; distinct from `Malformed` (each record may be well-formed, but the count exceeds the cap) — the consumer fails closed the same as every other variant. +- `Timeout` — also carries a lineage walk that exceeded its wall-clock budget (§4a). - `LineageTooDeep { limit }` — a singleton lineage walk exceeded its hop bound (§4a). The lineage it could build is INCOMPLETE, so it is refused rather than truncated: a partial member set would make `contains` answer `false` for genuine members, which is a fail-OPEN membership answer. @@ -83,8 +84,14 @@ reads would have to hand-roll the §4 money-critical requirement. The optional, ```rust pub const MAX_LINEAGE_DEPTH: usize = 100_000; +pub const DEFAULT_WALK_BUDGET: Duration = Duration::from_secs(45); -pub enum LineageWalkError { Source(E), Malformed(String), NotASingleton { coin_id }, TooDeep { limit } } +pub struct WalkBounds { pub max_hops: usize, pub budget: Duration } // Default: the two constants above + +pub enum LineageWalkError { + Source(E), Malformed(String), NotASingleton { coin_id }, + TooDeep { limit }, DeadlineExceeded { budget }, +} pub fn walk_singleton_lineage(source: &S, launcher_id: Bytes32) -> Result, LineageWalkError>; @@ -92,6 +99,9 @@ pub fn walk_singleton_lineage(source: &S, launcher_id: Bytes32) pub fn walk_singleton_lineage_bounded(source: &S, launcher_id: Bytes32, max_hops: usize) -> Result, LineageWalkError>; +pub fn walk_singleton_lineage_within(source: &S, launcher_id: Bytes32, bounds: WalkBounds) + -> Result, LineageWalkError>; + pub fn resolve_singleton_lineage_via_walk>( source: &S, launcher_id: Bytes32) -> Result, ChainSourceError>; ``` @@ -108,28 +118,48 @@ A conforming walk MUST: 2. **Bind each derived coin to chain state.** A CLVM solution is not committed to by a coin's puzzle hash, so a dishonest source could pair a genuine reveal with a fabricated solution. Each derived successor MUST be confirmed to exist via `coin_record` before it enters the lineage. -3. **Decode the `CREATE_COIN` amount as SIGNED.** CLVM atoms carry no sign, so the singleton melt - marker `-113` decodes into a `u64` as `143` — an odd, positive amount indistinguishable from an - ordinary recreation. A walk that made that mistake would invent a phantom successor for every - melted singleton instead of reporting the melt. -4. **Refuse, never truncate, past its bound** (`MAX_LINEAGE_DEPTH` spends by default) and reject a - repeated coin id as a cycle. -5. **Preserve the three-valued discipline of §3.** `Ok(None)` means the launcher id names no coin, +3. **Decode the `CREATE_COIN` amount as SIGNED, and decode it BEFORE the puzzle hash.** CLVM atoms + carry no sign, so the singleton melt marker `-113` decodes into a `u64` as `143` — an odd, + positive amount indistinguishable from an ordinary recreation. A walk that made that mistake + would invent a phantom successor for every melted singleton instead of reporting the melt. The + amount is also the DISCRIMINANT for the puzzle hash: the canonical melt condition is + `(51 () -113)`, carrying a NIL puzzle hash, which is what standard chia-wallet-sdk tooling emits. + A walk that required a 32-byte puzzle hash before testing the melt marker would refuse every such + melt, making a singleton melted with standard tooling permanently unanswerable. Both the nil and + the 32-byte melt forms MUST decode as a melt. +4. **Deserialize programs with the BACK-REFERENCE reader.** A puzzle reveal or solution may be + serialized in the CLVM back-reference form — the compressed encoding full nodes accept and block + generators emit, which a curried singleton reveal exercises heavily. A walk that reads only the + non-backref form reports a genuine singleton as `Malformed`, blaming an honest source. +5. **Refuse, never truncate, past EITHER bound** — `MAX_LINEAGE_DEPTH` spends and + `DEFAULT_WALK_BUDGET` of wall-clock time by default — and reject a repeated coin id as a cycle. + The hop cap alone is insufficient: it bounds neither elapsed time nor per-hop CLVM cost, so a + hostile source serving a structurally valid, ever-advancing chain of DISTINCT recreations trips + no other guard. `ChainSource` is synchronous, so that is the caller's thread. A budget overrun + MUST report as `LineageWalkError::DeadlineExceeded` (projecting to `ChainSourceError::Timeout`), + never as `TooDeep` or `Malformed` — the source may have been entirely honest. +6. **Bound per-hop CLVM memory.** A CLVM allocator is an arena that frees nothing until dropped, so + one shared across hops accumulates every hop's puzzle, solution and evaluation. A conforming walk + MUST start each hop with a fresh allocator (or restore a checkpoint). Sharing one both costs + memory linear in the chain length and exhausts the arena's own node ceiling BEFORE + `MAX_LINEAGE_DEPTH` is reached, which makes the documented `TooDeep` refusal unreachable and + misreports the exhaustion as `Malformed`. +7. **Preserve the three-valued discipline of §3.** `Ok(None)` means the launcher id names no coin, names a coin that is not wearing `SINGLETON_LAUNCHER_HASH`, was never spent into an eve, or the singleton was melted. Every read failure surfaces as `LineageWalkError::Source(_)` carrying the source's OWN error unchanged, so *unsupported* stays distinguishable from *unreadable* and neither is ever collapsed into an absence. -6. **Treat an unreadable spend as unknown, never as the tip.** `coin_spend` answers `Ok(None)` for +8. **Treat an unreadable spend as unknown, never as the tip.** `coin_spend` answers `Ok(None)` for "unspent **or** unknown" (§3), so a walk MUST consult the coin's own `spent_height` before concluding it has reached the tip. A coin recorded as SPENT whose spend the source does not serve MUST fail closed with `LineageWalkError::Malformed`. Reporting it as the tip would present a superseded state as current — and if the unserved spend was the melt, a dead singleton would authenticate as live; at the launcher it would degrade an unknown into "never launched", violating §3. -7. **Refuse an unreadable `CREATE_COIN`.** Once a condition's opcode is known to be `CREATE_COIN`, +9. **Refuse an unreadable `CREATE_COIN`.** Once a condition's opcode is known to be `CREATE_COIN`, arguments the walk cannot decode (including an amount outside `i64`, or a negative amount that is not the melt marker) MUST be a refusal. Skipping such a condition makes a spend look as though it - emitted no odd-amount child — a phantom melt, i.e. requirement 6's defect reached through the + emitted no odd-amount child — a phantom melt, i.e. requirement 8's defect reached through the condition decoder. `MAX_LINEAGE_DEPTH` is the ecosystem's SINGLE source of truth for this bound. A DIG crate that From 51ba1010ec03f09542aed105be3c5b80ebe7de2e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 11:20:42 -0700 Subject: [PATCH 13/16] fix(walk): memoize the reveal tree hash, closing a back-reference decompression bomb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `program_tree_hash` hashed a puzzle reveal with `clvm_utils::tree_hash`, a plain explicit-stack traversal with no memoization. Since `6b2c2b2` the reveal is deserialized with `node_from_bytes_backrefs`, which decodes back-references into a shared DAG — so a `k`-level self-referential DAG costs `2^k` hash operations. That makes a tiny puzzle reveal a decompression bomb, and it detonates on the FIRST hop at zero cost to the attacker: a source that serves an honest launcher record can answer `coin_spend` with the bomb, and `read_spend_of` must hash the reveal before it can compare it to the coin's puzzle hash. The walk's wall-clock budget does not help, because it is checked between hops rather than inside one. Measured at depth 24 (73 serialized bytes), debug: 78.2 s before, 0.15 s after. `tree_hash_from_bytes` is back-reference-aware AND memoizing — the identical call `chia-peer` makes at `provider.rs:215` — so compressed-reveal support, the point of `6b2c2b2`, is preserved. The new control test asserts that explicitly, so the bomb cannot be "fixed" by banning back-references again. Co-Authored-By: Claude --- src/walk.rs | 20 +++- tests/hostile_lineage_walk.rs | 199 +++++++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 5 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index f3a24d6..93ca1a8 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -46,7 +46,7 @@ use chia_puzzles::SINGLETON_LAUNCHER_HASH; use chia_sdk_driver::{Layer, Puzzle, SingletonLayer}; use chia_sdk_types::run_puzzle; use clvm_traits::FromClvm; -use clvm_utils::{tree_hash, TreeHash}; +use clvm_utils::{tree_hash_from_bytes, TreeHash}; use clvmr::serde::node_from_bytes_backrefs; use clvmr::{Allocator, NodePtr}; @@ -716,10 +716,22 @@ fn alloc(allocator: &mut Allocator, program: &Program) -> Result(program: &Program) -> Result> { - let mut allocator = Allocator::new(); - let ptr = alloc(&mut allocator, program)?; - Ok(tree_hash(&allocator, ptr)) + tree_hash_from_bytes(program.as_ref()) + .map_err(|error| LineageWalkError::Malformed(format!("undecodable program: {error}"))) } #[cfg(test)] diff --git a/tests/hostile_lineage_walk.rs b/tests/hostile_lineage_walk.rs index ca255cd..e1c2a61 100644 --- a/tests/hostile_lineage_walk.rs +++ b/tests/hostile_lineage_walk.rs @@ -23,7 +23,8 @@ use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; use chia_puzzle_types::{EveProof, Proof}; use chia_sdk_driver::SpendContext; use clvm_utils::tree_hash; -use clvmr::NodePtr; +use clvmr::serde::{node_from_bytes, node_to_bytes_backrefs}; +use clvmr::{Allocator, NodePtr}; use dig_chainsource_interface::{ walk_singleton_lineage_within, ChainSource, ChainSourceError, CoinRecord, LineageWalkError, SingletonLineage, WalkBounds, @@ -37,6 +38,14 @@ const QUOTE: i64 = 1; const CREATE_COIN: i64 = 51; /// The environment path selecting the FIRST element of the solution. const FIRST_SOLUTION_ARG: i64 = 2; +/// The serialization tag introducing a CLVM back-reference. +const BACKREF_TAG: u8 = 0xFE; + +/// Re-serializes `program` in the compressed, back-reference-bearing form, preserving its tree hash. +fn compress(ctx: &mut SpendContext, program: &Program) -> Result { + let node = node_from_bytes(ctx, program.as_ref())?; + Ok(Program::from(node_to_bytes_backrefs(ctx, node)?)) +} /// A singleton inner puzzle that recreates the singleton at whatever inner puzzle hash its solution /// names: `(c (c (q . 51) (c 2 (c (q . 1) ()))) ())`, i.e. it emits `((51 1))`. @@ -124,6 +133,18 @@ impl HostileSource { }) } + /// Re-serializes both puzzle reveals in the COMPRESSED, back-reference-bearing form a full node + /// emits, leaving every tree hash — and therefore every coin — unchanged. + fn with_backref_compressed_reveals(mut self, ctx: &mut SpendContext) -> Result { + self.singleton_reveal = compress(ctx, &self.singleton_reveal)?; + assert!( + self.singleton_reveal.as_ref().contains(&BACKREF_TAG), + "the control is only load-bearing if the reveal really carries a back-reference" + ); + self.launcher_reveal = compress(ctx, &self.launcher_reveal)?; + Ok(self) + } + fn launcher_id(&self) -> Bytes32 { self.launcher.coin_id() } @@ -336,6 +357,182 @@ fn measure_the_cost_of_the_default_hop_bound() -> Result<()> { Ok(()) } +/// A CLVM **back-reference decompression bomb**: a few dozen bytes that decode into a `depth`-level +/// self-referential DAG, i.e. a tree of `2^depth` leaves. +/// +/// Back-references are what make this expressible. The compressed serializer writes a repeated +/// subtree once and points at it thereafter, so consing a node with ITSELF `depth` times costs about +/// three bytes per level on the wire while doubling the notional tree at every level. A hash walk +/// that does not memoize therefore pays `2^depth` hash operations for an input a source can send for +/// free. +fn backref_bomb(depth: u32) -> Program { + let mut allocator = Allocator::new(); + let mut node = allocator + .new_atom(&[1]) + .expect("a one-byte atom always fits"); + for _ in 0..depth { + node = allocator + .new_pair(node, node) + .expect("a self-cons adds one pair"); + } + let bytes = node_to_bytes_backrefs(&allocator, node).expect("the DAG serializes"); + Program::from(bytes) +} + +/// A source whose launcher record is honest and whose launcher SPEND is a decompression bomb. +/// +/// This is the cheapest possible attack: the reveal-binding check at the top of the first hop must +/// hash the reveal BEFORE it can compare it, so a source that never owned a singleton and never +/// funded a coin can detonate on read. +struct BombSource { + launcher: Coin, + bomb: Program, +} + +impl BombSource { + fn at_depth(depth: u32) -> Self { + Self { + launcher: Coin::new( + Bytes32::new([0xB0; 32]), + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH), + 1, + ), + bomb: backref_bomb(depth), + } + } +} + +impl ChainSource for BombSource { + type Error = ChainSourceError; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + if coin_id != self.launcher.coin_id() { + return Ok(None); + } + Ok(Some(CoinRecord { + coin: self.launcher, + confirmed_height: Some(1), + spent_height: Some(2), + timestamp: None, + coinbase: false, + })) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + if coin_id != self.launcher.coin_id() { + return Ok(None); + } + Ok(Some(CoinSpend::new( + self.launcher, + self.bomb.clone(), + // NIL: the solution is never reached, because the reveal check refuses the spend first. + Program::from(vec![0x80]), + ))) + } + + fn resolve_singleton_lineage( + &self, + launcher_id: Bytes32, + ) -> Result, Self::Error> { + dig_chainsource_interface::resolve_singleton_lineage_via_walk(self, launcher_id) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } +} + +/// A ~74-byte puzzle reveal must not cost minutes of CPU — the reveal hash must be MEMOIZED. +/// +/// # Why this asserts on the clock and not only on the outcome +/// +/// The bomb's hash cannot possibly equal the launcher's puzzle hash, so the walk refuses it either +/// way: the outcome is `Malformed` whether the hash took 151 µs or 140 seconds. Only elapsed time +/// distinguishes a memoizing hash from a non-memoizing one, so elapsed time is what is asserted. +/// +/// # Why the wall-clock BUDGET does not cover this +/// +/// The budget is checked at the top of each hop, before the spend is read. On the first iteration no +/// time has passed, so the check passes and the hop then runs unbounded — the budget bounds the +/// NUMBER of hops, never the cost of one. +/// +/// Measured on this fixture at depth 24 (74 serialized bytes), debug: **140.4 s** with the +/// non-memoizing `clvm_utils::tree_hash`, **151 µs** with `tree_hash_from_bytes`. The threshold below +/// sits three orders of magnitude beneath the unfixed cost, so it cannot be met by a slow machine. +#[test] +fn a_backref_decompression_bomb_in_a_puzzle_reveal_is_hashed_in_bounded_time() -> Result<()> { + let source = BombSource::at_depth(24); + + let started = Instant::now(); + let error = walk_singleton_lineage_within( + &source, + source.launcher.coin_id(), + // A budget far larger than the threshold asserted below, so the BUDGET cannot be what makes + // this test pass — if the hash were unbounded the walk would still be inside its first hop. + WalkBounds::hops(4).within(Duration::from_secs(600)), + ) + .expect_err("a reveal that does not hash to the coin's puzzle hash is refused"); + let elapsed = started.elapsed(); + + assert!( + matches!(error, LineageWalkError::Malformed(ref message) if message.contains("puzzle reveal")), + "the bomb is refused as a reveal that does not match the coin, not by any other guard: \ + {error:?}" + ); + assert!( + elapsed < Duration::from_secs(5), + "hashing a {}-byte reveal must be memoized; it took {elapsed:?}", + source.bomb.as_ref().len() + ); + Ok(()) +} + +/// The bomb must be refused because it is MEMOIZED, never because back-references were banned again. +/// +/// Rejecting every back-reference would pass the test above while re-introducing the defect +/// `node_from_bytes_backrefs` was adopted to fix: a genuine compressed reveal — the form a full node +/// emits — read as malformed chain data. This is the honest control, and it uses the same +/// serializer the bomb does. +#[test] +fn a_genuinely_backref_compressed_reveal_is_still_accepted() -> Result<()> { + let ctx = &mut SpendContext::new(); + let source = HostileSource::new(ctx)?.with_backref_compressed_reveals(ctx)?; + + // Reaching the hop cap means every reveal along the way HASHED to its coin's puzzle hash, so + // the compressed form was read correctly rather than refused. + let error = walk_singleton_lineage_within( + &source, + source.launcher_id(), + WalkBounds::hops(4).within(Duration::from_secs(60)), + ) + .expect_err("four hops of an endless chain still exhaust the cap"); + assert_eq!( + error, + LineageWalkError::TooDeep { limit: 4 }, + "a back-reference-compressed reveal must be accepted, not reported as malformed" + ); + Ok(()) +} + /// This process's peak working set, in bytes. #[cfg(windows)] fn peak_working_set_bytes() -> u64 { From 9e98754849aa4ff597b3bbd36a78c7a83f83c724 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 12:19:19 -0700 Subject: [PATCH 14/16] fix(walk): bound a puzzle reveal's EXPANDED size, closing the bomb the memoizing hash left open The previous fix memoized the reveal-binding hash. That closed one detonation site and left a second, worse one: chia_sdk_driver::Puzzle::parse calls the NON-memoizing clvm_utils::tree_hash, and no cache protects it at any depth. Currying the same back-reference bomb as the INNER puzzle of an otherwise genuine singleton walks straight past the binding check -- the reveal really does hash to the eve's puzzle hash, because the walk DERIVES that hash from the bomb's own tree hash -- and detonates one hop later. Measured through the public API, release profile: a 1,120-byte reveal costs 75 seconds, doubling every three bytes on the wire. A cap on the reveal's serialized length cannot see that attack; every one of those reveals is about a kilobyte. So the bound is on the EXPANSION instead, computed in time linear in the DAG by a memoized saturating traversal, and placed ahead of every use of the bytes. Co-Authored-By: Claude --- src/error.rs | 12 ++ src/lib.rs | 2 +- src/walk.rs | 192 ++++++++++++++++++++++++++++--- tests/hostile_lineage_walk.rs | 211 +++++++++++++++++++++++++++++++--- 4 files changed, 384 insertions(+), 33 deletions(-) diff --git a/src/error.rs b/src/error.rs index 4d21310..ffd6a6c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -61,6 +61,18 @@ pub enum ChainSourceError { limit: usize, }, + /// A puzzle reveal expanded to more than the walk's decompressed-size bound. + /// + /// Distinct from [`Malformed`](Self::Malformed) on purpose: the reveal may be perfectly + /// well-formed chain data — it is simply larger, once its CLVM back-references are expanded, + /// than this walk will authenticate. Blaming the source for corruption would be a lie, and + /// would hide the one thing a consumer can act on: the payload was too big, not wrong. + #[error("puzzle reveal expands beyond the {limit}-byte bound")] + RevealTooLarge { + /// The expanded-size bound the walk refused to exceed. + limit: usize, + }, + /// A singleton lineage walk exceeded its hop bound before reaching the tip. /// /// Distinct from every other variant, and deliberately NOT a silent truncation: the walk found diff --git a/src/lib.rs b/src/lib.rs index 4774b55..eca2d46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,7 +73,7 @@ pub use source::{ChainSource, ChainSourceProvider}; pub use walk::{ resolve_singleton_lineage_via_walk, walk_singleton_lineage, walk_singleton_lineage_bounded, walk_singleton_lineage_within, LineageWalkError, WalkBounds, DEFAULT_WALK_BUDGET, - MAX_LINEAGE_DEPTH, + MAX_LINEAGE_DEPTH, MAX_REVEAL_EXPANDED_BYTES, }; #[cfg(feature = "testing")] diff --git a/src/walk.rs b/src/walk.rs index 93ca1a8..7c3fc6a 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -36,7 +36,7 @@ //! the hop bound was exceeded). NEVER collapsed into "no lineage": a caller that reads a //! transport failure as an absence is the class of bug that spends money twice. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::fmt; use std::time::{Duration, Instant}; @@ -48,7 +48,7 @@ use chia_sdk_types::run_puzzle; use clvm_traits::FromClvm; use clvm_utils::{tree_hash_from_bytes, TreeHash}; use clvmr::serde::node_from_bytes_backrefs; -use clvmr::{Allocator, NodePtr}; +use clvmr::{Allocator, NodePtr, SExp}; use crate::error::ChainSourceError; use crate::lineage::SingletonLineage; @@ -159,6 +159,19 @@ pub enum LineageWalkError { coin_id: Bytes32, }, + /// A coin's puzzle reveal expands, once its CLVM back-references are unfolded, beyond + /// [`MAX_REVEAL_EXPANDED_BYTES`] — so the walk will not authenticate it. + /// + /// Deliberately NOT [`Malformed`](Self::Malformed): the reveal may be entirely well-formed and + /// may even hash to the coin's puzzle hash. It is refused for its SIZE, and a consumer that + /// cannot tell "too big" from "corrupt" cannot tell a hostile source from a heavy one. + RevealTooLarge { + /// The coin whose reveal was refused. + coin_id: Bytes32, + /// The expanded-size bound the walk refused to exceed. + limit: usize, + }, + /// The walk exceeded its hop bound. The lineage found so far is INCOMPLETE and is deliberately /// discarded rather than returned as a truncated member set. TooDeep { @@ -186,6 +199,12 @@ impl fmt::Display for LineageWalkError { "coin {coin_id} is not a genuine singleton of this launcher" ) } + Self::RevealTooLarge { coin_id, limit } => { + write!( + f, + "the puzzle reveal of coin {coin_id} expands beyond the {limit}-byte bound" + ) + } Self::TooDeep { limit } => { write!(f, "singleton lineage walk exceeded its {limit}-hop bound") } @@ -214,6 +233,12 @@ impl From> for ChainSourceError { LineageWalkError::NotASingleton { coin_id } => ChainSourceError::Malformed(format!( "coin {coin_id} is not a genuine singleton of this launcher" )), + // Size, not corruption — so it keeps its own variant rather than collapsing into + // `Malformed`, which would accuse an honest source of serving bad data for the crime of + // serving a big one. + LineageWalkError::RevealTooLarge { limit, .. } => { + ChainSourceError::RevealTooLarge { limit } + } LineageWalkError::TooDeep { limit } => ChainSourceError::LineageTooDeep { limit }, // A budget overrun is exactly what `Timeout` means — "the read did not complete within // the deadline, so whether an answer exists is unknown". Mapping it to `Malformed` would @@ -493,6 +518,10 @@ fn read_spend_of( coin.coin_id() ))); } + // BEFORE the reveal is hashed, parsed or run. Every later use of these bytes — the binding hash + // here, `Puzzle::parse`, the CLVM evaluator — is downstream of this guard, which is the only + // placement that bounds all three (see `require_expandable_reveal`). + require_expandable_reveal(coin.coin_id(), &spend.puzzle_reveal)?; let revealed = program_tree_hash(&spend.puzzle_reveal)?; if Bytes32::from(revealed) != coin.puzzle_hash { return Err(LineageWalkError::Malformed(format!( @@ -715,20 +744,157 @@ fn alloc(allocator: &mut Allocator, program: &Program) -> Result usize { + /// One `0x01` prefix byte precedes an atom's own bytes. + const ATOM_PREFIX: usize = 1; + /// One `0x02` prefix byte precedes a pair's two 32-byte child hashes. + const PAIR_COST: usize = 1 + 32 + 32; + + // One byte past the limit is enough to refuse, and clamping every partial sum here is what + // keeps the doubling from ever being computed — or from overflowing `usize`. + let ceiling = limit.saturating_add(1); + + enum Step { + /// Cost this node, descending into it if it is an unseen pair. + Cost(NodePtr), + /// Both children of this pair are costed and on `costed`; combine them. + Combine(NodePtr), + } + + let mut sizes: HashMap = HashMap::new(); + let mut steps = vec![Step::Cost(root)]; + let mut costed: Vec = Vec::new(); + + while let Some(step) = steps.pop() { + match step { + Step::Cost(node) => { + if let Some(&known) = sizes.get(&node) { + costed.push(known); + continue; + } + match allocator.sexp(node) { + SExp::Atom => { + let size = ATOM_PREFIX + .saturating_add(allocator.atom_len(node)) + .min(ceiling); + sizes.insert(node, size); + costed.push(size); + } + SExp::Pair(left, right) => { + steps.push(Step::Combine(node)); + steps.push(Step::Cost(right)); + steps.push(Step::Cost(left)); + } + } + } + Step::Combine(node) => { + let (right, left) = ( + costed.pop().expect("a pair's right child was costed first"), + costed.pop().expect("a pair's left child was costed first"), + ); + let size = PAIR_COST + .saturating_add(left) + .saturating_add(right) + .min(ceiling); + sizes.insert(node, size); + costed.push(size); + } + } + } + + costed + .pop() + .expect("the traversal leaves the root's cost on the stack") +} + +/// Refuses a puzzle reveal whose expansion exceeds [`MAX_REVEAL_EXPANDED_BYTES`], BEFORE anything +/// hashes, parses or runs it. +/// +/// # Why this runs first, and on its own allocator +/// +/// The reveal-binding check is the first thing a hop does with attacker-supplied bytes, so every +/// later use of the reveal is downstream of it. Placing the bound here means the guard covers not +/// just the binding hash but `parse_spend`, `Puzzle::parse` and the CLVM evaluator too — a bound +/// applied at the binding hash alone would leave the non-memoizing hash inside `Puzzle::parse` +/// reachable one hop deeper, which is exactly how the 1,120-byte bomb defeats a size cap placed +/// there. The scratch allocator is dropped on return, so measuring costs no arena the hop keeps. +/// +/// Deserialization failure is reported as [`LineageWalkError::Malformed`] with the same wording +/// [`alloc`] uses, because it is the same fact about the same bytes. +fn require_expandable_reveal( + coin_id: Bytes32, + reveal: &Program, +) -> Result<(), LineageWalkError> { + let allocator = &mut Allocator::new(); + let node = alloc(allocator, reveal)?; + if expanded_hash_input_bytes(allocator, node, MAX_REVEAL_EXPANDED_BYTES) + > MAX_REVEAL_EXPANDED_BYTES + { + return Err(LineageWalkError::RevealTooLarge { + coin_id, + limit: MAX_REVEAL_EXPANDED_BYTES, + }); + } + Ok(()) +} + +/// The CLVM tree hash of a serialized [`Program`], without disturbing the walk's allocator. /// -/// [`tree_hash_from_bytes`] is back-reference-aware AND memoizing (it hashes each shared node once), -/// which is why `chia-peer` already uses it for the same job. +/// Memoizing and back-reference-aware, so each shared node is hashed once. That is a performance +/// property, NOT the walk's defense against a decompression bomb — see +/// [`require_expandable_reveal`], which is what makes this call safe to reach. fn program_tree_hash(program: &Program) -> Result> { tree_hash_from_bytes(program.as_ref()) .map_err(|error| LineageWalkError::Malformed(format!("undecodable program: {error}"))) diff --git a/tests/hostile_lineage_walk.rs b/tests/hostile_lineage_walk.rs index e1c2a61..e72f834 100644 --- a/tests/hostile_lineage_walk.rs +++ b/tests/hostile_lineage_walk.rs @@ -22,12 +22,12 @@ use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; use chia_puzzle_types::{EveProof, Proof}; use chia_sdk_driver::SpendContext; -use clvm_utils::tree_hash; -use clvmr::serde::{node_from_bytes, node_to_bytes_backrefs}; +use clvm_utils::{tree_hash, tree_hash_from_bytes}; +use clvmr::serde::{node_from_bytes, node_from_bytes_backrefs, node_to_bytes_backrefs}; use clvmr::{Allocator, NodePtr}; use dig_chainsource_interface::{ walk_singleton_lineage_within, ChainSource, ChainSourceError, CoinRecord, LineageWalkError, - SingletonLineage, WalkBounds, + SingletonLineage, WalkBounds, MAX_REVEAL_EXPANDED_BYTES, }; /// The `c` (cons) CLVM operator. @@ -461,13 +461,14 @@ impl ChainSource for BombSource { } } -/// A ~74-byte puzzle reveal must not cost minutes of CPU — the reveal hash must be MEMOIZED. +/// A 91-byte puzzle reveal must not cost minutes of CPU, and must be refused for its SIZE. /// -/// # Why this asserts on the clock and not only on the outcome +/// # Why this asserts on the clock as well as the outcome /// -/// The bomb's hash cannot possibly equal the launcher's puzzle hash, so the walk refuses it either -/// way: the outcome is `Malformed` whether the hash took 151 µs or 140 seconds. Only elapsed time -/// distinguishes a memoizing hash from a non-memoizing one, so elapsed time is what is asserted. +/// The bomb's hash cannot equal the launcher's puzzle hash, so a walk with no size bound at all +/// still refuses it — as `Malformed`, after however long the hash took. Elapsed time is what +/// separates a walk that bounded the expansion from one that computed it, so elapsed time is +/// asserted alongside the variant. /// /// # Why the wall-clock BUDGET does not cover this /// @@ -475,12 +476,16 @@ impl ChainSource for BombSource { /// time has passed, so the check passes and the hop then runs unbounded — the budget bounds the /// NUMBER of hops, never the cost of one. /// -/// Measured on this fixture at depth 24 (74 serialized bytes), debug: **140.4 s** with the -/// non-memoizing `clvm_utils::tree_hash`, **151 µs** with `tree_hash_from_bytes`. The threshold below -/// sits three orders of magnitude beneath the unfixed cost, so it cannot be met by a slow machine. +/// # The margin, measured rather than asserted +/// +/// Measured on this fixture against a non-memoizing hash, RELEASE profile: depth 24 → 1.91 s, +/// 26 → 7.51 s, 28 → 30.07 s, so this fixture's depth 30 costs about **120 s**. The 5-second +/// threshold therefore sits ~24x beneath the unfixed cost in the profile CI is least likely to +/// use — the depth matters, because at depth 24 the unfixed walk finishes in 1.91 s and this test +/// would pass with the defect present. #[test] -fn a_backref_decompression_bomb_in_a_puzzle_reveal_is_hashed_in_bounded_time() -> Result<()> { - let source = BombSource::at_depth(24); +fn a_backref_decompression_bomb_in_a_puzzle_reveal_is_refused_in_bounded_time() -> Result<()> { + let source = BombSource::at_depth(30); let started = Instant::now(); let error = walk_singleton_lineage_within( @@ -490,22 +495,190 @@ fn a_backref_decompression_bomb_in_a_puzzle_reveal_is_hashed_in_bounded_time() - // this test pass — if the hash were unbounded the walk would still be inside its first hop. WalkBounds::hops(4).within(Duration::from_secs(600)), ) - .expect_err("a reveal that does not hash to the coin's puzzle hash is refused"); + .expect_err("a reveal that expands beyond the bound is refused"); let elapsed = started.elapsed(); - assert!( - matches!(error, LineageWalkError::Malformed(ref message) if message.contains("puzzle reveal")), - "the bomb is refused as a reveal that does not match the coin, not by any other guard: \ - {error:?}" + assert_eq!( + error, + LineageWalkError::RevealTooLarge { + coin_id: source.launcher.coin_id(), + limit: MAX_REVEAL_EXPANDED_BYTES, + }, + "the bomb is refused for its EXPANSION, not as malformed chain data: {error:?}" ); assert!( elapsed < Duration::from_secs(5), - "hashing a {}-byte reveal must be memoized; it took {elapsed:?}", + "a {}-byte reveal must be bounded before it is hashed; it took {elapsed:?}", source.bomb.as_ref().len() ); Ok(()) } +/// A source whose launcher hop is entirely honest and whose EVE reveal hides the bomb one layer in. +/// +/// # Why a second bomb fixture, when [`BombSource`] already exists +/// +/// [`BombSource`] detonates at the reveal-binding hash, which is memoizing. Curry the same bomb as +/// the INNER puzzle of an otherwise genuine singleton and the binding hash sails through — the +/// reveal really does hash to the eve's puzzle hash, because the walk DERIVED that puzzle hash from +/// the bomb's own tree hash — and the spend then reaches `chia_sdk_driver::Puzzle::parse`, which +/// calls the NON-memoizing `clvm_utils::tree_hash`. No cache protects that call at any depth. +/// +/// Measured through the public API against a walk whose only defense was memoization, RELEASE +/// profile: reveal 1,084 B → 17 ms at depth 16, 1,108 B → 4.64 s at 24, 1,120 B → **75 s** at 28. +/// Roughly three bytes on the wire per doubling, unbounded. A cap on the reveal's SERIALIZED length +/// cannot see this attack at all: every one of those reveals is about a kilobyte. +/// +/// Reaching the second hop costs the attacker nothing, because the same lying source answers the +/// `coin_record` that binds the eve to "real" chain state. +struct EveInnerBombSource { + launcher: Coin, + launcher_solution: Program, + eve: Coin, + eve_reveal: Program, + records: HashMap, +} + +impl EveInnerBombSource { + fn at_depth(depth: u32) -> Result { + let ctx = &mut SpendContext::new(); + let launcher = Coin::new( + Bytes32::new([0xC0; 32]), + Bytes32::new(chia_puzzles::SINGLETON_LAUNCHER_HASH), + 1, + ); + let launcher_id = launcher.coin_id(); + + // The eve's puzzle hash is the one the WALK will derive, so it is computed the way the walk + // computes it: from the launcher id and the bomb's own (cheaply memoized) tree hash. + let bomb = backref_bomb(depth); + let inner_hash = tree_hash_from_bytes(bomb.as_ref()).expect("the bomb decodes"); + let inner = node_from_bytes_backrefs(ctx, bomb.as_ref())?; + let outer = ctx.curry(SingletonArgs::new(launcher_id, inner))?; + let eve_reveal = Program::from(node_to_bytes_backrefs(ctx, outer)?); + let eve_puzzle_hash = + Bytes32::from(SingletonArgs::curry_tree_hash(launcher_id, inner_hash)); + let eve = Coin::new(launcher_id, eve_puzzle_hash, 1); + + let solution = ctx.alloc(&(eve_puzzle_hash, (1u64, (Vec::::new(), ()))))?; + let spent_at = |coin: Coin, height: u32| CoinRecord { + coin, + confirmed_height: Some(1), + spent_height: Some(height), + timestamp: None, + coinbase: false, + }; + Ok(Self { + launcher, + launcher_solution: ctx.serialize(&solution)?, + eve, + eve_reveal, + records: HashMap::from([ + (launcher_id, spent_at(launcher, 2)), + (eve.coin_id(), spent_at(eve, 3)), + ]), + }) + } +} + +impl ChainSource for EveInnerBombSource { + type Error = ChainSourceError; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.records.get(&coin_id).cloned()) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + if coin_id == self.launcher.coin_id() { + return Ok(Some(CoinSpend::new( + self.launcher, + Program::from(chia_puzzles::SINGLETON_LAUNCHER.to_vec()), + self.launcher_solution.clone(), + ))); + } + if coin_id == self.eve.coin_id() { + return Ok(Some(CoinSpend::new( + self.eve, + self.eve_reveal.clone(), + // Never reached: the eve's reveal is refused for its expansion first. + Program::from(vec![0x80]), + ))); + } + Ok(None) + } + + fn resolve_singleton_lineage( + &self, + launcher_id: Bytes32, + ) -> Result, Self::Error> { + dig_chainsource_interface::resolve_singleton_lineage_via_walk(self, launcher_id) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } +} + +/// A bomb that PASSES the reveal-binding hash must still be refused, before anything parses it. +/// +/// This is the test that pins the bound's PLACEMENT. A size guard sitting at the binding hash would +/// satisfy [`a_backref_decompression_bomb_in_a_puzzle_reveal_is_refused_in_bounded_time`] and leave +/// this one detonating, because the eve's reveal is a genuine singleton whose hash genuinely matches +/// its coin — the guard has to sit ahead of every use of the bytes, not ahead of one of them. +/// +/// Against a walk defended only by memoization this fixture costs about **300 s** at depth 30 +/// (75 s measured at depth 28, doubling per level), so the 5-second threshold has a ~60x margin in +/// the RELEASE profile. +#[test] +fn a_bomb_curried_inside_a_genuine_singleton_reveal_is_refused_before_it_is_parsed() -> Result<()> { + let source = EveInnerBombSource::at_depth(30)?; + + let started = Instant::now(); + let error = walk_singleton_lineage_within( + &source, + source.launcher.coin_id(), + WalkBounds::hops(4).within(Duration::from_secs(600)), + ) + .expect_err("a reveal that expands beyond the bound is refused"); + let elapsed = started.elapsed(); + + assert_eq!( + error, + LineageWalkError::RevealTooLarge { + // The EVE's reveal, not the launcher's: the launcher hop is honest and must be walked + // successfully for this fixture to be testing what it claims to test. + coin_id: source.eve.coin_id(), + limit: MAX_REVEAL_EXPANDED_BYTES, + }, + "the inner bomb is refused for its EXPANSION, at the eve hop: {error:?}" + ); + assert!( + elapsed < Duration::from_secs(5), + "a {}-byte reveal must be bounded before it is parsed; it took {elapsed:?}", + source.eve_reveal.as_ref().len() + ); + Ok(()) +} + /// The bomb must be refused because it is MEMOIZED, never because back-references were banned again. /// /// Rejecting every back-reference would pass the test above while re-introducing the defect From 81166e5fad34ec3e62a1a7d3b3b6a2e7e7422cb0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 12:39:34 -0700 Subject: [PATCH 15/16] fix(walk): bound one hop's CLVM cost, and decode a CREATE_COIN hash before the parity skip Three hardening changes folded into the reveal-bound round. run_for_continuation evaluated at MAINNET_CONSTANTS.max_block_cost_clvm -- a whole block -- against a solution bound to nothing, so one hop could burn an entire block's evaluation and the between-hops wall-clock check was a promise the walk could not keep. It now evaluates at an explicit MAX_HOP_CLVM_COST, chosen from measured honest hops (launcher spend 11,932; p2 inner spend 18,092). The Bytes32 decode moves above the even-amount skip. Melts still return earlier, so the melt fix is preserved, while an unreadable puzzle hash is refused for every non-melt CREATE_COIN rather than only the odd-amount ones. WalkBounds gains #[non_exhaustive], private fields and a clamp on max_hops, so no struct literal can silently disable either guard. It has no published consumers: walk.rs does not exist on main and 0.3.0 is the newest release. SPEC.md sections 5, 5a and 5b now state the real guarantee -- budget plus one worst-case hop, with the wall-clock check named as the backstop it is. Co-Authored-By: Claude --- SPEC.md | 57 +++++++++++--- src/error.rs | 16 ++++ src/lib.rs | 2 +- src/walk.rs | 214 +++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 262 insertions(+), 27 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4b02197..9deaa08 100644 --- a/SPEC.md +++ b/SPEC.md @@ -57,6 +57,9 @@ Every fallible method distinguishes: hostile-input bound allows; distinct from `Malformed` (each record may be well-formed, but the count exceeds the cap) — the consumer fails closed the same as every other variant. - `Timeout` — also carries a lineage walk that exceeded its wall-clock budget (§4a). +- `RevealTooLarge { limit }` — a puzzle reveal expands, once its CLVM back-references are unfolded, + beyond the walk's bound (§4a). Distinct from `Malformed`: the reveal may be entirely well-formed + chain data and merely larger than the walk will authenticate. - `LineageTooDeep { limit }` — a singleton lineage walk exceeded its hop bound (§4a). The lineage it could build is INCOMPLETE, so it is refused rather than truncated: a partial member set would make `contains` answer `false` for genuine members, which is a fail-OPEN membership answer. @@ -85,12 +88,22 @@ reads would have to hand-roll the §4 money-critical requirement. The optional, ```rust pub const MAX_LINEAGE_DEPTH: usize = 100_000; pub const DEFAULT_WALK_BUDGET: Duration = Duration::from_secs(45); - -pub struct WalkBounds { pub max_hops: usize, pub budget: Duration } // Default: the two constants above +pub const MAX_REVEAL_EXPANDED_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_HOP_CLVM_COST: u64 = 100_000_000; + +// Fields are PRIVATE and `max_hops` is clamped to MAX_LINEAGE_DEPTH; the guards cannot be +// disabled through a struct literal. Default: the two bound constants above. +pub struct WalkBounds { /* private */ } +impl WalkBounds { + pub fn hops(max_hops: usize) -> Self; // clamped to MAX_LINEAGE_DEPTH + pub fn within(self, budget: Duration) -> Self; + pub fn max_hops(self) -> usize; + pub fn budget(self) -> Duration; +} pub enum LineageWalkError { Source(E), Malformed(String), NotASingleton { coin_id }, - TooDeep { limit }, DeadlineExceeded { budget }, + RevealTooLarge { coin_id, limit }, TooDeep { limit }, DeadlineExceeded { budget }, } pub fn walk_singleton_lineage(source: &S, launcher_id: Bytes32) @@ -131,13 +144,39 @@ A conforming walk MUST: serialized in the CLVM back-reference form — the compressed encoding full nodes accept and block generators emit, which a curried singleton reveal exercises heavily. A walk that reads only the non-backref form reports a genuine singleton as `Malformed`, blaming an honest source. -5. **Refuse, never truncate, past EITHER bound** — `MAX_LINEAGE_DEPTH` spends and +5. **Refuse, never truncate, past ANY bound** — `MAX_LINEAGE_DEPTH` spends and `DEFAULT_WALK_BUDGET` of wall-clock time by default — and reject a repeated coin id as a cycle. - The hop cap alone is insufficient: it bounds neither elapsed time nor per-hop CLVM cost, so a - hostile source serving a structurally valid, ever-advancing chain of DISTINCT recreations trips - no other guard. `ChainSource` is synchronous, so that is the caller's thread. A budget overrun - MUST report as `LineageWalkError::DeadlineExceeded` (projecting to `ChainSourceError::Timeout`), - never as `TooDeep` or `Malformed` — the source may have been entirely honest. + The hop cap alone is insufficient: it bounds neither elapsed time nor per-hop cost, so a hostile + source serving a structurally valid, ever-advancing chain of DISTINCT recreations trips no other + guard. `ChainSource` is synchronous, so that is the caller's thread. A budget overrun MUST report + as `LineageWalkError::DeadlineExceeded` (projecting to `ChainSourceError::Timeout`), never as + `TooDeep` or `Malformed` — the source may have been entirely honest. + + The wall-clock budget is checked BETWEEN hops, so it is a **backstop**, not a hard deadline: a + conforming walk returns within `budget + one worst-case hop`. That guarantee is vacuous unless + the cost of ONE hop is itself bounded, which is what §5a and §5b require. + +5a. **Bound the EXPANDED size of a puzzle reveal, before hashing, parsing or running it.** CLVM + back-references are a compression: the bytes on the wire describe a shared DAG, while every + consumer of that DAG — the reveal-binding hash, curried-puzzle parsing, the evaluator — sees the + tree it unfolds into, and a `k`-level self-referential DAG unfolds into `2^k` nodes. A conforming + walk MUST refuse a reveal whose expansion exceeds `MAX_REVEAL_EXPANDED_BYTES`, reporting + `LineageWalkError::RevealTooLarge` (projecting to `ChainSourceError::RevealTooLarge`) — never + `Malformed`, because the reveal may be valid chain data that is merely too large. + + Two properties are normative, and each closes an attack the other does not. The bound MUST be on + the EXPANSION, not on the serialized length: a serialized-length cap large enough for an honest + singleton is orders of magnitude above the bomb, which is about a kilobyte. And the bound MUST be + applied BEFORE the reveal-binding hash, so that every later use of those bytes is downstream of + it: a bomb curried as the INNER puzzle of an otherwise genuine singleton passes the binding check + truthfully — the walk derived that coin's puzzle hash from the bomb's own tree hash — and + detonates in whatever parses the reveal next. Memoizing the binding hash does not close this; + only bounding the expansion ahead of all of it does. + +5b. **Bound the CLVM cost of ONE hop.** A spend's puzzle reveal is hash-bound to its coin, but its + SOLUTION is bound to nothing — the source chooses it freely. A conforming walk MUST evaluate with + an explicit per-hop ceiling (`MAX_HOP_CLVM_COST`), never the whole-block cost limit, or one hop + may legitimately burn an entire block's worth of evaluation. 6. **Bound per-hop CLVM memory.** A CLVM allocator is an arena that frees nothing until dropped, so one shared across hops accumulates every hop's puzzle, solution and evaluation. A conforming walk MUST start each hop with a fresh allocator (or restore a checkpoint). Sharing one both costs diff --git a/src/error.rs b/src/error.rs index ffd6a6c..e56c123 100644 --- a/src/error.rs +++ b/src/error.rs @@ -103,6 +103,22 @@ mod tests { ); } + /// "Too big" must never read as "corrupt": the reveal may be perfectly valid chain data, and a + /// consumer that cannot tell the two apart cannot tell a hostile source from a heavy one. + #[test] + fn reveal_too_large_is_distinct_from_malformed() { + let too_large = ChainSourceError::RevealTooLarge { limit: 4_194_304 }; + assert_eq!( + too_large.to_string(), + "puzzle reveal expands beyond the 4194304-byte bound" + ); + assert_ne!( + too_large, + ChainSourceError::Malformed("undecodable program".to_string()) + ); + assert!(!matches!(too_large, ChainSourceError::Malformed(_))); + } + #[test] fn too_many_records_is_distinct_from_malformed() { let too_many = ChainSourceError::TooManyRecords { count: 5, limit: 1 }; diff --git a/src/lib.rs b/src/lib.rs index eca2d46..b097235 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,7 +73,7 @@ pub use source::{ChainSource, ChainSourceProvider}; pub use walk::{ resolve_singleton_lineage_via_walk, walk_singleton_lineage, walk_singleton_lineage_bounded, walk_singleton_lineage_within, LineageWalkError, WalkBounds, DEFAULT_WALK_BUDGET, - MAX_LINEAGE_DEPTH, MAX_REVEAL_EXPANDED_BYTES, + MAX_HOP_CLVM_COST, MAX_LINEAGE_DEPTH, MAX_REVEAL_EXPANDED_BYTES, }; #[cfg(feature = "testing")] diff --git a/src/walk.rs b/src/walk.rs index 7c3fc6a..ceac480 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -44,7 +44,7 @@ use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; use chia_puzzle_types::singleton::SingletonArgs; use chia_puzzles::SINGLETON_LAUNCHER_HASH; use chia_sdk_driver::{Layer, Puzzle, SingletonLayer}; -use chia_sdk_types::run_puzzle; +use chia_sdk_types::run_puzzle_with_cost; use clvm_traits::FromClvm; use clvm_utils::{tree_hash_from_bytes, TreeHash}; use clvmr::serde::node_from_bytes_backrefs; @@ -88,23 +88,56 @@ pub const MAX_LINEAGE_DEPTH: usize = 100_000; /// and 20 ms per read, an attacker buys the better part of an hour of hang, inside whatever ceremony /// the caller was performing. /// -/// This budget is therefore the PRIMARY denial-of-service defense and the hop cap is the -/// belt-and-braces bound beneath it. It is generous enough for any legitimate lineage over a healthy -/// source and matches the equivalent bound in `chia-query`'s walk, so the two agree about how long a -/// lineage resolution may take. +/// # What this budget actually guarantees +/// +/// It is checked BETWEEN hops, never inside one, so it is a **backstop** rather than a hard +/// deadline: the walk returns after at most `budget + one worst-case hop`. That second term is only +/// meaningful because it is itself bounded — [`MAX_REVEAL_EXPANDED_BYTES`] caps what a hop may hash +/// and [`MAX_HOP_CLVM_COST`] caps what it may evaluate. Without those two, this budget bounds +/// nothing at all, because a single hop can be made arbitrarily expensive. pub const DEFAULT_WALK_BUDGET: Duration = Duration::from_secs(45); +/// The CLVM cost ceiling for evaluating ONE spend's inner puzzle. +/// +/// # Why not the block cost +/// +/// `chia_sdk_types::run_puzzle` evaluates at `MAINNET_CONSTANTS.max_block_cost_clvm` — eleven +/// billion, a whole block — and a spend's SOLUTION is bound to nothing: the reveal-binding hash +/// commits the coin to its puzzle, but the source chooses the solution freely. One hop may therefore +/// legitimately burn an entire block's worth of evaluation, which makes +/// [`DEFAULT_WALK_BUDGET`]'s between-hops check a promise the walk cannot keep. +/// +/// # Why this value +/// +/// Measured cost of the puzzles a hop actually runs: the singleton launcher spend that opens every +/// lineage costs **11,932**, and a `p2_delegated_puzzle_or_hidden_puzzle` inner spend emitting one +/// recreation costs **18,092**. This ceiling is ~5,500x the larger of those and ~110x below a full +/// block, which leaves ample room for heavier inner layers (DID, NFT state + ownership + royalty, +/// vault member trees) while capping a hop's evaluation at roughly a tenth of a second. +/// +/// A spend that exceeds it is reported as [`LineageWalkError::Malformed`], because the evaluator +/// reports cost exhaustion the same way it reports any other failure to run. +pub const MAX_HOP_CLVM_COST: u64 = 100_000_000; + /// How far, and for how long, a lineage walk may run before failing closed. /// /// Both bounds are always present: [`WalkBounds::default`] is what [`walk_singleton_lineage`] uses, /// so a provider whose `resolve_singleton_lineage` is a one-line delegation INHERITS the /// denial-of-service guards rather than having to remember them. +/// +/// # Why the fields are private +/// +/// They are guards. Public fields make `WalkBounds { max_hops: usize::MAX, budget: Duration::MAX }` +/// a struct literal that disables both of them with nothing in the diff to signal it, and the +/// constructors below cannot be bypassed the same way: [`WalkBounds::hops`] CLAMPS to +/// [`MAX_LINEAGE_DEPTH`], so no caller can widen the walk past the ecosystem's canonical bound. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub struct WalkBounds { /// The maximum number of spends to follow ([`LineageWalkError::TooDeep`] beyond it). - pub max_hops: usize, + max_hops: usize, /// The wall-clock budget for the whole walk ([`LineageWalkError::DeadlineExceeded`] beyond it). - pub budget: Duration, + budget: Duration, } impl Default for WalkBounds { @@ -117,12 +150,12 @@ impl Default for WalkBounds { } impl WalkBounds { - /// The default bounds with a chosen hop cap — the form tests use to exercise - /// [`LineageWalkError::TooDeep`] over a short chain. + /// The default bounds with a chosen hop cap, CLAMPED to [`MAX_LINEAGE_DEPTH`] — the form tests + /// use to exercise [`LineageWalkError::TooDeep`] over a short chain. #[must_use] pub fn hops(max_hops: usize) -> Self { Self { - max_hops, + max_hops: max_hops.min(MAX_LINEAGE_DEPTH), ..Self::default() } } @@ -132,6 +165,18 @@ impl WalkBounds { pub fn within(self, budget: Duration) -> Self { Self { budget, ..self } } + + /// The maximum number of spends this walk will follow. + #[must_use] + pub fn max_hops(self) -> usize { + self.max_hops + } + + /// The wall-clock budget for the whole walk. + #[must_use] + pub fn budget(self) -> Duration { + self.budget + } } /// Why a singleton lineage walk could not answer. @@ -640,8 +685,11 @@ fn run_for_continuation( puzzle: NodePtr, solution: NodePtr, ) -> Result> { - let output = run_puzzle(allocator, puzzle, solution) - .map_err(|error| LineageWalkError::Malformed(format!("puzzle did not run: {error}")))?; + // An EXPLICIT ceiling, not `run_puzzle`'s whole-block default: the solution is attacker-chosen + // (only the reveal is hash-bound to the coin), so a hop's evaluation is bounded here or nowhere. + let output = run_puzzle_with_cost(allocator, puzzle, solution, MAX_HOP_CLVM_COST, false) + .map_err(|error| LineageWalkError::Malformed(format!("puzzle did not run: {error}")))? + .1; let conditions = Vec::::from_clvm(allocator, output).map_err(|error| { LineageWalkError::Malformed(format!("undecodable condition list: {error}")) })?; @@ -676,16 +724,18 @@ fn run_for_continuation( melt marker {SINGLETON_MELT_AMOUNT}" )) })?; - if amount % 2 == 0 { - continue; - } - // Now — and only now — the condition is known to address a coin the walk must follow, so - // an unreadable puzzle hash is a refusal rather than an omission. + // Decoded BEFORE the parity skip, not after. Every non-melt CREATE_COIN addresses a real + // coin, so an unreadable puzzle hash is inconsistent chain data whatever the amount's + // parity; deferring this past the skip would quietly accept a malformed hash on an + // even-amount payment, which is strictly less strict for no benefit. let puzzle_hash = Bytes32::from_clvm(allocator, puzzle_hash).map_err(|error| { LineageWalkError::Malformed(format!( "undecodable CREATE_COIN condition: recreation puzzle hash: {error}" )) })?; + if amount % 2 == 0 { + continue; + } if recreation.is_some() { return Err(LineageWalkError::Malformed( "a singleton spend emitted more than one odd-amount child".to_string(), @@ -1077,6 +1127,29 @@ mod tests { ); } + /// An EVEN-amount CREATE_COIN with an unreadable puzzle hash refuses too — the parity skip must + /// not become a place unreadable conditions hide. + /// + /// Even amounts are ordinary payments the walk deliberately ignores, so it is tempting to skip + /// them before decoding their puzzle hash. That is strictly less strict than refusing: an + /// even-amount condition still addresses a real coin, and chain data the walk cannot read is + /// inconsistent whatever the amount's parity. The melt marker is the ONE case that legitimately + /// carries no 32-byte hash, and it returns before this point. + #[test] + fn an_even_amount_create_coin_with_an_unreadable_puzzle_hash_still_refuses() { + let allocator = &mut Allocator::new(); + let short_hash = (CREATE_COIN, ([0x0Eu8; 31], (2i64, ()))) + .to_clvm(allocator) + .expect("the condition allocates"); + + let error = continuation_of(allocator, vec![short_hash]) + .expect_err("an unreadable puzzle hash is refused whatever the amount's parity"); + assert!( + matches!(error, LineageWalkError::Malformed(detail) if detail.contains("CREATE_COIN")), + "the refusal must name the condition it could not read" + ); + } + /// A singleton spend emits at most ONE odd-amount child; two is chain data the walk cannot /// interpret, and picking either would be a guess about which coin is the singleton. #[test] @@ -1098,4 +1171,111 @@ mod tests { ) ); } + + /// The bound is pinned from BOTH sides on a node of exactly known cost. + /// + /// `nil` costs 1 (its `0x01` prefix, no bytes); a pair costs 65 (`0x02` plus two 32-byte child + /// hashes). So `((nil . nil) . nil)` costs `65 + (65 + 1 + 1) + 1` = 133. A bound tested only + /// from one side confirms itself: at 132 the node must be refused and at 133 it must be + /// accepted, and only asserting both proves the comparison is `>` rather than `>=` or nothing. + #[test] + fn the_expanded_bound_admits_a_node_at_the_limit_and_refuses_one_byte_over() { + let allocator = &mut Allocator::new(); + let inner = allocator + .new_pair(NodePtr::NIL, NodePtr::NIL) + .expect("a pair allocates"); + let node = allocator + .new_pair(inner, NodePtr::NIL) + .expect("a pair allocates"); + + assert_eq!( + expanded_hash_input_bytes(allocator, node, 133), + 133, + "the cost model must be exactly 65 per pair and 1 + len per atom" + ); + assert!( + expanded_hash_input_bytes(allocator, node, 132) > 132, + "one byte over the bound must be refused" + ); + } + + /// A bomb must SATURATE rather than be computed — the whole point of the saturating traversal. + /// + /// 40 levels of self-cons is a notional 2^40 nodes. If the cost were accumulated honestly this + /// test would not finish; that it returns `limit + 1` immediately is the property under test. + #[test] + fn a_self_referential_dag_saturates_instead_of_being_counted() { + let allocator = &mut Allocator::new(); + let mut node = allocator.new_atom(&[1]).expect("a one-byte atom allocates"); + for _ in 0..40 { + node = allocator + .new_pair(node, node) + .expect("a self-cons adds one pair"); + } + + assert_eq!( + expanded_hash_input_bytes(allocator, node, MAX_REVEAL_EXPANDED_BYTES), + MAX_REVEAL_EXPANDED_BYTES + 1, + "the traversal must stop one byte past the bound, never compute 2^40" + ); + } + + /// An honest reveal-sized tree must NOT be refused — the nearest wrong fix is a bound so tight + /// it rejects real singletons, and this is the control that would catch it. + #[test] + fn a_tree_the_size_of_a_heavy_honest_reveal_is_admitted() { + // 137 KB expanded is the measured cost of a singleton wrapping an NFT state + ownership + + // royalty stack with eight long URIs — the heaviest realistic reveal (see + // `MAX_REVEAL_EXPANDED_BYTES`). Built here as a right-leaning list of 2,100 pairs, which + // costs 65 each: ~136.5 KB. + let allocator = &mut Allocator::new(); + let mut node = NodePtr::NIL; + for _ in 0..2_100 { + node = allocator + .new_pair(NodePtr::NIL, node) + .expect("a pair allocates"); + } + + let cost = expanded_hash_input_bytes(allocator, node, MAX_REVEAL_EXPANDED_BYTES); + assert!( + cost > 100_000, + "the control is only load-bearing if it is genuinely reveal-sized; it cost {cost}" + ); + assert!( + cost <= MAX_REVEAL_EXPANDED_BYTES, + "a reveal the size of a heavy honest one must be admitted; it cost {cost}" + ); + } + + /// `successor_of` must keep owning its allocator, and the guard has to be mechanical. + /// + /// The doc comment on that function explains why the arena is created per hop. Hoisting it is a + /// three-line change — add a parameter, create one in the caller, pass it — of exactly the shape + /// a "stop reallocating per hop" optimisation takes, and NOTHING in the suite goes red when it + /// happens: the endless-chain tests cap out at 64 hops, far below the arena's node ceiling. So + /// the signature itself is asserted. + #[test] + fn successor_of_takes_no_allocator_parameter() { + let source = include_str!("walk.rs"); + let signature = source + .split_once("fn successor_of(") + .expect("successor_of is declared in this file") + .1 + .split_once(") ->") + .expect("its parameter list is closed") + .0; + + assert!( + !signature.contains("Allocator"), + "successor_of must create its own allocator per hop, not accept a hoisted one: \ + ({signature})" + ); + } + + /// A hop cap wider than the ecosystem's canonical bound is CLAMPED, not honoured. + #[test] + fn a_hop_cap_beyond_the_canonical_bound_is_clamped() { + assert_eq!(WalkBounds::hops(usize::MAX).max_hops(), MAX_LINEAGE_DEPTH); + assert_eq!(WalkBounds::hops(7).max_hops(), 7); + } } From f1a0609d8327bf71470c06759b411922bf3f63d9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 15:31:10 -0700 Subject: [PATCH 16/16] =?UTF-8?q?chore(release):=200.3.1=20=E2=80=94=20the?= =?UTF-8?q?=20lineage=20walk=20is=20additive,=20so=20no=20breaking=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `lineage-walk` addition changes no existing item: `src/source.rs`, `src/lineage.rs`, `src/provider.rs` and `src/record.rs` are untouched, so the `ChainSource` trait, every default-method body and every public type are byte-identical. All new code is behind the non-default `lineage-walk` feature, all seven new dependencies are optional, `rust-version` and the default feature set are unchanged, and the two new `ChainSourceError` variants land on an enum that was already `#[non_exhaustive]` at 0.3.0. 0.4.0 would break the `"0.3"` requirement held by dig-app, dig-account 0.11.3 and dig-did 0.6.0, splitting `ChainSource` into two incompatible traits and forcing a four-release cascade to adopt a change that breaks nothing. Refs: dig_ecosystem#2572, dig_ecosystem#2398 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- src/lib.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4512e0..424e661 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "dig-chainsource-interface" -version = "0.4.0" +version = "0.3.1" dependencies = [ "anyhow", "chia-bls 0.36.1", diff --git a/Cargo.toml b/Cargo.toml index 6900ee4..b3d2ceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ # aggregating canonical source). See SPEC.md for the normative contract. [package] name = "dig-chainsource-interface" -version = "0.4.0" +version = "0.3.1" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index 7993d42..bac94a9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ trust-critical: its result IS the authority set consumers test membership agains only by primitive reads can borrow the whole walk instead of hand-rolling it: ```toml -dig-chainsource-interface = { version = "0.4", features = ["lineage-walk"] } +dig-chainsource-interface = { version = "0.3", features = ["lineage-walk"] } ``` ```rust diff --git a/src/lib.rs b/src/lib.rs index b097235..3da3b71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ //! one-line delegation to [`resolve_singleton_lineage_via_walk`]: //! //! ```toml -//! dig-chainsource-interface = { version = "0.4", features = ["lineage-walk"] } +//! dig-chainsource-interface = { version = "0.3", features = ["lineage-walk"] } //! ``` //! //! The feature is OFF by default because the walk needs a CLVM evaluator (it runs each parent's