diff --git a/Cargo.lock b/Cargo.lock index 7e54acb..424e661 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.3.1" +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..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.3.0" +version = "0.3.1" 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..bac94a9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,36 @@ 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.3", 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 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. + ## Implementing a provider Implement `ChainSource` over your backend, choosing `type Error` (`ChainSourceError` is recommended diff --git a/SPEC.md b/SPEC.md index e6fed02..9deaa08 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,13 @@ 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). +- `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. Absence MUST NOT be encoded as an error; an error MUST NOT be degraded to a value. @@ -72,6 +79,141 @@ 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 const DEFAULT_WALK_BUDGET: Duration = Duration::from_secs(45); +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 }, + RevealTooLarge { coin_id, limit }, TooDeep { limit }, DeadlineExceeded { budget }, +} + +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 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>; +``` + +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, 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 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 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 + 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. +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. +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 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 +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 `CoinRecord { coin: Coin, confirmed_height: Option, spent_height: Option, @@ -114,5 +256,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..e56c123 100644 --- a/src/error.rs +++ b/src/error.rs @@ -60,6 +60,31 @@ pub enum ChainSourceError { /// The maximum number of records the consumer will accept. 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 + /// 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)] @@ -78,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 d2739e2..3da3b71 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.3", 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,13 @@ 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, + walk_singleton_lineage_within, LineageWalkError, WalkBounds, DEFAULT_WALK_BUDGET, + MAX_HOP_CLVM_COST, MAX_LINEAGE_DEPTH, MAX_REVEAL_EXPANDED_BYTES, +}; + #[cfg(feature = "testing")] pub use testing::MockChainSource; diff --git a/src/walk.rs b/src/walk.rs new file mode 100644 index 0000000..ceac480 --- /dev/null +++ b/src/walk.rs @@ -0,0 +1,1281 @@ +//! [`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, HashMap}; +use std::fmt; +use std::time::{Duration, Instant}; + +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_with_cost; +use clvm_traits::FromClvm; +use clvm_utils::{tree_hash_from_bytes, TreeHash}; +use clvmr::serde::node_from_bytes_backrefs; +use clvmr::{Allocator, NodePtr, SExp}; + +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 +/// [`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 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; + +/// 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. +/// +/// # 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). + max_hops: usize, + /// The wall-clock budget for the whole walk ([`LineageWalkError::DeadlineExceeded`] beyond it). + 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, 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.min(MAX_LINEAGE_DEPTH), + ..Self::default() + } + } + + /// These bounds with a chosen wall-clock budget. + #[must_use] + 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. +/// +/// 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, + }, + + /// 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 { + /// 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 { + 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::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") + } + Self::DeadlineExceeded { budget } => { + write!( + f, + "singleton lineage walk exceeded its {budget:?} wall-clock budget" + ) + } + } + } +} + +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" + )), + // 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 + // accuse an honest source of serving bad data for the crime of being slow. + LineageWalkError::DeadlineExceeded { .. } => ChainSourceError::Timeout, + } + } +} + +/// 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 by [`WalkBounds::default`] — [`MAX_LINEAGE_DEPTH`] hops within [`DEFAULT_WALK_BUDGET`]; +/// use [`walk_singleton_lineage_within`] to choose other bounds. +/// +/// # 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)` | +/// | 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, 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) +/// +/// 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, +) -> Result, LineageWalkError> { + walk_singleton_lineage_within(source, launcher_id, WalkBounds::default()) +} + +/// [`walk_singleton_lineage`] with an explicit hop bound, at the default wall-clock budget. +/// +/// 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 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 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..=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 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); + }; + + // 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. + let record = require_coin_exists(source, successor)?; + + admit_member(&mut members, successor.coin_id())?; + current = successor; + current_spent_height = record.spent_height; + rule = HopRule::Recreate { launcher_id }; + } + + 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 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. +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. +/// +/// 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> { + 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)) +} + +/// 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. `spent_height` is `coin`'s own record field. +/// +/// 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 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!( + "source returned a spend of coin {} when asked for {}", + spend.coin.coin_id(), + 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!( + "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, 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> { + source + .coin_record(coin.coin_id()) + .map_err(LineageWalkError::Source)? + .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. +/// +/// 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. +#[derive(Debug)] +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> { + // 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}")) + })?; + + let mut recreation: Option<(Bytes32, u64)> = None; + for condition in conditions { + // 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}")) + })?; + + // 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); + } + 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}" + )) + })?; + // 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(), + )); + } + 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; + +/// 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. +/// +/// # 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( + 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`], 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> { + node_from_bytes_backrefs(allocator, program.as_ref()) + .map_err(|error| LineageWalkError::Malformed(format!("undecodable program: {error}"))) +} + +/// How many bytes a puzzle reveal may feed into SHA-256 once its CLVM back-references are +/// **expanded**, before the walk refuses to authenticate it. +/// +/// # Why an EXPANDED-size bound, and not a bound on the serialized reveal +/// +/// CLVM back-references are a compression: a repeated subtree is written once and pointed at +/// thereafter, so the bytes on the wire describe a shared DAG rather than a tree. Every consumer of +/// that DAG — the reveal-binding hash, `Puzzle::parse`, the CLVM evaluator — sees the TREE the DAG +/// unfolds into, and a `k`-level self-referential DAG unfolds into `2^k` nodes. The serialized size +/// is therefore no evidence at all about the cost of using it: measured through this crate's public +/// API, a **1,120-byte** reveal (a singleton curried around a 28-level self-cons) costs **75 +/// seconds**, and each additional 3 bytes on the wire DOUBLES that. A byte-length cap large enough +/// for an honest singleton is orders of magnitude larger than the bomb, so it bounds nothing. +/// +/// This bound is measured on the expansion instead, which is exactly the quantity the work is +/// proportional to (see [`expanded_hash_input_bytes`]). Amplification is then capped at 1 by +/// construction: no serialized size, at any depth, can buy more work than this. +/// +/// # Why this value +/// +/// Measured expansion of real reveals, curried from the canonical chia puzzles: +/// +/// | reveal | serialized | expanded | +/// |---|---|---| +/// | `p2_delegated_puzzle_or_hidden_puzzle` alone | 291 B | 8.1 KB | +/// | `singleton(p2)` — the plainest singleton there is | 1.4 KB | 41.2 KB | +/// | `singleton(did_innerpuz(p2))`, empty metadata | 2.5 KB | 75.0 KB | +/// | `singleton(nft state + ownership + royalty)`, 8 long URIs | 6.3 KB | 136.5 KB | +/// | `singleton(did_innerpuz(p2))` carrying 8 KB of curried metadata | 34.5 KB | 632.7 KB | +/// +/// 4 MiB is **30x** the heaviest realistic reveal there (the extravagant NFT) and **6.6x** the +/// deliberately absurd 8 KB-metadata DID, so the bound has room for singletons far heavier than +/// anything the ecosystem builds — the failure it must never produce is refusing an honest +/// singleton on a money path. At the same time it caps a hop's hashing at roughly two milliseconds, +/// which leaves [`DEFAULT_WALK_BUDGET`] the effective outer bound on the walk as a whole rather than +/// something reveal size can steer past. +/// +/// It is a hostile-input guard, not a policy limit: a reveal beyond it is refused as +/// [`LineageWalkError::RevealTooLarge`], which says "too big to authenticate", never "malformed". +pub const MAX_REVEAL_EXPANDED_BYTES: usize = 4 * 1024 * 1024; + +/// The number of bytes `clvm_utils::tree_hash` would feed into SHA-256 for the tree `root` expands +/// into, saturating at `limit + 1` so a bomb is detected rather than computed. +/// +/// This is the CLVM tree-hash cost model, stated exactly: an atom contributes its `0x01` prefix +/// plus its own bytes, and a pair contributes its `0x02` prefix plus its two 32-byte child hashes. +/// Bounding it therefore bounds every downstream tree-hash of the same reveal — including the +/// NON-memoizing `clvm_utils::tree_hash` that `chia_sdk_driver::Puzzle::parse` performs, which no +/// cache protects and which is the site the 1,120-byte bomb above detonates. +/// +/// Runs in time linear in the DAG (each distinct node is costed once, memoized by [`NodePtr`]), and +/// iteratively rather than recursively, because the DAG may be tens of thousands of levels deep. +fn expanded_hash_input_bytes(allocator: &Allocator, root: NodePtr, limit: usize) -> 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. +/// +/// 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}"))) +} + +#[cfg(test)] +mod tests { + use clvm_traits::ToClvm; + + use super::*; + + /// The cycle guard, pinned at the only level it can be: no end-to-end fixture can reach it. + /// + /// A successor's `parent_coin_info` is always the CURRENT coin's id, and + /// [`require_coin_exists`] binds the successor to a real record by full [`Coin`] equality — so + /// a member could only repeat if two distinct hops produced the same coin id, i.e. a SHA-256 + /// collision. The guard is therefore unreachable defense-in-depth today, and stays because a + /// future hop rule must not be able to loop silently. + #[test] + fn admitting_the_same_coin_twice_is_refused_as_a_cycle() { + let coin_id = Bytes32::new([0x5A; 32]); + let mut members = BTreeSet::new(); + + assert_eq!( + admit_member::(&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); + } + + /// 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 + )); + } + + /// 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" + ); + } + + /// 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] + 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() + ) + ); + } + + /// 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); + } +} diff --git a/tests/hostile_lineage_walk.rs b/tests/hostile_lineage_walk.rs new file mode 100644 index 0000000..e72f834 --- /dev/null +++ b/tests/hostile_lineage_walk.rs @@ -0,0 +1,752 @@ +//! 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, 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, MAX_REVEAL_EXPANDED_BYTES, +}; + +/// 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; +/// 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))`. +/// +/// 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), + }) + } + + /// 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() + } + + /// 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. +/// +/// # 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<()> { + 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(()) +} + +/// 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 91-byte puzzle reveal must not cost minutes of CPU, and must be refused for its SIZE. +/// +/// # Why this asserts on the clock as well as the outcome +/// +/// 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 +/// +/// 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. +/// +/// # 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_refused_in_bounded_time() -> Result<()> { + let source = BombSource::at_depth(30); + + 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 expands beyond the bound is refused"); + let elapsed = started.elapsed(); + + 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), + "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 +/// `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 { + #[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 +} diff --git a/tests/lineage_walk.rs b/tests/lineage_walk.rs new file mode 100644 index 0000000..45a61ff --- /dev/null +++ b/tests/lineage_walk.rs @@ -0,0 +1,1111 @@ +//! 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::{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, +}; + +// --------------------------------------------------------------------------------------------- +// 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, + /// 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, + /// 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<'_> { + 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)) + } + + 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> { + if self.withheld_spend == Some(coin_id) { + return Ok(None); + } + Ok(self + .sim + .coin_spend(coin_id) + .map(|spend| self.serialized_as_configured(spend))) + } + + 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), + withheld_spend: None, + withheld_record: None, + backref_reveals: false, + } +} + +// --------------------------------------------------------------------------------------------- +// 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 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. +// --------------------------------------------------------------------------------------------- + +/// 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())); +} + +/// 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(); + 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 { + 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))?; + 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)), + )?; + + 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. +// --------------------------------------------------------------------------------------------- + +/// 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(()) +} + +/// 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 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(asked_for.coin_id(), record(asked_for)) + .with_coin(eve.coin_id(), record(eve)) + .with_spend( + asked_for.coin_id(), + CoinSpend::new( + other, + launcher_reveal(), + launcher_solution(eve_puzzle_hash, 1), + ), + ); + + let error = walk_singleton_lineage(&source, asked_for.coin_id()) + .expect_err("a mismatched spend must fail closed"); + 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] +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()) + ); +} + +// --------------------------------------------------------------------------------------------- +// 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(()) +} + +/// 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, + confirmed_height: Some(1), + spent_height: None, + timestamp: None, + 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) + } +}