diff --git a/Cargo.lock b/Cargo.lock index 80194c56..f33e4e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -62,15 +62,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -103,9 +103,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -586,19 +586,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -688,9 +689,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -734,9 +735,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -744,9 +745,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -756,9 +757,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -768,9 +769,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-debugger" @@ -795,9 +796,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "concurrent-queue" @@ -1616,15 +1617,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -1633,13 +1634,13 @@ dependencies = [ [[package]] name = "kaspa-addresses" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "js-sys", "serde", "smallvec", - "thiserror 1.0.69", + "thiserror 2.0.18", "wasm-bindgen", "workflow-log", "workflow-wasm", @@ -1648,7 +1649,7 @@ dependencies = [ [[package]] name = "kaspa-consensus-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "arc-swap", "async-trait", @@ -1685,7 +1686,7 @@ dependencies = [ [[package]] name = "kaspa-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "anyhow", "cfg-if", @@ -1705,7 +1706,7 @@ dependencies = [ [[package]] name = "kaspa-hashes" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "blake2b_simd", "blake3", @@ -1725,7 +1726,7 @@ dependencies = [ [[package]] name = "kaspa-math" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "faster-hex 0.9.0", @@ -1745,7 +1746,7 @@ dependencies = [ [[package]] name = "kaspa-merkle" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "kaspa-hashes", ] @@ -1753,7 +1754,7 @@ dependencies = [ [[package]] name = "kaspa-muhash" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "kaspa-hashes", "kaspa-math", @@ -1764,7 +1765,7 @@ dependencies = [ [[package]] name = "kaspa-txscript" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "ark-bn254", "ark-ec", @@ -1810,7 +1811,7 @@ dependencies = [ [[package]] name = "kaspa-txscript-errors" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "kaspa-hashes", @@ -1821,7 +1822,7 @@ dependencies = [ [[package]] name = "kaspa-utils" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1844,14 +1845,14 @@ dependencies = [ "sysinfo", "thiserror 1.0.69", "triggered", - "uuid 1.22.0", + "uuid 1.23.0", "wasm-bindgen", ] [[package]] name = "kaspa-wasm-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "faster-hex 0.9.0", "hexplay", @@ -1907,9 +1908,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -2141,9 +2142,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2176,9 +2177,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2186,9 +2187,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", @@ -2230,9 +2231,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2480,9 +2481,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bitflags 2.11.0", "num-traits", @@ -3032,9 +3033,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -3215,9 +3216,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3252,18 +3253,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -3273,9 +3274,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] @@ -3363,9 +3364,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -3411,9 +3412,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3481,9 +3482,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", @@ -3491,17 +3492,29 @@ dependencies = [ "serde", "serde_json", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", - "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3510,9 +3523,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3520,22 +3533,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.117", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] @@ -3576,9 +3589,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -3951,9 +3964,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -4184,18 +4197,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/debugger/cli/README.md b/debugger/cli/README.md index c0c8e255..6a01653c 100644 --- a/debugger/cli/README.md +++ b/debugger/cli/README.md @@ -20,6 +20,13 @@ cli-debugger ./vault.sil -f inspect --arg '{"amount":7,"tag":"0xbeef"}' cli-debugger ./vault.sil -f inspect_many --arg '[{"amount":7},{"amount":9}]' ``` +Contracts with source-level covenant declarations use the same debugger entrypoints. Pass the source function name to `--function`; the CLI resolves it to the generated wrapper and, when the fixture includes covenant transaction context, exposes `prev_state` and `prev_states` in scope while you step through the transition. + +```bash +cli-debugger ./counter.sil --function step --test-file ./counter.test.json --test-name source_leader +cli-debugger ./counter.sil --function rebalance --delegate --test-file ./counter.test.json --test-name source_delegate +``` + --- ## Interactive Debugging @@ -151,6 +158,33 @@ Structured args use the same JSON object and object-array form inside `.test.jso } ``` +For covenant flows, the `.test.json` file can describe the full state transition: the covenant states being spent on the input side, and the covenant states the transaction is expected to create on the output side. That gives the debugger enough context to populate `prev_state` / `prev_states` and makes the test data read like the transition under inspection. + +```json +{ + "tests": [ + { + "name": "source_leader", + "function": "step", + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { "utxo_value": 1000, "covenant_id": 1, "state": { "value": 7 } }, + { "utxo_value": 1000, "covenant_id": 1, "state": { "value": 9 } } + ], + "outputs": [ + { "value": 1000, "covenant_id": 1, "state": { "value": 11 } }, + { "value": 1000, "covenant_id": 1, "state": { "value": 13 } } + ] + } + } + ] +} +``` + +Here the inputs describe the prior covenant state, and the outputs describe the next one. If `args` is omitted, `State` / `State[]` call args are inferred from matching `tx.outputs[*].state`. + ### Test Commands ```bash diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 97f1ba95..03840634 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -4,8 +4,8 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use clap::Parser; -use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes}; -use debugger_session::session::{DebugEngine, DebugSession, ShadowTxContext, Variable, VariableOrigin}; +use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes, parse_state_value}; +use debugger_session::session::{DebugEngine, DebugSession, DebugValue, ShadowTxContext, Variable, VariableOrigin}; use debugger_session::test_runner::{ TestExpectation, TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved, discover_sidecar_path, resolve_contract_test, @@ -21,8 +21,11 @@ use kaspa_txscript::caches::Cache; use kaspa_txscript::covenants::CovenantsContext; use kaspa_txscript::script_builder::ScriptBuilder; use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; -use silverscript_lang::ast::{ContractAst, parse_contract_ast}; -use silverscript_lang::compiler::{CompileOptions, compile_contract}; +use silverscript_lang::ast::{ContractAst, Expr, ExprKind, parse_contract_ast}; +use silverscript_lang::compiler::{ + CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, ResolvedCovenantCallTarget, compile_contract, + resolve_contract_state_expr, +}; const PROMPT: &str = "(sdb) "; @@ -46,6 +49,8 @@ struct CliArgs { raw_ctor_args: Vec, #[arg(long = "arg", short = 'a')] raw_args: Vec, + #[arg(long = "delegate")] + delegate: bool, } fn compile_script_for_ctor_args( @@ -63,6 +68,186 @@ fn compile_script_for_ctor_args( Ok(compiled.script) } +fn expr_to_debug_value(expr: &Expr<'_>) -> Result { + use debugger_session::session::DebugValue; + + match &expr.kind { + ExprKind::Int(value) => Ok(DebugValue::Int(*value)), + ExprKind::Bool(value) => Ok(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Ok(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Ok(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + return Ok(DebugValue::Bytes( + values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(), + )); + } + Ok(DebugValue::Array(values.iter().map(expr_to_debug_value).collect::, _>>()?)) + } + ExprKind::StateObject(fields) => Ok(DebugValue::Object( + fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.expr)?))) + .collect::, String>>()?, + )), + other => Err(format!("unsupported resolved state expression in debugger: {other:?}")), + } +} + +fn resolve_state_for_ctor_args( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + cache: &mut HashMap, debugger_session::session::DebugValue>, +) -> Result> { + if let Some(value) = cache.get(raw_ctor_args) { + return Ok(value.clone()); + } + + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &ctor_args, compile_opts)?; + let debug_info = compiled + .debug_info + .as_ref() + .ok_or_else(|| "state resolution requires debug-enabled compilation".to_string()) + .map_err(|err| -> Box { err.into() })?; + let expr = resolve_contract_state_expr(&compiled.ast, &debug_info.constructor_args, &debug_info.constants) + .map_err(|err| -> Box { err.into() })?; + let value = expr_to_debug_value(&expr).map_err(|err| -> Box { err.into() })?; + cache.insert(raw_ctor_args.to_vec(), value.clone()); + Ok(value) +} + +fn resolve_state_from_raw( + parsed_contract: &ContractAst<'_>, + raw_state: &str, + cache: &mut HashMap, +) -> Result> { + if let Some(value) = cache.get(raw_state) { + return Ok(value.clone()); + } + + let expr = parse_state_value(parsed_contract, raw_state)?; + let value = expr_to_debug_value(&expr)?; + cache.insert(raw_state.to_string(), value.clone()); + Ok(value) +} + +fn infer_omitted_covenant_args( + contract: &ContractAst<'_>, + target: &ResolvedCovenantCallTarget, + tx: &TestTxScenarioResolved, +) -> Result, String> { + if tx.active_input_index >= tx.inputs.len() { + return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len())); + } + + let generated_entrypoint_name = target.generated_entrypoint_name(); + let function = contract + .functions + .iter() + .find(|function| function.name == generated_entrypoint_name) + .ok_or_else(|| format!("generated covenant entrypoint '{}' not found", generated_entrypoint_name))?; + let output_states = matching_covenant_output_states(target, tx)?; + + let mut inferred = Vec::with_capacity(function.params.len()); + let mut unresolved = Vec::new(); + for param in &function.params { + let type_name = param.type_ref.type_name(); + if type_name == "State" { + if output_states.len() == 1 { + inferred.push(output_states[0].to_string()); + } else { + unresolved.push(format!( + "{} ({}) requires exactly 1 matching tx.outputs[*].state, found {}", + param.name, + type_name, + output_states.len() + )); + } + continue; + } + + if type_name.starts_with("State[") { + inferred.push(encode_state_array_arg(&output_states)?); + continue; + } + + unresolved.push(format!("{} ({})", param.name, type_name)); + } + + if unresolved.is_empty() { + Ok(inferred) + } else { + Err(format!( + "cannot infer omitted args for covenant '{}'; provide explicit args for {}", + target.info.source_name, + unresolved.join(", ") + )) + } +} + +fn matching_covenant_output_states<'a>( + target: &ResolvedCovenantCallTarget, + tx: &'a TestTxScenarioResolved, +) -> Result, String> { + let active_input = u16::try_from(tx.active_input_index) + .map_err(|_| format!("tx.active_input_index {} exceeds supported range", tx.active_input_index))?; + + let matching_outputs = match target.info.binding { + CovenantDeclBinding::Auth => tx + .outputs + .iter() + .enumerate() + .filter(|(_, output)| output.covenant_id.is_some() && output.authorizing_input.unwrap_or(active_input) == active_input) + .collect::>(), + CovenantDeclBinding::Cov => { + let active_covenant_id = tx.inputs[tx.active_input_index].covenant_id.as_ref().ok_or_else(|| { + format!( + "cannot infer omitted args for covenant '{}'; tx.inputs[{}].covenant_id is required", + target.info.source_name, tx.active_input_index + ) + })?; + tx.outputs + .iter() + .enumerate() + .filter(|(_, output)| output.covenant_id.as_ref() == Some(active_covenant_id)) + .collect::>() + } + }; + + let mut states = Vec::with_capacity(matching_outputs.len()); + let mut missing_state_indexes = Vec::new(); + for (index, output) in matching_outputs { + if let Some(state) = output.state.as_deref() { + states.push(state); + } else { + missing_state_indexes.push(index); + } + } + + if missing_state_indexes.is_empty() { + Ok(states) + } else { + Err(format!( + "cannot infer omitted args for covenant '{}'; add tx.outputs[*].state for output indexes {}", + target.info.source_name, + missing_state_indexes.iter().map(|index| index.to_string()).collect::>().join(", ") + )) + } +} + +fn encode_state_array_arg(output_states: &[&str]) -> Result { + Ok(format!("[{}]", output_states.join(","))) +} + fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { let bytes = parse_hex_bytes(raw)?; if bytes.len() != 32 { @@ -74,6 +259,14 @@ fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box Result> { + if raw.starts_with("0x") || raw.starts_with("0X") { + return Ok(Hash::from_bytes(parse_short_or_full_hex_32(raw, "hash")?)); + } + + if let Ok(value) = raw.parse::() { + return Ok(Hash::from_bytes(u64_to_hash_bytes(value))); + } + Ok(Hash::from_bytes(parse_hex_32(raw, "hash")?)) } @@ -81,6 +274,22 @@ fn parse_txid32(raw: &str) -> Result> Ok(TransactionId::from_bytes(parse_hex_32(raw, "txid")?)) } +fn parse_short_or_full_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() > 32 { + return Err(format!("{name} expects at most 32 bytes, got {}", bytes.len()).into()); + } + let mut array = [0u8; 32]; + array[32 - bytes.len()..].copy_from_slice(&bytes); + Ok(array) +} + +fn u64_to_hash_bytes(value: u64) -> [u8; 32] { + let mut array = [0u8; 32]; + array[24..].copy_from_slice(&value.to_be_bytes()); + array +} + fn build_p2pk_script(pubkey: &[u8]) -> Vec { ScriptBuilder::new() .add_data(pubkey) @@ -172,8 +381,10 @@ fn print_non_status_stdout(stdout: &str) { fn show_step_view(session: &DebugSession<'_, '_>, console_lines: &[String]) { show_source_context(session); show_vars(session); - println!("Console:"); - print_console_messages(console_lines); + if !console_lines.is_empty() { + println!("Console:"); + print_console_messages(console_lines); + } } fn print_failure(session: &DebugSession<'_, '_>, err: kaspa_txscript_errors::TxScriptError) { @@ -195,7 +406,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box { let console_output = session.take_console_output(); @@ -218,7 +429,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box match session.step_into() { + "step" | "s" | "into" => match session.step_into() { Ok(Some(_)) => { let console_output = session.take_console_output(); show_step_view(session, &console_output); @@ -248,7 +459,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box match session.step_out() { + "finish" | "out" | "so" => match session.step_out() { Ok(Some(_)) => { let console_output = session.take_console_output(); show_step_view(session, &console_output); @@ -329,11 +540,11 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box break, "help" | "h" | "?" => { println!( - "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" + "Commands: next/over (n), step/into (s), step opcode (si), finish/out/so, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" ) } _ => println!( - "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" + "Commands: next/over (n), step/into (s), step opcode (si), finish/out/so, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" ), } } @@ -408,39 +619,76 @@ fn main() -> Result<(), Box> { return run_all_tests(&test_file, cli.script_path.as_deref()); } - // Resolve source, ctor args, function, call args, and tx from test file or CLI flags + // Resolve source, constructor args, function, call args, and tx from test file or CLI flags let inferred_test_file = if cli.test_file.is_some() || cli.test_name.is_some() { resolve_test_file_path(cli.test_file.as_deref(), cli.script_path.as_deref(), "run-test")? } else { None }; - let (script_path, raw_ctor_args, selected_name, raw_args, tx_scenario, expect) = + let (script_path, raw_constructor_args, selected_name, raw_args, allow_omitted_test_args_inference, delegate, tx_scenario, expect) = if let Some(test_file) = inferred_test_file.as_deref() { let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; let script_override = cli.script_path.as_deref().map(Path::new); let resolved = resolve_contract_test(test_file, test_name, script_override) .map_err(|e| -> Box { e.into() })?; - let ctor = if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; + let constructor_args = + if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; let fname = cli.function_name.clone().unwrap_or(resolved.test.function); - let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; + let (args, allow_inference) = if !cli.raw_args.is_empty() { + (cli.raw_args.clone(), false) + } else if let Some(args) = resolved.test.args { + (args, false) + } else { + (Vec::new(), true) + }; let expect = Some(resolved.test.expect); - (resolved.script_path, ctor, fname, args, resolved.test.tx, expect) + ( + resolved.script_path, + constructor_args, + fname, + args, + allow_inference, + cli.delegate || resolved.test.delegate, + resolved.test.tx, + expect, + ) } else { let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; - let ctor = cli.raw_ctor_args.clone(); - let args = cli.raw_args.clone(); - (PathBuf::from(path), ctor, cli.function_name.clone().unwrap_or_default(), args, None, None) + let constructor_args = cli.raw_ctor_args.clone(); + let entrypoint_args = cli.raw_args.clone(); + ( + PathBuf::from(path), + constructor_args, + cli.function_name.clone().unwrap_or_default(), + entrypoint_args, + false, + cli.delegate, + None, + None, + ) }; let source = fs::read_to_string(&script_path)?; let parsed_contract = parse_contract_ast(&source)?; - let ctor_args = parse_ctor_args(&parsed_contract, &raw_ctor_args)?; + let ctor_args = parse_ctor_args(&parsed_contract, &raw_constructor_args)?; let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; let compiled = compile_contract(&source, &ctor_args, compile_opts)?; let debug_info = compiled.debug_info.clone(); let mut ctor_script_cache = HashMap::, Vec>::new(); - ctor_script_cache.insert(raw_ctor_args.clone(), compiled.script.clone()); + let mut ctor_state_cache = HashMap::, debugger_session::session::DebugValue>::new(); + let mut explicit_state_cache = HashMap::::new(); + ctor_script_cache.insert(raw_constructor_args.clone(), compiled.script.clone()); + if !parsed_contract.fields.is_empty() { + let root_state = if let Some(debug_info) = debug_info.as_ref() { + let expr = resolve_contract_state_expr(&compiled.ast, &debug_info.constructor_args, &debug_info.constants) + .map_err(|err| -> Box { err.into() })?; + expr_to_debug_value(&expr).map_err(|err| -> Box { err.into() })? + } else { + resolve_state_for_ctor_args(&source, &parsed_contract, &raw_constructor_args, &mut ctor_state_cache)? + }; + ctor_state_cache.insert(raw_constructor_args.clone(), root_state); + } let selected_name = if selected_name.is_empty() { compiled.abi.first().map(|entry| entry.name.clone()).ok_or("contract has no functions")? @@ -448,8 +696,31 @@ fn main() -> Result<(), Box> { selected_name }; - let typed_args = parse_call_args(&compiled.ast, &selected_name, &raw_args)?; - let sigscript = compiled.build_sig_script(&selected_name, typed_args)?; + let covenant_target = compiled.resolve_covenant_call_target(&selected_name, CovenantDeclCallOptions { is_leader: !delegate }); + let covenant_binding = covenant_target.as_ref().map(|target| target.info.binding); + let raw_args = if allow_omitted_test_args_inference { + if let (Some(target), Some(tx)) = (covenant_target.as_ref(), tx_scenario.as_ref()) { + infer_omitted_covenant_args(&compiled.ast, target, tx).map_err(|err| -> Box { err.into() })? + } else { + raw_args + } + } else { + raw_args + }; + let sigscript = if let Some(target) = covenant_target.as_ref() { + if delegate && target.info.binding != CovenantDeclBinding::Cov { + return Err("--delegate only applies to binding=cov covenant declarations".into()); + } + let generated_entrypoint_name = target.generated_entrypoint_name(); + let typed_args = parse_call_args(&compiled.ast, &generated_entrypoint_name, &raw_args)?; + compiled.build_sig_script_for_covenant_decl(&selected_name, typed_args, CovenantDeclCallOptions { is_leader: !delegate })? + } else { + if delegate { + return Err("--delegate only applies when --function names a source-level binding=cov covenant declaration".into()); + } + let typed_args = parse_call_args(&compiled.ast, &selected_name, &raw_args)?; + compiled.build_sig_script(&selected_name, typed_args)? + }; let tx = tx_scenario.unwrap_or_else(|| TestTxScenarioResolved { version: 1, @@ -463,6 +734,7 @@ fn main() -> Result<(), Box> { utxo_value: 5000, covenant_id: None, constructor_args: None, + state: None, signature_script_hex: None, utxo_script_hex: None, }], @@ -471,6 +743,7 @@ fn main() -> Result<(), Box> { covenant_id: None, authorizing_input: None, constructor_args: None, + state: None, script_hex: None, p2pk_pubkey: None, }], @@ -485,6 +758,9 @@ fn main() -> Result<(), Box> { let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_ids = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_states = Vec::with_capacity(tx.inputs.len()); + let mut input_redeem_scripts = Vec::with_capacity(tx.inputs.len()); for (input_idx, input) in tx.inputs.iter().enumerate() { let mut default_prev_txid = [0u8; 32]; default_prev_txid.fill(input_idx as u8); @@ -494,9 +770,24 @@ fn main() -> Result<(), Box> { TransactionId::from_bytes(default_prev_txid) }; - let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let input_constructor_args = input.constructor_args.clone().unwrap_or_else(|| raw_constructor_args.clone()); + let input_covenant_state = if let Some(raw_state) = input.state.as_deref() { + Some(resolve_state_from_raw(&parsed_contract, raw_state, &mut explicit_state_cache)?) + } else if input.utxo_script_hex.is_none() || input.constructor_args.is_some() { + Some(resolve_state_for_ctor_args(&source, &parsed_contract, &input_constructor_args, &mut ctor_state_cache)?) + } else { + None + }; let redeem_script = if input.utxo_script_hex.is_none() { - Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_ctor_raw, &mut ctor_script_cache)?) + if let Some(raw_state) = input.state.as_deref() { + let ctor_args = parse_ctor_args(&parsed_contract, &input_constructor_args)?; + let state = parse_state_value(&parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(&source, &ctor_args, compile_opts)?; + Some(compiled.encode_state(&state)?) + } else { + Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_constructor_args, &mut ctor_script_cache)?) + } } else { None }; @@ -527,6 +818,9 @@ fn main() -> Result<(), Box> { sig_op_count: input.sig_op_count, }); utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + input_covenant_ids.push(covenant_id); + input_covenant_states.push(input_covenant_state); + input_redeem_scripts.push(redeem_script); } let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); @@ -538,8 +832,16 @@ fn main() -> Result<(), Box> { let p2pk_script = build_p2pk_script(&pubkey_bytes); ScriptPublicKey::new(0, p2pk_script.into()) } else { - let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); - let output_script = compile_script_for_ctor_args(&source, &parsed_contract, &output_ctor_raw, &mut ctor_script_cache)?; + let output_constructor_args = output.constructor_args.clone().unwrap_or_else(|| raw_constructor_args.clone()); + let output_script = if let Some(raw_state) = output.state.as_deref() { + let ctor_args = parse_ctor_args(&parsed_contract, &output_constructor_args)?; + let state = parse_state_value(&parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(&source, &ctor_args, compile_opts)?; + compiled.encode_state(&state)? + } else { + compile_script_for_ctor_args(&source, &parsed_contract, &output_constructor_args, &mut ctor_script_cache)? + }; pay_to_script_hash_script(&output_script) }; @@ -572,16 +874,33 @@ fn main() -> Result<(), Box> { kas_tx.inputs.get(tx.active_input_index).ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; let active_utxo = populated_tx.utxo(tx.active_input_index).ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; - let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); - let shadow_tx_context = ShadowTxContext { - tx: &populated_tx, - input: active_input, - input_index: tx.active_input_index, - utxo_entry: active_utxo, - covenants_ctx: &cov_ctx, + let active_input_state = input_covenant_states.get(tx.active_input_index).cloned().flatten(); + let active_lockscript = + input_redeem_scripts.get(tx.active_input_index).cloned().flatten().unwrap_or_else(|| compiled.script.clone()); + let covenant_input_states = active_utxo.covenant_id.and_then(|covenant_id| { + let mut values = Vec::new(); + for (input_covenant_id, covenant_input_state) in input_covenant_ids.iter().zip(input_covenant_states.iter()) { + if *input_covenant_id != Some(covenant_id) { + continue; + } + values.push(covenant_input_state.clone()?); + } + Some(values) + }); + let covenant_param_value = match covenant_binding { + Some(CovenantDeclBinding::Auth) => active_input_state.clone(), + Some(CovenantDeclBinding::Cov) => covenant_input_states.clone().map(DebugValue::Array), + None => None, }; - let mut session = - DebugSession::full(&sigscript, &compiled.script, &source, debug_info, engine)?.with_shadow_tx_context(shadow_tx_context); + let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); + let shadow_tx_context = + ShadowTxContext { tx: &populated_tx, input: active_input, input_index: tx.active_input_index, utxo_entry: active_utxo }; + let mut session = DebugSession::full(&sigscript, &active_lockscript, &source, debug_info, engine)? + .with_shadow_tx_context(shadow_tx_context) + .with_active_contract_state(active_input_state.clone()); + if let Some(covenant_target) = covenant_target { + session = session.with_covenant_mode(covenant_param_value, covenant_target); + } if cli.run { let expect_fail = expect == Some(TestExpectation::Fail); diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index b40094fa..08ebcc5d 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -265,6 +265,197 @@ contract StructuredCtor(Pair seed) { ) } +fn write_covenant_omitted_args_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "cov_debug_demo.sil", + "cov_debug_demo.test.json", + r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + } + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step_with_nonce(State[] prev_states, State[] new_states, int nonce) { + require(nonce == bump); + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + } +} +"#, + r#"{ + "tests": [ + { + "name": "source_leader_infers_args", + "function": "step", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + }, + { + "name": "source_leader_missing_nonce", + "function": "step_with_nonce", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + }, + { + "name": "source_leader_explicit_empty_args", + "function": "step", + "constructor_args": [2], + "args": [], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + } + ] +} +"#, + ) +} + +fn write_covenant_local_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "cov_debug_locals.sil", + "cov_debug_locals.test.json", + r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + int a = prev_states[0].value; + int b = a + bump + value; + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + require(b == 16); + } +} +"#, + r#"{ + "tests": [ + { + "name": "source_leader_local", + "function": "step", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + } + ] +} +"#, + ) +} + #[test] fn cli_debugger_repl_all_commands_smoke() { let tmp = std::env::temp_dir().join("cli_test_if_statement.sil"); @@ -668,6 +859,139 @@ fn cli_debugger_run_all_supports_structured_constructor_args_from_test_file() { assert!(stdout.contains("1 tests: 1 passed, 0 failed"), "missing summary line: {stdout}"); } +#[test] +fn cli_debugger_run_test_file_infers_covenant_state_args_when_args_omitted() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_infers_args") + .output() + .expect("run cli-debugger covenant inference test"); + + assert!( + output.status.success(), + "expected success, status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PASS"), "expected PASS in stdout, got: {stdout}"); +} + +#[test] +fn cli_debugger_run_test_file_reports_missing_non_state_covenant_args_when_args_omitted() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_missing_nonce") + .output() + .expect("run cli-debugger covenant nonce inference test"); + + assert!(!output.status.success(), "expected missing nonce inference to fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot infer omitted args for covenant 'step_with_nonce'; provide explicit args for nonce (int)"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn cli_debugger_run_test_file_preserves_explicit_empty_args_for_covenant_calls() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_explicit_empty_args") + .output() + .expect("run cli-debugger explicit empty covenant args test"); + + assert!(!output.status.success(), "expected explicit empty args to remain strict"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("function expects 1 arguments, got 0"), "unexpected stderr: {stderr}"); +} + +#[test] +fn cli_debugger_covenant_vars_render_source_level_args_and_active_state() { + let (script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("step") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_infers_args") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\nq\n"; + child.stdin.as_mut().expect("stdin available").write_all(input).expect("write stdin"); + + let output = child.wait_with_output().expect("wait for cli-debugger"); + assert!(output.status.success(), "cli-debugger exited with status {:?}", output.status.code()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("Contract State:\n value (int) = 7"), "missing active contract state: {stdout}"); + assert!(stdout.contains("prev_states (State[]) = [{value: 7}, {value: 7}]"), "missing prev_states call arg: {stdout}"); + assert!(stdout.contains("new_states (State[]) = [{value: 9}, {value: 7}]"), "missing new_states call arg: {stdout}"); + + let call_args_index = stdout.find("Call Arguments:").expect("missing Call Arguments section"); + let new_states_index = stdout.find("new_states (State[]) = [{value: 9}, {value: 7}]").expect("missing new_states render"); + let locals_index = stdout.find("Locals:"); + assert!(call_args_index < new_states_index, "new_states should appear under Call Arguments: {stdout}"); + assert!(locals_index.is_none_or(|index| new_states_index < index), "new_states should not render as a local: {stdout}"); +} + +#[test] +fn cli_debugger_resolves_covenant_local_from_prev_states_array() { + let (script_path, test_file_path) = write_covenant_local_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("step") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_local") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"n\nvars\ne a\nq\n"; + child.stdin.as_mut().expect("stdin available").write_all(input).expect("write stdin"); + + let output = child.wait_with_output().expect("wait for cli-debugger"); + assert!(output.status.success(), "cli-debugger exited with status {:?}", output.status.code()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("a (int) = 7"), "missing resolved local a: {stdout}"); + assert!(stdout.contains("a = (int) 7"), "missing resolved eval for a: {stdout}"); + assert!(!stdout.contains("a (int) = Result, String> { Ok(out) } -pub fn bytes_expr(bytes: Vec) -> Expr<'static> { +pub fn bytes_expr<'i>(bytes: Vec) -> Expr<'i> { Expr::new(ExprKind::Array(bytes.into_iter().map(Expr::byte).collect()), span::Span::default()) } @@ -98,13 +98,13 @@ fn validate_array_len(type_ref: &TypeRef, len: usize) -> Result<(), String> { Ok(()) } -fn parse_byte_array_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { +fn parse_byte_array_arg<'i>(type_ref: &TypeRef, raw: &str) -> Result, String> { let bytes = parse_hex_bytes(raw)?; validate_byte_array_len(type_ref, bytes.len())?; Ok(bytes_expr(bytes)) } -fn parse_scalar_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { +fn parse_scalar_arg<'i>(type_ref: &TypeRef, raw: &str) -> Result, String> { match type_ref.base { TypeBase::Int => Ok(Expr::int(parse_int_arg(raw)?)), TypeBase::Bool => match raw { @@ -143,11 +143,11 @@ fn parse_scalar_arg(type_ref: &TypeRef, raw: &str) -> Result, Stri } } -fn parse_struct_arg( +fn parse_struct_arg<'i>( entries: &serde_json::Map, declared_fields: &[StructShapeField], shapes: &StructShapeRegistry, -) -> Result, String> { +) -> Result, String> { let mut provided = entries.iter().collect::>(); let mut out = Vec::with_capacity(declared_fields.len()); @@ -168,7 +168,7 @@ fn parse_struct_arg( Ok(Expr::new(ExprKind::StateObject(out), span::Span::default())) } -fn parse_array_arg(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { +fn parse_array_arg<'i>(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { validate_array_len(type_ref, values.len())?; let element_type = type_ref.element_type().ok_or_else(|| format!("unsupported arg type '{}'", type_ref.type_name()))?; values @@ -178,7 +178,7 @@ fn parse_array_arg(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeReg .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())) } -fn parse_json_value_for_type(value: &Value, type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { +fn parse_json_value_for_type<'i>(value: &Value, type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { if matches!(value, Value::Null) { return Err("null is not supported in structured args".to_string()); } @@ -218,7 +218,7 @@ fn parse_json_value_for_type(value: &Value, type_ref: &TypeRef, shapes: &StructS } } -fn parse_typed_arg(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) -> Result, String> { +fn parse_typed_arg<'i>(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) -> Result, String> { let trimmed = raw.trim(); if trimmed == "null" { return Err("null is not supported in structured args".to_string()); @@ -245,7 +245,7 @@ fn parse_typed_arg(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) parse_scalar_arg(type_ref, trimmed) } -fn parse_params(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: &[String]) -> Result>, String> { +fn parse_params<'i>(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: &[String]) -> Result>, String> { if params.len() != raw_args.len() { return Err(format!("function expects {} arguments, got {}", params.len(), raw_args.len())); } @@ -257,7 +257,7 @@ fn parse_params(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: Ok(typed_args) } -pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { +pub fn parse_ctor_args<'i>(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { let shapes = StructShapeRegistry::from_contract(parsed_contract); if parsed_contract.params.len() != raw_ctor_args.len() { return Err(format!("constructor expects {} arguments, got {}", parsed_contract.params.len(), raw_ctor_args.len())); @@ -265,7 +265,7 @@ pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[Strin parse_params(&parsed_contract.params, &shapes, raw_ctor_args) } -pub fn parse_call_args(contract: &ContractAst<'_>, function_name: &str, raw_args: &[String]) -> Result>, String> { +pub fn parse_call_args<'i>(contract: &ContractAst<'_>, function_name: &str, raw_args: &[String]) -> Result>, String> { let function = contract .functions .iter() @@ -275,9 +275,24 @@ pub fn parse_call_args(contract: &ContractAst<'_>, function_name: &str, raw_args parse_params(&function.params, &shapes, raw_args) } +pub fn parse_state_value<'i>(contract: &ContractAst<'_>, raw_state: &str) -> Result, String> { + let value = serde_json::from_str::(raw_state).map_err(|err| format!("invalid State value '{raw_state}': {err}"))?; + let Value::Object(entries) = value else { + return Err("State value must be a JSON object".to_string()); + }; + + let shapes = StructShapeRegistry::from_contract(contract); + let declared_fields = contract + .fields + .iter() + .map(|field| StructShapeField { name: field.name.clone(), type_ref: field.type_ref.clone() }) + .collect::>(); + parse_struct_arg(&entries, &declared_fields, &shapes) +} + #[cfg(test)] mod tests { - use super::{parse_call_args, parse_ctor_args}; + use super::{parse_call_args, parse_ctor_args, parse_state_value}; use silverscript_lang::ast::{ExprKind, parse_contract_ast}; fn debug_shapes_contract() -> silverscript_lang::ast::ContractAst<'static> { @@ -347,6 +362,19 @@ mod tests { assert!(matches!(tag.expr.kind, ExprKind::Array(ref values) if values.len() == 1)); } + #[test] + fn parses_explicit_state_value() { + let contract = debug_shapes_contract(); + let value = parse_state_value(&contract, r#"{"amount":9,"active":false,"tag":"0xcc"}"#).expect("parse State value"); + let ExprKind::StateObject(fields) = &value.kind else { + panic!("expected state object"); + }; + assert_eq!(fields.len(), 3); + assert!(fields.iter().any(|field| field.name == "amount")); + assert!(fields.iter().any(|field| field.name == "active")); + assert!(fields.iter().any(|field| field.name == "tag")); + } + #[test] fn rejects_missing_struct_field() { let contract = debug_shapes_contract(); diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 23437da7..1e596425 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -9,9 +9,13 @@ use kaspa_txscript::{DynOpcodeImplementation, EngineCtx, EngineFlags, TxScriptEn use serde::{Deserialize, Serialize}; use silverscript_lang::ast::{ - Expr, ExprKind, StateFieldExpr, TypeBase, UnarySuffixKind, parse_contract_ast, parse_expression_ast, parse_type_ref, + ContractAst, Expr, ExprKind, StateFieldExpr, TypeBase, TypeRef, UnarySuffixKind, parse_contract_ast, parse_expression_ast, + parse_type_ref, +}; +use silverscript_lang::compiler::{ + CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget, compile_debug_expr, flattened_struct_name, + resolve_contract_state_expr, }; -use silverscript_lang::compiler::{compile_debug_expr, flattened_struct_name}; use silverscript_lang::debug_info::{ DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepId, StepKind, @@ -27,13 +31,12 @@ pub type DebugReused = SigHashReusedValuesUnsync; pub type DebugOpcode<'a> = DynOpcodeImplementation, DebugReused>; pub type DebugEngine<'a> = TxScriptEngine<'a, DebugTx<'a>, DebugReused>; -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct ShadowTxContext<'a> { pub tx: &'a DebugTx<'a>, pub input: &'a TransactionInput, pub input_index: usize, pub utxo_entry: &'a UtxoEntry, - pub covenants_ctx: &'a CovenantsContext, } #[derive(Debug, Clone)] @@ -127,6 +130,29 @@ pub struct OpcodeMeta<'i> { pub step: Option>, } +#[derive(Clone)] +struct CovenantSessionContext { + injected_param_value: Option, + call_target: ResolvedCovenantCallTarget, +} + +impl CovenantSessionContext { + fn binding_for_function(&self, function_name: &str) -> Option<&CovenantDeclInfo> { + self.call_target.info.matches_generated_name(function_name).then_some(&self.call_target.info) + } + + fn display_name_for_function(&self, function_name: &str) -> Option { + if self.call_target.info.policy_function_name() == function_name { + return Some(self.call_target.display_name()); + } + self.call_target.info.display_name_for_function(function_name) + } + + fn hides_name(&self, name: &str) -> bool { + name.starts_with("__cov_") || name.starts_with("__covenant_policy_") + } +} + pub struct DebugSession<'a, 'i> { engine: DebugEngine<'a>, shadow_tx_context: Option>, @@ -136,7 +162,9 @@ pub struct DebugSession<'a, 'i> { script_len: usize, pc: usize, debug_info: DebugInfo<'i>, - function_param_counts: HashMap, + contract_ast: Option>, + covenant_ctx: Option, + active_contract_state: Option, step_order: Vec, current_step_index: Option, source_lines: Vec, @@ -153,15 +181,15 @@ struct ShadowBindingValue { value: Vec, } -struct VariableContext<'a> { - function_name: &'a str, +struct VariableContext { + function_name: String, function_start: usize, function_end: usize, step_id: StepId, } struct VisibleScope<'a, 'i> { - context: VariableContext<'a>, + context: VariableContext, updates: HashMap>, } @@ -170,6 +198,7 @@ enum ScopeValueSource<'i> { RuntimeSlot { from_top: i64 }, StructuredBinding { base_name: String, leaf_bindings: Vec }, Expr(Expr<'i>), + Unavailable { message: String }, } struct ScopeBinding<'i> { @@ -194,7 +223,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { pub fn full( sigscript: &[u8], lockscript: &[u8], - source: &str, + source: &'i str, debug_info: Option>, mut engine: DebugEngine<'a>, ) -> Result { @@ -205,15 +234,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Internal constructor: parses script, prepares opcodes, extracts statement steps. pub fn from_scripts( script: &[u8], - source: &str, + source: &'i str, debug_info: Option>, engine: DebugEngine<'a>, ) -> Result { let debug_info = debug_info.unwrap_or_else(DebugInfo::empty); - let function_param_counts = parse_contract_ast(source) - .ok() - .map(|contract| contract.functions.into_iter().map(|function| (function.name, function.params.len())).collect()) - .unwrap_or_default(); + let contract_ast = parse_contract_ast(source).ok(); let opcodes = parse_script::, DebugReused>(script).collect::, _>>()?; let op_displays = opcodes.iter().map(|op| format!("{op:?}")).collect(); let opcodes: Vec>> = opcodes.into_iter().map(Some).collect(); @@ -237,7 +263,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { script_len, pc: 0, debug_info, - function_param_counts, + contract_ast, + covenant_ctx: None, + active_contract_state: None, step_order, current_step_index: None, source_lines, @@ -266,6 +294,16 @@ impl<'a, 'i> DebugSession<'a, 'i> { self } + pub fn with_active_contract_state(mut self, active_contract_state: Option) -> Self { + self.active_contract_state = active_contract_state; + self + } + + pub fn with_covenant_mode(mut self, param_value: Option, active_call: ResolvedCovenantCallTarget) -> Self { + self.covenant_ctx = Some(CovenantSessionContext { injected_param_value: param_value, call_target: active_call }); + self + } + /// Step into: advance to next source step regardless of call depth. pub fn step_into(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { self.step_with_depth_predicate(|_, _| true) @@ -347,7 +385,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Advances execution to the first user statement, skipping dispatcher/synthetic bytecode. /// Call this after session creation to skip over contract setup code. - /// Skips opcodes until the first source step is encountered. + /// Skips opcodes until the first real source step is encountered. pub fn run_to_first_executed_statement(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { if self.step_order.is_empty() { return Ok(None); @@ -358,7 +396,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { } let offset = self.current_byte_offset(); if self.engine.is_executing() { - if let Some(index) = self.steppable_step_index_for_offset(offset, None) { + if let Some(index) = self.initial_step_index_for_offset(offset, None) { self.mark_step_executed(index); return Ok(Some(self.state())); } @@ -491,8 +529,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Returns a specific variable by name, or error if not in scope. pub fn variable_by_name(&self, name: &str) -> Result { let scope_state = self.current_scope_state()?; - let variables = self.collect_variables_map(&scope_state); - variables.get(name).cloned().ok_or_else(|| format!("unknown variable '{name}'")) + let binding = scope_state.get(name).ok_or_else(|| format!("unknown variable '{name}'"))?; + let value = self.resolve_scope_binding(&scope_state, binding).unwrap_or_else(DebugValue::Unknown); + Ok(Variable { name: name.to_string(), type_name: binding.type_name.clone(), value, origin: binding.origin }) } pub fn evaluate_expression(&self, expr_src: &str) -> Result<(String, DebugValue), String> { @@ -519,7 +558,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut stack = Vec::new(); for step in self.active_steps() { match &step.kind { - StepKind::InlineCallEnter { callee } => stack.push(callee.clone()), + StepKind::InlineCallEnter { callee } => stack.push(self.display_function_name(callee)), StepKind::InlineCallExit { .. } => { stack.pop(); } @@ -535,7 +574,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { for step in self.active_steps() { match &step.kind { StepKind::InlineCallEnter { callee } => stack.push(CallStackEntry { - callee_name: callee.clone(), + callee_name: self.display_function_name(callee), call_site_span: Some(step.span), sequence: step.sequence, frame_id: step.frame_id, @@ -550,16 +589,113 @@ impl<'a, 'i> DebugSession<'a, 'i> { } /// Returns the name of the function currently being executed. - pub fn current_function_name(&self) -> Option<&str> { - self.current_function_range().map(|range| range.name.as_str()) + pub fn current_function_name(&self) -> Option { + self.current_compiled_function_name().map(|function_name| self.display_function_name(&function_name)) } - fn current_function_range(&self) -> Option<&DebugFunctionRange> { - let offset = self.current_byte_offset(); - self.debug_info.functions.iter().find(|function| offset >= function.bytecode_start && offset < function.bytecode_end) + fn current_compiled_function_name(&self) -> Option { + self.compiled_function_name_for_step(self.current_scope_step_id()) + } + + fn function_range_for_step(&self, step_id: StepId) -> Option<&DebugFunctionRange> { + let offset = self + .debug_info + .steps + .iter() + .find(|step| step.id() == step_id) + .map(|step| step.bytecode_start) + .unwrap_or_else(|| self.current_byte_offset()); + self.debug_info.functions.iter().find(|function| range_matches_offset(function.bytecode_start, function.bytecode_end, offset)) + } + + fn compiled_function_name_for_step(&self, step_id: StepId) -> Option { + let entrypoint = self.function_range_for_step(step_id)?; + if step_id.frame_id == 0 { + return Some(entrypoint.name.clone()); + } + + let mut active_calls = Vec::new(); + let mut steps = self + .debug_info + .steps + .iter() + .filter(|step| { + step.sequence <= step_id.sequence + && range_matches_offset(entrypoint.bytecode_start, entrypoint.bytecode_end, step.bytecode_start) + }) + .collect::>(); + steps.sort_by_key(|step| step.sequence); + + for step in steps { + match &step.kind { + StepKind::InlineCallEnter { callee } => active_calls.push((step.frame_id, callee.clone())), + StepKind::InlineCallExit { .. } => { + active_calls.pop(); + } + StepKind::Source {} => {} + } + } + + active_calls + .into_iter() + .rev() + .find_map(|(frame_id, callee)| (frame_id == step_id.frame_id).then_some(callee)) + .or_else(|| Some(entrypoint.name.clone())) + } + + fn covenant_ctx(&self) -> Option<&CovenantSessionContext> { + self.covenant_ctx.as_ref() + } + + fn param_origin(&self, name: &str) -> VariableOrigin { + self.binding_origin_for_function(None, name) + } + + fn binding_origin_for_function(&self, function_name: Option<&str>, name: &str) -> VariableOrigin { + if self.contract_ast.as_ref().is_some_and(|contract| contract.fields.iter().any(|field| field.name == name)) { + return VariableOrigin::ContractField; + } + + if function_name.is_none() { + return VariableOrigin::Param; + } + + let Some(contract) = self.contract_ast.as_ref() else { + return VariableOrigin::Local; + }; + + let source_function_name = function_name.and_then(|function_name| { + contract.functions.iter().find(|function| function.name == function_name).map(|function| function.name.as_str()).or_else( + || { + self.covenant_ctx() + .and_then(|ctx| ctx.binding_for_function(function_name)) + .map(|binding| binding.source_name.as_str()) + }, + ) + }); + + if source_function_name.is_some_and(|function_name| { + contract + .functions + .iter() + .find(|function| function.name == function_name) + .is_some_and(|function| function.params.iter().any(|param| param.name == name)) + }) { + VariableOrigin::Param + } else { + VariableOrigin::Local + } } - fn current_variable_updates(&self, context: &VariableContext<'_>) -> HashMap> { + fn display_function_name(&self, function_name: &str) -> String { + self.covenant_ctx().and_then(|ctx| ctx.display_name_for_function(function_name)).unwrap_or_else(|| function_name.to_string()) + } + + fn is_hidden_debug_name(&self, name: &str) -> bool { + is_inline_synthetic_name(name) || self.covenant_ctx().is_some_and(|ctx| ctx.hides_name(name)) + } + + fn current_variable_updates(&self, context: &VariableContext) -> HashMap> { let mut latest_by_name: HashMap)> = HashMap::new(); for step in self.debug_info.steps.iter().filter(|step| self.step_updates_are_visible(step, context)) { for update in &step.variable_updates { @@ -574,14 +710,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { latest_by_name.into_iter().map(|(name, (_, update))| (name, update)).collect() } - fn current_variable_context(&self, step_id: StepId) -> Result, String> { - let function = self.current_function_range().ok_or_else(|| "No function context available".to_string())?; - Ok(VariableContext { - function_name: function.name.as_str(), - function_start: function.bytecode_start, - function_end: function.bytecode_end, - step_id, - }) + fn current_variable_context(&self, step_id: StepId) -> Result { + let function = self.function_range_for_step(step_id).ok_or_else(|| "No function context available".to_string())?; + let function_name = + self.compiled_function_name_for_step(step_id).ok_or_else(|| "No function context available".to_string())?; + Ok(VariableContext { function_name, function_start: function.bytecode_start, function_end: function.bytecode_end, step_id }) } fn scope_state(&self, step_id: StepId) -> Result, String> { @@ -594,10 +727,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut bindings = HashMap::new(); let function_params: Vec<_> = self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name).collect(); - let source_param_count = self.function_param_counts.get(scope.context.function_name).copied().unwrap_or(function_params.len()); - for (index, param) in function_params.into_iter().enumerate() { - let origin = if index < source_param_count { VariableOrigin::Param } else { VariableOrigin::ContractField }; + for param in function_params { + let origin = self.param_origin(¶m.name); match ¶m.binding { DebugParamBinding::SingleValue { stack_index } => { bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { @@ -642,6 +774,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { record_debug_named_values(&mut bindings, &self.debug_info.constructor_args, VariableOrigin::ConstructorArg); record_debug_named_values(&mut bindings, &self.debug_info.constants, VariableOrigin::Constant); + self.inject_contract_state_bindings(&mut bindings); let frozen_inline_names = if scope.context.step_id.frame_id == 0 { HashSet::new() @@ -671,19 +804,131 @@ impl<'a, 'i> DebugSession<'a, 'i> { .and_modify(|binding| { binding.type_name = update.type_name.clone(); binding.source = source.clone(); - binding.hidden = is_inline_synthetic_name(name); + binding.hidden = self.is_hidden_debug_name(name); }) .or_insert_with(|| ScopeBinding { type_name: update.type_name.clone(), source, - origin: VariableOrigin::Local, - hidden: is_inline_synthetic_name(name), + origin: self.binding_origin_for_function(Some(&scope.context.function_name), name), + hidden: self.is_hidden_debug_name(name), }); } + self.inject_covenant_overlay_bindings(scope, &mut bindings); bindings } + fn inject_covenant_overlay_bindings(&self, scope: &VisibleScope<'_, 'i>, bindings: &mut ScopeState<'i>) { + let Some(binding_spec) = self.covenant_ctx().and_then(|ctx| ctx.binding_for_function(&scope.context.function_name)) else { + return; + }; + let Some((source_param_name, source_param_type)) = binding_spec.source_param() else { + return; + }; + + let injected = self.covenant_ctx().and_then(|ctx| ctx.injected_param_value.as_ref()).and_then(|value| { + self.inject_debug_value_binding(bindings, source_param_name, source_param_type, value, VariableOrigin::Param) + }); + if injected.is_some() { + return; + } + + let message = match binding_spec.binding { + CovenantDeclBinding::Auth => "prev_state is unavailable".to_string(), + CovenantDeclBinding::Cov => "prev_states is unavailable".to_string(), + }; + bindings.insert( + source_param_name.to_string(), + ScopeBinding { + type_name: source_param_type.type_name(), + source: ScopeValueSource::Unavailable { message }, + origin: VariableOrigin::Param, + hidden: false, + }, + ); + } + + fn inject_contract_state_bindings(&self, bindings: &mut ScopeState<'i>) { + let Some(contract) = self.contract_ast.as_ref() else { + return; + }; + + let state_value = if let Some(state_value) = self.active_contract_state.as_ref() { + state_value.clone() + } else if let Some(state_value) = self.resolved_contract_state_value() { + state_value + } else { + return; + }; + + for field in &contract.fields { + let field_path = vec![field.name.clone()]; + let Some(field_value) = structured_leaf_value(&state_value, &field_path) else { + continue; + }; + let _ = + self.inject_debug_value_binding(bindings, &field.name, &field.type_ref, &field_value, VariableOrigin::ContractField); + } + } + + fn inject_debug_value_binding( + &self, + bindings: &mut ScopeState<'i>, + name: &str, + type_ref: &TypeRef, + value: &DebugValue, + origin: VariableOrigin, + ) -> Option<()> { + let type_name = type_ref.type_name(); + if !is_structured_type_ref(type_ref) { + let expr = debug_value_to_expr(value)?; + bindings.insert(name.to_string(), ScopeBinding { type_name, source: ScopeValueSource::Expr(expr), origin, hidden: false }); + return Some(()); + } + + let leaf_specs = flatten_contract_type_leaves(self.contract_ast.as_ref()?, type_ref).ok()?; + if leaf_specs.is_empty() { + let expr = debug_value_to_expr(value)?; + bindings.insert(name.to_string(), ScopeBinding { type_name, source: ScopeValueSource::Expr(expr), origin, hidden: false }); + return Some(()); + } + let leaf_bindings = leaf_specs + .iter() + .map(|(field_path, leaf_type)| DebugLeafBinding { + field_path: field_path.clone(), + type_name: leaf_type.type_name(), + stack_index: None, + }) + .collect::>(); + + bindings.insert( + name.to_string(), + ScopeBinding { + type_name, + source: ScopeValueSource::StructuredBinding { base_name: name.to_string(), leaf_bindings: leaf_bindings.clone() }, + origin, + hidden: false, + }, + ); + + for (field_path, leaf_type) in leaf_specs { + let leaf_value = structured_leaf_value(value, &field_path)?; + let leaf_expr = debug_value_to_expr(&leaf_value)?; + let leaf_name = flattened_struct_name(name, &field_path); + bindings.insert( + leaf_name, + ScopeBinding { type_name: leaf_type.type_name(), source: ScopeValueSource::Expr(leaf_expr), origin, hidden: true }, + ); + } + Some(()) + } + + fn resolved_contract_state_value(&self) -> Option { + let contract = self.contract_ast.as_ref()?; + let expr = resolve_contract_state_expr(contract, &self.debug_info.constructor_args, &self.debug_info.constants).ok()?; + expr_to_debug_value(&expr).ok() + } + fn freeze_inline_snapshot_bindings(&self, bindings: &mut ScopeState<'i>, frame_id: u32) -> HashSet { let Some(parent_vars) = self.inline_scope_snapshots.get(&frame_id) else { return HashSet::new(); @@ -723,6 +968,21 @@ impl<'a, 'i> DebugSession<'a, 'i> { continue; } + if is_structured_type_name(&variable.type_name) + && let Ok(type_ref) = parse_type_ref(&variable.type_name) + && self.inject_debug_value_binding(bindings, name, &type_ref, &variable.value, variable.origin).is_some() + { + frozen_names.insert(name.clone()); + if let Some(contract) = self.contract_ast.as_ref() + && let Ok(leaf_specs) = flatten_contract_type_leaves(contract, &type_ref) + { + for (field_path, _) in leaf_specs { + frozen_names.insert(flattened_struct_name(name, &field_path)); + } + } + continue; + } + bindings.insert( name.clone(), ScopeBinding { @@ -755,7 +1015,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { variables } - fn step_updates_are_visible(&self, step: &DebugStep<'i>, context: &VariableContext<'_>) -> bool { + fn step_updates_are_visible(&self, step: &DebugStep<'i>, context: &VariableContext) -> bool { if step.bytecode_start < context.function_start || step.bytecode_start >= context.function_end { return false; } @@ -837,7 +1097,17 @@ impl<'a, 'i> DebugSession<'a, 'i> { return; } - let step_id = self.current_scope_step_id(); + let step_id = self + .current_step_index + .map(|index| { + let parent_frame_id = if index == 0 { + 0 + } else { + self.step_at_order(index.saturating_sub(1)).map(|previous| previous.frame_id).unwrap_or(0) + }; + StepId::new(step.sequence, parent_frame_id) + }) + .unwrap_or_else(|| self.current_scope_step_id()); let Ok(scope_state) = self.scope_state(step_id) else { return; }; @@ -893,7 +1163,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { // InlineCallEnter is steppable so `step_into` can land on a call // boundary and build call-stack transitions. InlineCallExit is not // steppable to avoid synthetic extra stops while unwinding. - matches!(&step.kind, StepKind::Source {} | StepKind::InlineCallEnter { .. }) + match &step.kind { + StepKind::Source {} => !is_synthetic_default_span(step.span), + StepKind::InlineCallEnter { .. } => true, + StepKind::InlineCallExit { .. } => false, + } } fn steppable_step_index_for_offset(&self, offset: usize, min_sequence: Option) -> Option { @@ -917,6 +1191,28 @@ impl<'a, 'i> DebugSession<'a, 'i> { }) } + fn initial_step_index_for_offset(&self, offset: usize, min_sequence: Option) -> Option { + let mut best: Option<(usize, u32, u32)> = None; + for (order_index, &step_index) in self.step_order.iter().enumerate() { + let Some(step) = self.debug_info.steps.get(step_index) else { + continue; + }; + if !self.is_steppable_step(step) + || is_synthetic_default_span(step.span) + || !range_matches_offset(step.bytecode_start, step.bytecode_end, offset) + || min_sequence.is_some_and(|min_sequence| step.sequence < min_sequence) + { + continue; + } + + match best { + Some((_, best_depth, best_sequence)) if (best_depth, best_sequence) <= (step.call_depth, step.sequence) => {} + _ => best = Some((order_index, step.call_depth, step.sequence)), + } + } + best.map(|(order_index, _, _)| order_index) + } + fn find_steppable_step_index(&self, predicate: impl Fn(&DebugStep<'i>) -> bool) -> Option { self.step_order.iter().enumerate().find_map(|(order_index, &step_index)| { let step = self.debug_info.steps.get(step_index)?; @@ -1016,7 +1312,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn step_hits_breakpoint(&self, step: &DebugStep<'i>) -> bool { - (step.span.line..=step.span.end_line).any(|line| self.breakpoints.contains(&line)) + matches!(step.kind, StepKind::Source {}) + && !is_synthetic_default_span(step.span) + && (step.span.line..=step.span.end_line).any(|line| self.breakpoints.contains(&line)) } /// Returns the current main stack as hex-encoded strings. @@ -1052,14 +1350,14 @@ impl<'a, 'i> DebugSession<'a, 'i> { pub fn build_failure_report(&self, error: &kaspa_txscript_errors::TxScriptError) -> FailureReport { let failure_span = self.current_span(); let call_stack = self.call_stack_with_spans(); - let innermost_function = self.current_function_name().unwrap_or("").to_string(); + let innermost_function = self.current_function_name().unwrap_or_else(|| "".to_string()); let innermost_vars: Vec = self.list_variables().unwrap_or_default().into_iter().filter(|v| v.origin != VariableOrigin::Constant).collect(); let mut frames = vec![FailureFrame { function_name: innermost_function.clone(), span: failure_span, variables: innermost_vars }]; - let entry_name = self.current_function_name().unwrap_or("").to_string(); + let entry_name = self.current_function_name().unwrap_or_else(|| "".to_string()); for idx in (0..call_stack.len()).rev() { let entry = &call_stack[idx]; let caller_vars: Vec = self @@ -1086,6 +1384,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings) } ScopeValueSource::Expr(expr) => self.evaluate_scope_expr_as(scope_state, expr, &binding.type_name), + ScopeValueSource::Unavailable { message } => Err(message.clone()), } } @@ -1140,7 +1439,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { ShadowBindingValue { name: name.clone(), stack_index: *from_top, value: self.read_stack_at_index(*from_top)? }, ); } - ScopeValueSource::StructuredBinding { .. } => {} + ScopeValueSource::StructuredBinding { .. } | ScopeValueSource::Unavailable { .. } => {} ScopeValueSource::Expr(expr) => { env.insert(name.clone(), expr.clone()); } @@ -1149,10 +1448,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut shadow_bindings = shadow_by_name.into_values().collect::>(); shadow_bindings.sort_by(|left, right| right.stack_index.cmp(&left.stack_index)); + let binding_count = shadow_bindings.len(); let stack_bindings = shadow_bindings .iter() .enumerate() - .map(|(index, binding)| (binding.name.clone(), (shadow_bindings.len() - 1 - index) as i64)) + .map(|(index, binding)| (binding.name.clone(), (binding_count - index - 1) as i64)) .collect(); Ok((shadow_bindings, env, stack_bindings, eval_types)) } @@ -1169,24 +1469,32 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn execute_shadow_script(&self, script: &[u8]) -> Result, String> { let sig_cache = Cache::new(0); let reused_values = SigHashReusedValuesUnsync::new(); - let mut engine: DebugEngine<'_> = if let Some(shadow) = self.shadow_tx_context { - let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(shadow.covenants_ctx); - TxScriptEngine::from_transaction_input( + if let Some(shadow) = self.shadow_tx_context.as_ref() { + let covenants_ctx = CovenantsContext::from_tx(shadow.tx) + .map_err(|err| format!("failed to build covenants context for shadow evaluation: {err}"))?; + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&covenants_ctx); + let mut engine = TxScriptEngine::from_transaction_input( shadow.tx, shadow.input, shadow.input_index, shadow.utxo_entry, ctx, EngineFlags { covenants_enabled: true }, - ) + ); + for opcode in parse_script::, DebugReused>(script) { + let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; + engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + } + engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } else { - TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }) - }; - for opcode in parse_script::, DebugReused>(script) { - let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; - engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + let mut engine = + TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }); + for opcode in parse_script::, DebugReused>(script) { + let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; + engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + } + engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } - engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } fn read_stack_at_index(&self, index: i64) -> Result, String> { @@ -1269,6 +1577,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings).ok() } ScopeValueSource::Expr(expr) => self.try_resolve_expr_value(scope_state, expr, visiting), + ScopeValueSource::Unavailable { .. } => None, } } @@ -1464,6 +1773,36 @@ fn debug_value_to_expr<'i>(value: &DebugValue) -> Option> { } } +fn expr_to_debug_value(expr: &Expr<'_>) -> Result { + match &expr.kind { + ExprKind::Int(value) => Ok(DebugValue::Int(*value)), + ExprKind::Bool(value) => Ok(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Ok(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Ok(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + return Ok(DebugValue::Bytes( + values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(), + )); + } + Ok(DebugValue::Array(values.iter().map(expr_to_debug_value).collect::, _>>()?)) + } + ExprKind::StateObject(fields) => Ok(DebugValue::Object( + fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.expr)?))) + .collect::, String>>()?, + )), + other => Err(format!("unsupported resolved state expression in debugger: {other:?}")), + } +} + /// Executes sigscript to seed the stack before debugging lockscript. fn seed_engine_with_sigscript(engine: &mut DebugEngine<'_>, sigscript: &[u8]) -> Result<(), kaspa_txscript_errors::TxScriptError> { for opcode in parse_script::, DebugReused>(sigscript) { @@ -1496,6 +1835,10 @@ fn range_matches_offset(bytecode_start: usize, bytecode_end: usize, offset: usiz if bytecode_start == bytecode_end { offset == bytecode_start } else { offset >= bytecode_start && offset < bytecode_end } } +fn is_synthetic_default_span(span: SourceSpan) -> bool { + span.line == 1 && span.col == 1 && span.end_line == 1 && span.end_col == 1 +} + fn map_expr_children_for_eval<'i, F>(expr: &'i Expr<'i>, map_child: &mut F) -> Result, String> where F: FnMut(&'i Expr<'i>) -> Result, String>, @@ -1687,6 +2030,37 @@ fn is_inline_synthetic_name(name: &str) -> bool { name.starts_with("__arg_") || name.starts_with("__struct_") } +fn contract_struct_fields<'i>(contract: &ContractAst<'i>, name: &str) -> Option> { + if name == "State" { + return Some(contract.fields.iter().map(|field| (field.name.clone(), field.type_ref.clone())).collect()); + } + contract + .structs + .iter() + .find(|item| item.name == name) + .map(|item| item.fields.iter().map(|field| (field.name.clone(), field.type_ref.clone())).collect()) +} + +fn flatten_contract_type_leaves<'i>(contract: &ContractAst<'i>, type_ref: &TypeRef) -> Result, TypeRef)>, String> { + let TypeBase::Custom(name) = &type_ref.base else { + return Ok(vec![(Vec::new(), type_ref.clone())]); + }; + let Some(fields) = contract_struct_fields(contract, name) else { + return Ok(vec![(Vec::new(), type_ref.clone())]); + }; + + let mut leaves = Vec::new(); + for (field_name, field_type_ref) in fields { + let mut nested_type = field_type_ref; + nested_type.array_dims.extend(type_ref.array_dims.iter().cloned()); + for (mut path, leaf_type) in flatten_contract_type_leaves(contract, &nested_type)? { + path.insert(0, field_name.clone()); + leaves.push((path, leaf_type)); + } + } + Ok(leaves) +} + fn is_structured_type_name(type_name: &str) -> bool { parse_type_ref(type_name).ok().is_some_and(|type_ref| is_structured_type_ref(&type_ref)) } @@ -1995,6 +2369,53 @@ mod tests { } } + #[test] + fn active_contract_state_preserves_top_level_struct_fields() { + let sig_cache = Box::leak(Box::new(Cache::new(10_000))); + let reused_values: &'static SigHashReusedValuesUnsync = Box::leak(Box::new(SigHashReusedValuesUnsync::new())); + let engine: DebugEngine<'static> = + TxScriptEngine::new(EngineCtx::new(sig_cache).with_reused(reused_values), EngineFlags { covenants_enabled: true }); + let source = r#" + contract Sample() { + struct Pair { + int left; + int right; + } + + Pair pair = { left: 1, right: 2 }; + + entrypoint function main() { + require(true); + } + } + "#; + let debug_info = DebugInfo { + source: source.to_string(), + steps: vec![], + params: vec![], + functions: vec![DebugFunctionRange { name: "main".to_string(), bytecode_start: 0, bytecode_end: 1 }], + constructor_args: vec![], + constants: vec![], + }; + let session = DebugSession::full(&[], &[], source, Some(debug_info), engine).unwrap().with_active_contract_state(Some( + DebugValue::Object(vec![( + "pair".to_string(), + DebugValue::Object(vec![("left".to_string(), DebugValue::Int(7)), ("right".to_string(), DebugValue::Int(9))]), + )]), + )); + + let scope_state = session.scope_state(StepId::ROOT).unwrap(); + let vars = session.collect_variables_map(&scope_state); + let pair = vars.get("pair").expect("pair variable"); + match &pair.value { + DebugValue::Object(fields) => { + assert!(matches!(fields[0], (ref name, DebugValue::Int(7)) if name == "left")); + assert!(matches!(fields[1], (ref name, DebugValue::Int(9)) if name == "right")); + } + other => panic!("expected object debug value, got {other:?}"), + } + } + #[test] fn shadow_eval_resolves_nested_inline_synthetic_chain() { let mut sig_builder = ScriptBuilder::new(); diff --git a/debugger/session/src/test_runner.rs b/debugger/session/src/test_runner.rs index 8d65267b..276fa8aa 100644 --- a/debugger/session/src/test_runner.rs +++ b/debugger/session/src/test_runner.rs @@ -13,9 +13,11 @@ pub struct ContractTestCase { pub name: String, pub function: String, #[serde(default)] + pub delegate: bool, + #[serde(default)] pub constructor_args: Vec, #[serde(default)] - pub args: Vec, + pub args: Option>, pub expect: TestExpectation, #[serde(default)] pub tx: Option, @@ -52,10 +54,12 @@ pub struct TestTxInputScenario { pub sig_op_count: u8, pub utxo_value: u64, #[serde(default)] - pub covenant_id: Option, + pub covenant_id: Option, #[serde(default)] pub constructor_args: Option>, #[serde(default)] + pub state: Option, + #[serde(default)] pub signature_script_hex: Option, #[serde(default)] pub utxo_script_hex: Option, @@ -65,12 +69,14 @@ pub struct TestTxInputScenario { pub struct TestTxOutputScenario { pub value: u64, #[serde(default)] - pub covenant_id: Option, + pub covenant_id: Option, #[serde(default)] pub authorizing_input: Option, #[serde(default)] pub constructor_args: Option>, #[serde(default)] + pub state: Option, + #[serde(default)] pub script_hex: Option, #[serde(default)] pub p2pk_pubkey: Option, @@ -87,8 +93,9 @@ pub struct ResolvedContractTest { pub struct ContractTestCaseResolved { pub name: String, pub function: String, + pub delegate: bool, pub constructor_args: Vec, - pub args: Vec, + pub args: Option>, pub expect: TestExpectation, pub tx: Option, } @@ -111,6 +118,7 @@ pub struct TestTxInputScenarioResolved { pub utxo_value: u64, pub covenant_id: Option, pub constructor_args: Option>, + pub state: Option, pub signature_script_hex: Option, pub utxo_script_hex: Option, } @@ -121,6 +129,7 @@ pub struct TestTxOutputScenarioResolved { pub covenant_id: Option, pub authorizing_input: Option, pub constructor_args: Option>, + pub state: Option, pub script_hex: Option, pub p2pk_pubkey: Option, } @@ -175,8 +184,9 @@ pub fn resolve_contract_test( let resolved = ContractTestCaseResolved { name: test.name, function: test.function, + delegate: test.delegate, constructor_args: values_to_args(&test.constructor_args)?, - args: values_to_args(&test.args)?, + args: test.args.as_ref().map(|values| values_to_args(values)).transpose()?, expect: test.expect, tx: test.tx.map(resolve_tx_scenario).transpose()?, }; @@ -206,8 +216,9 @@ pub fn resolve_tx_scenario(tx: TestTxScenario) -> Result Result Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Counter(int init_value) { + int value = init_value; + + #[covenant.singleton] + function step(State prev_state, State[] new_states) { + require(prev_state.value == value); + require(new_states.length <= 1); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[Expr::int(7)], compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let covenant_target = compiled + .resolve_covenant_call_target("step", CovenantDeclCallOptions { is_leader: false }) + .ok_or("missing covenant call target")?; + let sigscript = compiled.build_sig_script_for_covenant_decl( + "step", + vec![vec![struct_object(vec![("value", Expr::int(8))])].into()], + CovenantDeclCallOptions { is_leader: false }, + )?; + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x55u8; 32]), index: 0 }, + signature_script: sigscript.clone(), + sequence: 0, + sig_op_count: 0, + }; + let output = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output], 0, Default::default(), 0, vec![]); + + let covenant_id = Hash::from_bytes([0x33u8; 32]); + let utxo_entry = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let populated_tx = PopulatedTransaction::new(&tx, vec![utxo_entry]); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let input_ref = &tx.inputs[0]; + let utxo_ref = populated_tx.utxo(0).ok_or("missing utxo for input 0")?; + let engine = debugger_session::session::DebugEngine::from_transaction_input( + &populated_tx, + input_ref, + 0, + utxo_ref, + ctx, + EngineFlags { covenants_enabled: true }, + ); + + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)? + .with_shadow_tx_context(shadow_ctx) + .with_active_contract_state(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]))) + .with_covenant_mode(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))])), covenant_target); + + session.run_to_first_executed_statement()?; + + for _ in 0..24 { + if !session.call_stack().is_empty() { + assert_eq!(session.call_stack(), vec!["step".to_string()]); + assert_eq!(session.current_function_name().as_deref(), Some("step")); + + let prev_state = session.variable_by_name("prev_state")?; + assert_eq!(prev_state.type_name, "State"); + assert_eq!(format_value(&prev_state.type_name, &prev_state.value), "{value: 7}"); + + let new_states = session.variable_by_name("new_states")?; + assert_eq!(new_states.origin.label(), "arg"); + + let value = session.variable_by_name("value")?; + assert_eq!(value.origin.label(), "state"); + assert_eq!(format_value(&value.type_name, &value.value), "7"); + + let (type_name, value) = session.evaluate_expression("prev_state.value")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + return Ok(()); + } + + if session.step_into()?.is_none() { + break; + } + } + + Err("expected to step into source-level covenant policy frame".into()) +} + +#[test] +fn debug_session_resolves_covenant_locals_derived_from_prev_state_arrays() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + int a = prev_states[0].value; + int b = a + bump + value; + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + require(b == 16); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[Expr::int(2)], compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let covenant_target = compiled + .resolve_covenant_call_target("step", CovenantDeclCallOptions { is_leader: true }) + .ok_or("missing covenant call target")?; + let sigscript = compiled.build_sig_script_for_covenant_decl( + "step", + vec![vec![struct_object(vec![("value", Expr::int(9))]), struct_object(vec![("value", Expr::int(7))])].into()], + CovenantDeclCallOptions { is_leader: true }, + )?; + + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x66u8; 32]), index: 0 }, + signature_script: sigscript.clone(), + sequence: 0, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x77u8; 32]), index: 0 }, + signature_script: vec![OpTrue], + sequence: 0, + sig_op_count: 0, + }; + let output0 = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let output1 = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let tx = Transaction::new(1, vec![input0, input1], vec![output0, output1], 0, Default::default(), 0, vec![]); + + let covenant_id = Hash::from_bytes([0x44u8; 32]); + let utxo_entry0 = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let utxo_entry1 = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let populated_tx = PopulatedTransaction::new(&tx, vec![utxo_entry0, utxo_entry1]); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let input_ref = &tx.inputs[0]; + let utxo_ref = populated_tx.utxo(0).ok_or("missing utxo for input 0")?; + let engine = debugger_session::session::DebugEngine::from_transaction_input( + &populated_tx, + input_ref, + 0, + utxo_ref, + ctx, + EngineFlags { covenants_enabled: true }, + ); + + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)? + .with_shadow_tx_context(shadow_ctx) + .with_active_contract_state(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]))) + .with_covenant_mode( + Some(DebugValue::Array(vec![ + DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]), + DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]), + ])), + covenant_target, + ); + + session.run_to_first_executed_statement()?; + session.step_over()?; + + let a = session.variable_by_name("a")?; + assert_eq!(a.type_name, "int"); + assert_eq!(format_value(&a.type_name, &a.value), "7"); + + let (type_name, value) = session.evaluate_expression("a")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + + let (type_name, value) = session.evaluate_expression("prev_states[0].value")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + Ok(()) +} diff --git a/silverscript-lang/src/ast.rs b/silverscript-lang/src/ast.rs index ec6f3f84..d1f7dee3 100644 --- a/silverscript-lang/src/ast.rs +++ b/silverscript-lang/src/ast.rs @@ -2134,7 +2134,10 @@ fn apply_number_unit<'i>(expr: Expr<'i>, unit: &str) -> Result, Compile "kas" => 100_000_000, _ => return Err(CompilerError::Unsupported(format!("number unit '{unit}' not supported"))), }; - Ok(Expr::new(ExprKind::Int(value.saturating_mul(multiplier)), span)) + let scaled = value + .checked_mul(multiplier) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("number literal overflow for unit '{unit}'")))?; + Ok(Expr::new(ExprKind::Int(scaled), span)) } fn parse_date_literal<'i>(pair: Pair<'i, Rule>) -> Result, CompilerError> { diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index b5038f18..11d048e2 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -10,11 +10,12 @@ use crate::ast::{ ParamAst, SplitPart, StateBindingAst, StateFieldExpr, Statement, TimeVar, TypeBase, TypeRef, UnaryOp, UnarySuffixKind, parse_contract_ast, parse_type_ref, }; -use crate::debug_info::{DebugInfo, RuntimeBinding, SourceSpan}; +use crate::debug_info::{DebugInfo, DebugNamedValue, RuntimeBinding, SourceSpan}; pub use crate::errors::{CompilerError, ErrorSpan}; use crate::span; mod covenant_declarations; use covenant_declarations::lower_covenant_declarations; +pub use covenant_declarations::{CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget}; mod debug_recording; mod debug_value_types; @@ -80,10 +81,72 @@ pub struct CompiledContract<'i> { pub ast: ContractAst<'i>, pub abi: Vec, pub without_selector: bool, + #[serde(default)] + pub covenant_infos: Vec, pub state_layout: CompiledStateLayout, pub debug_info: Option>, } +impl<'i> CompiledContract<'i> { + pub fn encode_state(&self, state: &Expr<'i>) -> Result, CompilerError> { + let structs = build_struct_registry(&self.ast)?; + let state_type = TypeRef { base: TypeBase::Custom("State".to_string()), array_dims: Vec::new() }; + let encoded_state = encode_struct_value(state, &state_type, &structs)?; + if encoded_state.len() != self.state_layout.len { + return Err(CompilerError::Unsupported(format!( + "encoded state size mismatch: expected {} bytes, got {}", + self.state_layout.len, + encoded_state.len() + ))); + } + + let layout = self.state_layout; + let mut script = self.script.clone(); + script[layout.start..layout.start + layout.len].copy_from_slice(&encoded_state); + Ok(script) + } +} + +pub fn resolve_contract_state_expr<'i>( + contract: &ContractAst<'i>, + constructor_args: &[DebugNamedValue<'i>], + constants: &[DebugNamedValue<'i>], +) -> Result, CompilerError> { + let mut env = HashMap::new(); + for constant in constants { + env.insert(constant.name.clone(), constant.value.clone()); + } + for arg in constructor_args { + env.insert(arg.name.clone(), arg.value.clone()); + } + + let mut fields = Vec::with_capacity(contract.fields.len()); + for field in &contract.fields { + let resolved = resolve_expr(field.expr.clone(), &env, &mut HashSet::new())?; + env.insert(field.name.clone(), resolved.clone()); + fields.push(StateFieldExpr { name: field.name.clone(), expr: resolved, span: field.span, name_span: field.name_span }); + } + + Ok(Expr::new(ExprKind::StateObject(fields), span::Span::default())) +} + +fn collect_state_object_entries<'a, 'i>( + state_expr: &'a Expr<'i>, + object_name: &str, +) -> Result>, CompilerError> { + let ExprKind::StateObject(entries) = &state_expr.kind else { + return Err(CompilerError::Unsupported(format!("{object_name} must be an object literal"))); + }; + + let mut provided = HashMap::new(); + for entry in entries { + if provided.insert(entry.name.as_str(), &entry.expr).is_some() { + return Err(CompilerError::Unsupported(format!("duplicate state field '{}'", entry.name))); + } + } + Ok(provided) +} + #[derive(Clone, Default)] struct LoweringScope { vars: HashMap, @@ -486,7 +549,7 @@ fn read_input_state_field_expr_symbolic<'i>( ) -> Result, CompilerError> { let state_start_offset = state_start_offset(contract_field_prefix_len, contract_fields, contract_constants)?; let script_size_expr = Expr::new(ExprKind::Nullary(NullaryOp::ThisScriptSize), span::Span::default()); - let (field_payload_len, decode_numeric) = fixed_state_field_payload_len(field, contract_constants)?; + let field_payload_len = fixed_state_field_payload_len(field, contract_constants)?; let field_payload_offset = state_start_offset + field_chunk_offset + data_prefix(field_payload_len).len(); let sig_len = Expr::call("OpTxInputScriptSigLen", vec![input_idx.clone()]); @@ -507,7 +570,7 @@ fn read_input_state_field_expr_symbolic<'i>( ); let substr = Expr::call("OpTxInputScriptSigSubstr", vec![input_idx.clone(), start, end]); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, &field.type_ref) } fn read_input_state_with_template_values<'i>( @@ -930,7 +993,7 @@ fn compile_contract_impl<'i>( constants.insert(param.name.clone(), value.clone()); } - let lowered_contract = lower_covenant_declarations(contract, &constants)?; + let (lowered_contract, covenant_infos) = lower_covenant_declarations(contract, &constants)?; let structs = build_struct_registry(&lowered_contract)?; validate_struct_graph(&structs)?; validate_contract_struct_usage(&lowered_contract, &structs)?; @@ -1028,6 +1091,7 @@ fn compile_contract_impl<'i>( ast: lowered_contract.clone(), abi: function_abi_entries, without_selector, + covenant_infos: covenant_infos.clone(), state_layout, debug_info, }); @@ -1041,6 +1105,7 @@ fn compile_contract_impl<'i>( ast: lowered_contract.clone(), abi: function_abi_entries, without_selector, + covenant_infos: covenant_infos.clone(), state_layout, debug_info, }); @@ -1834,18 +1899,16 @@ fn fixed_type_size_with_constants_ref<'i>(type_ref: &TypeRef, constants: &HashMa fn fixed_state_field_payload_len_for_type_ref<'i>( type_ref: &TypeRef, contract_constants: &HashMap>, -) -> Result<(usize, bool), CompilerError> { - let payload_len = fixed_type_size_with_constants_ref(type_ref, contract_constants).ok_or_else(|| { +) -> Result { + fixed_type_size_with_constants_ref(type_ref, contract_constants).ok_or_else(|| { CompilerError::Unsupported(format!("readInputState does not support field type {}", type_name_from_ref(type_ref))) - })?; - let decode_numeric = type_ref.array_dims.is_empty() && matches!(type_ref.base, TypeBase::Int | TypeBase::Bool); - Ok((payload_len, decode_numeric)) + }) } fn fixed_state_field_payload_len<'i>( field: &ContractFieldAst<'i>, contract_constants: &HashMap>, -) -> Result<(usize, bool), CompilerError> { +) -> Result { fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants) } @@ -2214,6 +2277,18 @@ fn infer_fixed_array_type_from_initializer<'i>( } impl<'i> CompiledContract<'i> { + pub fn resolve_covenant_call_target( + &self, + function_name: &str, + options: CovenantDeclCallOptions, + ) -> Option { + self.covenant_infos + .iter() + .find(|info| info.source_name == function_name) + .cloned() + .map(|info| ResolvedCovenantCallTarget { info, is_leader: options.is_leader }) + } + pub fn build_sig_script(&self, function_name: &str, args: Vec>) -> Result, CompilerError> { let structs = build_struct_registry(&self.ast)?; let function = self @@ -2250,22 +2325,11 @@ impl<'i> CompiledContract<'i> { args: Vec>, options: CovenantDeclCallOptions, ) -> Result, CompilerError> { - let auth_entrypoint = generated_covenant_entrypoint_name(function_name); - if self.abi.iter().any(|entry| entry.name == auth_entrypoint) { - return self.build_sig_script(&auth_entrypoint, args); - } - - let entrypoint = if options.is_leader { - generated_covenant_leader_entrypoint_name(function_name) - } else { - generated_covenant_delegate_entrypoint_name(function_name) - }; - - if self.abi.iter().any(|entry| entry.name == entrypoint) { - return self.build_sig_script(&entrypoint, args); - } - - Err(CompilerError::Unsupported(format!("covenant declaration '{}' not found", function_name))) + let target = self + .resolve_covenant_call_target(function_name, options) + .ok_or_else(|| CompilerError::Unsupported(format!("covenant declaration '{}' not found", function_name)))?; + let generated_entrypoint_name = target.generated_entrypoint_name(); + self.build_sig_script(&generated_entrypoint_name, args) } } @@ -3710,7 +3774,7 @@ fn encoded_field_chunk_size<'i>( field: &ContractFieldAst<'i>, contract_constants: &HashMap>, ) -> Result { - let (payload_size, _) = fixed_state_field_payload_len(field, contract_constants)?; + let payload_size = fixed_state_field_payload_len(field, contract_constants)?; Ok(data_prefix(payload_size).len() + payload_size) } @@ -3718,7 +3782,7 @@ fn encoded_field_chunk_size_for_type_ref<'i>( type_ref: &TypeRef, contract_constants: &HashMap>, ) -> Result { - let (payload_size, _) = fixed_state_field_payload_len_for_type_ref(type_ref, contract_constants)?; + let payload_size = fixed_state_field_payload_len_for_type_ref(type_ref, contract_constants)?; Ok(data_prefix(payload_size).len() + payload_size) } @@ -3771,7 +3835,7 @@ fn read_input_state_binding_expr<'i>( script_size_value: i64, contract_constants: &HashMap>, ) -> Result, CompilerError> { - let (field_payload_len, decode_numeric) = fixed_state_field_payload_len(field, contract_constants)?; + let field_payload_len = fixed_state_field_payload_len(field, contract_constants)?; let field_payload_offset = state_start_offset + field_chunk_offset + data_prefix(field_payload_len).len(); let sig_len = Expr::call("OpTxInputScriptSigLen", vec![input_idx.clone()]); @@ -3792,7 +3856,7 @@ fn read_input_state_binding_expr<'i>( ); let substr = Expr::call("OpTxInputScriptSigSubstr", vec![input_idx.clone(), start, end]); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, &field.type_ref) } fn read_input_state_field_expr_with_type<'i>( @@ -3804,10 +3868,9 @@ fn read_input_state_field_expr_with_type<'i>( contract_constants: &HashMap>, builtin_name: &str, ) -> Result, CompilerError> { - let (field_payload_len, decode_numeric) = - fixed_state_field_payload_len_for_type_ref(field_type, contract_constants).map_err(|_| { - CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(field_type))) - })?; + let field_payload_len = fixed_state_field_payload_len_for_type_ref(field_type, contract_constants).map_err(|_| { + CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(field_type))) + })?; let field_payload_offset = binary_expr( BinaryOp::Add, state_start_offset_expr, @@ -3817,7 +3880,15 @@ fn read_input_state_field_expr_with_type<'i>( let end = binary_expr(BinaryOp::Add, start.clone(), Expr::int(field_payload_len as i64)); let substr = input_sigscript_substr_expr(input_idx, start, end); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, field_type) +} + +fn cast_read_input_state_expr<'i>(substr: Expr<'i>, type_ref: &TypeRef) -> Result, CompilerError> { + let type_name = type_name_from_ref(type_ref); + match type_ref.base { + TypeBase::Custom(_) => Err(CompilerError::Unsupported(format!("readInputState does not support field type {type_name}"))), + _ => Ok(Expr::call(type_name.as_str(), vec![substr])), + } } #[allow(clippy::too_many_arguments)] @@ -4438,16 +4509,7 @@ fn compile_encoded_object_with_layout( contract_constants: &HashMap>, builtin_name: &str, ) -> Result { - let ExprKind::StateObject(state_entries) = &state_expr.kind else { - return Err(CompilerError::Unsupported(format!("{builtin_name} second argument must be an object literal"))); - }; - - let mut provided = HashMap::new(); - for entry in state_entries { - if provided.insert(entry.name.as_str(), &entry.expr).is_some() { - return Err(CompilerError::Unsupported(format!("duplicate state field '{}'", entry.name))); - } - } + let mut provided = collect_state_object_entries(state_expr, &format!("{builtin_name} second argument"))?; if provided.len() != layout_fields.len() { return Err(CompilerError::Unsupported("new_state must include all contract fields exactly once".to_string())); } @@ -4458,15 +4520,11 @@ fn compile_encoded_object_with_layout( return Err(CompilerError::Unsupported(format!("missing state field '{}'", field.name))); }; - let (field_size, encode_numeric) = - fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants).map_err(|_| { - CompilerError::Unsupported(format!( - "{builtin_name} does not support field type {}", - type_name_from_ref(&field.type_ref) - )) - })?; + let field_size = fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants).map_err(|_| { + CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(&field.type_ref))) + })?; - if encode_numeric { + if field.type_ref.array_dims.is_empty() && matches!(field.type_ref.base, TypeBase::Int | TypeBase::Bool) { compile_expr( new_value, env, @@ -5197,8 +5255,11 @@ fn compile_for_statement<'i>( script_size: Option, recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { - let max_iterations = eval_const_int(max_iterations_expr, contract_constants) - .map_err(|_| CompilerError::Unsupported("for loop max iterations must be a compile-time integer".to_string()))?; + let max_iterations = match eval_const_int(max_iterations_expr, contract_constants) { + Ok(value) => value, + Err(CompilerError::InvalidLiteral(message)) => return Err(CompilerError::InvalidLiteral(message)), + Err(_) => return Err(CompilerError::Unsupported("for loop max iterations must be a compile-time integer".to_string())), + }; if max_iterations < 0 { return Err(CompilerError::Unsupported("for loop max iterations must be a non-negative compile-time integer".to_string())); } @@ -5442,26 +5503,37 @@ fn eval_const_int<'i>(expr: &Expr<'i>, constants: &HashMap>) -> Some(value) => eval_const_int(value, constants), None => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), }, - ExprKind::Unary { op: UnaryOp::Neg, expr } => Ok(-eval_const_int(expr, constants)?), + ExprKind::Unary { op: UnaryOp::Neg, expr } => { + let value = eval_const_int(expr, constants)?; + value.checked_neg().ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: -({value})"))) + } ExprKind::Unary { .. } => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), ExprKind::Binary { op, left, right } => { let lhs = eval_const_int(left, constants)?; let rhs = eval_const_int(right, constants)?; match op { - BinaryOp::Add => Ok(lhs + rhs), - BinaryOp::Sub => Ok(lhs - rhs), - BinaryOp::Mul => Ok(lhs * rhs), + BinaryOp::Add => lhs + .checked_add(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} + {rhs}"))), + BinaryOp::Sub => lhs + .checked_sub(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} - {rhs}"))), + BinaryOp::Mul => lhs + .checked_mul(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} * {rhs}"))), BinaryOp::Div => { if rhs == 0 { return Err(CompilerError::InvalidLiteral("division by zero in for loop bounds".to_string())); } - Ok(lhs / rhs) + lhs.checked_div(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} / {rhs}"))) } BinaryOp::Mod => { if rhs == 0 { return Err(CompilerError::InvalidLiteral("modulo by zero in for loop bounds".to_string())); } - Ok(lhs % rhs) + lhs.checked_rem(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} % {rhs}"))) } _ => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), } @@ -6373,9 +6445,6 @@ fn compile_expr<'i>( *stack_depth -= 1; builder.add_op(OpSubstr)?; *stack_depth -= 2; - if element_type == "int" { - builder.add_op(OpBin2Num)?; - } Ok(()) } ExprKind::Slice { source, start, end, .. } => { @@ -7178,7 +7247,7 @@ fn compile_call_expr<'i>( )?; Ok(()) } - "bool" | "string" => { + "byte" | "bool" | "string" => { if args.len() != 1 { return Err(CompilerError::Unsupported(format!("{name}() expects a single argument"))); } @@ -7573,21 +7642,15 @@ pub fn compile_debug_expr<'i>( Ok((builder.drain(), type_name)) } -pub(super) fn resolve_expr_for_debug<'i>( - expr: Expr<'i>, - env: &HashMap>, - visiting: &mut HashSet, -) -> Result, CompilerError> { - resolve_expr(expr, env, visiting) -} - #[cfg(test)] mod tests { use std::collections::HashMap; use kaspa_txscript::opcodes::codes::OpData1; - use super::{Op0, OpPushData1, OpPushData2, StackBindings, data_prefix}; + use crate::ast::{BinaryOp, Expr, ExprKind, UnaryOp}; + + use super::{Op0, OpPushData1, OpPushData2, StackBindings, data_prefix, eval_const_int}; #[test] fn data_prefix_encodes_small_pushes() { @@ -7631,4 +7694,59 @@ mod tests { ["field_b", "field_a", "param_b", "param_a"].into_iter().map(str::to_string).collect::>() ); } + + #[test] + fn eval_const_int_rejects_checked_arithmetic_overflow() { + let constants = HashMap::new(); + let cases = [ + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Add, left: Box::new(Expr::int(i64::MAX)), right: Box::new(Expr::int(1)) }, + Default::default(), + ), + format!("constant integer overflow: {} + 1", i64::MAX), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Sub, left: Box::new(Expr::int(-i64::MAX)), right: Box::new(Expr::int(2)) }, + Default::default(), + ), + format!("constant integer overflow: {} - 2", -i64::MAX), + ), + ( + Expr::new( + ExprKind::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::int(3_037_000_500)), + right: Box::new(Expr::int(3_037_000_500)), + }, + Default::default(), + ), + "constant integer overflow: 3037000500 * 3037000500".to_string(), + ), + ( + Expr::new(ExprKind::Unary { op: UnaryOp::Neg, expr: Box::new(Expr::int(i64::MIN)) }, Default::default()), + format!("constant integer overflow: -({})", i64::MIN), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Div, left: Box::new(Expr::int(i64::MIN)), right: Box::new(Expr::int(-1)) }, + Default::default(), + ), + format!("constant integer overflow: {} / -1", i64::MIN), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Mod, left: Box::new(Expr::int(i64::MIN)), right: Box::new(Expr::int(-1)) }, + Default::default(), + ), + format!("constant integer overflow: {} % -1", i64::MIN), + ), + ]; + + for (expr, expected) in cases { + let err = eval_const_int(&expr, &constants).expect_err("overflow should be rejected"); + assert!(err.to_string().contains(&expected), "unexpected error: {err}"); + } + } } diff --git a/silverscript-lang/src/compiler/covenant_declarations.rs b/silverscript-lang/src/compiler/covenant_declarations.rs index dd8f0bd7..7830104e 100644 --- a/silverscript-lang/src/compiler/covenant_declarations.rs +++ b/silverscript-lang/src/compiler/covenant_declarations.rs @@ -1,17 +1,116 @@ use super::*; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CovenantBinding { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CovenantDeclBinding { Auth, Cov, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CovenantMode { +enum CovenantDeclMode { Verification, Transition, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CovenantSourceParam { + name: String, + type_ref: TypeRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CovenantDeclInfo { + pub source_name: String, + pub binding: CovenantDeclBinding, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_param: Option, +} + +impl CovenantDeclInfo { + pub fn policy_function_name(&self) -> String { + generated_covenant_policy_name(&self.source_name) + } + + pub fn generated_entrypoint_name(&self, is_leader: bool) -> String { + match self.binding { + CovenantDeclBinding::Auth => generated_covenant_entrypoint_name(&self.source_name), + CovenantDeclBinding::Cov => { + if is_leader { + generated_covenant_leader_entrypoint_name(&self.source_name) + } else { + generated_covenant_delegate_entrypoint_name(&self.source_name) + } + } + } + } + + pub fn matches_generated_name(&self, function_name: &str) -> bool { + if self.policy_function_name() == function_name { + return true; + } + match self.binding { + CovenantDeclBinding::Auth => self.generated_entrypoint_name(true) == function_name, + CovenantDeclBinding::Cov => { + self.generated_entrypoint_name(true) == function_name || self.generated_entrypoint_name(false) == function_name + } + } + } + + pub fn display_name_for_function(&self, function_name: &str) -> Option { + if self.policy_function_name() == function_name { + return Some(self.source_name.clone()); + } + match self.binding { + CovenantDeclBinding::Auth => { + if self.generated_entrypoint_name(true) == function_name { + Some(self.source_name.clone()) + } else { + None + } + } + CovenantDeclBinding::Cov => { + if self.generated_entrypoint_name(true) == function_name { + Some(format!("{} [leader]", self.source_name)) + } else if self.generated_entrypoint_name(false) == function_name { + Some(format!("{} [delegate]", self.source_name)) + } else { + None + } + } + } + } + + pub fn source_param(&self) -> Option<(&str, &TypeRef)> { + self.source_param.as_ref().map(|param| (param.name.as_str(), ¶m.type_ref)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedCovenantCallTarget { + pub info: CovenantDeclInfo, + pub is_leader: bool, +} + +impl ResolvedCovenantCallTarget { + pub fn generated_entrypoint_name(&self) -> String { + self.info.generated_entrypoint_name(self.is_leader) + } + + pub fn display_name(&self) -> String { + match self.info.binding { + CovenantDeclBinding::Auth => self.info.source_name.clone(), + CovenantDeclBinding::Cov => { + if self.is_leader { + format!("{} [leader]", self.info.source_name) + } else { + format!("{} [delegate]", self.info.source_name) + } + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CovenantGroups { Single, @@ -26,8 +125,8 @@ enum CovenantTermination { #[derive(Debug, Clone)] struct CovenantDeclaration<'i> { - binding: CovenantBinding, - mode: CovenantMode, + binding: CovenantDeclBinding, + mode: CovenantDeclMode, groups: CovenantGroups, singleton: bool, termination: CovenantTermination, @@ -48,8 +147,9 @@ enum OutputStateSource<'i> { pub(super) fn lower_covenant_declarations<'i>( contract: &ContractAst<'i>, constants: &HashMap>, -) -> Result, CompilerError> { +) -> Result<(ContractAst<'i>, Vec), CompilerError> { let mut lowered = Vec::new(); + let mut infos = Vec::new(); for function in &contract.functions { if function.attributes.is_empty() { @@ -60,6 +160,8 @@ pub(super) fn lower_covenant_declarations<'i>( let declaration = parse_covenant_declaration(function, constants)?; validate_covenant_policy_state_shape(function, &declaration, &contract.fields)?; + infos.push(build_covenant_decl_info(function, declaration.binding)); + let policy_name = generated_covenant_policy_name(&function.name); let mut policy = function.clone(); @@ -69,13 +171,13 @@ pub(super) fn lower_covenant_declarations<'i>( lowered.push(policy.clone()); match declaration.binding { - CovenantBinding::Auth => { + CovenantDeclBinding::Auth => { let entrypoint_name = generated_covenant_entrypoint_name(&function.name); let mut wrapper = build_auth_wrapper(&policy, &policy_name, declaration.clone(), entrypoint_name, &contract.fields)?; wrapper.params = preserved_entrypoint_params(function, declaration, true, &contract.fields); lowered.push(wrapper); } - CovenantBinding::Cov => { + CovenantDeclBinding::Cov => { let leader_name = generated_covenant_leader_entrypoint_name(&function.name); let mut leader_wrapper = build_cov_wrapper(&policy, &policy_name, declaration.clone(), leader_name, true, &contract.fields)?; @@ -93,7 +195,14 @@ pub(super) fn lower_covenant_declarations<'i>( let mut lowered_contract = contract.clone(); lowered_contract.functions = lowered; - Ok(lowered_contract) + infos.sort_by(|left, right| left.source_name.cmp(&right.source_name)); + Ok((lowered_contract, infos)) +} + +fn build_covenant_decl_info<'i>(function: &FunctionAst<'i>, binding: CovenantDeclBinding) -> CovenantDeclInfo { + let source_param = + function.params.first().map(|param| CovenantSourceParam { name: param.name.clone(), type_ref: param.type_ref.clone() }); + CovenantDeclInfo { source_name: function.name.clone(), binding, source_param } } fn parse_covenant_declaration<'i>( @@ -192,13 +301,13 @@ fn parse_covenant_declaration<'i>( return Err(CompilerError::Unsupported("covenant 'to' must be >= 1".to_string())); } - let default_binding = if from_value == 1 { CovenantBinding::Auth } else { CovenantBinding::Cov }; + let default_binding = if from_value == 1 { CovenantDeclBinding::Auth } else { CovenantDeclBinding::Cov }; let binding = match args_by_name.get("binding").copied() { Some(expr) => { let binding_name = parse_attr_ident_arg("binding", Some(expr))?; match binding_name.as_str() { - "auth" => CovenantBinding::Auth, - "cov" => CovenantBinding::Cov, + "auth" => CovenantDeclBinding::Auth, + "cov" => CovenantDeclBinding::Cov, other => { return Err(CompilerError::Unsupported(format!("covenant binding must be auth|cov, got '{}'", other))); } @@ -211,8 +320,8 @@ fn parse_covenant_declaration<'i>( Some(expr) => { let mode_name = parse_attr_ident_arg("mode", Some(expr))?; match mode_name.as_str() { - "verification" => CovenantMode::Verification, - "transition" => CovenantMode::Transition, + "verification" => CovenantDeclMode::Verification, + "transition" => CovenantDeclMode::Transition, other => { return Err(CompilerError::Unsupported(format!("covenant mode must be verification|transition, got '{}'", other))); } @@ -220,9 +329,9 @@ fn parse_covenant_declaration<'i>( } None => { if function.return_types.is_empty() { - CovenantMode::Verification + CovenantDeclMode::Verification } else { - CovenantMode::Transition + CovenantDeclMode::Transition } } }; @@ -239,8 +348,8 @@ fn parse_covenant_declaration<'i>( } } None => match binding { - CovenantBinding::Auth => CovenantGroups::Multiple, - CovenantBinding::Cov => CovenantGroups::Single, + CovenantDeclBinding::Auth => CovenantGroups::Multiple, + CovenantDeclBinding::Cov => CovenantGroups::Single, }, }; @@ -261,30 +370,30 @@ fn parse_covenant_declaration<'i>( None => CovenantTermination::Disallowed, }; - if binding == CovenantBinding::Auth && from_value != 1 { + if binding == CovenantDeclBinding::Auth && from_value != 1 { return Err(CompilerError::Unsupported("binding=auth requires from = 1".to_string())); } - if binding == CovenantBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { + if binding == CovenantDeclBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { eprintln!( "warning: #[covenant(...)] on function '{}' uses binding=cov with from=1; binding=auth is usually a better default", function.name ); } - if binding == CovenantBinding::Cov && groups == CovenantGroups::Multiple { + if binding == CovenantDeclBinding::Cov && groups == CovenantGroups::Multiple { return Err(CompilerError::Unsupported("binding=cov with groups=multiple is not supported yet".to_string())); } - if args_by_name.contains_key("termination") && mode != CovenantMode::Transition { + if args_by_name.contains_key("termination") && mode != CovenantDeclMode::Transition { return Err(CompilerError::Unsupported("termination is only supported in mode=transition".to_string())); } if args_by_name.contains_key("termination") && !(from_value == 1 && to_value == 1) { return Err(CompilerError::Unsupported("termination is only supported for singleton covenants (from=1, to=1)".to_string())); } - if mode == CovenantMode::Verification && !function.return_types.is_empty() { + if mode == CovenantDeclMode::Verification && !function.return_types.is_empty() { return Err(CompilerError::Unsupported("verification mode policy functions must not declare return values".to_string())); } - if mode == CovenantMode::Transition && function.return_types.is_empty() { + if mode == CovenantDeclMode::Transition && function.return_types.is_empty() { return Err(CompilerError::Unsupported("transition mode policy functions must declare return values".to_string())); } @@ -317,7 +426,7 @@ fn validate_covenant_policy_state_shape<'i>( } match (declaration.binding, declaration.mode) { - (CovenantBinding::Auth, CovenantMode::Verification) => { + (CovenantDeclBinding::Auth, CovenantDeclMode::Verification) => { if policy.params.len() < 2 || !is_state_type_ref(&policy.params[0].type_ref) || !is_state_array_type_ref(&policy.params[1].type_ref) @@ -328,7 +437,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Cov, CovenantMode::Verification) => { + (CovenantDeclBinding::Cov, CovenantDeclMode::Verification) => { if policy.params.len() < 2 || !is_state_array_type_ref(&policy.params[0].type_ref) || !is_state_array_type_ref(&policy.params[1].type_ref) @@ -339,7 +448,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Auth, CovenantMode::Transition) => { + (CovenantDeclBinding::Auth, CovenantDeclMode::Transition) => { if policy.params.is_empty() || !is_state_type_ref(&policy.params[0].type_ref) { return Err(CompilerError::Unsupported(format!( "mode=transition with binding=auth on function '{}' expects parameters '(State prev_state, ...)'", @@ -347,7 +456,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Cov, CovenantMode::Transition) => { + (CovenantDeclBinding::Cov, CovenantDeclMode::Transition) => { if policy.params.is_empty() || !is_state_array_type_ref(&policy.params[0].type_ref) { return Err(CompilerError::Unsupported(format!( "mode=transition with binding=cov on function '{}' expects parameters '(State[] prev_states, ...)'", @@ -357,7 +466,7 @@ fn validate_covenant_policy_state_shape<'i>( } } - if declaration.mode == CovenantMode::Transition { + if declaration.mode == CovenantDeclMode::Transition { if policy.return_types.len() != 1 { return Err(CompilerError::Unsupported(format!( "mode=transition on function '{}' with contract state expects exactly one return type: 'State' or 'State[]'", @@ -392,16 +501,16 @@ fn preserved_entrypoint_params<'i>( ) -> Vec> { if contract_fields.is_empty() { return match (declaration.binding, leader) { - (CovenantBinding::Cov, false) => Vec::new(), + (CovenantDeclBinding::Cov, false) => Vec::new(), _ => function.params.clone(), }; } match (declaration.binding, declaration.mode, leader) { - (CovenantBinding::Auth, _, _) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, CovenantMode::Verification, true) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, CovenantMode::Transition, true) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, _, false) => Vec::new(), + (CovenantDeclBinding::Auth, _, _) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, CovenantDeclMode::Verification, true) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, CovenantDeclMode::Transition, true) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, _, false) => Vec::new(), } } @@ -433,7 +542,7 @@ fn build_auth_wrapper<'i>( if !contract_fields.is_empty() { match declaration.mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { entrypoint_params = policy.params.iter().skip(1).cloned().collect(); let prev_state_name = &policy.params[0].name; let new_states_name = &policy.params[1].name; @@ -457,7 +566,7 @@ fn build_auth_wrapper<'i>( new_states_name, ); } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { entrypoint_params = policy.params.iter().skip(1).cloned().collect(); let prev_state_name = &policy.params[0].name; body.push(var_def_statement( @@ -526,7 +635,7 @@ fn build_auth_wrapper<'i>( if !contract_fields.is_empty() { match state_source { OutputStateSource::Single(next_state_expr) => { - if declaration.mode == CovenantMode::Transition || declaration.singleton { + if declaration.mode == CovenantDeclMode::Transition || declaration.singleton { body.push(require_statement(binary_expr(BinaryOp::Eq, identifier_expr(out_count_name), Expr::int(1)))); let out_idx_name = "__cov_out_idx"; body.push(var_def_statement( @@ -607,7 +716,7 @@ fn build_cov_wrapper<'i>( if !contract_fields.is_empty() { match declaration.mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { leader_params = policy.params.iter().skip(1).cloned().collect(); let prev_states_name = &policy.params[0].name; let new_states_name = &policy.params[1].name; @@ -637,7 +746,7 @@ fn build_cov_wrapper<'i>( new_states_name, ); } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { leader_params = policy.params.iter().skip(1).cloned().collect(); let prev_states_name = &policy.params[0].name; append_cov_input_state_reads_into_state_array( @@ -709,7 +818,7 @@ fn build_cov_wrapper<'i>( if !contract_fields.is_empty() { match state_source { OutputStateSource::Single(next_state_expr) => { - if declaration.mode == CovenantMode::Transition || declaration.singleton { + if declaration.mode == CovenantDeclMode::Transition || declaration.singleton { body.push(require_statement(binary_expr(BinaryOp::Eq, identifier_expr(out_count_name), Expr::int(1)))); let out_idx_name = "__cov_out_idx"; body.push(var_def_statement( @@ -996,18 +1105,18 @@ fn append_policy_call_and_capture_next_state<'i>( body: &mut Vec>, policy: &FunctionAst<'i>, policy_name: &str, - mode: CovenantMode, + mode: CovenantDeclMode, singleton: bool, termination: CovenantTermination, contract_fields: &[ContractFieldAst<'i>], call_args: Vec>, ) -> Result, CompilerError> { match mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { body.push(call_statement(policy_name, call_args)); Ok(OutputStateSource::Single(state_object_expr_from_contract_fields(contract_fields))) } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { if policy.return_types.len() != contract_fields.len() { return Err(CompilerError::Unsupported(format!( "transition mode policy function '{}' must return exactly {} values (one per contract field)", diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index 6d948051..022d6b97 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -7,7 +7,7 @@ use crate::debug_info::{ DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepKind, }; -use super::{CompilerError, StackBindings, resolve_expr_for_debug}; +use super::{CompilerError, StackBindings}; /// Contract-level debug recorder used by the compiler. /// @@ -307,7 +307,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { }; let updates = collect_variable_updates(&frame.env_before, &frame.stack_bindings_before, env, types, stack_bindings, structs)?; - let console_args = collect_console_args(stmt, env)?; + let console_args = collect_console_args(stmt, env, types)?; let span = SourceSpan::from(stmt.span()); let bytecode_len = bytecode_end.saturating_sub(frame.start); let step_index = entrypoint.push_step(frame.start, frame.start + bytecode_len, span, StepKind::Source {}); @@ -351,6 +351,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { let has_structured_binding = structured_leaf_bindings.is_some(); resolve_variable_update( env, + types, &mut updates, ¶m.name, ¶m.type_ref.type_name(), @@ -359,7 +360,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { structured_leaf_bindings, )?; if has_structured_binding { - collect_inline_struct_leaf_updates(env, &mut updates, param, &expr, stack_bindings, structs)?; + collect_inline_struct_leaf_updates(env, types, &mut updates, param, &expr, stack_bindings, structs)?; } } @@ -664,7 +665,7 @@ fn collect_variable_updates<'i>( continue; } - resolve_variable_update(after_env, &mut updates, &name, type_name, after_expr, after_runtime_binding, None)?; + resolve_variable_update(after_env, types, &mut updates, &name, type_name, after_expr, after_runtime_binding, None)?; } for (name, type_name) in types { @@ -690,6 +691,7 @@ fn collect_variable_updates<'i>( resolve_variable_update( after_env, + types, &mut updates, name, type_name, @@ -704,6 +706,7 @@ fn collect_variable_updates<'i>( fn resolve_variable_update<'i>( env: &HashMap>, + types: &HashMap, updates: &mut Vec>, name: &str, type_name: &str, @@ -711,7 +714,7 @@ fn resolve_variable_update<'i>( runtime_binding: Option, structured_leaf_bindings: Option>, ) -> Result<(), CompilerError> { - let resolved = resolve_expr_for_debug(expr, env, &mut HashSet::new())?; + let resolved = super::resolve_expr_for_runtime(expr, env, types, &mut HashSet::new())?; updates.push(DebugVariableUpdate { name: name.to_string(), type_name: type_name.to_string(), @@ -722,12 +725,16 @@ fn resolve_variable_update<'i>( Ok(()) } -fn collect_console_args<'i>(stmt: &Statement<'i>, env: &HashMap>) -> Result>, CompilerError> { +fn collect_console_args<'i>( + stmt: &Statement<'i>, + env: &HashMap>, + types: &HashMap, +) -> Result>, CompilerError> { let Statement::Console { args, .. } = stmt else { return Ok(Vec::new()); }; - args.iter().cloned().map(|expr| resolve_expr_for_debug(expr, env, &mut HashSet::new())).collect() + args.iter().cloned().map(|expr| super::resolve_expr_for_runtime(expr, env, types, &mut HashSet::new())).collect() } fn static_binding_for_stack_name(name: &str, stack_bindings: &HashMap) -> Option { @@ -771,13 +778,14 @@ fn collect_inline_runtime_updates<'i>( let expr = env.get(&name).cloned().unwrap_or_else(|| Expr::identifier(name.clone())); let runtime_binding = runtime_binding_for_stack_name(&name, stack_bindings) .or_else(|| runtime_binding_for_inline_binding(&expr, stack_bindings)); - resolve_variable_update(env, &mut updates, &name, type_name, expr, runtime_binding, None)?; + resolve_variable_update(env, types, &mut updates, &name, type_name, expr, runtime_binding, None)?; } Ok(updates) } fn collect_inline_struct_leaf_updates<'i>( env: &HashMap>, + types: &HashMap, updates: &mut Vec>, param: &ParamAst<'i>, param_expr: &Expr<'i>, @@ -794,6 +802,7 @@ fn collect_inline_struct_leaf_updates<'i>( let runtime_binding = runtime_binding_for_stack_name(&source_leaf_name, stack_bindings); resolve_variable_update( env, + types, updates, &target_leaf_name, &super::type_name_from_ref(&field_type), diff --git a/silverscript-lang/tests/chess_apps_tests.rs b/silverscript-lang/tests/chess_apps_tests.rs index 23ddee33..766af461 100644 --- a/silverscript-lang/tests/chess_apps_tests.rs +++ b/silverscript-lang/tests/chess_apps_tests.rs @@ -657,9 +657,9 @@ fn size_snapshots() -> Vec { SizeSnapshot { name: "player.sil", ctor: player_constructor_args, - expected_script_len: 2922, - expected_instruction_count: 2146, - expected_charged_op_count: 1496, + expected_script_len: 2915, + expected_instruction_count: 2139, + expected_charged_op_count: 1489, }, SizeSnapshot { name: "chess_mux.sil", @@ -671,65 +671,65 @@ fn size_snapshots() -> Vec { SizeSnapshot { name: "chess_settle.sil", ctor: settle_constructor_args, - expected_script_len: 2666, - expected_instruction_count: 2058, - expected_charged_op_count: 1347, + expected_script_len: 2654, + expected_instruction_count: 2046, + expected_charged_op_count: 1335, }, SizeSnapshot { name: "chess_pawn.sil", ctor: pawn_constructor_args, - expected_script_len: 1833, - expected_instruction_count: 1208, - expected_charged_op_count: 794, + expected_script_len: 1834, + expected_instruction_count: 1207, + expected_charged_op_count: 788, }, SizeSnapshot { name: "chess_knight.sil", ctor: pawn_constructor_args, - expected_script_len: 1383, - expected_instruction_count: 794, - expected_charged_op_count: 527, + expected_script_len: 1384, + expected_instruction_count: 793, + expected_charged_op_count: 525, }, SizeSnapshot { name: "chess_vert.sil", ctor: pawn_constructor_args, - expected_script_len: 2035, - expected_instruction_count: 1393, - expected_charged_op_count: 915, + expected_script_len: 2036, + expected_instruction_count: 1392, + expected_charged_op_count: 913, }, SizeSnapshot { name: "chess_horiz.sil", ctor: pawn_constructor_args, - expected_script_len: 2035, - expected_instruction_count: 1393, - expected_charged_op_count: 915, + expected_script_len: 2036, + expected_instruction_count: 1392, + expected_charged_op_count: 913, }, SizeSnapshot { name: "chess_diag.sil", ctor: pawn_constructor_args, - expected_script_len: 1814, - expected_instruction_count: 1210, - expected_charged_op_count: 793, + expected_script_len: 1815, + expected_instruction_count: 1209, + expected_charged_op_count: 791, }, SizeSnapshot { name: "chess_king.sil", ctor: pawn_constructor_args, - expected_script_len: 1512, - expected_instruction_count: 921, - expected_charged_op_count: 611, + expected_script_len: 1513, + expected_instruction_count: 920, + expected_charged_op_count: 609, }, SizeSnapshot { name: "chess_castle.sil", ctor: pawn_constructor_args, - expected_script_len: 1523, - expected_instruction_count: 928, - expected_charged_op_count: 608, + expected_script_len: 1522, + expected_instruction_count: 925, + expected_charged_op_count: 604, }, SizeSnapshot { name: "chess_castle_challenge.sil", ctor: pawn_constructor_args, - expected_script_len: 1735, - expected_instruction_count: 1124, - expected_charged_op_count: 733, + expected_script_len: 1736, + expected_instruction_count: 1123, + expected_charged_op_count: 731, }, ] } diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index d2db2cbb..88313827 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -505,15 +505,15 @@ fn sorting_network_over_fixed_array_matches_rust_model_across_cases() { let (instruction_count, charged_op_count) = script_op_counts(&compiled.script); println!("sorting_network {script_len} / {instruction_count} / {charged_op_count}"); assert_eq!( - script_len, 780, + script_len, 772, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); assert_eq!( - instruction_count, 780, + instruction_count, 772, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); assert_eq!( - charged_op_count, 607, + charged_op_count, 599, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); @@ -3246,8 +3246,6 @@ fn compiles_int_array_index_to_expected_script() { .unwrap() .add_op(OpSubstr) .unwrap() - .add_op(OpBin2Num) - .unwrap() .add_i64(7) .unwrap() .add_op(OpNumEqual) @@ -3684,6 +3682,35 @@ fn rejects_non_constant_for_loop_max_iterations() { assert!(err.to_string().contains("for loop max iterations must be a compile-time integer")); } +#[test] +fn rejects_overflow_in_constant_for_loop_bounds() { + let cases = [ + ("9223372036854775807 + 1", "constant integer overflow: 9223372036854775807 + 1"), + ("(-9223372036854775807) - 2", "constant integer overflow: -9223372036854775807 - 2"), + ("3037000500 * 3037000500", "constant integer overflow: 3037000500 * 3037000500"), + ("-(-9223372036854775807 - 1)", "constant integer overflow: -(-9223372036854775808)"), + ("(-9223372036854775807 - 1) / -1", "constant integer overflow: -9223372036854775808 / -1"), + ("(-9223372036854775807 - 1) % -1", "constant integer overflow: -9223372036854775808 % -1"), + ]; + + for (expr, expected) in cases { + let source = format!( + r#" + contract Loops() {{ + entrypoint function main() {{ + for (i, 0, 1, {expr}) {{ + require(i >= 0); + }} + }} + }} + "# + ); + + let err = compile_contract(&source, &[], CompileOptions::default()).expect_err("compile should fail"); + assert!(err.to_string().contains(expected), "unexpected error: {err}"); + } +} + #[test] fn runs_runtime_bounded_for_loop_example() { let source = r#" @@ -4431,6 +4458,41 @@ fn compiled_template_parts_and_hash(compiled: &CompiledContract) -> (Vec, Ve (prefix, suffix, template_hash) } +#[test] +fn encode_state_matches_replaced_state_segment() { + let source = r#" + contract A(int initX, byte[2] initY) { + int x = initX; + byte[2] y = initY; + + entrypoint function main() { + require(true); + } + } + "#; + + let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); + let state = struct_object(vec![("x", Expr::int(6)), ("y", vec![0x34u8, 0x12u8].into())]); + + let materialized_script = compiled.encode_state(&state).expect("encode succeeds"); + + let mut contract = parse_contract_ast(source).expect("parse succeeds"); + let ExprKind::StateObject(entries) = &state.kind else { + panic!("expected state object"); + }; + for field in &mut contract.fields { + field.expr = + entries.iter().find(|entry| entry.name == field.name).map(|entry| entry.expr.clone()).expect("state field exists"); + } + let fully_materialized = + compile_contract_ast(&contract, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); + + assert_eq!(materialized_script, fully_materialized.script); + let layout = compiled.state_layout; + assert_eq!(compiled.script[..layout.start], materialized_script[..layout.start]); + assert_eq!(compiled.script[layout.start + layout.len..], materialized_script[layout.start + layout.len..]); +} + fn run_read_input_state_with_template_case( reader_source: &str, reader_constructor_args: &[Expr<'static>], @@ -4965,6 +5027,33 @@ fn read_input_state_accepts_self_state_under_selector_dispatch() { assert!(result.is_ok(), "readInputState should read the current state under selector dispatch: {result:?}"); } +#[test] +fn read_input_state_int_addition_uses_numeric_semantics() { + let source = r#" + contract C(int initX) { + int x = initX; + + entrypoint function main() { + State s = readInputState(this.activeInputIndex); + int y = s.x + 5; + require(y == 10); + } + } + "#; + + let compiled = compile_contract(source, &[5.into()], CompileOptions::default()).expect("compile succeeds"); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "readInputState int arithmetic should use numeric semantics: {result:?}"); +} + #[test] fn read_input_state_accepts_three_field_state_under_selector_dispatch() { let source = r#" @@ -5041,6 +5130,298 @@ fn read_input_state_accepts_pubkey_and_bool_fields_under_selector_dispatch() { assert!(result.is_ok(), "readInputState should read pubkey and bool state under selector dispatch: {result:?}"); } +#[test] +fn read_input_state_runtime_preserves_supported_field_types_across_contract_shapes() { + let run_case = |source: &str, args: Vec>, label: &str| { + let compiled = compile_contract(source, &args, CompileOptions::default()).unwrap_or_else(|err| panic!("{label}: {err:?}")); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "{label}: {result:?}"); + }; + + run_case( + r#" + contract C(int initInt) { + int someInt = initInt; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInt + 5 == 15); + } + } + "#, + vec![10.into()], + "int fields should preserve numeric semantics", + ); + + run_case( + r#" + contract C(int[2] initInts) { + int[2] someInts = initInts; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInts.length == 2); + require(x.someInts[0] == 1); + require(x.someInts[1] + 5 == 7); + } + } + "#, + vec![vec![Expr::int(1), Expr::int(2)].into()], + "int[2] fields should preserve array indexing semantics", + ); + + run_case( + r#" + contract C(bool initBool) { + bool someBool = initBool; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBool); + } + } + "#, + vec![true.into()], + "bool fields should preserve boolean semantics", + ); + + run_case( + r#" + contract C(bool[2] initBools) { + bool[2] someBools = initBools; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBools.length == 2); + require(x.someBools[0]); + require(!x.someBools[1]); + } + } + "#, + vec![vec![Expr::bool(true), Expr::bool(false)].into()], + "bool[2] fields should preserve array indexing semantics", + ); + + run_case( + r#" + contract C(byte[2] initBytes2) { + byte[2] someBytes2 = initBytes2; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBytes2.length == 2); + require(x.someBytes2 == 0x3412); + } + } + "#, + vec![vec![0x34u8, 0x12u8].into()], + "byte[2] fields should preserve fixed-byte-array semantics", + ); + + run_case( + r#" + contract C(pubkey initPubkey) { + pubkey somePubkey = initPubkey; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.somePubkey == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + byte[] owner = byte[](x.somePubkey); + owner.push(byte(3)); + require(owner.length == 33); + } + } + "#, + vec![vec![2u8; 32].into()], + "pubkey fields should preserve fixed-size byte semantics", + ); + + run_case( + r#" + contract C(sig initSig) { + sig someSig = initSig; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someSig == sig(0x1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111)); + + byte[] sigBytes = byte[](x.someSig); + sigBytes.push(byte(0x42)); + require(sigBytes.length == 66); + } + } + "#, + vec![vec![0x11u8; 65].into()], + "sig fields should preserve fixed-size byte semantics", + ); + + run_case( + r#" + contract C(datasig initDatasig) { + datasig someDatasig = initDatasig; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someDatasig == datasig(0x22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222)); + + byte[] datasigBytes = byte[](x.someDatasig); + datasigBytes.push(byte(0x24)); + require(datasigBytes.length == 65); + } + } + "#, + vec![vec![0x22u8; 64].into()], + "datasig fields should preserve fixed-size byte semantics", + ); +} + +#[test] +fn read_input_state_runtime_preserves_supported_field_types_without_selector_dispatch() { + let run_case = |source: &str, args: Vec>, label: &str| { + let compiled = compile_contract(source, &args, CompileOptions::default()).unwrap_or_else(|err| panic!("{label}: {err:?}")); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "{label}: {result:?}"); + }; + + run_case( + r#" + contract C(int initInt) { + int someInt = initInt; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInt + 5 == 15); + } + } + "#, + vec![10.into()], + "single-entrypoint int fields should preserve numeric semantics", + ); + + run_case( + r#" + contract C(byte[2] initBytes2) { + byte[2] someBytes2 = initBytes2; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBytes2.length == 2); + require(x.someBytes2 == 0x3412); + } + } + "#, + vec![vec![0x34u8, 0x12u8].into()], + "single-entrypoint byte[2] fields should preserve fixed-byte-array semantics", + ); + + run_case( + r#" + contract C(pubkey initPubkey) { + pubkey somePubkey = initPubkey; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.somePubkey == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + byte[] owner = byte[](x.somePubkey); + owner.push(byte(3)); + require(owner.length == 33); + } + } + "#, + vec![vec![2u8; 32].into()], + "single-entrypoint pubkey fields should preserve fixed-size byte semantics", + ); +} + +// TODO: Fix this bug by using builder.add_data_with_push_opcode instead of builder.add_data after covpp-reset2 is finalized. +#[test] +fn read_input_state_scalar_byte_regression_repros_runtime_mismatch() { + let source = r#" + contract C(byte initByte, pubkey initOwner) { + byte someByte = initByte; + pubkey someOwner = initOwner; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + + // The companion pubkey field proves the state offsets are otherwise correct for this layout. + require(x.someOwner == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + // This should succeed once scalar byte fields round-trip through readInputState with + // the same semantics as ordinary byte values. Today it still fails at runtime. + require(x.someByte == 7); + } + } + "#; + + let compiled = + compile_contract(source, &[Expr::byte(7), vec![2u8; 32].into()], CompileOptions::default()).expect("compile succeeds"); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_err(), "scalar byte readInputState regression should currently fail at runtime"); +} + #[test] fn validate_output_state_accepts_state_under_selector_dispatch() { let source = r#" @@ -5359,9 +5740,6 @@ fn compiles_read_input_state_to_expected_script() { // bytes = sigScriptSubstr(input=1, start_x, end_x) .add_op(OpTxInputScriptSigSubstr) .unwrap() - // decode bytes -> int - .add_op(OpBin2Num) - .unwrap() // literal threshold .add_i64(7) .unwrap() diff --git a/silverscript-lang/tests/parser_tests.rs b/silverscript-lang/tests/parser_tests.rs index 49277390..a452a7f2 100644 --- a/silverscript-lang/tests/parser_tests.rs +++ b/silverscript-lang/tests/parser_tests.rs @@ -32,6 +32,20 @@ fn parses_timeops_and_console() { assert!(result.is_ok()); } +#[test] +fn rejects_number_unit_overflow() { + let input = r#" + contract TimeLock() { + entrypoint function main() { + require(this.age >= 9223372036854775807 weeks); + } + } + "#; + + let err = parse_contract_ast(input).expect_err("unit multiplication overflow should be rejected"); + assert!(err.to_string().contains("overflow"), "unexpected error: {err}"); +} + #[test] fn parses_arrays_and_introspection() { let input = r#"