diff --git a/Cargo.lock b/Cargo.lock index 3b6c054..62ba5b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -46,9 +46,9 @@ dependencies = [ [[package]] name = "age" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d86e4272c093c88caf7864a2d09af52a5159180848ca4832a3cdbd7d014d5" +checksum = "047a482d1843edf1ce76ada63183698144030fe1191bd5ddba6e41e164e0bc43" dependencies = [ "age-core", "base64 0.21.7", @@ -61,10 +61,10 @@ dependencies = [ "lazy_static", "nom 7.1.3", "pin-project", - "rand 0.8.6", + "rand 0.8.7", "rust-embed", "scrypt", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", @@ -82,9 +82,9 @@ dependencies = [ "hkdf", "io_tee", "nom 7.1.3", - "rand 0.8.6", + "rand 0.8.7", "secrecy", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -103,9 +103,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -118,9 +118,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -177,33 +177,43 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "ar_archive_writer" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ "object", ] [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] +[[package]] +name = "ariadne" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31beedec3ce83ae6da3a79592b3d8d7afd146a5b15bb9bb940279aced60faa89" +dependencies = [ + "unicode-width 0.1.14", + "yansi", +] + [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "atomic-waker" @@ -272,7 +282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "serde", "unicode-normalization", @@ -295,9 +305,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitcoin" -version = "0.32.101" +version = "0.32.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed8ccb78a9ff7a6fbb90e2fb9b8588b4a9928d49d8af3cb789108a84ea6b0ce" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" dependencies = [ "base58ck", "base64 0.21.7", @@ -313,21 +323,20 @@ dependencies = [ [[package]] name = "bitcoin-consensus-encoding" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" dependencies = [ "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", ] [[package]] name = "bitcoin-internals" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" [[package]] name = "bitcoin-io" @@ -391,9 +400,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -404,6 +413,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bmp-monochrome" version = "1.1.0" @@ -441,15 +459,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -463,9 +481,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -475,7 +493,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -485,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -525,16 +554,16 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -542,9 +571,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -554,14 +583,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -585,10 +614,16 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie-factory" version = "0.3.3" @@ -623,6 +658,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -643,6 +687,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -659,7 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -675,7 +728,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -738,11 +791,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -766,13 +830,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -783,9 +847,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "electrum-client" @@ -878,9 +942,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" @@ -990,9 +1054,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1005,9 +1069,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1015,15 +1079,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1032,38 +1096,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1115,11 +1179,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1129,8 +1191,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1204,9 +1269,9 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.3.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ "arrayvec", ] @@ -1232,14 +1297,14 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1247,9 +1312,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1257,9 +1322,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1274,11 +1339,20 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1308,7 +1382,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] @@ -1385,7 +1459,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.118", + "syn 2.0.119", "unic-langid", ] @@ -1399,7 +1473,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1581,9 +1655,9 @@ checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1674,15 +1748,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -1731,7 +1805,7 @@ dependencies = [ "elements-miniscript", "getrandom 0.2.17", "qr_code", - "rand 0.8.6", + "rand 0.8.7", "thiserror 1.0.69", ] @@ -1742,7 +1816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357528be7457df199f8835865ee0e823da41ed8bcbcb1b6f04fea6ebc97eb174" dependencies = [ "bitcoin", - "rand 0.8.6", + "rand 0.8.7", "tempfile", "testcontainers", ] @@ -1759,7 +1833,7 @@ dependencies = [ "log", "lwk_common", "lwk_containers", - "rand 0.8.6", + "rand 0.8.7", "reqwest", "serde", "serde_bytes", @@ -1804,7 +1878,7 @@ dependencies = [ "log", "lwk_common", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "regex-lite", "reqwest", "serde", @@ -1818,9 +1892,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1828,6 +1902,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1866,9 +1950,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1980,9 +2064,9 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "memchr", ] @@ -2034,7 +2118,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -2061,7 +2145,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2076,7 +2160,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2088,7 +2172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2136,23 +2220,23 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -2178,10 +2262,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2189,20 +2273,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2210,23 +2295,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2245,23 +2330,24 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2274,16 +2360,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2295,11 +2371,17 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", ] [[package]] @@ -2330,7 +2412,7 @@ checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.16", + "regex-automata 0.4.18", "regex-syntax 0.8.11", ] @@ -2347,9 +2429,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2414,7 +2496,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] @@ -2433,9 +2515,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -2444,24 +2526,25 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.118", + "syn 2.0.119", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2", + "sha2 0.11.0", "walkdir", ] @@ -2473,9 +2556,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2501,9 +2584,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -2516,9 +2599,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2537,9 +2620,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2586,7 +2669,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2603,7 +2686,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2613,7 +2696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.7", "secp256k1-sys", "serde", ] @@ -2634,7 +2717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a44aed3002b5ae975f8624c5df3a949cfbf00479e18778b6058fcd213b76e3" dependencies = [ "bitcoin-private", - "rand 0.8.6", + "rand 0.8.7", "secp256k1", "secp256k1-zkp-sys", "serde", @@ -2665,14 +2748,14 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" dependencies = [ - "self_cell 1.2.2", + "self_cell 1.3.0", ] [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "semver" @@ -2682,9 +2765,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2712,22 +2795,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -2738,14 +2821,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2795,8 +2878,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2813,9 +2907,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simplicity-lang" @@ -2846,9 +2940,10 @@ dependencies = [ [[package]] name = "simplicityhl" -version = "0.6.0-rc.0" -source = "git+https://github.com/BlockstreamResearch/SimplicityHL#9e77379d343e76eb92cb57c2668af9f8e0c4f46b" +version = "0.7.0" +source = "git+https://github.com/BlockstreamResearch/SimplicityHL#46867308f28affa386a426f4d4e3e2a8c9db9217" dependencies = [ + "ariadne", "base64 0.21.7", "chumsky", "clap", @@ -2856,6 +2951,7 @@ dependencies = [ "getrandom 0.2.17", "itertools", "miniscript", + "semver", "serde", "serde_json", "simplicity-lang", @@ -2875,9 +2971,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2891,9 +2987,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", @@ -2933,9 +3029,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -2959,7 +3066,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3007,10 +3114,10 @@ dependencies = [ "hex", "hmac", "log", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3024,11 +3131,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3039,25 +3146,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -3096,9 +3203,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3111,9 +3218,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3126,13 +3233,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -3147,13 +3254,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -3253,7 +3361,7 @@ dependencies = [ "lwk_common", "lwk_signer", "lwk_wollet", - "rand 0.8.6", + "rand 0.8.7", "schemars", "serde", "serde_json", @@ -3281,7 +3389,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] @@ -3309,6 +3417,12 @@ dependencies = [ "tinystr", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3330,6 +3444,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3342,7 +3462,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3484,7 +3604,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -3529,14 +3649,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -3593,7 +3713,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3604,7 +3724,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3648,7 +3768,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3657,16 +3777,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3684,31 +3795,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3717,96 +3811,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3831,6 +3877,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" @@ -3850,28 +3902,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3891,7 +3943,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -3912,7 +3964,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3946,11 +3998,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index a8e10b6..76b15de 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ manifest-wallet/ ├── p2pk/ # "hello world" — pay-to-public-key via Simplicity ├── lending/ # P2P collateralised lending protocol ├── dex/ # keyless atomic swap offers (Mosaik's Tessera covenant) + ├── deadcat/ # binary prediction market with on-chain oracle resolution + ├── deadcat_v2/ # …unblinded tokens — a documented dead end + ├── deadcat_v3/ # …derivable blinding factors; the runnable fork └── last_will/ # time-locked inheritance ``` diff --git a/examples/deadcat/README.md b/examples/deadcat/README.md new file mode 100644 index 0000000..f1c0f80 --- /dev/null +++ b/examples/deadcat/README.md @@ -0,0 +1,236 @@ +# deadcat — binary prediction market + +A port of [Deadcat.Live](https://github.com/Resolvr-io/deadcat)'s prediction-market +covenant to a transaction manifest. `prediction_market.simf` is a **verbatim** copy of +`src-tauri/crates/deadcat-sdk/contract/prediction_market.simf`; the upstream SDK drives it +from Rust (`src/pset/*.rs`), this manifest declares the same seven spending paths. + +## What the protocol does + +A market has two outcome tokens, YES and NO. Anyone may deposit collateral and mint +**matched pairs**: one YES and one NO per `2 × COLLATERAL_PER_TOKEN` satoshis. The pair +always costs what it can pay out, so the market is fully collateralised at all times and +needs no issuer — the covenant is the counterparty. + +Settlement is where the design earns its keep. Rather than checking the oracle's signature +at redemption time, the oracle's outcome is **committed on-chain** as a state transition. +An oracle that signs both YES and NO cannot start a race to drain the pool: the first +attestation to confirm moves the market to a resolved state, and there is no path back or +sideways. The cost is serialisation — every spend touches the same collateral UTXO — which +upstream expects a batching swap service to absorb. + +After a resolve, each winning token draws `2 × COLLATERAL_PER_TOKEN` (the whole pair's +backing, the loser's stake included), so the pool drains exactly. If the oracle never +attests, `EXPIRY_TIME` opens a symmetric escape hatch: both sides redeem at +`1 × COLLATERAL_PER_TOKEN` and everyone gets their deposit back. + +## State lives in the address + +There is no state variable. The market's state is a **tapdata leaf** in the covenant's tap +tree — 8 bytes, big-endian — branched with the Simplicity leaf and tweaked onto the NUMS +key. Four states, four addresses: + +| State | Name | Holds | +|-------|------|-------| +| 0 | dormant | the two reissuance tokens, no collateral | +| 1 | unresolved | both reissuance tokens + one consolidated collateral UTXO | +| 2 | resolved-YES | same three UTXOs; YES redeems | +| 3 | resolved-NO | same three UTXOs; NO redeems | + +`main()` takes the state as a witness and then proves it: it recomputes the address for the +claimed state and asserts the input is really being spent from there. Lying about the state +is not detected, it is impossible. + +That is what the `extra_leaves` block on each `utxo_type` encodes: + +```json +{ "type": "tapdata", "payload": [ { "value": "1", "type": "u64", "endian": "be" } ] } +``` + +`examples/deadcat` is verified against upstream by +[`txmanifest_lib/examples/deadcat_recon.rs`](../../txmanifest_lib/examples/deadcat_recon.rs): + +```sh +cargo run -p tx-manifest-lib --example deadcat_recon +``` + +It compiles the covenant through the manifest's own `utxo_type` wiring, derives all four +addresses, and compares each against a transcription of deadcat-sdk's `taproot.rs` +(hand-rolled tagged hashes + `add_tweak`, an independent code path). It also parses every +witness literal this manifest writes against the compiled program's ABI — the seven `PATH` +branches are a nested `Either` tree written by hand, and a mis-nested `Left`/`Right` would +otherwise surface only as a failed spend. + +## Actions + +| Action | Path | Transition | +|--------|------|-----------| +| `IssueReissuanceTokens` | — (plain Elements tx) | mints the two reissuance tokens to your wallet, writes the instance | +| `CreateMarket` | — (plain Elements tx) | moves both tokens into state 0 | +| `PrepareInitialIssuance` | — (funding tx) | cuts an exactly-sized collateral UTXO | +| `InitialIssuance` | 1 | 0 → 1 | +| `MintPairs` | 2 | 1 → 1 | +| `ResolveYes` / `ResolveNo` | 3 | 1 → 2 / 1 → 3 | +| `RedeemYes` / `RedeemNo` | 4 | 2 → 2 / 3 → 3 | +| `RedeemExpired` | 5 | 1 → 1, at or after `EXPIRY_TIME` | +| `CancelPairs` | 6 (partial) | 1 → 1 | +| `CancelAll` | 6 (full) | 1 → 0 | + +Path 7 has no action of its own: it is the witness every *secondary* covenant input carries +(`Right(Right(()))`). It proves only that the input comes from the same address as input 0 +and delegates every transaction-level check to input 0's path — which is why the reissuance +tokens and the collateral can be spent together in one transaction. + +### Why bootstrapping takes two transactions + +There is a dependency cycle. `CreateMarket` pays its outputs to the state-0 covenant +address; that address is a function of all four asset ids; those ids are functions of the +outpoints being spent. The engine snapshots compile params *before* it resolves any input, +so one action cannot both mint an asset and pay it to an address derived from that asset. + +`IssueReissuanceTokens` breaks the cycle by paying only to the wallet. It is also the +**constructor** — it writes the instance file — and that is not an arbitrary choice: the +asset ids exist only for the length of that run unless something records them. You cannot +recover them afterwards from what lands in the wallet, because the reissuance token id and +the outcome asset id are sibling hashes of the same entropy (`SHA256(entropy ‖ 0x00)` and +`SHA256(entropy ‖ 0x01)`) — holding one tells you nothing about the other. So +`create_instance` reads all four straight off the inputs that created them: + +```json +"create_instance": { "fields": { + "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", + "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token" +}} +``` + +`CreateMarket` consequently takes **no params at all** — everything comes from the instance. + +Two things about that spelling are worth knowing. `$inputs..` is a *string* +lookup; a bare expression goes through the arithmetic evaluator, which returns a `u64` and +rejects a 32-byte id outright. And it is `issued_asset`, **not** `asset`: on an input +carrying an issuance those are different values — `asset` is the asset of the UTXO being +spent, which here is L-BTC. Writing `asset` would put L-BTC's id into `YES_TOKEN_ASSET`, +and nothing downstream would object, because it is a perfectly well-formed asset id. + +Upstream Deadcat does both in one transaction (`pset/creation.rs`) because it computes the +ids in Rust before building anything. The two-transaction bootstrap costs one extra fee and +one extra confirmation and lands the market in exactly the same on-chain state; the covenant +cannot tell the difference, since it only ever sees the tokens sitting at the state-0 +address. + +### No YES or NO tokens exist until InitialIssuance + +`IssueReissuanceTokens` mints `asset_amount_sat: 0` — zero units of the outcome assets, and +one unit each of their **reissuance tokens**. So after it confirms your wallet holds two +1-unit token UTXOs and nothing named YES or NO. That is correct, and it has to be: a token +minted in this transaction would be backed by no collateral, and after a resolve its holder +could redeem `2 × COLLATERAL_PER_TOKEN` out of collateral belonging to someone else. Supply +can only be created by a covenant-validated issuance, which is exactly what the reissuance +token — locked at a covenant address by `CreateMarket` — enforces. + +### Everything else + +`ResolveYes` / `ResolveNo` and `RedeemYes` / `RedeemNo` are pairs of actions rather than one +action with a runtime outcome, because the outcome *is* the destination: state 2 and state 3 +are different addresses, and the engine has no conditional destination. + +`RedeemYes` / `RedeemNo` / `RedeemExpired` / `CancelPairs` model the **partial** form +(collateral remains). Draining the pool to zero uses a different output layout — the burn +becomes output 0 and there is no collateral output — and would need its own action. +`CancelAll` is the one full-drain form that is modelled, because it is the only one that +also cycles the reissuance tokens back to dormant. + +## Fee-output placement + +The covenant checks the fee output at a **fixed index** on the issuance and resolve paths +(5 and 3), and at `num_outputs - 1` elsewhere. The engine appends declared outputs, then any +change, then the fee — so on those two paths the manifest declares **no change output**, and +the fee absorbs the L-BTC surplus. Practical consequence: size the collateral input exactly +(`prepare` will split one for you), because surplus on those paths is paid to miners rather +than returned. + +`ResolveYes` / `ResolveNo` additionally have `num_outputs == 4` enforced, so a change output +there is not merely misplaced — it invalidates the spend. + +## What is not executable yet + +This manifest is a faithful, validated model of the protocol; it is **not** a working +Deadcat client today. Five things stand between the two, four of them engine gaps: + +1. **Confidential reissuance tokens.** The covenant verifies the reissuance-token + inputs and outputs as Pedersen commitments, taking the asset and value blinding factors + as witnesses (`verify_token_commitment`). This requires those UTXOs to be *blinded*, and + the engine builds every covenant output explicit — `pset_builder` warns and falls back + when `confidential: true` is set. `unwrap_left` on an explicit asset commitment fails, so + this is not a degradation, it is a hard stop for the issuance, resolve and full-cancel + paths. It also means the eight `*_ABF` / `*_VBF` witnesses have no manifest spelling: + they are per-UTXO secrets, and a witness `value` can only reference values the engine + already holds. + + In practice it surfaces on `InitialIssuance` as `Execution reached a pruned branch`, + with `no_reissuance_in` (path 7, script-hash comparison only) finalizing while + `yes_reissuance_in` (path 1, commitment arithmetic) fails. + + **[`examples/deadcat_v2`](../deadcat_v2/README.md) is the same protocol with these + tokens read explicitly**, and it runs. It is a fork — different CMR, different + addresses, not Deadcat-compatible — which is why this unmodified port stays: it is what + `deadcat_recon` checks against upstream. +2. **Reissuance from a confidential input.** Relatedly, `apply_reissuance` sets the + issuance blinding nonce to a fixed marker (`[0…0, 1]`) rather than the input's real asset + blinding factor, which is correct only for explicit reissuance-token UTXOs. +3. **Burn outputs must be zero-length scripts.** `ensure_output_is_op_return` compares + against `sha256("")` — the hash of an *empty* scriptPubKey, which in Elements is the fee + marker, not an `OP_RETURN`. The engine's `{"type": "burn"}` destination emits a one-byte + `OP_RETURN` (`0x6a`), whose hash does not match. Every burn leg here is written as + `{"type": "burn"}` and marked in its `description`; a zero-length-script burn destination + would close the gap. +4. **Absolute nLockTime**, for `RedeemExpired` only. It calls + `jet::check_lock_height(EXPIRY_TIME)` and the engine cannot set a transaction nLockTime — + the same limitation the `dex` example's `Refund` hits, tracked in + `meta/tasks/upnext/12-engine-absolute-locktime.md`. The *pre*-expiry paths are fine: they + assert `lock_time < EXPIRY_TIME`, which the default locktime of 0 satisfies. +5. Nothing is hand-carried any more. The two issuance entropies used to be, because a + reissuance cannot recover its entropy from anything on chain — the reissuance token UTXO + holds no trace of the outpoint that created the asset. The constructor now captures them + as instance fields, and each reissuance input names the one it needs: + + ```json + "issuance": { "kind": "reissue", "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" } + ``` + + `issued_asset` is a check, not an input: the engine re-derives the asset id from the + entropy and refuses to build if they disagree. Worth having, because an entropy is + opaque — and the byte order a block explorer prints is the **reverse** of the one used + here, so a value copied from one is well-formed, builds, broadcasts, and reissues the + wrong asset. + +Witness values *do* interpolate — `"value": "params.TOKENS_BURNED"` is substituted before +the SimplicityHL parser sees it — but they are parsed as raw literals with no type hint, so +`RedeemExpired`'s `BURN_TOKEN_ASSET` must be pasted `0x`-prefixed and byte-**reversed** +(internal order), unlike a `liquid.asset_id` compile param, which the engine reverses for +you. `ORACLE_SIGNATURE` likewise wants a `0x`-prefixed 64-byte value. + +## Deadcat's other covenant + +`maker_order.simf` — the limit-order book behind the app's trade tab — is deliberately not +modelled. It uses the **maker's own key** as the taproot internal key, while this engine +hardcodes the NUMS key (`covenant.rs::NUMS_KEY_BYTES`), so no address it derived would be +correct. Its `MAKER_RECEIVE_SPK_HASH` is also a per-order tweaked key +(`sha256("deadcat/order_uid" ‖ …)` → tweak → `P_order`), which no `compute` form expresses. + +## Try it + +```sh +cargo run -- validate examples/deadcat/txmanifest.json +cargo run -- describe examples/deadcat/txmanifest.json +cargo run -p tx-manifest-lib --example deadcat_recon + +# Bootstrap, on a funded testnet wallet: +cargo run -- run examples/deadcat/txmanifest.json IssueReissuanceTokens --wallet wallet.json +cargo run -- run examples/deadcat/txmanifest.json CreateMarket --wallet wallet.json +``` + +The first run writes `txmanifest.instance.1.json` with all eight fields. Pass it to the +second with `--instance`; `CreateMarket` prompts for nothing. diff --git a/examples/deadcat/oracle-wallet.json b/examples/deadcat/oracle-wallet.json new file mode 100644 index 0000000..bbb8904 --- /dev/null +++ b/examples/deadcat/oracle-wallet.json @@ -0,0 +1,4 @@ +{ + "network": "testnet", + "mnemonic": "nasty hurt define drink kingdom obtain carry maximum trash festival script key" +} \ No newline at end of file diff --git a/examples/deadcat/params.json b/examples/deadcat/params.json new file mode 100644 index 0000000..f14f7ac --- /dev/null +++ b/examples/deadcat/params.json @@ -0,0 +1,8 @@ +{ + "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + "COLLATERAL_PER_TOKEN": "1000", + "EXPIRY_TIME": "2560000", + "NO_REISSUANCE_TOKEN": "53d19885868f1b32d9c5ad3c7fdc83576ffefbd5ba573d03c61cf3e557855e5d", + "YES_REISSUANCE_TOKEN": "d8175b4e10fcb1b8eb78f34d4942b7c59635b7b30cb20e4f2801215005d9bf1b", + "ORACLE_PUBLIC_KEY": "5907ed9ec3cb2ff851da548b617b54af6d1967386775e7c7c3c9ad153f5884d9" +} \ No newline at end of file diff --git a/examples/deadcat/prediction_market.simf b/examples/deadcat/prediction_market.simf new file mode 100644 index 0000000..d10668e --- /dev/null +++ b/examples/deadcat/prediction_market.simf @@ -0,0 +1,557 @@ +// Binary Prediction Market Covenant +// SimplicityHL contract for Liquid + +// ============================================================================ +// Type aliases +// ============================================================================ + +// PATH dispatch types (7-way nested Either) +type Path1or2 = Either<(), ()>; +type Path3or4 = Either<(), ()>; +type Path1to4 = Either; +type Path5or6 = Either<(), ()>; +type Path5to7 = Either; + +// Blinding factor groupings +type BlindingQuad = (u256, u256, u256, u256); + +// ============================================================================ +// Boolean helpers +// ============================================================================ + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn ensure_zero_bit(b: bool) { + assert!(not(b)); +} + +// ============================================================================ +// Utility functions +// ============================================================================ + +fn get_input_script_hash(index: u32) -> u256 { + unwrap(jet::input_script_hash(index)) +} + +fn get_output_explicit_asset(index: u32) -> u256 { + unwrap_right::<(u1, u256)>(unwrap(jet::output_asset(index))) +} + +fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn ensure_output_script_hash_eq(index: u32, expected: u256) { + let actual: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(actual, expected)); +} + +fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn empty_script_hash() -> u256 { + 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +} + +fn ensure_output_is_op_return(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +fn ensure_fee_output(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +// ============================================================================ +// Pedersen commitment verification +// ============================================================================ + +fn verify_token_commitment( + asset_commitment: (u1, u256), + amount_commitment: (u1, u256), + expected_token_id: u256, + abf: u256, + vbf: u256 +) { + // Recompute the asset generator: H + abf*G + let h_point: Ge = jet::hash_to_curve(expected_token_id); + let abf_point: Gej = jet::generate(abf); + let asset_gen: Gej = jet::gej_ge_add(abf_point, h_point); + + let asset_ge: Ge = unwrap(jet::gej_normalize(asset_gen)); + let (asset_x, asset_y): (u256, u256) = asset_ge; + let (stored_asset_parity, stored_asset_x): (u1, u256) = asset_commitment; + assert!(jet::eq_256(asset_x, stored_asset_x)); + // Generator parity encodes quadratic residue status, not odd/even bit parity. + // is_none(fe_square_root(y)) == true when y is NOT a QR, matching stored parity = 1. + assert!(jet::eq_1(::into(is_none::(jet::fe_square_root(asset_y))), stored_asset_parity)); + + // Recompute the value commitment: asset_gen + vbf*G + let vbf_point: Gej = jet::generate(vbf); + let value_gen: Gej = jet::gej_add(asset_gen, vbf_point); + + let value_ge: Ge = unwrap(jet::gej_normalize(value_gen)); + let (value_x, value_y): (u256, u256) = value_ge; + let (stored_value_parity, stored_value_x): (u1, u256) = amount_commitment; + assert!(jet::eq_256(value_x, stored_value_x)); + assert!(jet::eq_1(::into(is_none::(jet::fe_square_root(value_y))), stored_value_parity)); +} + +fn verify_input_reissuance_token( + index: u32, + expected_token: u256, + abf: u256, + vbf: u256 +) { + let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::input_asset(index))); + let (_, amount_either): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let amount_commitment: (u1, u256) = unwrap_left::(amount_either); + verify_token_commitment(asset_commitment, amount_commitment, expected_token, abf, vbf); +} + +fn verify_output_reissuance_token( + index: u32, + expected_token: u256, + abf: u256, + vbf: u256 +) { + let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::output_asset(index))); + let (_, amount_either): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let amount_commitment: (u1, u256) = unwrap_left::(amount_either); + verify_token_commitment(asset_commitment, amount_commitment, expected_token, abf, vbf); +} + +// ============================================================================ +// Taproot address computation +// ============================================================================ + +fn covenant_nums_key() -> u256 { + 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 +} + +fn compute_p2tr_script_hash_from_output_key(output_key: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn script_hash_for_input_script(state: u64) -> u256 { + let tap_leaf: u256 = jet::tapleaf_hash(); + let state_ctx: Ctx8 = jet::tapdata_init(); + let state_ctx: Ctx8 = jet::sha_256_ctx_8_add_8(state_ctx, state); + let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx); + let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); + let tweaked_key: u256 = jet::build_taptweak(covenant_nums_key(), tap_node); + compute_p2tr_script_hash_from_output_key(tweaked_key) +} + +// ============================================================================ +// Arithmetic helpers +// ============================================================================ + +fn safe_multiply(a: u64, b: u64) -> u64 { + let result: u128 = jet::multiply_64(a, b); + let (high, low): (u64, u64) = ::into(result); + assert!(jet::is_zero_64(high)); + low +} + +fn safe_add(a: u64, b: u64) -> u64 { + let (carry, sum): (bool, u64) = jet::add_64(a, b); + ensure_zero_bit(carry); + sum +} + +fn safe_subtract(a: u64, b: u64) -> u64 { + let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); + ensure_zero_bit(borrow); + diff +} + +fn safe_subtract_32(a: u32, b: u32) -> u32 { + let (borrow, diff): (bool, u32) = jet::subtract_32(a, b); + ensure_zero_bit(borrow); + diff +} + +// ============================================================================ +// Market-specific functions +// ============================================================================ + +fn compute_market_id() -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::YES_TOKEN_ASSET); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::NO_TOKEN_ASSET); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { + let market_id: u256 = compute_market_id(); + let outcome_byte: u8 = match outcome_yes { + true => 1, + false => 0, + }; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); + let message: u256 = jet::sha_256_ctx_8_finalize(ctx); + jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); +} + +fn ensure_pre_expiry() { + let tx_lock_time: u32 = jet::lock_time(); + assert!(jet::lt_32(tx_lock_time, param::EXPIRY_TIME)); +} + +fn dormant_script_hash() -> u256 { + script_hash_for_input_script(0) +} + +fn unresolved_script_hash() -> u256 { + script_hash_for_input_script(1) +} + +fn collateral_for_pairs(pairs: u64) -> u64 { + let two_cpt: u64 = safe_multiply(2, param::COLLATERAL_PER_TOKEN); + safe_multiply(pairs, two_cpt) +} + +fn get_issuance_amount(index: u32) -> u64 { + unwrap_right::<(u1, u256)>(unwrap(unwrap(jet::issuance_asset_amount(index)))) +} + +// ============================================================================ +// Spending paths +// ============================================================================ + +/// Path 1: Initial Issuance (state 0 → 1) +fn initial_issuance_path(state: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { + assert!(jet::is_zero_64(state)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; + let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let total_collateral: u64 = collateral_for_pairs(pairs); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(5); +} + +/// Path 2: Subsequent Issuance (state 1 → 1) +fn subsequent_issuance_path(state: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; + let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let new_collateral: u64 = collateral_for_pairs(pairs); + + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, old_collateral): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + let total_collateral: u64 = safe_add(old_collateral, new_collateral); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(5); +} + +/// Path 3: Oracle Resolve (state 1 → 2 or 3) +fn oracle_resolve_path( + state: u64, + outcome_yes: bool, + sig: Signature, + yes_bf: BlindingQuad, + no_bf: BlindingQuad +) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + verify_oracle_signature(outcome_yes, sig); + + let new_state: u64 = match outcome_yes { + true => 2, + false => 3, + }; + let new_state_hash: u256 = script_hash_for_input_script(new_state); + + let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; + let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); + ensure_output_script_hash_eq(0, new_state_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); + ensure_output_script_hash_eq(1, new_state_hash); + + let unresolved_hash: u256 = unresolved_script_hash(); + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, coll_amount); + ensure_output_script_hash_eq(2, new_state_hash); + + assert!(jet::eq_32(jet::num_outputs(), 4)); + ensure_fee_output(3); +} + +/// Path 4: Post-Resolution Redemption (state 2 or 3) +fn post_resolution_redemption_path(state: u64, tokens_burned: u64) { + let is_yes: bool = jet::eq_64(state, 2); + let is_no: bool = jet::eq_64(state, 3); + assert!(or(is_yes, is_no)); + + let state_hash: u256 = script_hash_for_input_script(state); + + let winner_asset: u256 = match is_yes { + true => param::YES_TOKEN_ASSET, + false => param::NO_TOKEN_ASSET, + }; + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let payout: u64 = collateral_for_pairs(tokens_burned); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, state_hash); + ensure_output_asset_with_amount_eq(1, winner_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, winner_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 5: Expiry Redemption (state 1, post-expiry) +fn expiry_redemption_path(state: u64, tokens_burned: u64, burn_asset: u256) { + assert!(jet::eq_64(state, 1)); + jet::check_lock_height(param::EXPIRY_TIME); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let is_yes: bool = jet::eq_256(burn_asset, param::YES_TOKEN_ASSET); + let is_no: bool = jet::eq_256(burn_asset, param::NO_TOKEN_ASSET); + assert!(or(is_yes, is_no)); + + let payout: u64 = safe_multiply(tokens_burned, param::COLLATERAL_PER_TOKEN); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, burn_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, burn_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 6: Cancellation (state 1 → 1 partial, 1 → 0 full) +fn cancellation_path(state: u64, pairs_burned: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { + assert!(jet::eq_64(state, 1)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let refund: u64 = collateral_for_pairs(pairs_burned); + let remaining: u64 = safe_subtract(coll_amount, refund); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(1); + ensure_output_asset_with_amount_eq(2, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + let dormant_hash: u256 = dormant_script_hash(); + let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; + let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; + + verify_input_reissuance_token(1, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); + verify_input_reissuance_token(2, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); + ensure_output_script_hash_eq(0, dormant_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); + ensure_output_script_hash_eq(1, dormant_hash); + + ensure_output_asset_with_amount_eq(2, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + ensure_output_asset_with_amount_eq(3, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(3); + + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 7: Secondary Covenant Input +fn secondary_covenant_input_path() { + let my_index: u32 = jet::current_index(); + let my_hash: u256 = get_input_script_hash(my_index); + let primary_hash: u256 = get_input_script_hash(0); + assert!(jet::eq_256(my_hash, primary_hash)); + ensure_zero_bit(jet::eq_32(my_index, 0)); +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +fn main() { + let state: u64 = witness::STATE; + + let expected_hash: u256 = script_hash_for_input_script(state); + let actual_hash: u256 = get_input_script_hash(jet::current_index()); + assert!(jet::eq_256(expected_hash, actual_hash)); + + // Budget padding: these witnesses increase the serialized witness size so that + // the Simplicity execution budget (witness_stack_bytes + 50 WU) covers the + // program's cost. Without this, the pruned program's cost slightly exceeds + // the budget derived from its natural witness + program size. + let budget_pad_a: u256 = witness::BUDGET_PAD_A; + let budget_pad_b: u256 = witness::BUDGET_PAD_B; + assert!(jet::eq_256(budget_pad_a, budget_pad_b)); + let budget_pad_c: u256 = witness::BUDGET_PAD_C; + let budget_pad_d: u256 = witness::BUDGET_PAD_D; + assert!(jet::eq_256(budget_pad_c, budget_pad_d)); + + // Read all witnesses in main (required by SimplicityHL) + let yes_in_abf: u256 = witness::YES_REISSUANCE_INPUT_ABF; + let yes_in_vbf: u256 = witness::YES_REISSUANCE_INPUT_VBF; + let yes_out_abf: u256 = witness::YES_REISSUANCE_OUTPUT_ABF; + let yes_out_vbf: u256 = witness::YES_REISSUANCE_OUTPUT_VBF; + let no_in_abf: u256 = witness::NO_REISSUANCE_INPUT_ABF; + let no_in_vbf: u256 = witness::NO_REISSUANCE_INPUT_VBF; + let no_out_abf: u256 = witness::NO_REISSUANCE_OUTPUT_ABF; + let no_out_vbf: u256 = witness::NO_REISSUANCE_OUTPUT_VBF; + let oracle_sig: Signature = witness::ORACLE_SIGNATURE; + let oracle_outcome: bool = witness::ORACLE_OUTCOME_YES; + let tokens_burned: u64 = witness::TOKENS_BURNED; + let burn_asset: u256 = witness::BURN_TOKEN_ASSET; + let pairs_burned: u64 = witness::PAIRS_BURNED; + + let yes_bf: BlindingQuad = (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf); + let no_bf: BlindingQuad = (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf); + + match witness::PATH { + Left(l: Path1to4) => match l { + Left(i: Path1or2) => match i { + Left(u: ()) => initial_issuance_path(state, yes_bf, no_bf), + Right(u: ()) => subsequent_issuance_path(state, yes_bf, no_bf), + }, + Right(r: Path3or4) => match r { + Left(u: ()) => oracle_resolve_path(state, oracle_outcome, oracle_sig, yes_bf, no_bf), + Right(u: ()) => post_resolution_redemption_path(state, tokens_burned), + }, + }, + Right(r: Path5to7) => match r { + Left(rd: Path5or6) => match rd { + Left(u: ()) => expiry_redemption_path(state, tokens_burned, burn_asset), + Right(u: ()) => cancellation_path(state, pairs_burned, yes_bf, no_bf), + }, + Right(u: ()) => secondary_covenant_input_path(), + }, + } +} diff --git a/examples/deadcat/txmanifest.json b/examples/deadcat/txmanifest.json new file mode 100644 index 0000000..c3e709a --- /dev/null +++ b/examples/deadcat/txmanifest.json @@ -0,0 +1,1746 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct. See README.md for the full list of what is and is not executable today.", + "manifest_version": "0.1.0", + "protocol": "deadcat-prediction-market", + "description": "Deadcat — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", + "chain": "liquid", + "simplicity_hl": { + "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", + "debug_symbols": false + }, + "utxo_types": { + "market_dormant": { + "description": "STATE 0 — DORMANT. Holds only the two reissuance tokens, no collateral. This is where CreateMarket parks them and where a full Cancel returns them. The address is the Simplicity leaf branched with a tapdata leaf carrying the u64 0 (big-endian, 8 bytes) — see taproot.rs::tapdata_hash. Nothing else about the address differs between the four states.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "$comment": "state = 0. jet::tapdata_init() + sha_256_ctx_8_add_8(state) hashes exactly 8 big-endian bytes, so no pad_to here — unlike the lending example's 32-byte storage slots.", + "type": "tapdata", + "payload": [ + { + "value": "0", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "YES_REISSUANCE_TOKEN", + "confidential": false + }, + "market_unresolved": { + "description": "STATE 1 — UNRESOLVED, the live market. Holds three UTXOs: the YES reissuance token, the NO reissuance token, and the single consolidated collateral UTXO. 'Single' is load-bearing: every issuance path must consume the existing collateral UTXO and re-emit one consolidated output, so the oracle can move the whole market to a resolved address in one transaction (design doc section 6).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "1", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_yes": { + "description": "STATE 2 — RESOLVED YES. The oracle attested YES, so YES tokens redeem at 2 x COLLATERAL_PER_TOKEN each and NO tokens are worth nothing. There is no path from here to state 3: that is the whole point of committing the outcome on-chain rather than checking the oracle signature at redemption time (design doc section 9.2).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "2", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_no": { + "description": "STATE 3 — RESOLVED NO. Mirror of state 2: NO tokens redeem, YES tokens are worthless.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "3", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + } + }, + "contract_templates": { + "binary_market": { + "description": "One binary prediction market. IssueReissuanceTokens is the constructor: it mints the minting rights and writes the instance file that defines the market. CreateMarket then locks those rights into the covenant (a plain Elements transaction — no covenant runs), InitialIssuance brings the market to life, then anyone may MintPairs or Cancel while it is unresolved. It ends one of two ways: the oracle resolves it (ResolveYes / ResolveNo, then RedeemYes / RedeemNo), or it expires unresolved and both sides redeem at half rate (RedeemExpired). All eight fields below are compile params of prediction_market.simf, so changing any one of them is a different market at four different addresses.", + "fields": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "X-only BIP340 key the resolve path checks. In Deadcat this is the aggregate key of a 2-of-3 FROST committee — on-chain it is just one key, the threshold signing happens off-chain." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset backing the market, normally L-BTC. Also the asset the network fee is paid in, which is why the covenant's fee-output checks are satisfiable." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "description": "Satoshis backing ONE token. A pair costs 2 x this. Deliberately per-token, not per-pair, so the covenant only ever multiplies — no division means no rounding bug (design doc section 4.3)." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Absolute block height. Before it, the oracle may resolve and anyone may mint. At or after it, RedeemExpired unlocks and every token — winning or losing — redeems at 1 x COLLATERAL_PER_TOKEN." + }, + "YES_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the YES outcome token, derived from the outpoint pinned as CreateMarket in[0]. Also half of MARKET_ID = sha256(YES || NO), the oracle's domain separator." + }, + "NO_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the NO outcome token, derived from the outpoint pinned as CreateMarket in[1]." + }, + "YES_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for YES. Holding it is what permits minting more YES, and it only ever lives at a covenant address — that is the enforcement mechanism behind collateral consolidation." + }, + "NO_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for NO. Same role as YES_REISSUANCE_TOKEN." + }, + "YES_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the YES mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value YES_TOKEN_ASSET itself is derived from. Every later reissuance needs it and NOTHING on chain carries it: the reissuance token UTXO holds no trace of the outpoint that created it. Captured by the constructor at the one moment it exists." + }, + "NO_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the NO mint. Same role, from the other defining outpoint." + } + }, + "actions": { + "IssueReissuanceTokens": { + "$comment": "The constructor: it is this action, not CreateMarket, that writes the instance file. That is forced by where the asset ids come from — they are derived from the outpoints THIS transaction spends, and nothing downstream can recover them (the reissuance token id and the asset id are sibling hashes of the entropy, so you cannot walk from one to the other). Capturing them here — create_instance reads them straight off the two inputs via $inputs.. — is the only point at which they exist and can still be recorded.\n\nWhy the bootstrap is two transactions at all: CreateMarket pays to the state-0 covenant address, and that address is a function of all four asset ids, which are functions of the outpoints being spent. The engine snapshots compile params BEFORE it resolves any input, so one action cannot both mint an asset and pay it to an address derived from that asset. This action sidesteps it by paying only to the wallet. Upstream Deadcat does both in one transaction (pset/creation.rs) because it computes the ids in Rust before building anything; the two-transaction bootstrap lands the market in exactly the same on-chain state.", + "description": "Step 1 of 2 in bootstrapping a market, and the action that defines it. Mints the YES and NO reissuance tokens — 1 unit each — from two of your L-BTC UTXOs and keeps them in your wallet, then writes the instance file recording the market's full definition: the four asset ids this transaction just fixed, plus the terms you supply below. NOTE: it mints ZERO units of the YES and NO tokens themselves. Only the minting rights exist at this point; outcome tokens appear at InitialIssuance, and they have to, because tokens minted here would be backed by no collateral at all. Run CreateMarket next to lock the two tokens into the covenant.", + "intent": "define a market backed by {params.COLLATERAL_ASSET_ID:symbol} and mint its minting rights", + "params": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "The oracle's x-only key. Baked into every one of this market's four addresses, so it cannot be changed later." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset backing the market. Use the L-BTC policy asset unless you know why you want something else." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "default": "1000", + "description": "Satoshis behind one token. A YES/NO pair therefore costs twice this, and a winning token pays out twice this." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Block height after which the market expires unresolved and everyone redeems at half rate. Leave the oracle real time to attest." + } + }, + "inputs": [ + { + "id": "yes_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the YES asset pair: YES_TOKEN_ASSET = AssetId::new_issuance(outpoint, zero contract hash) and YES_REISSUANCE_TOKEN = AssetId::new_reissuance_token(outpoint, zero contract hash, confidential=false). Issues 0 asset units and 1 inflation (reissuance) token — YES tokens themselves are not minted until InitialIssuance reissues against this one.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 0, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the YES asset", + "role": "issuance" + } + }, + { + "id": "no_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the NO asset pair, the same way. Must be a different outpoint from yes_defining_in, or YES and NO would be the same asset.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 1, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the NO asset", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, to your wallet. The asset reference reads the id the engine derived from in[0] once it resolved. `inputs.` is the explicit spelling of an input reference; the bare `yes_defining_in.reissuance_token` also works but is ambiguous by shape with every other namespace.", + "destination": "wallet", + "asset": "inputs.yes_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "YES minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, to your wallet.", + "destination": "wallet", + "asset": "inputs.no_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "NO minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the two defining inputs.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "$comment": "The four asset ids are read straight off the inputs that created them — `$inputs..` is a string lookup, unlike a bare expression, which is arithmetic and would reject a 32-byte id. Note `issued_asset`, NOT `asset`: on an input carrying an issuance those differ, and `asset` is the asset of the UTXO being spent (L-BTC here). The four terms come from this action's params. All eight are compile params of prediction_market.simf, so from here the market's four covenant addresses are fully determined and every later action can derive them from the instance file alone.", + "fields": { + "ORACLE_PUBLIC_KEY": "$params.ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "COLLATERAL_PER_TOKEN": "$params.COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "$params.EXPIRY_TIME", + "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", + "NO_TOKEN_ASSET": "$inputs.no_defining_in.issued_asset", + "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token", + "NO_REISSUANCE_TOKEN": "$inputs.no_defining_in.reissuance_token", + "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy", + "NO_ISSUANCE_ENTROPY": "$inputs.no_defining_in.issuance_entropy" + } + } + }, + "CreateMarket": { + "$comment": "Run IssueReissuanceTokens first: it writes the instance file this action reads. Every value here comes from `instance.*` — the four asset ids and the four terms — which is why this action takes no params at all. Those fields are loaded into the context before the engine snapshots compile params, so the state-0 address computes correctly even though the assets were minted in a different transaction.", + "description": "Step 2 of 2: lock the minting rights into the covenant. A PLAIN Elements transaction — no covenant input, nothing validated on-chain, so a malformed creation simply produces unspendable UTXOs — that moves both reissuance tokens from your wallet to the state-0 (dormant) address. No outcome tokens are minted and no collateral is deposited; that is InitialIssuance's job. Anyone evaluating a Deadcat market should re-check this transaction by hand: the covenant vouches for everything after it, and for nothing in it.", + "intent": "lock the minting rights into a market backed by {instance.COLLATERAL_ASSET_ID:symbol}", + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token in your wallet, minted by IssueReissuanceTokens. Nothing is issued in this transaction — the token merely changes hands, from you to the covenant.", + "utxo_source": "wallet", + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token in your wallet, from the same run.", + "utxo_source": "wallet", + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee. Needed here because both other inputs are token UTXOs with no L-BTC in them.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, parked at the state-0 (dormant) address. From here it can only ever move by a covenant spend.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, at the same state-0 address.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "creation_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "PrepareInitialIssuance": { + "$comment": "A funding action, not a protocol one: nothing here touches the covenant. It exists because InitialIssuance declares no change output — the covenant pins the fee at output index 5, and a change output would displace it — so whatever L-BTC the collateral input carries beyond the collateral IS the fee. Feeding it an ordinary wallet UTXO therefore pays the entire remainder to miners. This action cuts a UTXO of exactly the right size first, which turns that behaviour from a hazard into an intent: the surplus becomes FEE_ALLOWANCE and nothing more.", + "description": "Cut an exactly-sized collateral UTXO for InitialIssuance. Reads COLLATERAL_PER_TOKEN and COLLATERAL_ASSET_ID from the instance and takes the number of pairs you intend to mint, so the output is PAIRS x 2 x COLLATERAL_PER_TOKEN plus a fee allowance — the exact amount InitialIssuance consumes. Run this first, then InitialIssuance, which will select the UTXO this produced (pin it with --input collateral_in=: if your wallet holds other L-BTC of a similar size).", + "intent": "cut a {params.PAIRS}-pair collateral UTXO for opening the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many YES/NO pairs InitialIssuance will mint. Must match the PAIRS you pass there — a mismatch just means the sizing is wrong, and the surplus or shortfall shows up as a fee error." + }, + "FEE_ALLOWANCE": { + "type": "u64", + "default": "2000", + "description": "Extra L-BTC to include on top of the collateral, which becomes InitialIssuance's fee. That transaction is large (three inputs, two of them Simplicity covenant spends with sizeable witnesses), so leave real headroom. Anything unspent here is paid to miners, so do not inflate it either." + } + }, + "inputs": [ + { + "id": "funding_in", + "description": "Wallet L-BTC UTXO to cut from. Must hold at least the collateral plus the fee allowance plus this transaction's own fee.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE" + }, + "ui": { + "label": "wallet funds to cut the collateral from", + "role": "funding" + } + } + ], + "outputs": [ + { + "id": "sized_collateral", + "description": "The exactly-sized UTXO InitialIssuance will spend as its collateral input: PAIRS x 2 x COLLATERAL_PER_TOKEN of collateral, plus FEE_ALLOWANCE which becomes that transaction's fee.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE", + "ui": { + "label": "collateral sized for opening the market", + "role": "collateral" + } + }, + { + "id": "funding_change", + "description": "The rest of the funding UTXO, back to your wallet. Present precisely because this action is allowed a change output, unlike InitialIssuance.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "InitialIssuance": { + "$comment": "Path 1. Fee MUST land at output index 5, which it does only because no change output is declared here: the engine appends declared outputs, then any change, then the fee. Declaring a change output would push the fee to index 6 and the covenant would reject the spend. Size the collateral input exactly (see `prepare`) — surplus L-BTC is swallowed by the fee.", + "description": "First covenant-validated transaction: state 0 -> 1. Reissues the first batch of YES/NO pairs, deposits their collateral, and moves all three covenant UTXOs to the state-1 address. Collateral comes from your wallet, not from the covenant — in the dormant state there is no collateral UTXO yet.", + "intent": "open the market: mint {params.PAIRS} YES/NO pairs and lock their collateral", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many matched YES/NO pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN and receive PAIRS YES tokens and PAIRS NO tokens — you are the market's first counterparty on both sides, and you can sell either leg." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token at the state-0 address, input 0 — the covenant asserts current_index == 0 for this path. Its reissuance mints PAIRS YES tokens.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0", + "description": "Claimed state. Not trusted: main() recomputes the state-0 address from it and asserts the input is actually being spent from there, so lying is impossible rather than merely detectable." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Left(())))", + "description": "Path 1 — initial issuance. The seven paths are a nested Either tree; see witness.rs::build_path_value upstream." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token, input 1. It runs path 7 (secondary covenant input), which only proves it is spent from the same address as input 0 and leaves every transaction-level check to input 0's path.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "Your collateral, input 2. Unlike every later issuance this comes from the wallet: the dormant market holds no collateral to consolidate with.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 2, + "ui": { + "label": "collateral you are depositing", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token cycled forward to the state-1 address (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token cycled forward to the state-1 address (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The market's one and only collateral UTXO (output 2), at the state-1 address. Exactly PAIRS x 2 x COLLATERAL_PER_TOKEN — the covenant computes this itself from the issuance amount and rejects anything else.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "The minted YES tokens (output 3), to your wallet. Unconstrained by the covenant beyond asset balance — it only cares that YES and NO were minted in equal amounts and fully collateralised.", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "The minted NO tokens (output 4), to your wallet.", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + } + ] + }, + "MintPairs": { + "$comment": "Path 2. Same index-5 fee requirement as InitialIssuance, so again no change output is declared. The difference from path 1 is input 2: the existing collateral UTXO is consumed and re-emitted as old + new, which is what keeps the market to exactly one collateral UTXO.", + "description": "Mint more pairs into a live market (state 1 -> 1). Permissionless: anyone who deposits collateral gets tokens, there is no issuer and no allowlist. Consumes the market's collateral UTXO and re-emits the consolidated total.", + "intent": "mint {params.PAIRS} more YES/NO pairs into the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "Additional pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN of new collateral on top of whatever the market already holds." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token from state 1, input 0 (the primary — current_index must be 0).", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Right(())))", + "description": "Path 2 — subsequent issuance." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token from state 1, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "existing_collateral_in", + "description": "The market's current collateral UTXO, input 2 — the covenant checks this index's script hash against the state-1 address by hand. Also on path 7. Its amount comes from the state file.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "collateral already in the market", + "role": "collateral" + } + }, + { + "id": "new_collateral_in", + "description": "Your new collateral, input 3, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 3, + "ui": { + "label": "collateral you are adding", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token back to state 1 (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token back to state 1 (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The re-consolidated collateral UTXO (output 2): the old amount plus this issuance's deposit, computed by the covenant as safe_add(old, pairs x 2 x CPT).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "existing_collateral_in.amount_sat + params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "all collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "Newly minted YES tokens to your wallet (output 3).", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "Newly minted NO tokens to your wallet (output 4).", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + } + ] + }, + "ResolveYes": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = true. Modelled as two actions rather than one with a runtime outcome because the destination address IS the outcome — state 2 and state 3 are different covenant addresses, and the engine has no conditional destination. The covenant demands exactly 4 outputs, so no change output may be declared: the fee absorbs any L-BTC surplus, which lands it at index 3 as required.", + "description": "Commit a YES outcome on-chain (state 1 -> 2). Permissionless to submit: the oracle signs a message that mentions only the market, never this transaction, so anyone holding the attestation can post it. Nothing moves except the three covenant UTXOs, which all shift to the state-2 address; the collateral amount is preserved exactly.", + "intent": "resolve the market YES using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x01), where MARKET_ID = sha256(YES_TOKEN_ASSET || NO_TOKEN_ASSET) over the asset ids in internal byte order. Paste it 0x-prefixed. Produced off-chain; the wallet cannot compute it." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — the primary input, and the one that verifies the oracle signature.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "true", + "description": "Picks the YES branch, which both selects the signed message byte 0x01 and sends every covenant output to the state-2 address." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE", + "description": "Substituted from the action param before the value is parsed." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7. Its amount is read from the state file and must be reproduced exactly on output 2.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-2 address (output 0).", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-2 address (output 1). Minting is over, but the tokens are carried along so nothing is stranded.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-2 address (output 2). No value enters or leaves in a resolve.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by YES", + "role": "collateral" + } + } + ] + }, + "ResolveNo": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = false. Identical to ResolveYes except the signed outcome byte is 0x00 and every covenant output goes to state 3. Same 4-output rule: declare no change.", + "description": "Commit a NO outcome on-chain (state 1 -> 3). Whichever of ResolveYes / ResolveNo confirms first wins — that is the equivocation protection. An oracle that signs both outcomes cannot start a race to drain the pool, because after the first resolve there is no path back to state 1 and none between states 2 and 3.", + "intent": "resolve the market NO using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x00). Paste it 0x-prefixed." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — still the primary input on a NO resolve; the covenant's layout is fixed regardless of outcome.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "false", + "description": "Picks the NO branch: outcome byte 0x00 and state-3 outputs." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-3 address (output 1).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-3 address (output 2).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by NO", + "role": "collateral" + } + } + ] + }, + "RedeemYes": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 2, PARTIAL form (remaining collateral > 0). The full-drain form is a different output layout — the burn becomes output 0 and there is no collateral output at all — and needs its own action, not modelled here. The fee is checked at num_outputs - 1, so change outputs are fine on this path.", + "description": "Redeem winning YES tokens for collateral (state 2). Each YES token draws 2 x COLLATERAL_PER_TOKEN — the whole pair's backing, both your stake and the loser's — so the pool drains exactly as all winners redeem, with nothing stranded. Losing NO tokens have no path; they are simply worthless.", + "intent": "redeem {params.TOKENS_BURNED} winning YES tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many YES tokens to burn. Must be strictly less than the market's total remaining backing, or this becomes a full drain and needs the other output layout." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-2 address, input 0 — this path reads amounts from index 0, so it must come first.", + "utxo_source": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "2", + "description": "State 2 also selects YES_TOKEN_ASSET as the only asset the covenant will accept as a burn." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED", + "description": "Drives both the burn output amount and the payout; the covenant checks both against it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your winning YES tokens, from the wallet. Size this exactly — any surplus becomes a change output, which is allowed here but pointless.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning YES tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "What is left of the pool, back to the state-2 address (output 0). The reissuance tokens are untouched by this transaction and stay where they are by consensus.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "The YES tokens destroyed (output 1). The covenant requires a ZERO-LENGTH scriptPubKey here — sha256 of the empty script — not the 1-byte OP_RETURN this engine emits. See README.md.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2): TOKENS_BURNED x 2 x COLLATERAL_PER_TOKEN. The covenant does not check this leg at all — it constrains what stays behind and what is burned, and the rest follows from asset balance.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "YES token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.YES_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned YES tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemNo": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 3 — the mirror of RedeemYes. Same partial-only caveat.", + "description": "Redeem winning NO tokens for collateral (state 3). Each NO token draws 2 x COLLATERAL_PER_TOKEN.", + "intent": "redeem {params.TOKENS_BURNED} winning NO tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many NO tokens to burn." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-3 address, input 0.", + "utxo_source": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "3", + "description": "State 3 selects NO_TOKEN_ASSET as the winning side." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "no_tokens_in", + "description": "Your winning NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning NO tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "no_tokens_burn", + "description": "The NO tokens destroyed (output 1). Same zero-length-script requirement as RedeemYes.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "NO token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.NO_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned NO tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemExpired": { + "allow_change": "lbtc_only", + "$comment": "Path 5, partial form. NOT EXECUTABLE YET: this is the one Deadcat path that calls jet::check_lock_height(EXPIRY_TIME), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. (The pre-expiry paths are fine: they assert lock_time < EXPIRY_TIME, which a locktime of 0 satisfies.) BURN_TOKEN_ASSET must be supplied byte-reversed, because witness values are parsed as raw SimplicityHL literals with no liquid.asset_id type hint to trigger the reversal that compile params get.", + "description": "Redeem after the market expired unresolved (state 1, at or after EXPIRY_TIME). Both sides redeem at 1 x COLLATERAL_PER_TOKEN — half the winner's rate — so YES and NO holders are made whole together and the pool drains exactly. This is the escape hatch for an oracle that never attests.", + "intent": "redeem {params.TOKENS_BURNED} tokens from the expired market", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many tokens to burn, of whichever side you hold." + }, + "BURN_TOKEN_ASSET": { + "type": "bytes", + "description": "The asset id of the side you are burning — YES_TOKEN_ASSET or NO_TOKEN_ASSET — 0x-prefixed and in INTERNAL byte order, i.e. the display id reversed. The covenant accepts either, and rejects anything else." + }, + "BURN_TOKEN_ASSET_DISPLAY": { + "type": "liquid.asset_id", + "description": "The same asset id in normal display order, used for the transaction's burn output and token input. Must be the reverse of BURN_TOKEN_ASSET." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, still at the state-1 address, input 0. The sequence below is ENABLE_LOCKTIME_NO_RBF: any value other than 0xFFFFFFFF enables the nLockTime that check_lock_height reads. The engine will warn that the BIP68 disable bit is set — expected, since this timeout is an absolute height.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "sequence": 4294967294, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Left(())))", + "description": "Path 5 — expiry redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + }, + "BURN_TOKEN_ASSET": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.BURN_TOKEN_ASSET", + "description": "Tells the covenant which side you are burning. It checks the value equals YES_TOKEN_ASSET or NO_TOKEN_ASSET and then holds output 1 to it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "tokens_in", + "description": "The tokens you are burning, from the wallet — either side.", + "utxo_source": "wallet", + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0) — the market never leaves state 1 on this path.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other holders", + "role": "collateral" + } + }, + { + "id": "tokens_burn", + "description": "The tokens destroyed (output 1). Zero-length script required, as on the other burn paths.", + "destination": { + "type": "burn" + }, + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your refund (output 2): TOKENS_BURNED x COLLATERAL_PER_TOKEN, half the resolved rate, because the other half belongs to the holder of the matching token on the other side.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your refund", + "role": "settlement" + } + } + ] + }, + "CancelPairs": { + "allow_change": "lbtc_only", + "$comment": "Path 6, partial form (state 1 -> 1). The full form, which drains the pool to zero and cycles the reissuance tokens back to state 0, is CancelAll.", + "description": "Unwind part of your position while the market is unresolved. Burn matched YES and NO in equal amounts and take back the full 2 x COLLATERAL_PER_TOKEN per pair — you are undoing an issuance, so there is no loss and no time constraint. Only matched pairs qualify: burning one side alone would leave the other side under-collateralised.", + "intent": "cancel {params.PAIRS_BURNED} pairs and reclaim their collateral", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "How many matched pairs to burn. You must hold this many of BOTH YES and NO." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation. The partial and full forms are the same path; the covenant picks between them by testing whether any collateral remains." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "YES side of the pairs you are burning", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "Your NO tokens, from the wallet, in the same amount.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "NO side of the pairs you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0). Non-zero is what makes this the partial branch — if it were zero the covenant would demand CancelAll's layout instead.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral still backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "YES tokens destroyed (output 1). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "NO tokens destroyed (output 2), same amount — the covenant checks both indices, which is how equal burning is enforced.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "Your refund (output 3): the full deposit for those pairs, PAIRS_BURNED x 2 x COLLATERAL_PER_TOKEN.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 3, + "ui": { + "label": "collateral refunded to you", + "role": "settlement" + } + } + ] + }, + "CancelAll": { + "allow_change": "lbtc_only", + "$comment": "Path 6, full form (state 1 -> 0). PAIRS_BURNED must equal the whole pool: collateral_in.amount_sat / (2 x COLLATERAL_PER_TOKEN). If it does not, the covenant takes the partial branch instead and this output layout is rejected.", + "description": "Wind the market all the way down. Burn every outstanding pair, take back all the collateral, and cycle both reissuance tokens back to the state-0 address so the market is dormant rather than dead. Without this the tokens would be stranded at a collateral-less state-1 address and the market could never be reissued — the reason the dormant state exists at all (design doc section 5.3).", + "intent": "wind the market down and return it to dormant", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "Every outstanding pair. Must equal the pool's total collateral divided by 2 x COLLATERAL_PER_TOKEN, exactly." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0 — the primary input, running path 6.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation, full branch (chosen by the covenant when nothing remains)." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "the whole collateral pool", + "role": "collateral" + } + }, + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 1 — the full branch verifies it at exactly this index. Runs path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 2, also on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_in", + "description": "All outstanding YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every YES token, being burned", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "All outstanding NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every NO token, being burned", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token returned to the state-0 (dormant) address (output 0).", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token returned to the state-0 address (output 1). From here the market can be reopened with another InitialIssuance.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_burn", + "description": "All YES tokens destroyed (output 2). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "All NO tokens destroyed (output 3).", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 3, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "The entire pool refunded to you (output 4). The covenant does not constrain this leg; it falls out of asset balance once outputs 0-3 are pinned.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 4, + "ui": { + "label": "all collateral refunded to you", + "role": "settlement" + } + } + ] + } + } + } + } +} diff --git a/examples/deadcat_v2/README.md b/examples/deadcat_v2/README.md new file mode 100644 index 0000000..9cdfb8b --- /dev/null +++ b/examples/deadcat_v2/README.md @@ -0,0 +1,104 @@ +# deadcat_v2 — explicit reissuance tokens + +> **Superseded — this fork does not work.** Elements' reissuance branch rebuilds the spent +> token's asset tag from the blinding nonce and byte-compares it against the input's asset +> *commitment* (`confidential_validation.cpp`). An explicit asset starts `0x01`, a generator +> starts `0x0a`/`0x0b`, so no nonce can ever make an explicit reissuance token validate. +> Unblinding the tokens is therefore impossible, not merely inadvisable, and Deadcat's +> design doc §13 was literal. See [`deadcat_v3`](../deadcat_v3/README.md) for the fork that +> works. Kept as the record of what was tried and why it failed. + +Same protocol as [`examples/deadcat`](../deadcat/README.md), one forked covenant. Read that +README first — the state model, the seven spending paths, the fee-index rules and the +bootstrap flow are all identical and are documented there. This file only covers the delta. + +## What changed and why + +`examples/deadcat` is a faithful port, and it cannot be executed. Its issuance, resolve and +full-cancel paths verify the two reissuance tokens as **Pedersen commitments**: + +```rust +let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::input_asset(index))); +``` + +`unwrap_left` demands a *confidential* asset commitment, so the token UTXOs must be blinded. +The engine emits every covenant output explicit (`pset_builder` warns and falls back when a +`utxo_type` sets `confidential: true`), so the Right branch is taken, and since that branch +was pruned during witness satisfaction you get: + +``` +Execution reached a pruned branch: 744339c859e7ff6f8d33f9afa73734e1c908684feedc8c4d0a6112d3bf361317 +``` + +The symptom is diagnostic: on `InitialIssuance`, `no_reissuance_in` finalizes fine — it runs +path 7, which only compares script hashes — while `yes_reissuance_in` fails, because path 1 +is the one doing commitment arithmetic. + +v2 reads those tokens explicitly instead: + +```rust +fn verify_input_reissuance_token(index: u32, expected_token: u256) { + ensure_input_asset_with_amount_eq(index, expected_token, reissuance_token_amount()); +} +``` + +**The amount check is not weakened.** Upstream's value-commitment step computes +`asset_gen + vbf*G`, which is a Pedersen commitment to value exactly 1 — so `eq_64(amount, 1)` +asserts precisely the same fact, in the clear. + +Everything else is untouched: same states, same paths, same amounts, same output layouts, +same `BUDGET_PAD_*` witnesses. + +## Consequences + +- **All four covenant addresses move.** Dropping `hash_to_curve`, `generate`, `gej_ge_add`, + `gej_normalize` and `fe_square_root` changes the CMR. A v2 market is not interoperable + with a Deadcat market, and tokens parked at a v1 address cannot be spent by this program. + If you were mid-bootstrap on `examples/deadcat`, start again here — those UTXOs are + stranded. +- **The witness surface shrinks from 19 to 11.** The eight `*_ABF` / `*_VBF` witnesses are + gone, which also removes the one thing in this protocol that a manifest had no way to + express: per-UTXO blinding secrets. +- **Privacy is unchanged in practice.** The reissuance tokens are 1-unit capability markers + sitting at a covenant address whose script is public; blinding them hid nothing an + observer could not infer from the address. + +Both properties are pinned by +[`txmanifest_lib/examples/deadcat_v2_recon.rs`](../../txmanifest_lib/examples/deadcat_v2_recon.rs): + +```sh +cargo run -p tx-manifest-lib --example deadcat_v2_recon # v2 forked: addresses moved, witnesses dropped +cargo run -p tx-manifest-lib --example deadcat_recon # v1 faithful: addresses match upstream +``` + +The first asserts every v2 address *differs* from the matching v1 address — if they ever +collide, the fork silently stopped being a fork. The second still proves `examples/deadcat` +reproduces upstream Deadcat byte-for-byte, which is why the unmodified port is kept. + +## The open question + +This fork is also the cheap experiment for the thing that actually blocks v1: **will Elements +accept a reissuance whose reissuance-token input is explicit?** + +A reissuance needs a non-null `assetBlindingNonce` — null means "new issuance" — and the true +asset blinding factor of an explicit UTXO is zero. `pset_builder::apply_reissuance` therefore +fakes it with the minimal non-zero scalar `[0…0, 1]`. Consensus is expected to check only +null-vs-non-null and then match the input's asset against the entropy-derived token id, which +works fine in the clear. But no example in this repo has ever broadcast a reissuance, so that +path is untested, and reasoning will not settle it. + +If v2's `InitialIssuance` confirms on testnet, explicit reissuance works and the engine never +needs confidential covenant outputs for this protocol. If it is rejected, Deadcat's blinded +design is forced and the engine gap is the only way through. + +## Try it + +```sh +cargo run -- validate examples/deadcat_v2/txmanifest.json +cargo run -p tx-manifest-lib --example deadcat_v2_recon + +# Bootstrap from scratch — v1 instance/state files do not carry over. +cargo run -- run examples/deadcat_v2/txmanifest.json IssueReissuanceTokens --wallet wallet.json +cargo run -- run examples/deadcat_v2/txmanifest.json CreateMarket --wallet wallet.json +cargo run -- run examples/deadcat_v2/txmanifest.json InitialIssuance --wallet wallet.json +``` diff --git a/examples/deadcat_v2/params.json b/examples/deadcat_v2/params.json new file mode 100644 index 0000000..f14f7ac --- /dev/null +++ b/examples/deadcat_v2/params.json @@ -0,0 +1,8 @@ +{ + "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + "COLLATERAL_PER_TOKEN": "1000", + "EXPIRY_TIME": "2560000", + "NO_REISSUANCE_TOKEN": "53d19885868f1b32d9c5ad3c7fdc83576ffefbd5ba573d03c61cf3e557855e5d", + "YES_REISSUANCE_TOKEN": "d8175b4e10fcb1b8eb78f34d4942b7c59635b7b30cb20e4f2801215005d9bf1b", + "ORACLE_PUBLIC_KEY": "5907ed9ec3cb2ff851da548b617b54af6d1967386775e7c7c3c9ad153f5884d9" +} \ No newline at end of file diff --git a/examples/deadcat_v2/prediction_market.simf b/examples/deadcat_v2/prediction_market.simf new file mode 100644 index 0000000..b48aa48 --- /dev/null +++ b/examples/deadcat_v2/prediction_market.simf @@ -0,0 +1,508 @@ +// Binary Prediction Market Covenant - v2, explicit reissuance tokens +// SimplicityHL contract for Liquid +// +// Derived from examples/deadcat/prediction_market.simf (Deadcat.Live), which is kept +// verbatim. The ONLY behavioural difference: the two reissuance tokens are read as +// explicit outputs rather than Pedersen commitments. Upstream verifies them with +// `verify_token_commitment`, recomputing H + abf*G and asset_gen + vbf*G from blinding +// factors supplied as witnesses - which requires the token UTXOs to be blinded, and the +// tx-manifest engine emits every covenant output explicit. +// +// The amount check is not weakened. Upstream's value-commitment step computes +// `asset_gen + vbf*G`, a commitment to value exactly 1, so `eq_64(amount, 1)` here +// asserts the same thing. +// +// This is a FORK: dropping the EC operations changes the CMR and therefore all four +// covenant addresses. Markets built from this file do NOT interoperate with Deadcat's. + +// ============================================================================ +// Type aliases +// ============================================================================ + +// PATH dispatch types (7-way nested Either) +type Path1or2 = Either<(), ()>; +type Path3or4 = Either<(), ()>; +type Path1to4 = Either; +type Path5or6 = Either<(), ()>; +type Path5to7 = Either; + +// ============================================================================ +// Boolean helpers +// ============================================================================ + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn ensure_zero_bit(b: bool) { + assert!(not(b)); +} + +// ============================================================================ +// Utility functions +// ============================================================================ + +fn get_input_script_hash(index: u32) -> u256 { + unwrap(jet::input_script_hash(index)) +} + +fn get_output_explicit_asset(index: u32) -> u256 { + unwrap_right::<(u1, u256)>(unwrap(jet::output_asset(index))) +} + +fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn ensure_output_script_hash_eq(index: u32, expected: u256) { + let actual: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(actual, expected)); +} + +fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn ensure_input_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_input_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn empty_script_hash() -> u256 { + 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +} + +fn ensure_output_is_op_return(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +fn ensure_fee_output(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +// ============================================================================ +// Reissuance token verification (explicit) +// ============================================================================ + +// A reissuance token UTXO always holds exactly 1 unit. Upstream proves this against a +// Pedersen commitment; here the value is in the clear, so the check is a comparison. +fn reissuance_token_amount() -> u64 { + 1 +} + +fn verify_input_reissuance_token(index: u32, expected_token: u256) { + ensure_input_asset_with_amount_eq(index, expected_token, reissuance_token_amount()); +} + +fn verify_output_reissuance_token(index: u32, expected_token: u256) { + ensure_output_asset_with_amount_eq(index, expected_token, reissuance_token_amount()); +} + +// ============================================================================ +// Taproot address computation +// ============================================================================ + +fn covenant_nums_key() -> u256 { + 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 +} + +fn compute_p2tr_script_hash_from_output_key(output_key: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn script_hash_for_input_script(state: u64) -> u256 { + let tap_leaf: u256 = jet::tapleaf_hash(); + let state_ctx: Ctx8 = jet::tapdata_init(); + let state_ctx: Ctx8 = jet::sha_256_ctx_8_add_8(state_ctx, state); + let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx); + let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); + let tweaked_key: u256 = jet::build_taptweak(covenant_nums_key(), tap_node); + compute_p2tr_script_hash_from_output_key(tweaked_key) +} + +// ============================================================================ +// Arithmetic helpers +// ============================================================================ + +fn safe_multiply(a: u64, b: u64) -> u64 { + let result: u128 = jet::multiply_64(a, b); + let (high, low): (u64, u64) = ::into(result); + assert!(jet::is_zero_64(high)); + low +} + +fn safe_add(a: u64, b: u64) -> u64 { + let (carry, sum): (bool, u64) = jet::add_64(a, b); + ensure_zero_bit(carry); + sum +} + +fn safe_subtract(a: u64, b: u64) -> u64 { + let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); + ensure_zero_bit(borrow); + diff +} + +fn safe_subtract_32(a: u32, b: u32) -> u32 { + let (borrow, diff): (bool, u32) = jet::subtract_32(a, b); + ensure_zero_bit(borrow); + diff +} + +// ============================================================================ +// Market-specific functions +// ============================================================================ + +fn compute_market_id() -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::YES_TOKEN_ASSET); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::NO_TOKEN_ASSET); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { + let market_id: u256 = compute_market_id(); + let outcome_byte: u8 = match outcome_yes { + true => 1, + false => 0, + }; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); + let message: u256 = jet::sha_256_ctx_8_finalize(ctx); + jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); +} + +fn ensure_pre_expiry() { + let tx_lock_time: u32 = jet::lock_time(); + assert!(jet::lt_32(tx_lock_time, param::EXPIRY_TIME)); +} + +fn dormant_script_hash() -> u256 { + script_hash_for_input_script(0) +} + +fn unresolved_script_hash() -> u256 { + script_hash_for_input_script(1) +} + +fn collateral_for_pairs(pairs: u64) -> u64 { + let two_cpt: u64 = safe_multiply(2, param::COLLATERAL_PER_TOKEN); + safe_multiply(pairs, two_cpt) +} + +fn get_issuance_amount(index: u32) -> u64 { + unwrap_right::<(u1, u256)>(unwrap(unwrap(jet::issuance_asset_amount(index)))) +} + +// ============================================================================ +// Spending paths +// ============================================================================ + +/// Path 1: Initial Issuance (state 0 → 1) +fn initial_issuance_path(state: u64) { + assert!(jet::is_zero_64(state)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let total_collateral: u64 = collateral_for_pairs(pairs); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(5); +} + +/// Path 2: Subsequent Issuance (state 1 → 1) +fn subsequent_issuance_path(state: u64) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let new_collateral: u64 = collateral_for_pairs(pairs); + + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, old_collateral): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + let total_collateral: u64 = safe_add(old_collateral, new_collateral); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(5); +} + +/// Path 3: Oracle Resolve (state 1 → 2 or 3) +fn oracle_resolve_path(state: u64, outcome_yes: bool, sig: Signature) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + verify_oracle_signature(outcome_yes, sig); + + let new_state: u64 = match outcome_yes { + true => 2, + false => 3, + }; + let new_state_hash: u256 = script_hash_for_input_script(new_state); + + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(0, new_state_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(1, new_state_hash); + + let unresolved_hash: u256 = unresolved_script_hash(); + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, coll_amount); + ensure_output_script_hash_eq(2, new_state_hash); + + assert!(jet::eq_32(jet::num_outputs(), 4)); + ensure_fee_output(3); +} + +/// Path 4: Post-Resolution Redemption (state 2 or 3) +fn post_resolution_redemption_path(state: u64, tokens_burned: u64) { + let is_yes: bool = jet::eq_64(state, 2); + let is_no: bool = jet::eq_64(state, 3); + assert!(or(is_yes, is_no)); + + let state_hash: u256 = script_hash_for_input_script(state); + + let winner_asset: u256 = match is_yes { + true => param::YES_TOKEN_ASSET, + false => param::NO_TOKEN_ASSET, + }; + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let payout: u64 = collateral_for_pairs(tokens_burned); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, state_hash); + ensure_output_asset_with_amount_eq(1, winner_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, winner_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 5: Expiry Redemption (state 1, post-expiry) +fn expiry_redemption_path(state: u64, tokens_burned: u64, burn_asset: u256) { + assert!(jet::eq_64(state, 1)); + jet::check_lock_height(param::EXPIRY_TIME); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let is_yes: bool = jet::eq_256(burn_asset, param::YES_TOKEN_ASSET); + let is_no: bool = jet::eq_256(burn_asset, param::NO_TOKEN_ASSET); + assert!(or(is_yes, is_no)); + + let payout: u64 = safe_multiply(tokens_burned, param::COLLATERAL_PER_TOKEN); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, burn_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, burn_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 6: Cancellation (state 1 → 1 partial, 1 → 0 full) +fn cancellation_path(state: u64, pairs_burned: u64) { + assert!(jet::eq_64(state, 1)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let refund: u64 = collateral_for_pairs(pairs_burned); + let remaining: u64 = safe_subtract(coll_amount, refund); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(1); + ensure_output_asset_with_amount_eq(2, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + let dormant_hash: u256 = dormant_script_hash(); + + verify_input_reissuance_token(1, param::YES_REISSUANCE_TOKEN); + verify_input_reissuance_token(2, param::NO_REISSUANCE_TOKEN); + + verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(0, dormant_hash); + + verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); + ensure_output_script_hash_eq(1, dormant_hash); + + ensure_output_asset_with_amount_eq(2, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + ensure_output_asset_with_amount_eq(3, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(3); + + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 7: Secondary Covenant Input +fn secondary_covenant_input_path() { + let my_index: u32 = jet::current_index(); + let my_hash: u256 = get_input_script_hash(my_index); + let primary_hash: u256 = get_input_script_hash(0); + assert!(jet::eq_256(my_hash, primary_hash)); + ensure_zero_bit(jet::eq_32(my_index, 0)); +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +fn main() { + let state: u64 = witness::STATE; + + let expected_hash: u256 = script_hash_for_input_script(state); + let actual_hash: u256 = get_input_script_hash(jet::current_index()); + assert!(jet::eq_256(expected_hash, actual_hash)); + + // Budget padding: these witnesses increase the serialized witness size so that + // the Simplicity execution budget (witness_stack_bytes + 50 WU) covers the + // program's cost. Without this, the pruned program's cost slightly exceeds + // the budget derived from its natural witness + program size. + let budget_pad_a: u256 = witness::BUDGET_PAD_A; + let budget_pad_b: u256 = witness::BUDGET_PAD_B; + assert!(jet::eq_256(budget_pad_a, budget_pad_b)); + let budget_pad_c: u256 = witness::BUDGET_PAD_C; + let budget_pad_d: u256 = witness::BUDGET_PAD_D; + assert!(jet::eq_256(budget_pad_c, budget_pad_d)); + + // Read all witnesses in main (required by SimplicityHL). The eight reissuance + // blinding factors upstream reads here are gone with the commitment checks. + let oracle_sig: Signature = witness::ORACLE_SIGNATURE; + let oracle_outcome: bool = witness::ORACLE_OUTCOME_YES; + let tokens_burned: u64 = witness::TOKENS_BURNED; + let burn_asset: u256 = witness::BURN_TOKEN_ASSET; + let pairs_burned: u64 = witness::PAIRS_BURNED; + + match witness::PATH { + Left(l: Path1to4) => match l { + Left(i: Path1or2) => match i { + Left(u: ()) => initial_issuance_path(state), + Right(u: ()) => subsequent_issuance_path(state), + }, + Right(r: Path3or4) => match r { + Left(u: ()) => oracle_resolve_path(state, oracle_outcome, oracle_sig), + Right(u: ()) => post_resolution_redemption_path(state, tokens_burned), + }, + }, + Right(r: Path5to7) => match r { + Left(rd: Path5or6) => match rd { + Left(u: ()) => expiry_redemption_path(state, tokens_burned, burn_asset), + Right(u: ()) => cancellation_path(state, pairs_burned), + }, + Right(u: ()) => secondary_covenant_input_path(), + }, + } +} diff --git a/examples/deadcat_v2/txmanifest.json b/examples/deadcat_v2/txmanifest.json new file mode 100644 index 0000000..d3ccd06 --- /dev/null +++ b/examples/deadcat_v2/txmanifest.json @@ -0,0 +1,1746 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "v2 of examples/deadcat — SAME protocol, one forked covenant. prediction_market.simf here treats the two reissuance tokens as EXPLICIT rather than as Pedersen commitments, because the engine emits every covenant output explicit and upstream's commitment check (unwrap_left on a confidential asset) therefore fails on the issuance, resolve and full-cancel paths. Dropping the EC operations changes the CMR, so all four covenant addresses differ from examples/deadcat and markets are NOT interoperable with Deadcat's. Everything else — states, paths, amounts, output layouts — is unchanged; examples/deadcat remains the faithful port and is what deadcat_recon checks against upstream. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct. See README.md for the full list of what is and is not executable today.", + "manifest_version": "0.1.0", + "protocol": "deadcat-prediction-market-v2", + "description": "Deadcat v2 — a binary (YES/NO) prediction market on Liquid, with explicit (unblinded) reissuance tokens so it can actually be executed by this engine. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", + "chain": "liquid", + "simplicity_hl": { + "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", + "debug_symbols": false + }, + "utxo_types": { + "market_dormant": { + "description": "STATE 0 — DORMANT. Holds only the two reissuance tokens, no collateral. This is where CreateMarket parks them and where a full Cancel returns them. The address is the Simplicity leaf branched with a tapdata leaf carrying the u64 0 (big-endian, 8 bytes) — see taproot.rs::tapdata_hash. Nothing else about the address differs between the four states.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "$comment": "state = 0. jet::tapdata_init() + sha_256_ctx_8_add_8(state) hashes exactly 8 big-endian bytes, so no pad_to here — unlike the lending example's 32-byte storage slots.", + "type": "tapdata", + "payload": [ + { + "value": "0", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "YES_REISSUANCE_TOKEN", + "confidential": false + }, + "market_unresolved": { + "description": "STATE 1 — UNRESOLVED, the live market. Holds three UTXOs: the YES reissuance token, the NO reissuance token, and the single consolidated collateral UTXO. 'Single' is load-bearing: every issuance path must consume the existing collateral UTXO and re-emit one consolidated output, so the oracle can move the whole market to a resolved address in one transaction (design doc section 6).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "1", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_yes": { + "description": "STATE 2 — RESOLVED YES. The oracle attested YES, so YES tokens redeem at 2 x COLLATERAL_PER_TOKEN each and NO tokens are worth nothing. There is no path from here to state 3: that is the whole point of committing the outcome on-chain rather than checking the oracle signature at redemption time (design doc section 9.2).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "2", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_no": { + "description": "STATE 3 — RESOLVED NO. Mirror of state 2: NO tokens redeem, YES tokens are worthless.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "3", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + } + }, + "contract_templates": { + "binary_market": { + "description": "One binary prediction market. IssueReissuanceTokens is the constructor: it mints the minting rights and writes the instance file that defines the market. CreateMarket then locks those rights into the covenant (a plain Elements transaction — no covenant runs), InitialIssuance brings the market to life, then anyone may MintPairs or Cancel while it is unresolved. It ends one of two ways: the oracle resolves it (ResolveYes / ResolveNo, then RedeemYes / RedeemNo), or it expires unresolved and both sides redeem at half rate (RedeemExpired). All eight fields below are compile params of prediction_market.simf, so changing any one of them is a different market at four different addresses.", + "fields": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "X-only BIP340 key the resolve path checks. In Deadcat this is the aggregate key of a 2-of-3 FROST committee — on-chain it is just one key, the threshold signing happens off-chain." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset backing the market, normally L-BTC. Also the asset the network fee is paid in, which is why the covenant's fee-output checks are satisfiable." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "description": "Satoshis backing ONE token. A pair costs 2 x this. Deliberately per-token, not per-pair, so the covenant only ever multiplies — no division means no rounding bug (design doc section 4.3)." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Absolute block height. Before it, the oracle may resolve and anyone may mint. At or after it, RedeemExpired unlocks and every token — winning or losing — redeems at 1 x COLLATERAL_PER_TOKEN." + }, + "YES_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the YES outcome token, derived from the outpoint pinned as CreateMarket in[0]. Also half of MARKET_ID = sha256(YES || NO), the oracle's domain separator." + }, + "NO_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the NO outcome token, derived from the outpoint pinned as CreateMarket in[1]." + }, + "YES_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for YES. Holding it is what permits minting more YES, and it only ever lives at a covenant address — that is the enforcement mechanism behind collateral consolidation." + }, + "NO_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for NO. Same role as YES_REISSUANCE_TOKEN." + }, + "YES_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the YES mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value YES_TOKEN_ASSET itself is derived from. Every later reissuance needs it and NOTHING on chain carries it: the reissuance token UTXO holds no trace of the outpoint that created it. Captured by the constructor at the one moment it exists." + }, + "NO_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the NO mint. Same role, from the other defining outpoint." + } + }, + "actions": { + "IssueReissuanceTokens": { + "$comment": "The constructor: it is this action, not CreateMarket, that writes the instance file. That is forced by where the asset ids come from — they are derived from the outpoints THIS transaction spends, and nothing downstream can recover them (the reissuance token id and the asset id are sibling hashes of the entropy, so you cannot walk from one to the other). Capturing them here — create_instance reads them straight off the two inputs via $inputs.. — is the only point at which they exist and can still be recorded.\n\nWhy the bootstrap is two transactions at all: CreateMarket pays to the state-0 covenant address, and that address is a function of all four asset ids, which are functions of the outpoints being spent. The engine snapshots compile params BEFORE it resolves any input, so one action cannot both mint an asset and pay it to an address derived from that asset. This action sidesteps it by paying only to the wallet. Upstream Deadcat does both in one transaction (pset/creation.rs) because it computes the ids in Rust before building anything; the two-transaction bootstrap lands the market in exactly the same on-chain state.", + "description": "Step 1 of 2 in bootstrapping a market, and the action that defines it. Mints the YES and NO reissuance tokens — 1 unit each — from two of your L-BTC UTXOs and keeps them in your wallet, then writes the instance file recording the market's full definition: the four asset ids this transaction just fixed, plus the terms you supply below. NOTE: it mints ZERO units of the YES and NO tokens themselves. Only the minting rights exist at this point; outcome tokens appear at InitialIssuance, and they have to, because tokens minted here would be backed by no collateral at all. Run CreateMarket next to lock the two tokens into the covenant.", + "intent": "define a market backed by {params.COLLATERAL_ASSET_ID:symbol} and mint its minting rights", + "params": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "The oracle's x-only key. Baked into every one of this market's four addresses, so it cannot be changed later." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset backing the market. Use the L-BTC policy asset unless you know why you want something else." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "default": "1000", + "description": "Satoshis behind one token. A YES/NO pair therefore costs twice this, and a winning token pays out twice this." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Block height after which the market expires unresolved and everyone redeems at half rate. Leave the oracle real time to attest." + } + }, + "inputs": [ + { + "id": "yes_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the YES asset pair: YES_TOKEN_ASSET = AssetId::new_issuance(outpoint, zero contract hash) and YES_REISSUANCE_TOKEN = AssetId::new_reissuance_token(outpoint, zero contract hash, confidential=false). Issues 0 asset units and 1 inflation (reissuance) token — YES tokens themselves are not minted until InitialIssuance reissues against this one.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 0, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the YES asset", + "role": "issuance" + } + }, + { + "id": "no_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the NO asset pair, the same way. Must be a different outpoint from yes_defining_in, or YES and NO would be the same asset.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 1, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the NO asset", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, to your wallet. The asset reference reads the id the engine derived from in[0] once it resolved. `inputs.` is the explicit spelling of an input reference; the bare `yes_defining_in.reissuance_token` also works but is ambiguous by shape with every other namespace.", + "destination": "wallet", + "asset": "inputs.yes_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "YES minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, to your wallet.", + "destination": "wallet", + "asset": "inputs.no_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "NO minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the two defining inputs.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "$comment": "The four asset ids are read straight off the inputs that created them — `$inputs..` is a string lookup, unlike a bare expression, which is arithmetic and would reject a 32-byte id. Note `issued_asset`, NOT `asset`: on an input carrying an issuance those differ, and `asset` is the asset of the UTXO being spent (L-BTC here). The four terms come from this action's params. All eight are compile params of prediction_market.simf, so from here the market's four covenant addresses are fully determined and every later action can derive them from the instance file alone.", + "fields": { + "ORACLE_PUBLIC_KEY": "$params.ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "COLLATERAL_PER_TOKEN": "$params.COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "$params.EXPIRY_TIME", + "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", + "NO_TOKEN_ASSET": "$inputs.no_defining_in.issued_asset", + "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token", + "NO_REISSUANCE_TOKEN": "$inputs.no_defining_in.reissuance_token", + "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy", + "NO_ISSUANCE_ENTROPY": "$inputs.no_defining_in.issuance_entropy" + } + } + }, + "CreateMarket": { + "$comment": "Run IssueReissuanceTokens first: it writes the instance file this action reads. Every value here comes from `instance.*` — the four asset ids and the four terms — which is why this action takes no params at all. Those fields are loaded into the context before the engine snapshots compile params, so the state-0 address computes correctly even though the assets were minted in a different transaction.", + "description": "Step 2 of 2: lock the minting rights into the covenant. A PLAIN Elements transaction — no covenant input, nothing validated on-chain, so a malformed creation simply produces unspendable UTXOs — that moves both reissuance tokens from your wallet to the state-0 (dormant) address. No outcome tokens are minted and no collateral is deposited; that is InitialIssuance's job. Anyone evaluating a Deadcat market should re-check this transaction by hand: the covenant vouches for everything after it, and for nothing in it.", + "intent": "lock the minting rights into a market backed by {instance.COLLATERAL_ASSET_ID:symbol}", + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token in your wallet, minted by IssueReissuanceTokens. Nothing is issued in this transaction — the token merely changes hands, from you to the covenant.", + "utxo_source": "wallet", + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token in your wallet, from the same run.", + "utxo_source": "wallet", + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee. Needed here because both other inputs are token UTXOs with no L-BTC in them.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, parked at the state-0 (dormant) address. From here it can only ever move by a covenant spend.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, at the same state-0 address.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "creation_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "PrepareInitialIssuance": { + "$comment": "A funding action, not a protocol one: nothing here touches the covenant. It exists because InitialIssuance declares no change output — the covenant pins the fee at output index 5, and a change output would displace it — so whatever L-BTC the collateral input carries beyond the collateral IS the fee. Feeding it an ordinary wallet UTXO therefore pays the entire remainder to miners. This action cuts a UTXO of exactly the right size first, which turns that behaviour from a hazard into an intent: the surplus becomes FEE_ALLOWANCE and nothing more.", + "description": "Cut an exactly-sized collateral UTXO for InitialIssuance. Reads COLLATERAL_PER_TOKEN and COLLATERAL_ASSET_ID from the instance and takes the number of pairs you intend to mint, so the output is PAIRS x 2 x COLLATERAL_PER_TOKEN plus a fee allowance — the exact amount InitialIssuance consumes. Run this first, then InitialIssuance, which will select the UTXO this produced (pin it with --input collateral_in=: if your wallet holds other L-BTC of a similar size).", + "intent": "cut a {params.PAIRS}-pair collateral UTXO for opening the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many YES/NO pairs InitialIssuance will mint. Must match the PAIRS you pass there — a mismatch just means the sizing is wrong, and the surplus or shortfall shows up as a fee error." + }, + "FEE_ALLOWANCE": { + "type": "u64", + "default": "2000", + "description": "Extra L-BTC to include on top of the collateral, which becomes InitialIssuance's fee. That transaction is large (three inputs, two of them Simplicity covenant spends with sizeable witnesses), so leave real headroom. Anything unspent here is paid to miners, so do not inflate it either." + } + }, + "inputs": [ + { + "id": "funding_in", + "description": "Wallet L-BTC UTXO to cut from. Must hold at least the collateral plus the fee allowance plus this transaction's own fee.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE" + }, + "ui": { + "label": "wallet funds to cut the collateral from", + "role": "funding" + } + } + ], + "outputs": [ + { + "id": "sized_collateral", + "description": "The exactly-sized UTXO InitialIssuance will spend as its collateral input: PAIRS x 2 x COLLATERAL_PER_TOKEN of collateral, plus FEE_ALLOWANCE which becomes that transaction's fee.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE", + "ui": { + "label": "collateral sized for opening the market", + "role": "collateral" + } + }, + { + "id": "funding_change", + "description": "The rest of the funding UTXO, back to your wallet. Present precisely because this action is allowed a change output, unlike InitialIssuance.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "InitialIssuance": { + "$comment": "Path 1. Fee MUST land at output index 5, which it does only because no change output is declared here: the engine appends declared outputs, then any change, then the fee. Declaring a change output would push the fee to index 6 and the covenant would reject the spend. Size the collateral input exactly (see `prepare`) — surplus L-BTC is swallowed by the fee.", + "description": "First covenant-validated transaction: state 0 -> 1. Reissues the first batch of YES/NO pairs, deposits their collateral, and moves all three covenant UTXOs to the state-1 address. Collateral comes from your wallet, not from the covenant — in the dormant state there is no collateral UTXO yet.", + "intent": "open the market: mint {params.PAIRS} YES/NO pairs and lock their collateral", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many matched YES/NO pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN and receive PAIRS YES tokens and PAIRS NO tokens — you are the market's first counterparty on both sides, and you can sell either leg." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token at the state-0 address, input 0 — the covenant asserts current_index == 0 for this path. Its reissuance mints PAIRS YES tokens.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0", + "description": "Claimed state. Not trusted: main() recomputes the state-0 address from it and asserts the input is actually being spent from there, so lying is impossible rather than merely detectable." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Left(())))", + "description": "Path 1 — initial issuance. The seven paths are a nested Either tree; see witness.rs::build_path_value upstream." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token, input 1. It runs path 7 (secondary covenant input), which only proves it is spent from the same address as input 0 and leaves every transaction-level check to input 0's path.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "Your collateral, input 2. Unlike every later issuance this comes from the wallet: the dormant market holds no collateral to consolidate with.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 2, + "ui": { + "label": "collateral you are depositing", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token cycled forward to the state-1 address (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token cycled forward to the state-1 address (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The market's one and only collateral UTXO (output 2), at the state-1 address. Exactly PAIRS x 2 x COLLATERAL_PER_TOKEN — the covenant computes this itself from the issuance amount and rejects anything else.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "The minted YES tokens (output 3), to your wallet. Unconstrained by the covenant beyond asset balance — it only cares that YES and NO were minted in equal amounts and fully collateralised.", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "The minted NO tokens (output 4), to your wallet.", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + } + ] + }, + "MintPairs": { + "$comment": "Path 2. Same index-5 fee requirement as InitialIssuance, so again no change output is declared. The difference from path 1 is input 2: the existing collateral UTXO is consumed and re-emitted as old + new, which is what keeps the market to exactly one collateral UTXO.", + "description": "Mint more pairs into a live market (state 1 -> 1). Permissionless: anyone who deposits collateral gets tokens, there is no issuer and no allowlist. Consumes the market's collateral UTXO and re-emits the consolidated total.", + "intent": "mint {params.PAIRS} more YES/NO pairs into the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "Additional pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN of new collateral on top of whatever the market already holds." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token from state 1, input 0 (the primary — current_index must be 0).", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Right(())))", + "description": "Path 2 — subsequent issuance." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token from state 1, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "existing_collateral_in", + "description": "The market's current collateral UTXO, input 2 — the covenant checks this index's script hash against the state-1 address by hand. Also on path 7. Its amount comes from the state file.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "collateral already in the market", + "role": "collateral" + } + }, + { + "id": "new_collateral_in", + "description": "Your new collateral, input 3, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 3, + "ui": { + "label": "collateral you are adding", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token back to state 1 (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token back to state 1 (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The re-consolidated collateral UTXO (output 2): the old amount plus this issuance's deposit, computed by the covenant as safe_add(old, pairs x 2 x CPT).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "existing_collateral_in.amount_sat + params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "all collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "Newly minted YES tokens to your wallet (output 3).", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "Newly minted NO tokens to your wallet (output 4).", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + } + ] + }, + "ResolveYes": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = true. Modelled as two actions rather than one with a runtime outcome because the destination address IS the outcome — state 2 and state 3 are different covenant addresses, and the engine has no conditional destination. The covenant demands exactly 4 outputs, so no change output may be declared: the fee absorbs any L-BTC surplus, which lands it at index 3 as required.", + "description": "Commit a YES outcome on-chain (state 1 -> 2). Permissionless to submit: the oracle signs a message that mentions only the market, never this transaction, so anyone holding the attestation can post it. Nothing moves except the three covenant UTXOs, which all shift to the state-2 address; the collateral amount is preserved exactly.", + "intent": "resolve the market YES using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x01), where MARKET_ID = sha256(YES_TOKEN_ASSET || NO_TOKEN_ASSET) over the asset ids in internal byte order. Paste it 0x-prefixed. Produced off-chain; the wallet cannot compute it." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — the primary input, and the one that verifies the oracle signature.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "true", + "description": "Picks the YES branch, which both selects the signed message byte 0x01 and sends every covenant output to the state-2 address." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE", + "description": "Substituted from the action param before the value is parsed." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7. Its amount is read from the state file and must be reproduced exactly on output 2.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-2 address (output 0).", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-2 address (output 1). Minting is over, but the tokens are carried along so nothing is stranded.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-2 address (output 2). No value enters or leaves in a resolve.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by YES", + "role": "collateral" + } + } + ] + }, + "ResolveNo": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = false. Identical to ResolveYes except the signed outcome byte is 0x00 and every covenant output goes to state 3. Same 4-output rule: declare no change.", + "description": "Commit a NO outcome on-chain (state 1 -> 3). Whichever of ResolveYes / ResolveNo confirms first wins — that is the equivocation protection. An oracle that signs both outcomes cannot start a race to drain the pool, because after the first resolve there is no path back to state 1 and none between states 2 and 3.", + "intent": "resolve the market NO using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x00). Paste it 0x-prefixed." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — still the primary input on a NO resolve; the covenant's layout is fixed regardless of outcome.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "false", + "description": "Picks the NO branch: outcome byte 0x00 and state-3 outputs." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-3 address (output 1).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-3 address (output 2).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by NO", + "role": "collateral" + } + } + ] + }, + "RedeemYes": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 2, PARTIAL form (remaining collateral > 0). The full-drain form is a different output layout — the burn becomes output 0 and there is no collateral output at all — and needs its own action, not modelled here. The fee is checked at num_outputs - 1, so change outputs are fine on this path.", + "description": "Redeem winning YES tokens for collateral (state 2). Each YES token draws 2 x COLLATERAL_PER_TOKEN — the whole pair's backing, both your stake and the loser's — so the pool drains exactly as all winners redeem, with nothing stranded. Losing NO tokens have no path; they are simply worthless.", + "intent": "redeem {params.TOKENS_BURNED} winning YES tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many YES tokens to burn. Must be strictly less than the market's total remaining backing, or this becomes a full drain and needs the other output layout." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-2 address, input 0 — this path reads amounts from index 0, so it must come first.", + "utxo_source": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "2", + "description": "State 2 also selects YES_TOKEN_ASSET as the only asset the covenant will accept as a burn." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED", + "description": "Drives both the burn output amount and the payout; the covenant checks both against it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your winning YES tokens, from the wallet. Size this exactly — any surplus becomes a change output, which is allowed here but pointless.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning YES tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "What is left of the pool, back to the state-2 address (output 0). The reissuance tokens are untouched by this transaction and stay where they are by consensus.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "The YES tokens destroyed (output 1). The covenant requires a ZERO-LENGTH scriptPubKey here — sha256 of the empty script — not the 1-byte OP_RETURN this engine emits. See README.md.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2): TOKENS_BURNED x 2 x COLLATERAL_PER_TOKEN. The covenant does not check this leg at all — it constrains what stays behind and what is burned, and the rest follows from asset balance.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "YES token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.YES_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned YES tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemNo": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 3 — the mirror of RedeemYes. Same partial-only caveat.", + "description": "Redeem winning NO tokens for collateral (state 3). Each NO token draws 2 x COLLATERAL_PER_TOKEN.", + "intent": "redeem {params.TOKENS_BURNED} winning NO tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many NO tokens to burn." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-3 address, input 0.", + "utxo_source": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "3", + "description": "State 3 selects NO_TOKEN_ASSET as the winning side." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "no_tokens_in", + "description": "Your winning NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning NO tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "no_tokens_burn", + "description": "The NO tokens destroyed (output 1). Same zero-length-script requirement as RedeemYes.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "NO token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.NO_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned NO tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemExpired": { + "allow_change": "lbtc_only", + "$comment": "Path 5, partial form. NOT EXECUTABLE YET: this is the one Deadcat path that calls jet::check_lock_height(EXPIRY_TIME), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. (The pre-expiry paths are fine: they assert lock_time < EXPIRY_TIME, which a locktime of 0 satisfies.) BURN_TOKEN_ASSET must be supplied byte-reversed, because witness values are parsed as raw SimplicityHL literals with no liquid.asset_id type hint to trigger the reversal that compile params get.", + "description": "Redeem after the market expired unresolved (state 1, at or after EXPIRY_TIME). Both sides redeem at 1 x COLLATERAL_PER_TOKEN — half the winner's rate — so YES and NO holders are made whole together and the pool drains exactly. This is the escape hatch for an oracle that never attests.", + "intent": "redeem {params.TOKENS_BURNED} tokens from the expired market", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many tokens to burn, of whichever side you hold." + }, + "BURN_TOKEN_ASSET": { + "type": "bytes", + "description": "The asset id of the side you are burning — YES_TOKEN_ASSET or NO_TOKEN_ASSET — 0x-prefixed and in INTERNAL byte order, i.e. the display id reversed. The covenant accepts either, and rejects anything else." + }, + "BURN_TOKEN_ASSET_DISPLAY": { + "type": "liquid.asset_id", + "description": "The same asset id in normal display order, used for the transaction's burn output and token input. Must be the reverse of BURN_TOKEN_ASSET." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, still at the state-1 address, input 0. The sequence below is ENABLE_LOCKTIME_NO_RBF: any value other than 0xFFFFFFFF enables the nLockTime that check_lock_height reads. The engine will warn that the BIP68 disable bit is set — expected, since this timeout is an absolute height.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "sequence": 4294967294, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Left(())))", + "description": "Path 5 — expiry redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + }, + "BURN_TOKEN_ASSET": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.BURN_TOKEN_ASSET", + "description": "Tells the covenant which side you are burning. It checks the value equals YES_TOKEN_ASSET or NO_TOKEN_ASSET and then holds output 1 to it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "tokens_in", + "description": "The tokens you are burning, from the wallet — either side.", + "utxo_source": "wallet", + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0) — the market never leaves state 1 on this path.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other holders", + "role": "collateral" + } + }, + { + "id": "tokens_burn", + "description": "The tokens destroyed (output 1). Zero-length script required, as on the other burn paths.", + "destination": { + "type": "burn" + }, + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your refund (output 2): TOKENS_BURNED x COLLATERAL_PER_TOKEN, half the resolved rate, because the other half belongs to the holder of the matching token on the other side.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your refund", + "role": "settlement" + } + } + ] + }, + "CancelPairs": { + "allow_change": "lbtc_only", + "$comment": "Path 6, partial form (state 1 -> 1). The full form, which drains the pool to zero and cycles the reissuance tokens back to state 0, is CancelAll.", + "description": "Unwind part of your position while the market is unresolved. Burn matched YES and NO in equal amounts and take back the full 2 x COLLATERAL_PER_TOKEN per pair — you are undoing an issuance, so there is no loss and no time constraint. Only matched pairs qualify: burning one side alone would leave the other side under-collateralised.", + "intent": "cancel {params.PAIRS_BURNED} pairs and reclaim their collateral", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "How many matched pairs to burn. You must hold this many of BOTH YES and NO." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation. The partial and full forms are the same path; the covenant picks between them by testing whether any collateral remains." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "YES side of the pairs you are burning", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "Your NO tokens, from the wallet, in the same amount.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "NO side of the pairs you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0). Non-zero is what makes this the partial branch — if it were zero the covenant would demand CancelAll's layout instead.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral still backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "YES tokens destroyed (output 1). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "NO tokens destroyed (output 2), same amount — the covenant checks both indices, which is how equal burning is enforced.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "Your refund (output 3): the full deposit for those pairs, PAIRS_BURNED x 2 x COLLATERAL_PER_TOKEN.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 3, + "ui": { + "label": "collateral refunded to you", + "role": "settlement" + } + } + ] + }, + "CancelAll": { + "allow_change": "lbtc_only", + "$comment": "Path 6, full form (state 1 -> 0). PAIRS_BURNED must equal the whole pool: collateral_in.amount_sat / (2 x COLLATERAL_PER_TOKEN). If it does not, the covenant takes the partial branch instead and this output layout is rejected.", + "description": "Wind the market all the way down. Burn every outstanding pair, take back all the collateral, and cycle both reissuance tokens back to the state-0 address so the market is dormant rather than dead. Without this the tokens would be stranded at a collateral-less state-1 address and the market could never be reissued — the reason the dormant state exists at all (design doc section 5.3).", + "intent": "wind the market down and return it to dormant", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "Every outstanding pair. Must equal the pool's total collateral divided by 2 x COLLATERAL_PER_TOKEN, exactly." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0 — the primary input, running path 6.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation, full branch (chosen by the covenant when nothing remains)." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "the whole collateral pool", + "role": "collateral" + } + }, + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 1 — the full branch verifies it at exactly this index. Runs path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 2, also on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_in", + "description": "All outstanding YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every YES token, being burned", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "All outstanding NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every NO token, being burned", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token returned to the state-0 (dormant) address (output 0).", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token returned to the state-0 address (output 1). From here the market can be reopened with another InitialIssuance.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_burn", + "description": "All YES tokens destroyed (output 2). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "All NO tokens destroyed (output 3).", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 3, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "The entire pool refunded to you (output 4). The covenant does not constrain this leg; it falls out of asset balance once outputs 0-3 are pinned.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 4, + "ui": { + "label": "all collateral refunded to you", + "role": "settlement" + } + } + ] + } + } + } + } +} diff --git a/examples/deadcat_v3/README.md b/examples/deadcat_v3/README.md new file mode 100644 index 0000000..2455368 --- /dev/null +++ b/examples/deadcat_v3/README.md @@ -0,0 +1,94 @@ +# deadcat_v3 — derivable blinding factors + +Same protocol as [`examples/deadcat`](../deadcat/README.md); read that first for the state +model, the seven paths and the bootstrap flow. This file covers only the fork. + +**This is the runnable one.** `deadcat` is the faithful port and cannot execute here; +[`deadcat_v2`](../deadcat_v2/README.md) tried unblinding the tokens and is a dead end. + +## Why the tokens must stay blinded + +`deadcat_v2` made the reissuance tokens explicit so the covenant could read them directly. +That cannot work, and the reason is in Elements' own consensus code +(`src/confidential_validation.cpp`, reissuance branch): + +```cpp +secp256k1_generator_generate_blinded(ctx, &gen, assetTokenID.begin(), + issuance.assetBlindingNonce.begin()); +... +if (memcmp(asset.vchCommitment.data(), derived_generator, 33)) return false; +``` + +A reissuance rebuilds the spent token's asset tag from the blinding nonce and byte-compares +it against the input's asset field. An explicit asset is 33 bytes starting `0x01`; a +serialized generator starts `0x0a`/`0x0b`. The comparison can never succeed **for any nonce +value**, so an explicit reissuance token is unreissuable. Deadcat's design doc §13 — +"Reissuance tokens remain confidential per Elements protocol requirements" — is literal. + +Note the corollary: `assetBlindingNonce` **is** the abf of the token UTXO being spent, the +same scalar the covenant takes as `*_REISSUANCE_INPUT_ABF`. One value, two consumers. + +## What v3 changes + +**The output factors are derived, not witnessed.** Each recreated token advances both +blinding factors by exactly one. Since the value is 1, that is a pure translation of the +commitments: + +``` +A_out = A_in + G C_out = C_in + 2G +``` + +so `verify_output_is_shifted_token` checks the outputs by point arithmetic against the +inputs. The four `*_OUTPUT_ABF` / `*_OUTPUT_VBF` witnesses disappear — 19 witnesses become +15 — and two of the four `hash_to_curve` + scalar-mult pairs go with them. + +The four **input** factors stay. They are what proves the spent UTXO is *this market's* +token and holds exactly 1 unit; without them anyone could pay a blinded UTXO of their own +asset to a covenant address and cycle it through the market's paths. Elements' own check +does not substitute: it only proves the input matches the entropy stated in the same +attacker-supplied witness. + +**The factors become recoverable from the chain.** The Simplicity witness is public, so the +next spender reads `abf_in`/`vbf_in` off the previous spend and adds one. Nothing has to be +persisted — no state fields, no hooks. The first pair has no prior witness to read and is +the documented constant **`abf = vbf = 1`**, established by `CreateMarket`. + +The cost is traceability: the factors are public, so anyone can open the commitments. That +is accepted here. The asset ids are compile params and the covenant addresses are public, so +blinding was never buying confidentiality — only reissuability. + +**The fee moved to `num_outputs - 1`** on the issuance and resolve paths, and resolve no +longer demands exactly four outputs. This is what lets those actions declare an L-BTC change +output. It is also load-bearing for the rule above: with both token outputs' factors fixed, +the value-balance identity + +``` +a₀+v₀ + b₀+w₀ + F·f+g == (a₀+1)+(v₀+1) + (b₀+1)+(w₀+1) + (C·c + z) +``` + +has no solution unless some output is free to absorb the residue `z`. Without the change +output the +1 rule is unsatisfiable. + +Relaxing the output count is safe: outputs 0–2 stay pinned by index to asset, amount and +script, so the only value that can reach a trailing output is the spender's own fee input. +Upstream's `num_outputs == 4` only prevented a second UTXO landing at the state address *in +this transaction* — anyone can pay one there at any time regardless, so it bought little. + +## Verify + +```sh +cargo run -- validate examples/deadcat_v3/txmanifest.json +cargo run -p tx-manifest-lib --example deadcat_v3_recon +``` + +The recon compiles v3, derives all four addresses, and asserts each **differs** from the +matching `deadcat` address — if they ever collide the fork silently stopped being a fork — +then checks the four output witnesses are gone and the four input ones survive. + +## Still needed before this runs + +The engine has to blind covenant outputs, and `blind_last` cannot be told "use `abf_in + 1` +for output 0" — it picks abfs itself. That needs a hand-written blinding pass: choose the +abfs, blind all but one output, solve the last vbf. `pset_builder::apply_reissuance` also +still writes the placeholder nonce `[0…0, 1]`; it must write the real abf of the token UTXO, +which under this fork is a derivable constant rather than a stored secret. diff --git a/examples/deadcat_v3/prediction_market.simf b/examples/deadcat_v3/prediction_market.simf new file mode 100644 index 0000000..5419ce0 --- /dev/null +++ b/examples/deadcat_v3/prediction_market.simf @@ -0,0 +1,593 @@ +// Binary Prediction Market Covenant - v3, derivable blinding factors +// SimplicityHL contract for Liquid +// +// Forked from examples/deadcat/prediction_market.simf (Deadcat.Live), which is kept +// verbatim. The reissuance tokens stay CONFIDENTIAL - Elements requires it, since a +// reissuance compares blind(token_id, nonce) against the spent input's asset COMMITMENT +// and an explicit asset can never match (see confidential_validation.cpp). What changes is +// how the recreated tokens are checked. +// +// Upstream verifies each output by recomputing its commitment from output blinding factors +// supplied as witnesses. Those factors are chosen at blinding time, cannot be recovered +// from the chain, and so have to be persisted out-of-band for the next spend. +// +// This version fixes the step instead: abf_out = abf_in + 1, vbf_out = vbf_in + 1. Because +// the value is 1, that is a pure translation of the commitments, +// +// A_out = A_in + G C_out = C_in + 2G +// +// which the covenant checks by point arithmetic. Two consequences: +// +// * The four output blinding witnesses disappear. Only the INPUT factors are needed - +// one to prove the input is this market's token, one to pin its amount to 1. +// * The factors become derivable. The Simplicity witness is on-chain, so the next +// spender reads abf_in/vbf_in off THIS transaction and adds one. Nothing has to be +// stored. The very first pair has no prior witness to read and is the documented +// constant abf = vbf = 1, set by CreateMarket. +// +// The tokens are consequently traceable on-chain: the factors are public, so anyone can +// open the commitments. That is accepted here - the asset ids are compile params and the +// covenant addresses are public, so blinding was never buying confidentiality, only +// reissuability. +// +// Also relaxed, so that a change output can exist and the L-BTC surplus stops being paid +// to miners: the issuance and resolve paths check the fee at num_outputs - 1 (as paths +// 4/5/6 already do) instead of at a fixed index, and the resolve path no longer demands +// exactly four outputs. Outputs 0-2 remain pinned by index to asset, amount and script, so +// no covenant-held value can reach a trailing output - only the spender's own fee input. +// +// This is a FORK: the CMR and all four covenant addresses differ from upstream. + +// ============================================================================ +// Type aliases +// ============================================================================ + +// PATH dispatch types (7-way nested Either) +type Path1or2 = Either<(), ()>; +type Path3or4 = Either<(), ()>; +type Path1to4 = Either; +type Path5or6 = Either<(), ()>; +type Path5to7 = Either; + +// The blinding factors of one reissuance-token INPUT: (abf, vbf). The matching output +// factors are abf+1 / vbf+1 and are never supplied - see the header. +type BlindingPair = (u256, u256); + +// ============================================================================ +// Boolean helpers +// ============================================================================ + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn ensure_zero_bit(b: bool) { + assert!(not(b)); +} + +// ============================================================================ +// Utility functions +// ============================================================================ + +fn get_input_script_hash(index: u32) -> u256 { + unwrap(jet::input_script_hash(index)) +} + +fn get_output_explicit_asset(index: u32) -> u256 { + unwrap_right::<(u1, u256)>(unwrap(jet::output_asset(index))) +} + +fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn ensure_output_script_hash_eq(index: u32, expected: u256) { + let actual: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(actual, expected)); +} + +fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn empty_script_hash() -> u256 { + 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +} + +fn ensure_output_is_op_return(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +fn ensure_fee_output(index: u32) { + let script_hash: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(script_hash, empty_script_hash())); +} + +// ============================================================================ +// Pedersen commitment verification +// ============================================================================ + +// Assert that a computed curve point equals a stored 33-byte commitment, which +// introspection hands back as (parity, x). +fn assert_point_eq_commitment(point: Gej, commitment: (u1, u256)) { + let ge: Ge = unwrap(jet::gej_normalize(point)); + let (x, y): (u256, u256) = ge; + let (stored_parity, stored_x): (u1, u256) = commitment; + assert!(jet::eq_256(x, stored_x)); + // Generator parity encodes quadratic residue status, not odd/even bit parity. + // is_none(fe_square_root(y)) == true when y is NOT a QR, matching stored parity = 1. + assert!(jet::eq_1(::into(is_none::(jet::fe_square_root(y))), stored_parity)); +} + +/// Prove the input at `index` holds exactly 1 unit of `expected_token`, and return the +/// commitments so the paired output can be checked as a translation of them. +fn verify_input_reissuance_token( + index: u32, + expected_token: u256, + bf: BlindingPair +) -> (Gej, Gej) { + let (abf, vbf): (u256, u256) = bf; + + let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::input_asset(index))); + let (_, amount_either): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let amount_commitment: (u1, u256) = unwrap_left::(amount_either); + + // Asset generator: H(token) + abf*G. Binding the input to THIS market's token id is + // the part Elements cannot do for us - its own reissuance check only proves the input + // matches the entropy stated in the same (attacker-supplied) witness. + let h_point: Ge = jet::hash_to_curve(expected_token); + let asset_gen: Gej = jet::gej_ge_add(jet::generate(abf), h_point); + assert_point_eq_commitment(asset_gen, asset_commitment); + + // Value commitment: asset_gen + vbf*G. There is no `value *` factor, so this is a + // commitment to exactly 1 - which is what pins the amount. + let value_gen: Gej = jet::gej_add(asset_gen, jet::generate(vbf)); + assert_point_eq_commitment(value_gen, amount_commitment); + + (asset_gen, value_gen) +} + +/// Prove the output at `index` recreates the token whose input commitments are given, +/// with both blinding factors advanced by exactly one. +/// +/// abf_out = abf_in + 1 shifts the asset generator by G; vbf_out = vbf_in + 1 shifts the +/// value commitment by a further G, for 2G in total. No witness is needed: every term is +/// either introspected or derived. +fn verify_output_is_shifted_token(index: u32, in_asset_gen: Gej, in_value_gen: Gej) { + let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::output_asset(index))); + let (_, amount_either): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let amount_commitment: (u1, u256) = unwrap_left::(amount_either); + + assert_point_eq_commitment(jet::gej_add(in_asset_gen, jet::generate(1)), asset_commitment); + assert_point_eq_commitment(jet::gej_add(in_value_gen, jet::generate(2)), amount_commitment); +} + +// ============================================================================ +// Taproot address computation +// ============================================================================ + +fn covenant_nums_key() -> u256 { + 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 +} + +fn compute_p2tr_script_hash_from_output_key(output_key: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn script_hash_for_input_script(state: u64) -> u256 { + let tap_leaf: u256 = jet::tapleaf_hash(); + let state_ctx: Ctx8 = jet::tapdata_init(); + let state_ctx: Ctx8 = jet::sha_256_ctx_8_add_8(state_ctx, state); + let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx); + let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); + let tweaked_key: u256 = jet::build_taptweak(covenant_nums_key(), tap_node); + compute_p2tr_script_hash_from_output_key(tweaked_key) +} + +// ============================================================================ +// Arithmetic helpers +// ============================================================================ + +fn safe_multiply(a: u64, b: u64) -> u64 { + let result: u128 = jet::multiply_64(a, b); + let (high, low): (u64, u64) = ::into(result); + assert!(jet::is_zero_64(high)); + low +} + +fn safe_add(a: u64, b: u64) -> u64 { + let (carry, sum): (bool, u64) = jet::add_64(a, b); + ensure_zero_bit(carry); + sum +} + +fn safe_subtract(a: u64, b: u64) -> u64 { + let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); + ensure_zero_bit(borrow); + diff +} + +fn safe_subtract_32(a: u32, b: u32) -> u32 { + let (borrow, diff): (bool, u32) = jet::subtract_32(a, b); + ensure_zero_bit(borrow); + diff +} + +// ============================================================================ +// Market-specific functions +// ============================================================================ + +fn compute_market_id() -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::YES_TOKEN_ASSET); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::NO_TOKEN_ASSET); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { + let market_id: u256 = compute_market_id(); + let outcome_byte: u8 = match outcome_yes { + true => 1, + false => 0, + }; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); + let message: u256 = jet::sha_256_ctx_8_finalize(ctx); + jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); +} + +fn ensure_pre_expiry() { + let tx_lock_time: u32 = jet::lock_time(); + assert!(jet::lt_32(tx_lock_time, param::EXPIRY_TIME)); +} + +fn dormant_script_hash() -> u256 { + script_hash_for_input_script(0) +} + +fn unresolved_script_hash() -> u256 { + script_hash_for_input_script(1) +} + +fn collateral_for_pairs(pairs: u64) -> u64 { + let two_cpt: u64 = safe_multiply(2, param::COLLATERAL_PER_TOKEN); + safe_multiply(pairs, two_cpt) +} + +fn get_issuance_amount(index: u32) -> u64 { + unwrap_right::<(u1, u256)>(unwrap(unwrap(jet::issuance_asset_amount(index)))) +} + +// ============================================================================ +// Spending paths +// ============================================================================ + +/// Path 1: Initial Issuance (state 0 → 1) +fn initial_issuance_path(state: u64, yes_bf: BlindingPair, no_bf: BlindingPair) { + assert!(jet::is_zero_64(state)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (yes_asset_gen, yes_value_gen): (Gej, Gej) = + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_bf); + let (no_asset_gen, no_value_gen): (Gej, Gej) = + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_bf); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let total_collateral: u64 = collateral_for_pairs(pairs); + + verify_output_is_shifted_token(0, yes_asset_gen, yes_value_gen); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_is_shifted_token(1, no_asset_gen, no_value_gen); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(safe_subtract_32(jet::num_outputs(), 1)); +} + +/// Path 2: Subsequent Issuance (state 1 → 1) +fn subsequent_issuance_path(state: u64, yes_bf: BlindingPair, no_bf: BlindingPair) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (yes_asset_gen, yes_value_gen): (Gej, Gej) = + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_bf); + let (no_asset_gen, no_value_gen): (Gej, Gej) = + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_bf); + + let yes_amount: u64 = get_issuance_amount(0); + let no_amount: u64 = get_issuance_amount(1); + assert!(jet::eq_64(yes_amount, no_amount)); + + let pairs: u64 = yes_amount; + let new_collateral: u64 = collateral_for_pairs(pairs); + + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, old_collateral): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + let total_collateral: u64 = safe_add(old_collateral, new_collateral); + + verify_output_is_shifted_token(0, yes_asset_gen, yes_value_gen); + ensure_output_script_hash_eq(0, unresolved_hash); + + verify_output_is_shifted_token(1, no_asset_gen, no_value_gen); + ensure_output_script_hash_eq(1, unresolved_hash); + + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); + ensure_output_script_hash_eq(2, unresolved_hash); + + ensure_fee_output(safe_subtract_32(jet::num_outputs(), 1)); +} + +/// Path 3: Oracle Resolve (state 1 → 2 or 3) +fn oracle_resolve_path( + state: u64, + outcome_yes: bool, + sig: Signature, + yes_bf: BlindingPair, + no_bf: BlindingPair +) { + assert!(jet::eq_64(state, 1)); + ensure_pre_expiry(); + assert!(jet::eq_32(jet::current_index(), 0)); + + verify_oracle_signature(outcome_yes, sig); + + let new_state: u64 = match outcome_yes { + true => 2, + false => 3, + }; + let new_state_hash: u256 = script_hash_for_input_script(new_state); + + let (yes_asset_gen, yes_value_gen): (Gej, Gej) = + verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_bf); + let (no_asset_gen, no_value_gen): (Gej, Gej) = + verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_bf); + + verify_output_is_shifted_token(0, yes_asset_gen, yes_value_gen); + ensure_output_script_hash_eq(0, new_state_hash); + + verify_output_is_shifted_token(1, no_asset_gen, no_value_gen); + ensure_output_script_hash_eq(1, new_state_hash); + + let unresolved_hash: u256 = unresolved_script_hash(); + assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(2); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, coll_amount); + ensure_output_script_hash_eq(2, new_state_hash); + + // Outputs 0-2 are pinned by index to asset, amount and script, so the only value that + // can reach a trailing output is the spender's own fee input. A change output is + // therefore harmless here, and upstream's num_outputs == 4 is dropped to allow it. + ensure_fee_output(safe_subtract_32(jet::num_outputs(), 1)); +} + +/// Path 4: Post-Resolution Redemption (state 2 or 3) +fn post_resolution_redemption_path(state: u64, tokens_burned: u64) { + let is_yes: bool = jet::eq_64(state, 2); + let is_no: bool = jet::eq_64(state, 3); + assert!(or(is_yes, is_no)); + + let state_hash: u256 = script_hash_for_input_script(state); + + let winner_asset: u256 = match is_yes { + true => param::YES_TOKEN_ASSET, + false => param::NO_TOKEN_ASSET, + }; + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let payout: u64 = collateral_for_pairs(tokens_burned); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, state_hash); + ensure_output_asset_with_amount_eq(1, winner_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, winner_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 5: Expiry Redemption (state 1, post-expiry) +fn expiry_redemption_path(state: u64, tokens_burned: u64, burn_asset: u256) { + assert!(jet::eq_64(state, 1)); + jet::check_lock_height(param::EXPIRY_TIME); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let is_yes: bool = jet::eq_256(burn_asset, param::YES_TOKEN_ASSET); + let is_no: bool = jet::eq_256(burn_asset, param::NO_TOKEN_ASSET); + assert!(or(is_yes, is_no)); + + let payout: u64 = safe_multiply(tokens_burned, param::COLLATERAL_PER_TOKEN); + let remaining: u64 = safe_subtract(coll_amount, payout); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, burn_asset, tokens_burned); + ensure_output_is_op_return(1); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + ensure_output_asset_with_amount_eq(0, burn_asset, tokens_burned); + ensure_output_is_op_return(0); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 6: Cancellation (state 1 → 1 partial, 1 → 0 full) +fn cancellation_path(state: u64, pairs_burned: u64, yes_bf: BlindingPair, no_bf: BlindingPair) { + assert!(jet::eq_64(state, 1)); + + let unresolved_hash: u256 = unresolved_script_hash(); + + let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); + + let refund: u64 = collateral_for_pairs(pairs_burned); + let remaining: u64 = safe_subtract(coll_amount, refund); + + let num_outputs: u32 = jet::num_outputs(); + let is_partial: bool = not(jet::is_zero_64(remaining)); + + match is_partial { + true => { + ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); + ensure_output_script_hash_eq(0, unresolved_hash); + ensure_output_asset_with_amount_eq(1, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(1); + ensure_output_asset_with_amount_eq(2, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + false => { + let dormant_hash: u256 = dormant_script_hash(); + + let (yes_asset_gen, yes_value_gen): (Gej, Gej) = + verify_input_reissuance_token(1, param::YES_REISSUANCE_TOKEN, yes_bf); + let (no_asset_gen, no_value_gen): (Gej, Gej) = + verify_input_reissuance_token(2, param::NO_REISSUANCE_TOKEN, no_bf); + + verify_output_is_shifted_token(0, yes_asset_gen, yes_value_gen); + ensure_output_script_hash_eq(0, dormant_hash); + + verify_output_is_shifted_token(1, no_asset_gen, no_value_gen); + ensure_output_script_hash_eq(1, dormant_hash); + + ensure_output_asset_with_amount_eq(2, param::YES_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(2); + ensure_output_asset_with_amount_eq(3, param::NO_TOKEN_ASSET, pairs_burned); + ensure_output_is_op_return(3); + + let fee_idx: u32 = safe_subtract_32(num_outputs, 1); + ensure_fee_output(fee_idx); + }, + }; +} + +/// Path 7: Secondary Covenant Input +fn secondary_covenant_input_path() { + let my_index: u32 = jet::current_index(); + let my_hash: u256 = get_input_script_hash(my_index); + let primary_hash: u256 = get_input_script_hash(0); + assert!(jet::eq_256(my_hash, primary_hash)); + ensure_zero_bit(jet::eq_32(my_index, 0)); +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +fn main() { + let state: u64 = witness::STATE; + + let expected_hash: u256 = script_hash_for_input_script(state); + let actual_hash: u256 = get_input_script_hash(jet::current_index()); + assert!(jet::eq_256(expected_hash, actual_hash)); + + // Budget padding: these witnesses increase the serialized witness size so that + // the Simplicity execution budget (witness_stack_bytes + 50 WU) covers the + // program's cost. Without this, the pruned program's cost slightly exceeds + // the budget derived from its natural witness + program size. + let budget_pad_a: u256 = witness::BUDGET_PAD_A; + let budget_pad_b: u256 = witness::BUDGET_PAD_B; + assert!(jet::eq_256(budget_pad_a, budget_pad_b)); + let budget_pad_c: u256 = witness::BUDGET_PAD_C; + let budget_pad_d: u256 = witness::BUDGET_PAD_D; + assert!(jet::eq_256(budget_pad_c, budget_pad_d)); + + // Read all witnesses in main (required by SimplicityHL). Only the INPUT blinding + // factors are witnesses now; the output factors are abf+1 / vbf+1 and are derived. + let yes_in_abf: u256 = witness::YES_REISSUANCE_INPUT_ABF; + let yes_in_vbf: u256 = witness::YES_REISSUANCE_INPUT_VBF; + let no_in_abf: u256 = witness::NO_REISSUANCE_INPUT_ABF; + let no_in_vbf: u256 = witness::NO_REISSUANCE_INPUT_VBF; + let oracle_sig: Signature = witness::ORACLE_SIGNATURE; + let oracle_outcome: bool = witness::ORACLE_OUTCOME_YES; + let tokens_burned: u64 = witness::TOKENS_BURNED; + let burn_asset: u256 = witness::BURN_TOKEN_ASSET; + let pairs_burned: u64 = witness::PAIRS_BURNED; + + let yes_bf: BlindingPair = (yes_in_abf, yes_in_vbf); + let no_bf: BlindingPair = (no_in_abf, no_in_vbf); + + match witness::PATH { + Left(l: Path1to4) => match l { + Left(i: Path1or2) => match i { + Left(u: ()) => initial_issuance_path(state, yes_bf, no_bf), + Right(u: ()) => subsequent_issuance_path(state, yes_bf, no_bf), + }, + Right(r: Path3or4) => match r { + Left(u: ()) => oracle_resolve_path(state, oracle_outcome, oracle_sig, yes_bf, no_bf), + Right(u: ()) => post_resolution_redemption_path(state, tokens_burned), + }, + }, + Right(r: Path5to7) => match r { + Left(rd: Path5or6) => match rd { + Left(u: ()) => expiry_redemption_path(state, tokens_burned, burn_asset), + Right(u: ()) => cancellation_path(state, pairs_burned, yes_bf, no_bf), + }, + Right(u: ()) => secondary_covenant_input_path(), + }, + } +} diff --git a/examples/deadcat_v3/txmanifest.json b/examples/deadcat_v3/txmanifest.json new file mode 100644 index 0000000..d591a47 --- /dev/null +++ b/examples/deadcat_v3/txmanifest.json @@ -0,0 +1,1790 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "v3 of examples/deadcat - the runnable fork. The reissuance tokens stay confidential (Elements requires it), but each recreated token advances both blinding factors by exactly one, so the covenant checks the outputs as a translation of the inputs and needs no output-side witnesses. The factors are therefore derivable from the previous spend's on-chain witness and nothing has to be persisted; the first pair is the constant abf = vbf = 1, set by CreateMarket. The fee is checked at num_outputs - 1 on every path, which lets these actions declare an L-BTC change output instead of paying the surplus to miners. Different CMR, different addresses, NOT interoperable with Deadcat. examples/deadcat remains the faithful port. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct. See README.md for the full list of what is and is not executable today.", + "manifest_version": "0.1.0", + "protocol": "deadcat-prediction-market-v3", + "description": "Deadcat v3 — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", + "chain": "liquid", + "simplicity_hl": { + "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", + "debug_symbols": false + }, + "utxo_types": { + "market_dormant": { + "description": "STATE 0 — DORMANT. Holds only the two reissuance tokens, no collateral. This is where CreateMarket parks them and where a full Cancel returns them. The address is the Simplicity leaf branched with a tapdata leaf carrying the u64 0 (big-endian, 8 bytes) — see taproot.rs::tapdata_hash. Nothing else about the address differs between the four states.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "$comment": "state = 0. jet::tapdata_init() + sha_256_ctx_8_add_8(state) hashes exactly 8 big-endian bytes, so no pad_to here — unlike the lending example's 32-byte storage slots.", + "type": "tapdata", + "payload": [ + { + "value": "0", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "YES_REISSUANCE_TOKEN", + "confidential": false + }, + "market_unresolved": { + "description": "STATE 1 — UNRESOLVED, the live market. Holds three UTXOs: the YES reissuance token, the NO reissuance token, and the single consolidated collateral UTXO. 'Single' is load-bearing: every issuance path must consume the existing collateral UTXO and re-emit one consolidated output, so the oracle can move the whole market to a resolved address in one transaction (design doc section 6).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "1", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_yes": { + "description": "STATE 2 — RESOLVED YES. The oracle attested YES, so YES tokens redeem at 2 x COLLATERAL_PER_TOKEN each and NO tokens are worth nothing. There is no path from here to state 3: that is the whole point of committing the outcome on-chain rather than checking the oracle signature at redemption time (design doc section 9.2).", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "2", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "market_resolved_no": { + "description": "STATE 3 — RESOLVED NO. Mirror of state 2: NO tokens redeem, YES tokens are worthless.", + "script": { + "type": "simplicity", + "source": "./prediction_market.simf", + "compile_params": { + "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", + "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", + "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", + "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", + "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "EXPIRY_TIME" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "3", + "type": "u64", + "endian": "be" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + } + }, + "contract_templates": { + "binary_market": { + "description": "One binary prediction market. IssueReissuanceTokens is the constructor: it mints the minting rights and writes the instance file that defines the market. CreateMarket then locks those rights into the covenant (a plain Elements transaction — no covenant runs), InitialIssuance brings the market to life, then anyone may MintPairs or Cancel while it is unresolved. It ends one of two ways: the oracle resolves it (ResolveYes / ResolveNo, then RedeemYes / RedeemNo), or it expires unresolved and both sides redeem at half rate (RedeemExpired). All eight fields below are compile params of prediction_market.simf, so changing any one of them is a different market at four different addresses.", + "fields": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "X-only BIP340 key the resolve path checks. In Deadcat this is the aggregate key of a 2-of-3 FROST committee — on-chain it is just one key, the threshold signing happens off-chain." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset backing the market, normally L-BTC. Also the asset the network fee is paid in, which is why the covenant's fee-output checks are satisfiable." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "description": "Satoshis backing ONE token. A pair costs 2 x this. Deliberately per-token, not per-pair, so the covenant only ever multiplies — no division means no rounding bug (design doc section 4.3)." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Absolute block height. Before it, the oracle may resolve and anyone may mint. At or after it, RedeemExpired unlocks and every token — winning or losing — redeems at 1 x COLLATERAL_PER_TOKEN." + }, + "YES_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the YES outcome token, derived from the outpoint pinned as CreateMarket in[0]. Also half of MARKET_ID = sha256(YES || NO), the oracle's domain separator." + }, + "NO_TOKEN_ASSET": { + "type": "liquid.asset_id", + "description": "Asset id of the NO outcome token, derived from the outpoint pinned as CreateMarket in[1]." + }, + "YES_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for YES. Holding it is what permits minting more YES, and it only ever lives at a covenant address — that is the enforcement mechanism behind collateral consolidation." + }, + "NO_REISSUANCE_TOKEN": { + "type": "liquid.asset_id", + "description": "The reissuance token for NO. Same role as YES_REISSUANCE_TOKEN." + }, + "YES_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the YES mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value YES_TOKEN_ASSET itself is derived from. Every later reissuance needs it and NOTHING on chain carries it: the reissuance token UTXO holds no trace of the outpoint that created it. Captured by the constructor at the one moment it exists." + }, + "NO_ISSUANCE_ENTROPY": { + "type": "bytes32", + "description": "Issuance entropy of the NO mint. Same role, from the other defining outpoint." + } + }, + "actions": { + "IssueReissuanceTokens": { + "$comment": "The constructor: it is this action, not CreateMarket, that writes the instance file. That is forced by where the asset ids come from — they are derived from the outpoints THIS transaction spends, and nothing downstream can recover them (the reissuance token id and the asset id are sibling hashes of the entropy, so you cannot walk from one to the other). Capturing them here — create_instance reads them straight off the two inputs via $inputs.. — is the only point at which they exist and can still be recorded.\n\nWhy the bootstrap is two transactions at all: CreateMarket pays to the state-0 covenant address, and that address is a function of all four asset ids, which are functions of the outpoints being spent. The engine snapshots compile params BEFORE it resolves any input, so one action cannot both mint an asset and pay it to an address derived from that asset. This action sidesteps it by paying only to the wallet. Upstream Deadcat does both in one transaction (pset/creation.rs) because it computes the ids in Rust before building anything; the two-transaction bootstrap lands the market in exactly the same on-chain state.", + "description": "Step 1 of 2 in bootstrapping a market, and the action that defines it. Mints the YES and NO reissuance tokens — 1 unit each — from two of your L-BTC UTXOs and keeps them in your wallet, then writes the instance file recording the market's full definition: the four asset ids this transaction just fixed, plus the terms you supply below. NOTE: it mints ZERO units of the YES and NO tokens themselves. Only the minting rights exist at this point; outcome tokens appear at InitialIssuance, and they have to, because tokens minted here would be backed by no collateral at all. Run CreateMarket next to lock the two tokens into the covenant.", + "intent": "define a market backed by {params.COLLATERAL_ASSET_ID:symbol} and mint its minting rights", + "params": { + "ORACLE_PUBLIC_KEY": { + "type": "pubkey", + "description": "The oracle's x-only key. Baked into every one of this market's four addresses, so it cannot be changed later." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset backing the market. Use the L-BTC policy asset unless you know why you want something else." + }, + "COLLATERAL_PER_TOKEN": { + "type": "u64", + "default": "1000", + "description": "Satoshis behind one token. A YES/NO pair therefore costs twice this, and a winning token pays out twice this." + }, + "EXPIRY_TIME": { + "type": "u32", + "description": "Block height after which the market expires unresolved and everyone redeems at half rate. Leave the oracle real time to attest." + } + }, + "inputs": [ + { + "id": "yes_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the YES asset pair: YES_TOKEN_ASSET = AssetId::new_issuance(outpoint, zero contract hash) and YES_REISSUANCE_TOKEN = AssetId::new_reissuance_token(outpoint, zero contract hash, confidential=false). Issues 0 asset units and 1 inflation (reissuance) token — YES tokens themselves are not minted until InitialIssuance reissues against this one.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 0, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the YES asset", + "role": "issuance" + } + }, + { + "id": "no_defining_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the NO asset pair, the same way. Must be a different outpoint from yes_defining_in, or YES and NO would be the same asset.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 1, + "issuance": { + "kind": "new", + "asset_amount_sat": 0, + "inflation_amount_sat": 1 + }, + "ui": { + "label": "input that defines the NO asset", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, to your wallet. The asset reference reads the id the engine derived from in[0] once it resolved. `inputs.` is the explicit spelling of an input reference; the bare `yes_defining_in.reissuance_token` also works but is ambiguous by shape with every other namespace.", + "destination": "wallet", + "asset": "inputs.yes_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "YES minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, to your wallet.", + "destination": "wallet", + "asset": "inputs.no_defining_in.reissuance_token", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "NO minting right, held by you", + "role": "reissuance_token" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the two defining inputs.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "$comment": "The four asset ids are read straight off the inputs that created them — `$inputs..` is a string lookup, unlike a bare expression, which is arithmetic and would reject a 32-byte id. Note `issued_asset`, NOT `asset`: on an input carrying an issuance those differ, and `asset` is the asset of the UTXO being spent (L-BTC here). The four terms come from this action's params. All eight are compile params of prediction_market.simf, so from here the market's four covenant addresses are fully determined and every later action can derive them from the instance file alone.", + "fields": { + "ORACLE_PUBLIC_KEY": "$params.ORACLE_PUBLIC_KEY", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "COLLATERAL_PER_TOKEN": "$params.COLLATERAL_PER_TOKEN", + "EXPIRY_TIME": "$params.EXPIRY_TIME", + "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", + "NO_TOKEN_ASSET": "$inputs.no_defining_in.issued_asset", + "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token", + "NO_REISSUANCE_TOKEN": "$inputs.no_defining_in.reissuance_token", + "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy", + "NO_ISSUANCE_ENTROPY": "$inputs.no_defining_in.issuance_entropy" + } + } + }, + "CreateMarket": { + "$comment": "Run IssueReissuanceTokens first: it writes the instance file this action reads. Every value here comes from `instance.*` — the four asset ids and the four terms — which is why this action takes no params at all. Those fields are loaded into the context before the engine snapshots compile params, so the state-0 address computes correctly even though the assets were minted in a different transaction.", + "description": "Step 2 of 2: lock the minting rights into the covenant. A PLAIN Elements transaction — no covenant input, nothing validated on-chain, so a malformed creation simply produces unspendable UTXOs — that moves both reissuance tokens from your wallet to the state-0 (dormant) address. No outcome tokens are minted and no collateral is deposited; that is InitialIssuance's job. Anyone evaluating a Deadcat market should re-check this transaction by hand: the covenant vouches for everything after it, and for nothing in it.", + "intent": "lock the minting rights into a market backed by {instance.COLLATERAL_ASSET_ID:symbol}", + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token in your wallet, minted by IssueReissuanceTokens. Nothing is issued in this transaction — the token merely changes hands, from you to the covenant.", + "utxo_source": "wallet", + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token in your wallet, from the same run.", + "utxo_source": "wallet", + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee. Needed here because both other inputs are token UTXOs with no L-BTC in them.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "The YES reissuance token, 1 unit, parked at the state-0 (dormant) address. From here it can only ever move by a covenant spend.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "The NO reissuance token, 1 unit, at the same state-0 address.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, locked in the market", + "role": "reissuance_token" + } + }, + { + "id": "creation_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "PrepareInitialIssuance": { + "$comment": "A funding action, not a protocol one: nothing here touches the covenant. It exists because InitialIssuance declares no change output — the covenant pins the fee at output index 5, and a change output would displace it — so whatever L-BTC the collateral input carries beyond the collateral IS the fee. Feeding it an ordinary wallet UTXO therefore pays the entire remainder to miners. This action cuts a UTXO of exactly the right size first, which turns that behaviour from a hazard into an intent: the surplus becomes FEE_ALLOWANCE and nothing more.", + "description": "Cut an exactly-sized collateral UTXO for InitialIssuance. Reads COLLATERAL_PER_TOKEN and COLLATERAL_ASSET_ID from the instance and takes the number of pairs you intend to mint, so the output is PAIRS x 2 x COLLATERAL_PER_TOKEN plus a fee allowance — the exact amount InitialIssuance consumes. Run this first, then InitialIssuance, which will select the UTXO this produced (pin it with --input collateral_in=: if your wallet holds other L-BTC of a similar size).", + "intent": "cut a {params.PAIRS}-pair collateral UTXO for opening the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many YES/NO pairs InitialIssuance will mint. Must match the PAIRS you pass there — a mismatch just means the sizing is wrong, and the surplus or shortfall shows up as a fee error." + }, + "FEE_ALLOWANCE": { + "type": "u64", + "default": "2000", + "description": "Extra L-BTC to include on top of the collateral, which becomes InitialIssuance's fee. That transaction is large (three inputs, two of them Simplicity covenant spends with sizeable witnesses), so leave real headroom. Anything unspent here is paid to miners, so do not inflate it either." + } + }, + "inputs": [ + { + "id": "funding_in", + "description": "Wallet L-BTC UTXO to cut from. Must hold at least the collateral plus the fee allowance plus this transaction's own fee.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE" + }, + "ui": { + "label": "wallet funds to cut the collateral from", + "role": "funding" + } + } + ], + "outputs": [ + { + "id": "sized_collateral", + "description": "The exactly-sized UTXO InitialIssuance will spend as its collateral input: PAIRS x 2 x COLLATERAL_PER_TOKEN of collateral, plus FEE_ALLOWANCE which becomes that transaction's fee.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE", + "ui": { + "label": "collateral sized for opening the market", + "role": "collateral" + } + }, + { + "id": "funding_change", + "description": "The rest of the funding UTXO, back to your wallet. Present precisely because this action is allowed a change output, unlike InitialIssuance.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "InitialIssuance": { + "$comment": "Path 1. Fee MUST land at output index 5, which it does only because no change output is declared here: the engine appends declared outputs, then any change, then the fee. Declaring a change output would push the fee to index 6 and the covenant would reject the spend. Size the collateral input exactly (see `prepare`) — surplus L-BTC is swallowed by the fee.", + "description": "First covenant-validated transaction: state 0 -> 1. Reissues the first batch of YES/NO pairs, deposits their collateral, and moves all three covenant UTXOs to the state-1 address. Collateral comes from your wallet, not from the covenant — in the dormant state there is no collateral UTXO yet.", + "intent": "open the market: mint {params.PAIRS} YES/NO pairs and lock their collateral", + "params": { + "PAIRS": { + "type": "u64", + "description": "How many matched YES/NO pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN and receive PAIRS YES tokens and PAIRS NO tokens — you are the market's first counterparty on both sides, and you can sell either leg." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "The YES reissuance token at the state-0 address, input 0 — the covenant asserts current_index == 0 for this path. Its reissuance mints PAIRS YES tokens.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0", + "description": "Claimed state. Not trusted: main() recomputes the state-0 address from it and asserts the input is actually being spent from there, so lying is impossible rather than merely detectable." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Left(())))", + "description": "Path 1 — initial issuance. The seven paths are a nested Either tree; see witness.rs::build_path_value upstream." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "The NO reissuance token, input 1. It runs path 7 (secondary covenant input), which only proves it is spent from the same address as input 0 and leaves every transaction-level check to input 0's path.", + "utxo_source": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "0" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "Your collateral, input 2. Unlike every later issuance this comes from the wallet: the dormant market holds no collateral to consolidate with.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 2, + "ui": { + "label": "collateral you are depositing", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token cycled forward to the state-1 address (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token cycled forward to the state-1 address (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market now live", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The market's one and only collateral UTXO (output 2), at the state-1 address. Exactly PAIRS x 2 x COLLATERAL_PER_TOKEN — the covenant computes this itself from the issuance amount and rejects anything else.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "The minted YES tokens (output 3), to your wallet. Unconstrained by the covenant beyond asset balance — it only cares that YES and NO were minted in equal amounts and fully collateralised.", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "The minted NO tokens (output 4), to your wallet.", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change back to your wallet. v3 checks the fee at num_outputs - 1 rather than at a fixed index, so a trailing change output is legal here — which is what stops the collateral input's surplus being paid to miners. It also supplies the free blinding factor the +1 rule needs: with both token outputs' factors fixed, this output absorbs the value-balance residue.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "MintPairs": { + "$comment": "Path 2. Same index-5 fee requirement as InitialIssuance, so again no change output is declared. The difference from path 1 is input 2: the existing collateral UTXO is consumed and re-emitted as old + new, which is what keeps the market to exactly one collateral UTXO.", + "description": "Mint more pairs into a live market (state 1 -> 1). Permissionless: anyone who deposits collateral gets tokens, there is no issuer and no allowlist. Consumes the market's collateral UTXO and re-emits the consolidated total.", + "intent": "mint {params.PAIRS} more YES/NO pairs into the market", + "params": { + "PAIRS": { + "type": "u64", + "description": "Additional pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN of new collateral on top of whatever the market already holds." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token from state 1, input 0 (the primary — current_index must be 0).", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Left(Right(())))", + "description": "Path 2 — subsequent issuance." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token from state 1, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "issuance": { + "kind": "reissue", + "asset_amount_sat": "params.PAIRS", + "entropy": "instance.NO_ISSUANCE_ENTROPY", + "issued_asset": "instance.NO_TOKEN_ASSET" + }, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "existing_collateral_in", + "description": "The market's current collateral UTXO, input 2 — the covenant checks this index's script hash against the state-1 address by hand. Also on path 7. Its amount comes from the state file.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))", + "description": "Path 7 — secondary covenant input." + } + }, + "ui": { + "label": "collateral already in the market", + "role": "collateral" + } + }, + { + "id": "new_collateral_in", + "description": "Your new collateral, input 3, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" + }, + "required_index": 3, + "ui": { + "label": "collateral you are adding", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token back to state 1 (output 0).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token back to state 1 (output 1).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "The re-consolidated collateral UTXO (output 2): the old amount plus this issuance's deposit, computed by the covenant as safe_add(old, pairs x 2 x CPT).", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "existing_collateral_in.amount_sat + params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "all collateral backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_out", + "description": "Newly minted YES tokens to your wallet (output 3).", + "destination": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 3, + "ui": { + "label": "YES tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_out", + "description": "Newly minted NO tokens to your wallet (output 4).", + "destination": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS", + "required_index": 4, + "ui": { + "label": "NO tokens minted to you", + "role": "outcome_token" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change back to your wallet. v3 checks the fee at num_outputs - 1 rather than at a fixed index, so a trailing change output is legal here — which is what stops the collateral input's surplus being paid to miners. It also supplies the free blinding factor the +1 rule needs: with both token outputs' factors fixed, this output absorbs the value-balance residue.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "ResolveYes": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = true. Modelled as two actions rather than one with a runtime outcome because the destination address IS the outcome — state 2 and state 3 are different covenant addresses, and the engine has no conditional destination. The covenant demands exactly 4 outputs, so no change output may be declared: the fee absorbs any L-BTC surplus, which lands it at index 3 as required.", + "description": "Commit a YES outcome on-chain (state 1 -> 2). Permissionless to submit: the oracle signs a message that mentions only the market, never this transaction, so anyone holding the attestation can post it. Nothing moves except the three covenant UTXOs, which all shift to the state-2 address; the collateral amount is preserved exactly.", + "intent": "resolve the market YES using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x01), where MARKET_ID = sha256(YES_TOKEN_ASSET || NO_TOKEN_ASSET) over the asset ids in internal byte order. Paste it 0x-prefixed. Produced off-chain; the wallet cannot compute it." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — the primary input, and the one that verifies the oracle signature.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "true", + "description": "Picks the YES branch, which both selects the signed message byte 0x01 and sends every covenant output to the state-2 address." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE", + "description": "Substituted from the action param before the value is parsed." + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7. Its amount is read from the state file and must be reproduced exactly on output 2.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-2 address (output 0).", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-2 address (output 1). Minting is over, but the tokens are carried along so nothing is stranded.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-2 address (output 2). No value enters or leaves in a resolve.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by YES", + "role": "collateral" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change back to your wallet. v3 checks the fee at num_outputs - 1 rather than at a fixed index, so a trailing change output is legal here — which is what stops the collateral input's surplus being paid to miners. It also supplies the free blinding factor the +1 rule needs: with both token outputs' factors fixed, this output absorbs the value-balance residue.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "ResolveNo": { + "$comment": "Path 3 with ORACLE_OUTCOME_YES = false. Identical to ResolveYes except the signed outcome byte is 0x00 and every covenant output goes to state 3. Same 4-output rule: declare no change.", + "description": "Commit a NO outcome on-chain (state 1 -> 3). Whichever of ResolveYes / ResolveNo confirms first wins — that is the equivocation protection. An oracle that signs both outcomes cannot start a race to drain the pool, because after the first resolve there is no path back to state 1 and none between states 2 and 3.", + "intent": "resolve the market NO using the oracle's attestation", + "params": { + "ORACLE_SIGNATURE": { + "type": "bytes", + "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x00). Paste it 0x-prefixed." + } + }, + "inputs": [ + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 0 — still the primary input on a NO resolve; the covenant's layout is fixed regardless of outcome.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Left(())))", + "description": "Path 3 — oracle resolve." + }, + "ORACLE_OUTCOME_YES": { + "type": "simplicityhl", + "simplicity_type": "bool", + "value": "false", + "description": "Picks the NO branch: outcome byte 0x00 and state-3 outputs." + }, + "ORACLE_SIGNATURE": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.ORACLE_SIGNATURE" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 1, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 2, on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "collateral backing the market", + "role": "collateral" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token moved to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token moved to the state-3 address (output 1).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, market resolved", + "role": "reissuance_token" + } + }, + { + "id": "collateral_out", + "description": "All collateral moved intact to the state-3 address (output 2).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 2, + "ui": { + "label": "collateral, now redeemable by NO", + "role": "collateral" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change back to your wallet. v3 checks the fee at num_outputs - 1 rather than at a fixed index, so a trailing change output is legal here — which is what stops the collateral input's surplus being paid to miners. It also supplies the free blinding factor the +1 rule needs: with both token outputs' factors fixed, this output absorbs the value-balance residue.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change returned to you", + "role": "change" + } + } + ] + }, + "RedeemYes": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 2, PARTIAL form (remaining collateral > 0). The full-drain form is a different output layout — the burn becomes output 0 and there is no collateral output at all — and needs its own action, not modelled here. The fee is checked at num_outputs - 1, so change outputs are fine on this path.", + "description": "Redeem winning YES tokens for collateral (state 2). Each YES token draws 2 x COLLATERAL_PER_TOKEN — the whole pair's backing, both your stake and the loser's — so the pool drains exactly as all winners redeem, with nothing stranded. Losing NO tokens have no path; they are simply worthless.", + "intent": "redeem {params.TOKENS_BURNED} winning YES tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many YES tokens to burn. Must be strictly less than the market's total remaining backing, or this becomes a full drain and needs the other output layout." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-2 address, input 0 — this path reads amounts from index 0, so it must come first.", + "utxo_source": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "2", + "description": "State 2 also selects YES_TOKEN_ASSET as the only asset the covenant will accept as a burn." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED", + "description": "Drives both the burn output amount and the payout; the covenant checks both against it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your winning YES tokens, from the wallet. Size this exactly — any surplus becomes a change output, which is allowed here but pointless.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning YES tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "What is left of the pool, back to the state-2 address (output 0). The reissuance tokens are untouched by this transaction and stay where they are by consensus.", + "destination": { + "utxo_type": "market_resolved_yes" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "The YES tokens destroyed (output 1). The covenant requires a ZERO-LENGTH scriptPubKey here — sha256 of the empty script — not the 1-byte OP_RETURN this engine emits. See README.md.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2): TOKENS_BURNED x 2 x COLLATERAL_PER_TOKEN. The covenant does not check this leg at all — it constrains what stays behind and what is burned, and the rest follows from asset balance.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "YES token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.YES_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned YES tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemNo": { + "allow_change": "lbtc_only", + "$comment": "Path 4 in state 3 — the mirror of RedeemYes. Same partial-only caveat.", + "description": "Redeem winning NO tokens for collateral (state 3). Each NO token draws 2 x COLLATERAL_PER_TOKEN.", + "intent": "redeem {params.TOKENS_BURNED} winning NO tokens for collateral", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many NO tokens to burn." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO at the state-3 address, input 0.", + "utxo_source": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "3", + "description": "State 3 selects NO_TOKEN_ASSET as the winning side." + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Left(Right(Right(())))", + "description": "Path 4 — post-resolution redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "no_tokens_in", + "description": "Your winning NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "winning NO tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-3 address (output 0).", + "destination": { + "utxo_type": "market_resolved_no" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other winners", + "role": "collateral" + } + }, + { + "id": "no_tokens_burn", + "description": "The NO tokens destroyed (output 1). Same zero-length-script requirement as RedeemYes.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your winnings (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your winnings", + "role": "settlement" + } + }, + { + "id": "token_change", + "description": "NO token change, if your input held more than you burned.", + "destination": "change", + "asset": "instance.NO_TOKEN_ASSET", + "optional": true, + "ui": { + "label": "unburned NO tokens returned to you", + "role": "change" + } + } + ] + }, + "RedeemExpired": { + "allow_change": "lbtc_only", + "$comment": "Path 5, partial form. NOT EXECUTABLE YET: this is the one Deadcat path that calls jet::check_lock_height(EXPIRY_TIME), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. (The pre-expiry paths are fine: they assert lock_time < EXPIRY_TIME, which a locktime of 0 satisfies.) BURN_TOKEN_ASSET must be supplied byte-reversed, because witness values are parsed as raw SimplicityHL literals with no liquid.asset_id type hint to trigger the reversal that compile params get.", + "description": "Redeem after the market expired unresolved (state 1, at or after EXPIRY_TIME). Both sides redeem at 1 x COLLATERAL_PER_TOKEN — half the winner's rate — so YES and NO holders are made whole together and the pool drains exactly. This is the escape hatch for an oracle that never attests.", + "intent": "redeem {params.TOKENS_BURNED} tokens from the expired market", + "params": { + "TOKENS_BURNED": { + "type": "u64", + "description": "How many tokens to burn, of whichever side you hold." + }, + "BURN_TOKEN_ASSET": { + "type": "bytes", + "description": "The asset id of the side you are burning — YES_TOKEN_ASSET or NO_TOKEN_ASSET — 0x-prefixed and in INTERNAL byte order, i.e. the display id reversed. The covenant accepts either, and rejects anything else." + }, + "BURN_TOKEN_ASSET_DISPLAY": { + "type": "liquid.asset_id", + "description": "The same asset id in normal display order, used for the transaction's burn output and token input. Must be the reverse of BURN_TOKEN_ASSET." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, still at the state-1 address, input 0. The sequence below is ENABLE_LOCKTIME_NO_RBF: any value other than 0xFFFFFFFF enables the nLockTime that check_lock_height reads. The engine will warn that the BIP68 disable bit is set — expected, since this timeout is an absolute height.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "sequence": 4294967294, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Left(())))", + "description": "Path 5 — expiry redemption." + }, + "TOKENS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.TOKENS_BURNED" + }, + "BURN_TOKEN_ASSET": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.BURN_TOKEN_ASSET", + "description": "Tells the covenant which side you are burning. It checks the value equals YES_TOKEN_ASSET or NO_TOKEN_ASSET and then holds output 1 to it." + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "tokens_in", + "description": "The tokens you are burning, from the wallet — either side.", + "utxo_source": "wallet", + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": { + "min_amount": "params.TOKENS_BURNED" + }, + "ui": { + "label": "tokens you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0) — the market never leaves state 1 on this path.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral left for other holders", + "role": "collateral" + } + }, + { + "id": "tokens_burn", + "description": "The tokens destroyed (output 1). Zero-length script required, as on the other burn paths.", + "destination": { + "type": "burn" + }, + "asset": "params.BURN_TOKEN_ASSET_DISPLAY", + "amount_sat": "params.TOKENS_BURNED", + "required_index": 1, + "ui": { + "label": "tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "payout_out", + "description": "Your refund (output 2): TOKENS_BURNED x COLLATERAL_PER_TOKEN, half the resolved rate, because the other half belongs to the holder of the matching token on the other side.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", + "required_index": 2, + "ui": { + "label": "your refund", + "role": "settlement" + } + } + ] + }, + "CancelPairs": { + "allow_change": "lbtc_only", + "$comment": "Path 6, partial form (state 1 -> 1). The full form, which drains the pool to zero and cycles the reissuance tokens back to state 0, is CancelAll.", + "description": "Unwind part of your position while the market is unresolved. Burn matched YES and NO in equal amounts and take back the full 2 x COLLATERAL_PER_TOKEN per pair — you are undoing an issuance, so there is no loss and no time constraint. Only matched pairs qualify: burning one side alone would leave the other side under-collateralised.", + "intent": "cancel {params.PAIRS_BURNED} pairs and reclaim their collateral", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "How many matched pairs to burn. You must hold this many of BOTH YES and NO." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation. The partial and full forms are the same path; the covenant picks between them by testing whether any collateral remains." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "collateral pool being drawn down", + "role": "collateral" + } + }, + { + "id": "yes_tokens_in", + "description": "Your YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "YES side of the pairs you are burning", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "Your NO tokens, from the wallet, in the same amount.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "NO side of the pairs you are burning", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "collateral_out", + "description": "Remaining pool back to the state-1 address (output 0). Non-zero is what makes this the partial branch — if it were zero the covenant would demand CancelAll's layout instead.", + "destination": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat - params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 0, + "ui": { + "label": "collateral still backing the market", + "role": "collateral" + } + }, + { + "id": "yes_tokens_burn", + "description": "YES tokens destroyed (output 1). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 1, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "NO tokens destroyed (output 2), same amount — the covenant checks both indices, which is how equal burning is enforced.", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "Your refund (output 3): the full deposit for those pairs, PAIRS_BURNED x 2 x COLLATERAL_PER_TOKEN.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", + "required_index": 3, + "ui": { + "label": "collateral refunded to you", + "role": "settlement" + } + } + ] + }, + "CancelAll": { + "allow_change": "lbtc_only", + "$comment": "Path 6, full form (state 1 -> 0). PAIRS_BURNED must equal the whole pool: collateral_in.amount_sat / (2 x COLLATERAL_PER_TOKEN). If it does not, the covenant takes the partial branch instead and this output layout is rejected.", + "description": "Wind the market all the way down. Burn every outstanding pair, take back all the collateral, and cycle both reissuance tokens back to the state-0 address so the market is dormant rather than dead. Without this the tokens would be stranded at a collateral-less state-1 address and the market could never be reissued — the reason the dormant state exists at all (design doc section 5.3).", + "intent": "wind the market down and return it to dormant", + "params": { + "PAIRS_BURNED": { + "type": "u64", + "description": "Every outstanding pair. Must equal the pool's total collateral divided by 2 x COLLATERAL_PER_TOKEN, exactly." + } + }, + "inputs": [ + { + "id": "collateral_in", + "description": "The market's collateral UTXO, input 0 — the primary input, running path 6.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "required_index": 0, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Left(Right(())))", + "description": "Path 6 — cancellation, full branch (chosen by the covenant when nothing remains)." + }, + "PAIRS_BURNED": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.PAIRS_BURNED" + } + }, + "ui": { + "label": "the whole collateral pool", + "role": "collateral" + } + }, + { + "id": "yes_reissuance_in", + "description": "YES reissuance token, input 1 — the full branch verifies it at exactly this index. Runs path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "YES minting right", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_in", + "description": "NO reissuance token, input 2, also on path 7.", + "utxo_source": { + "utxo_type": "market_unresolved" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 2, + "witnesses": { + "STATE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "1" + }, + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", + "value": "Right(Right(()))" + } + }, + "ui": { + "label": "NO minting right", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_in", + "description": "All outstanding YES tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every YES token, being burned", + "role": "outcome_token" + } + }, + { + "id": "no_tokens_in", + "description": "All outstanding NO tokens, from the wallet.", + "utxo_source": "wallet", + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": { + "min_amount": "params.PAIRS_BURNED" + }, + "ui": { + "label": "every NO token, being burned", + "role": "outcome_token" + } + } + ], + "outputs": [ + { + "id": "yes_reissuance_out", + "description": "YES reissuance token returned to the state-0 (dormant) address (output 0).", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.YES_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "YES minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "no_reissuance_out", + "description": "NO reissuance token returned to the state-0 address (output 1). From here the market can be reopened with another InitialIssuance.", + "destination": { + "utxo_type": "market_dormant" + }, + "asset": "instance.NO_REISSUANCE_TOKEN", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "NO minting right, back to dormant", + "role": "reissuance_token" + } + }, + { + "id": "yes_tokens_burn", + "description": "All YES tokens destroyed (output 2). Zero-length script required.", + "destination": { + "type": "burn" + }, + "asset": "instance.YES_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 2, + "ui": { + "label": "YES tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "no_tokens_burn", + "description": "All NO tokens destroyed (output 3).", + "destination": { + "type": "burn" + }, + "asset": "instance.NO_TOKEN_ASSET", + "amount_sat": "params.PAIRS_BURNED", + "required_index": 3, + "ui": { + "label": "NO tokens burned", + "role": "burn", + "group": "burned / protocol data" + } + }, + { + "id": "refund_out", + "description": "The entire pool refunded to you (output 4). The covenant does not constrain this leg; it falls out of asset balance once outputs 0-3 are pinned.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "collateral_in.amount_sat", + "required_index": 4, + "ui": { + "label": "all collateral refunded to you", + "role": "settlement" + } + } + ] + } + } + } + } +} diff --git a/examples/lending/txmanifest.json b/examples/lending/txmanifest.json index 140bbbd..74898d4 100644 --- a/examples/lending/txmanifest.json +++ b/examples/lending/txmanifest.json @@ -108,6 +108,7 @@ ] }, "PrepareLender": { + "allow_change": "any", "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", "inputs": [ { diff --git a/examples/lending_v2/txmanifest.json b/examples/lending_v2/txmanifest.json index 1ae0411..6fafcc2 100644 --- a/examples/lending_v2/txmanifest.json +++ b/examples/lending_v2/txmanifest.json @@ -104,6 +104,7 @@ ] }, "PrepareLender": { + "allow_change": "any", "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", "inputs": [ { diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index 49d5fa0..40520b0 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -10,6 +10,14 @@ "description": "Documentation only; ignored by the engine.", "type": "string" }, + "allow_change": { + "allOf": [ + { + "$ref": "#/definitions/AllowChange" + } + ], + "description": "Whether the engine may append a change output this action did not declare.\n\n**Every output a transaction carries must be written in the manifest. The network fee is the single exception, because it has no manifest spelling.** A change output is not an exception: its address and amount are chosen by the engine, so silently adding one moves value to a destination the manifest never named, in an amount nobody wrote down. That is how an oversized collateral input once turned 88,735 satoshis into a miner's fee without a word of warning.\n\nSo the default is [`AllowChange::None`]: a surplus in any asset — including L-BTC — is an error, and the action must size its inputs to what it spends. Relax it only where the surplus genuinely cannot be predicted:\n\n- `\"none\"` (default) — no change may be added; any surplus is an error. - `\"lbtc_only\"` — the engine may return an L-BTC surplus to the wallet. Use this for ordinary funding actions, where the fee is only known after the size is. A surplus in any other asset is still an error. - `\"any\"` — the engine may return a surplus in any asset.\n\nThis governs *undeclared* change. An output with `\"destination\": \"change\"` is declared, and permits change for its own asset regardless of this setting." + }, "create_instance": { "anyOf": [ { @@ -87,6 +95,32 @@ }, "type": "object" }, + "AllowChange": { + "description": "Which assets an action lets the engine return a surplus in, via a change output the manifest did not declare. See [`Action::allow_change`].\n\nSpelled as an enum rather than a boolean because the useful middle case — \"return leftover L-BTC, but never move a protocol asset I did not account for\" — is the one most funding actions want, and a boolean cannot say it.", + "oneOf": [ + { + "description": "No undeclared change. A surplus in any asset fails the build.", + "enum": [ + "none" + ], + "type": "string" + }, + { + "description": "Only the policy asset (L-BTC) may be returned.", + "enum": [ + "lbtc_only" + ], + "type": "string" + }, + { + "description": "Any asset may be returned.", + "enum": [ + "any" + ], + "type": "string" + } + ] + }, "ComputeSpec": { "anyOf": [ { @@ -209,7 +243,9 @@ "id": { "type": "string" }, - "issuance": true, + "issuance": { + "description": "An Elements asset issuance carried by this input.\n\n- `{\"kind\": \"new\", \"asset_amount_sat\": , \"inflation_amount_sat\": }` — mint a brand-new asset, whose id is derived from this input's outpoint. Either amount may be `0` (reissuance tokens only, or a fixed supply with no reissuance rights). - `{\"kind\": \"reissue\", \"asset_amount_sat\": , \"entropy\": }` — mint more of an existing asset by spending its reissuance token.\n\nA reissuance needs the **issuance entropy** of the original mint — `fast_merkle_root([sha256d(defining outpoint), contract_hash])`, the value the asset id itself is derived from. It cannot be recovered from anything on chain: the reissuance token UTXO carries no trace of the outpoint that created it. So a constructor has to capture it at the one moment it exists, and hand it back later:\n\n```json // in the minting action's create_instance: \"YES_ISSUANCE_ENTROPY\": \"$inputs.yes_defining_in.issuance_entropy\" // in the reissuing action's input: \"issuance\": { \"kind\": \"reissue\", \"asset_amount_sat\": \"params.PAIRS\", \"entropy\": \"instance.YES_ISSUANCE_ENTROPY\", \"issued_asset\": \"instance.YES_TOKEN_ASSET\" } ```\n\n`issued_asset` is optional and is a **check**, not an input: the engine re-derives the asset id from the entropy and refuses to build if the two disagree. An entropy is opaque, and the byte order block explorers print is the reverse of the one used here — without the check a transposed value still builds a broadcastable transaction that reissues the wrong asset.\n\nFailing that, the entropy may come from `provided_inputs..issuance_entropy` in the instance file. That works, but it travels with an outpoint override which pins the input for *every* action sharing its id — long after the pin is correct." + }, "on_resolved": { "anyOf": [ { @@ -316,7 +352,12 @@ ] }, "destination": { - "description": "\"change\" | \"params.\" | {\"utxo_type\": \"...\"} | {\"type\": \"burn\"} | conditional" + "allOf": [ + { + "$ref": "#/definitions/OutputDestination" + } + ], + "description": "Where this output's value goes. See [`OutputDestination`] for the accepted forms." }, "id": { "type": "string" @@ -353,6 +394,82 @@ ], "type": "object" }, + "OutputDestination": { + "anyOf": [ + { + "description": "`change`, `wallet`, a literal address, or a `params.X` / `instance.X` reference that resolves to one.", + "examples": [ + "change", + "wallet", + "params.receive_address" + ], + "type": "string" + }, + { + "description": "The covenant address derived for a declared `utxo_type`.", + "properties": { + "args": { + "additionalProperties": { + "type": "string" + }, + "description": "Binds the utxo_type's declared `params` for this site. Values are expressions in the ACTION's scope (`params.X`, `instance.X`, a literal). Every param without a default has to be bound here.", + "type": "object" + }, + "compile_params": { + "additionalProperties": { + "type": "string" + }, + "description": "Per-site compile-param overrides for this destination, resolved against the action's params.", + "type": "object" + }, + "utxo_type": { + "type": "string" + } + }, + "required": [ + "utxo_type" + ], + "type": "object" + }, + { + "description": "P2TR output built from a 32-byte script hash.", + "properties": { + "script_hash": { + "description": "32-byte hex, or a reference resolving to it.", + "type": "string" + } + }, + "required": [ + "script_hash" + ], + "type": "object" + }, + { + "description": "`op_return` / `burn` embed the output's own `data` field (bare OP_RETURN when absent). `fee` declares the fee leg and produces no PSET output of its own.", + "properties": { + "type": { + "enum": [ + "op_return", + "burn", + "fee" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Conditional destination. Parsed but NOT implemented — the engine has no arm for it and skips the output entirely.", + "required": [ + "if" + ], + "type": "object" + } + ], + "description": "Where this output's value goes. A string is `change` (wallet change, amount auto-computed), `wallet` (a fresh receive address), or an address / `params.X` reference resolving to one." + }, "ParamCompute": { "description": "Auto-computation spec for a derived compile param or action param.\n\nDispatched by `type`, the same discriminator every other tagged object in the format uses (`script.type`, `destination.type`, a witness's `type`). Note this is the *method* of computation; the value's data type is `ParamDef::type_`, one level up. The legacy key `lang` is still accepted as an alias for the discriminator: - `\"expr\"`: arithmetic expression over other compile params (`pow(base, exp)` supported) - `\"tapleaf\"`: compile a `.simf` file and return its Simplicity tapleaf hash (32 bytes hex) - `\"simf_fn\"`: call a named function in a `.simf` file and use its return value - `\"wallet\"`: take the value from the executing wallet rather than the manifest, with `wallet` selecting which ([`WalletValue`])\n\nThe `wallet` variant differs from the others in kind: `expr`, `tapleaf` and `simf_fn` are reproducible by anyone holding the manifest, whereas a `wallet_*` value depends on who is running the action. They live here anyway because from an author's point of view they answer the same question — where does this value come from, if not the user? — and having two fields for that (the old `source`) meant two things to check and a name that collided with `script.source`, a file path.", "oneOf": [ @@ -430,6 +547,51 @@ ], "type": "object" }, + { + "additionalProperties": false, + "description": "`sha256(scriptPubKey)` of an address — the exact value the Simplicity `output_script_hash` / `input_script_hash` jets return for a UTXO paying it.\n\nAn address and its script hash are two views of one destination: the covenant commits to the hash, the transaction pays to the address, and if they ever disagree the spend fails on-chain. Deriving one from the other is the only way to keep that true — a manifest that asks for both separately is asking to be given two values that must match and cannot be checked.\n\nBlinding is irrelevant here: a confidential address has the same scriptPubKey as its unconfidential form, so both hash alike (`script_hash_of_address` pins this).", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "address": { + "description": "An address, or a reference resolving to one (`params.payout_address`).", + "type": "string" + }, + "type": { + "enum": [ + "script_hash" + ], + "type": "string" + } + }, + "required": [ + "address", + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A value a **hook** supplies later in this run — declared here, set by an `on_resolved` / `on_pre_broadcast` block targeting `params.`.\n\nThis exists so a hook cannot invent an identifier. Without it, `\"set\": { \"params.YES_TOKN_ASSET\": \"asset\" }` is accepted, fills a slot nobody reads, and surfaces as a wrong covenant address much later; with it, `validate` rejects the typo and the declaration carries the `type` that byte-order handling depends on.\n\nIt lives under `compute` rather than as a separate `deferred: true` flag because `compute` already means exactly \"this value is derived, do not prompt for it\" — the only thing that differs here is *who* derives it. A second flag would need its own prompt-suppression path and would have to define what it means alongside a `compute` that is also present.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "type": { + "enum": [ + "hook" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, { "additionalProperties": false, "description": "A value taken from the executing wallet rather than the manifest.\n\nGrouped under one tag rather than spread across three so that \"is this wallet-derived?\" is a single check on `compute` before dispatching on `wallet` — and so adding a new wallet-derived value does not grow the top-level variant list.", @@ -555,6 +717,13 @@ "default": false, "description": "Whether covenant `.simf` programs are compiled with debug symbols included.\n\nThis changes the program's CMR **and therefore every covenant address**, because `assert!`/`panic!` embed source info into `fail`-node commitments. Set it to match the toolchain of any protocol this manifest must interoperate with — e.g. `true` for simplicity-lending / `smplx-sdk`, which compiles with debug symbols on.\n\nDefaults to `false` (production; debug symbols are a transitional feature).", "type": "boolean" + }, + "unstable_features": { + "description": "Unstable SimplicityHL compiler features this manifest's programs are allowed to use — the manifest form of `simc -Z `, one entry per feature:\n\n```json \"simplicity_hl\": { \"unstable_features\": [\"enums\"] } ```\n\nThe compiler rejects gated syntax unless the feature is enabled, so a program using `enum` fails to compile until `\"enums\"` is listed here. Enabling a feature the programs don't use is harmless: this only lifts a restriction, it never changes generated code, and therefore never changes a CMR or covenant address.\n\nManifest-wide rather than per-`utxo_type`, mirroring `simc`'s own per-invocation `-Z` flag — the whole point of a gate is that a reader can see, in one place, which unstable syntax this protocol depends on.\n\nDefaults to empty: nothing unstable is enabled.", + "items": { + "$ref": "#/definitions/UnstableFeatureName" + }, + "type": "array" } }, "type": "object" @@ -584,21 +753,104 @@ ], "type": "object" }, + "TaprootLeafKind": { + "description": "The hashing scheme for a [`TaprootLeafSpec`]'s payload.", + "oneOf": [ + { + "description": "Elements taproot data leaf — the only scheme the engine implements.", + "enum": [ + "tapdata" + ], + "type": "string" + } + ] + }, + "TaprootLeafPayloadItem": { + "anyOf": [ + { + "description": "Hex literal taken as raw bytes, e.g. \"0x01\". Whole bytes only.", + "pattern": "^(?:0[xX])?(?:[0-9a-fA-F]{2})*$", + "type": "string" + }, + { + "description": "Computed value, resolved against the run's params/instance fields and encoded per `type` / `endian` / `pad_to`.", + "properties": { + "align": { + "description": "Which end of the padded field the value occupies. Defaults to `right`.", + "enum": [ + "left", + "right" + ] + }, + "endian": { + "description": "Byte order for the integer types. Defaults to little-endian.", + "enum": [ + "be", + "le" + ] + }, + "pad_to": { + "description": "Pad the encoded value to this width in bytes — 32 for a slot the program hashes with `sha_256_ctx_8_add_32`.", + "type": "integer" + }, + "type": { + "enum": [ + "u8", + "u16", + "u32", + "u64", + "bytes32", + "bytes", + "pubkey" + ] + }, + "value": { + "description": "`params.X`, `instance.X`, `.`, a bare param name, or a literal.", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + { + "description": "Reference to a `state_vars` entry; its `default_value` is encoded as a single u8.", + "properties": { + "state_var": { + "type": "string" + } + }, + "required": [ + "state_var" + ], + "type": "object" + } + ], + "description": "One item of a taproot leaf payload. Items are concatenated, in order, into the bytes that get hashed as the leaf." + }, "TaprootLeafSpec": { "additionalProperties": false, - "description": "Describes one additional taproot leaf appended to the Simplicity program leaf.", + "description": "Describes one additional taproot leaf appended to the Simplicity program leaf.\n\nEach leaf's payload is hashed as `tapdata` — `SHA256(SHA256(\"TapData\") ‖ SHA256(\"TapData\") ‖ payload)`, which is the value a program computes with `jet::tapdata_init()`, `sha_256_ctx_8_add_*` and `finalize` — then folded into the tap tree with `TapBranch/elements` in declaration order, matching `jet::build_tapbranch`. The payload's **width must match what the `.simf` hashes**: `sha_256_ctx_8_add_32` wants exactly 32 bytes, `add_8` exactly 8. A mismatch yields a perfectly valid address that the covenant then refuses to recognize as its own.", "properties": { "$comment": { "description": "Documentation only; ignored by the engine.", "type": "string" }, "payload": { - "description": "Ordered payload items: each is either a hex literal string (\"0x01\") or a state_var reference ({\"state_var\": \"name\"}).", - "items": true, + "description": "Ordered payload items, concatenated into this leaf's byte string.", + "items": { + "$ref": "#/definitions/TaprootLeafPayloadItem" + }, "type": "array" }, "type": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/TaprootLeafKind" + } + ], + "description": "How the payload is hashed. Only `tapdata` is implemented, and it was previously accepted as a free string — so any other spelling was silently hashed as tapdata anyway, producing an address whose derivation nobody had written down." } }, "required": [ @@ -657,6 +909,45 @@ ], "description": "Per-input / per-output UI hint. Accepts either a bare label string (`\"collateral locked\"`) or a detailed object for finer control.\n\nHand-deserialized rather than `#[serde(untagged)]`: an untagged enum reports only `data did not match any variant of untagged enum UiSpec`, swallowing the real reason. Dispatching on the JSON shape lets `UiDetail`'s own error through, so a misspelled key names itself." }, + "UnstableFeatureName": { + "description": "Unstable SimplicityHL compiler feature (`simc -Z `).\n- imports — Module system syntax: 'use' imports, 'mod' modules, 'as' aliases, 'crate::' paths\n- enums — Enum syntax: 'enum' declarations and 'EnumName::Variant' match expressions", + "enum": [ + "imports", + "enums" + ], + "type": "string" + }, + "UtxoParamDef": { + "additionalProperties": false, + "description": "One entry of a [`UtxoType::params`] interface.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "default": { + "description": "Value to use when a site binds no `args` entry for this param.\n\nEvaluated in **instance scope**: a literal, or `instance.X` naming a field fixed when the contract was instantiated. Action scope is deliberately unreachable — a value that varies per run is exactly what a site must bind explicitly.\n\nWithout a default, every site must bind it, and `validate` says which ones don't.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Manifest type, used as the compile-param type hint (`u64`, `bytes32`, `liquid.asset_id`, …) — the same vocabulary action params use.", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, "UtxoScript": { "additionalProperties": false, "properties": { @@ -717,6 +1008,16 @@ "description": { "type": "string" }, + "params": { + "additionalProperties": { + "$ref": "#/definitions/UtxoParamDef" + }, + "description": "This type's parameter interface — everything the address derivation may read.\n\nDeclaring it switches the type to a **closed scope**: `script.compile_params` and `extra_leaves` resolve `params.X` against *these* params and nothing else. A site binds them with `args` (`{\"utxo_type\": \"t\", \"args\": {\"STATE\": \"params.x\"}}`), whose values are expressions evaluated in the *action's* scope.\n\nWithout it, the type keeps the legacy behaviour: leaves and compile params resolve against whatever is ambient at each mention. That is what makes one `utxo_type` derive two different addresses in two actions — `params.foo` means one thing where the action declares `foo` and something else where it does not — with no error, because an address is a hash and a wrong one looks exactly like a right one.", + "type": [ + "object", + "null" + ] + }, "script": { "anyOf": [ { diff --git a/txmanifest_lib/examples/deadcat_recon.rs b/txmanifest_lib/examples/deadcat_recon.rs new file mode 100644 index 0000000..f7f941b --- /dev/null +++ b/txmanifest_lib/examples/deadcat_recon.rs @@ -0,0 +1,237 @@ +// Reproduce Deadcat's four per-state covenant addresses and cross-check the engine's +// tapdata-state encoding against deadcat-sdk/src/taproot.rs. +// +// cargo run -p tx-manifest-lib --example deadcat_recon +// +// The engine reaches its address through `TaprootSpendInfo::new_key_spend` over a merkle +// root it folds itself; deadcat reaches it by hand-rolling the three tagged hashes and +// tweaking the NUMS key directly. This example runs the manifest's own utxo_type wiring +// (compile_params + the `tapdata` extra leaf carrying the u64 state, big-endian) through +// the first path and the transcription of deadcat's code through the second, and asserts +// they agree — which is what pins the state-leaf encoding. Test values use repeated bytes +// so the `liquid.asset_id` byte-reversal is a no-op and the two sides are comparable. +use std::collections::HashMap; + +use lwk_wollet::ElementsNetwork; +use lwk_wollet::elements::hashes::{Hash, HashEngine, sha256}; +use lwk_wollet::elements::secp256k1_zkp::{Scalar, Secp256k1, XOnlyPublicKey}; +use tx_manifest_lib::context::ExecutionContext; +use tx_manifest_lib::covenant; +use tx_manifest_lib::manifest::Manifest; + +/// deadcat-sdk/src/taproot.rs::NUMS_KEY_BYTES — also the engine's covenant internal key. +const NUMS_KEY_BYTES: [u8; 32] = [ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, +]; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// SHA256(SHA256(tag) || SHA256(tag) || data) — deadcat's `tagged_hash`. +fn tagged_hash(tag: &[u8], data: &[u8]) -> [u8; 32] { + let tag_hash = sha256::Hash::hash(tag); + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(data); + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// deadcat's `covenant_script_pubkey`, transcribed: tapdata(state) branched with the +/// Simplicity leaf, tweaked onto NUMS. +fn deadcat_spk(tapleaf_hash: [u8; 32], state: u64) -> String { + let data_leaf = tagged_hash(b"TapData", &state.to_be_bytes()); + let (a, b) = if tapleaf_hash <= data_leaf { + (tapleaf_hash, data_leaf) + } else { + (data_leaf, tapleaf_hash) + }; + let mut branch_data = Vec::with_capacity(64); + branch_data.extend_from_slice(&a); + branch_data.extend_from_slice(&b); + let branch = tagged_hash(b"TapBranch/elements", &branch_data); + + let mut tweak_data = Vec::with_capacity(64); + tweak_data.extend_from_slice(&NUMS_KEY_BYTES); + tweak_data.extend_from_slice(&branch); + let tweak = tagged_hash(b"TapTweak/elements", &tweak_data); + + let secp = Secp256k1::new(); + let nums = XOnlyPublicKey::from_slice(&NUMS_KEY_BYTES).expect("NUMS key"); + let (tweaked, _parity) = nums + .add_tweak(&secp, &Scalar::from_be_bytes(tweak).expect("scalar")) + .expect("tweak"); + + format!("5120{}", hex(&tweaked.serialize())) +} + +fn main() { + let dir = std::path::Path::new("examples/deadcat"); + let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); + let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); + + // Same shape as deadcat-sdk's own ContractParams test fixture. + let fields: [(&str, &str, &str); 8] = [ + ("ORACLE_PUBLIC_KEY", &"aa".repeat(32), "pubkey"), + ("COLLATERAL_ASSET_ID", &"bb".repeat(32), "liquid.asset_id"), + ("YES_TOKEN_ASSET", &"01".repeat(32), "liquid.asset_id"), + ("NO_TOKEN_ASSET", &"02".repeat(32), "liquid.asset_id"), + ("YES_REISSUANCE_TOKEN", &"03".repeat(32), "liquid.asset_id"), + ("NO_REISSUANCE_TOKEN", &"04".repeat(32), "liquid.asset_id"), + ("COLLATERAL_PER_TOKEN", "100000", "u64"), + ("EXPIRY_TIME", "1000000", "u32"), + ]; + + let mut params: HashMap = HashMap::new(); + let mut hints: HashMap = HashMap::new(); + let mut ctx = ExecutionContext::new(); + for (name, value, ty) in &fields { + params.insert((*name).to_string(), (*value).to_string()); + hints.insert((*name).to_string(), (*ty).to_string()); + ctx.set_compile_param(*name, *value); + } + + let simf = dir.join("prediction_market.simf"); + // debug_symbols: false — matches deadcat's `template.instantiate(args, false)`. + let tapleaf = covenant::compute_tapleaf_hash(&simf, ¶ms, &hints, false).expect("tapleaf"); + + eprintln!("---- result ----"); + println!("tapleaf_hash = {}", hex(&tapleaf)); + + let states: [(&str, u64); 4] = [ + ("market_dormant", 0), + ("market_unresolved", 1), + ("market_resolved_yes", 2), + ("market_resolved_no", 3), + ]; + + let mut seen: Vec = Vec::new(); + for (type_name, state) in states { + let ut = manifest.utxo_type(type_name).expect("utxo_type"); + let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); + assert_eq!( + leaves, + vec![state.to_be_bytes().to_vec()], + "{type_name}: the tapdata leaf must be the 8-byte big-endian state" + ); + + let addr = covenant::compute_covenant_address( + &simf, + ¶ms, + &hints, + &leaves, + ElementsNetwork::LiquidTestnet, + false, + ) + .expect("covenant address"); + let engine_spk = format!("{:x}", addr.script_pubkey()); + let sdk_spk = deadcat_spk(tapleaf, state); + + println!("state {state} ({type_name})"); + println!(" spk (engine) = {engine_spk}"); + println!(" spk (deadcat) = {sdk_spk}"); + println!(" address = {addr}"); + assert_eq!( + engine_spk, sdk_spk, + "state {state}: engine and deadcat-sdk disagree on the covenant scriptPubKey" + ); + assert!( + !seen.contains(&engine_spk), + "state {state} collides with an earlier state's address" + ); + seen.push(engine_spk); + } + + check_witness_literals(&simf, ¶ms); + + println!("\nOK: four distinct addresses, each matching deadcat-sdk's derivation."); +} + +/// Parse every witness literal the manifest writes against the compiled program's own +/// ABI types. `PATH` is a seven-leaf nested `Either` tree and the literals are written by +/// hand, so this is where a mis-nested `Left`/`Right` would otherwise go unnoticed until a +/// spend failed on chain. +fn check_witness_literals(simf: &std::path::Path, params: &HashMap) { + use simplicityhl::parse::ParseFromStr; + use simplicityhl::str::WitnessName; + use simplicityhl::value::Value; + use simplicityhl::{Arguments, CompiledProgram}; + use simplicityhl::ast::ElementsJetHinter; + + let asset_arg = |name: &str| { + format!( + r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, + params[name] + ) + }; + let args_json = format!( + "{{{}, {}, {}, {}, {}, {}, {}, {}}}", + asset_arg("ORACLE_PUBLIC_KEY"), + asset_arg("COLLATERAL_ASSET_ID"), + asset_arg("YES_TOKEN_ASSET"), + asset_arg("NO_TOKEN_ASSET"), + asset_arg("YES_REISSUANCE_TOKEN"), + asset_arg("NO_REISSUANCE_TOKEN"), + r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, + r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, + ); + let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); + let source = std::fs::read_to_string(simf).expect("read simf"); + let compiled = CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) + .expect("compile"); + let abi = compiled.generate_abi_meta().expect("abi"); + + eprintln!("---- witness ABI ----"); + for (name, ty) in abi.witness_types.iter() { + eprintln!(" {name}: {ty}"); + } + + // Every PATH literal the manifest uses, in path order. + let sig_literal = format!("0x{}", "ab".repeat(64)); + let burn_literal = format!("0x{}", "01".repeat(32)); + let literals: [(&str, &str, &str); 13] = [ + ("PATH", "Left(Left(Left(())))", "path 1 — initial issuance"), + ("PATH", "Left(Left(Right(())))", "path 2 — subsequent issuance"), + ("PATH", "Left(Right(Left(())))", "path 3 — oracle resolve"), + ("PATH", "Left(Right(Right(())))", "path 4 — post-resolution redemption"), + ("PATH", "Right(Left(Left(())))", "path 5 — expiry redemption"), + ("PATH", "Right(Left(Right(())))", "path 6 — cancellation"), + ("PATH", "Right(Right(()))", "path 7 — secondary covenant input"), + ("STATE", "0", "dormant"), + ("STATE", "3", "resolved-no"), + ("ORACLE_OUTCOME_YES", "true", "YES resolve"), + ("TOKENS_BURNED", "1234", "a redemption amount"), + ( + "ORACLE_SIGNATURE", + &sig_literal, + "a 0x-prefixed 64-byte oracle signature, as ResolveYes/ResolveNo substitute it", + ), + ( + "BURN_TOKEN_ASSET", + &burn_literal, + "a 0x-prefixed asset id in internal byte order, as RedeemExpired substitutes it", + ), + ]; + + let mut paths: Vec> = Vec::new(); + for (name, literal, note) in literals { + let wname = WitnessName::parse_from_str(name).expect("witness name"); + let ty = abi + .witness_types + .get(&wname) + .unwrap_or_else(|| panic!("{name} is not a witness of the compiled program")); + let value = Value::parse_from_str(literal, ty) + .unwrap_or_else(|e| panic!("{name} = {literal} ({note}) does not parse: {e}")); + if name == "PATH" { + let bits = format!("{value:?}").into_bytes(); + assert!( + !paths.contains(&bits), + "two PATH literals encode the same branch — {literal} ({note})" + ); + paths.push(bits); + } + } + println!("witness literals: all 7 PATH branches distinct and well-typed"); +} diff --git a/txmanifest_lib/examples/deadcat_v2_recon.rs b/txmanifest_lib/examples/deadcat_v2_recon.rs new file mode 100644 index 0000000..a6c8b3f --- /dev/null +++ b/txmanifest_lib/examples/deadcat_v2_recon.rs @@ -0,0 +1,151 @@ +// Check what the deadcat_v2 fork did and did not change. +// +// cargo run -p tx-manifest-lib --example deadcat_v2_recon +// +// `deadcat_recon` proves examples/deadcat reproduces upstream Deadcat's addresses +// byte-for-byte. This one is its complement: it proves examples/deadcat_v2 is a *real* +// fork — every covenant address moved and the blinding-factor witnesses are gone — while +// the parts the fork was not meant to touch still hold. +use std::collections::HashMap; + +use lwk_wollet::ElementsNetwork; +use tx_manifest_lib::context::ExecutionContext; +use tx_manifest_lib::covenant; +use tx_manifest_lib::manifest::Manifest; + +/// The eight witnesses the fork exists to remove. +const BLINDING_WITNESSES: [&str; 8] = [ + "YES_REISSUANCE_INPUT_ABF", + "YES_REISSUANCE_INPUT_VBF", + "YES_REISSUANCE_OUTPUT_ABF", + "YES_REISSUANCE_OUTPUT_VBF", + "NO_REISSUANCE_INPUT_ABF", + "NO_REISSUANCE_INPUT_VBF", + "NO_REISSUANCE_OUTPUT_ABF", + "NO_REISSUANCE_OUTPUT_VBF", +]; + +const STATES: [(&str, u64); 4] = [ + ("market_dormant", 0), + ("market_unresolved", 1), + ("market_resolved_yes", 2), + ("market_resolved_no", 3), +]; + +/// Same fixture as `deadcat_recon`: repeated bytes, so the `liquid.asset_id` reversal is a +/// no-op and the two examples are compared on equal terms. +fn fixture() -> (HashMap, HashMap, ExecutionContext) { + let fields: [(&str, String, &str); 8] = [ + ("ORACLE_PUBLIC_KEY", "aa".repeat(32), "pubkey"), + ("COLLATERAL_ASSET_ID", "bb".repeat(32), "liquid.asset_id"), + ("YES_TOKEN_ASSET", "01".repeat(32), "liquid.asset_id"), + ("NO_TOKEN_ASSET", "02".repeat(32), "liquid.asset_id"), + ("YES_REISSUANCE_TOKEN", "03".repeat(32), "liquid.asset_id"), + ("NO_REISSUANCE_TOKEN", "04".repeat(32), "liquid.asset_id"), + ("COLLATERAL_PER_TOKEN", "100000".to_string(), "u64"), + ("EXPIRY_TIME", "1000000".to_string(), "u32"), + ]; + let mut params = HashMap::new(); + let mut hints = HashMap::new(); + let mut ctx = ExecutionContext::new(); + for (name, value, ty) in &fields { + params.insert((*name).to_string(), value.clone()); + hints.insert((*name).to_string(), (*ty).to_string()); + ctx.set_compile_param(*name, value.clone()); + } + (params, hints, ctx) +} + +/// Derive the four per-state scriptPubKeys for one example directory, through that +/// example's own `utxo_type` wiring. +fn addresses(dir: &str) -> Vec { + let dir = std::path::Path::new(dir); + let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); + let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); + let (params, hints, ctx) = fixture(); + let simf = dir.join("prediction_market.simf"); + + STATES + .iter() + .map(|(type_name, _)| { + let ut = manifest.utxo_type(type_name).expect("utxo_type"); + let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); + let addr = covenant::compute_covenant_address( + &simf, + ¶ms, + &hints, + &leaves, + ElementsNetwork::LiquidTestnet, + false, + ) + .expect("covenant address"); + format!("{:x}", addr.script_pubkey()) + }) + .collect() +} + +fn main() { + let v1 = addresses("examples/deadcat"); + let v2 = addresses("examples/deadcat_v2"); + + eprintln!("---- result ----"); + for ((type_name, state), (a, b)) in STATES.iter().zip(v1.iter().zip(v2.iter())) { + println!("state {state} ({type_name})"); + println!(" v1 = {a}"); + println!(" v2 = {b}"); + assert_ne!( + a, b, + "state {state}: v2 must NOT share an address with v1. If these ever match, the \ + fork stopped being a fork and tokens could be parked at an address whose \ + program is not the one this example compiles" + ); + } + assert_eq!( + v2.iter().collect::>().len(), + 4, + "v2's four states must still be four distinct addresses" + ); + + // The point of the fork: nothing blinding-related left in the witness surface. + let names = v2_witness_names(); + println!("\nv2 witnesses ({}): {}", names.len(), names.join(", ")); + for gone in BLINDING_WITNESSES { + assert!( + !names.iter().any(|n| n == gone), + "{gone} is still a witness — the commitment check was not fully removed" + ); + } + for kept in ["STATE", "PATH", "ORACLE_SIGNATURE", "TOKENS_BURNED", "PAIRS_BURNED"] { + assert!(names.iter().any(|n| n == kept), "{kept} must survive the fork"); + } + + println!("\nOK: v2 moved all four addresses and dropped all eight blinding witnesses."); +} + +fn v2_witness_names() -> Vec { + use simplicityhl::ast::ElementsJetHinter; + use simplicityhl::{Arguments, CompiledProgram}; + + let (params, _, _) = fixture(); + let asset_arg = + |name: &str| format!(r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, params[name]); + let args_json = format!( + "{{{}, {}, {}, {}, {}, {}, {}, {}}}", + asset_arg("ORACLE_PUBLIC_KEY"), + asset_arg("COLLATERAL_ASSET_ID"), + asset_arg("YES_TOKEN_ASSET"), + asset_arg("NO_TOKEN_ASSET"), + asset_arg("YES_REISSUANCE_TOKEN"), + asset_arg("NO_REISSUANCE_TOKEN"), + r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, + r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, + ); + let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); + let source = std::fs::read_to_string("examples/deadcat_v2/prediction_market.simf") + .expect("read v2 simf"); + let abi = CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) + .expect("compile v2") + .generate_abi_meta() + .expect("abi"); + abi.witness_types.iter().map(|(n, _)| n.to_string()).collect() +} diff --git a/txmanifest_lib/examples/deadcat_v3_recon.rs b/txmanifest_lib/examples/deadcat_v3_recon.rs new file mode 100644 index 0000000..3648928 --- /dev/null +++ b/txmanifest_lib/examples/deadcat_v3_recon.rs @@ -0,0 +1,166 @@ +// Check what the deadcat_v3 fork did and did not change. +// +// cargo run -p tx-manifest-lib --example deadcat_v3_recon +// +// `deadcat_recon` proves examples/deadcat reproduces upstream Deadcat's addresses +// byte-for-byte. This is its complement for v3: every covenant address moved, the four +// OUTPUT blinding witnesses are gone (they are derived as abf+1 / vbf+1 now), and the four +// INPUT ones remain — those still have to prove the spent token is this market's, and pin +// its amount to 1. +use std::collections::HashMap; + +use lwk_wollet::ElementsNetwork; +use tx_manifest_lib::context::ExecutionContext; +use tx_manifest_lib::covenant; +use tx_manifest_lib::manifest::Manifest; + +/// Removed by the fork: the output factors are now derived, not witnessed. +const REMOVED_WITNESSES: [&str; 4] = [ + "YES_REISSUANCE_OUTPUT_ABF", + "YES_REISSUANCE_OUTPUT_VBF", + "NO_REISSUANCE_OUTPUT_ABF", + "NO_REISSUANCE_OUTPUT_VBF", +]; + +/// Kept by the fork. Dropping these would let anyone plant a blinded UTXO of their own +/// asset at a covenant address and cycle it through the market's paths. +const KEPT_WITNESSES: [&str; 6] = [ + "YES_REISSUANCE_INPUT_ABF", + "YES_REISSUANCE_INPUT_VBF", + "NO_REISSUANCE_INPUT_ABF", + "NO_REISSUANCE_INPUT_VBF", + "STATE", + "PATH", +]; + +const STATES: [(&str, u64); 4] = [ + ("market_dormant", 0), + ("market_unresolved", 1), + ("market_resolved_yes", 2), + ("market_resolved_no", 3), +]; + +/// Same fixture as `deadcat_recon`: repeated bytes, so the `liquid.asset_id` reversal is a +/// no-op and the two examples are compared on equal terms. +fn fixture() -> (HashMap, HashMap, ExecutionContext) { + let fields: [(&str, String, &str); 8] = [ + ("ORACLE_PUBLIC_KEY", "aa".repeat(32), "pubkey"), + ("COLLATERAL_ASSET_ID", "bb".repeat(32), "liquid.asset_id"), + ("YES_TOKEN_ASSET", "01".repeat(32), "liquid.asset_id"), + ("NO_TOKEN_ASSET", "02".repeat(32), "liquid.asset_id"), + ("YES_REISSUANCE_TOKEN", "03".repeat(32), "liquid.asset_id"), + ("NO_REISSUANCE_TOKEN", "04".repeat(32), "liquid.asset_id"), + ("COLLATERAL_PER_TOKEN", "100000".to_string(), "u64"), + ("EXPIRY_TIME", "1000000".to_string(), "u32"), + ]; + let mut params = HashMap::new(); + let mut hints = HashMap::new(); + let mut ctx = ExecutionContext::new(); + for (name, value, ty) in &fields { + params.insert((*name).to_string(), value.clone()); + hints.insert((*name).to_string(), (*ty).to_string()); + ctx.set_compile_param(*name, value.clone()); + } + (params, hints, ctx) +} + +/// Derive the four per-state scriptPubKeys for one example directory, through that +/// example's own `utxo_type` wiring. +fn addresses(dir: &str) -> Vec { + let dir = std::path::Path::new(dir); + let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); + let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); + let (params, hints, ctx) = fixture(); + let simf = dir.join("prediction_market.simf"); + + STATES + .iter() + .map(|(type_name, _)| { + let ut = manifest.utxo_type(type_name).expect("utxo_type"); + let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); + let addr = covenant::compute_covenant_address( + &simf, + ¶ms, + &hints, + &leaves, + ElementsNetwork::LiquidTestnet, + false, + ) + .expect("covenant address"); + format!("{:x}", addr.script_pubkey()) + }) + .collect() +} + +fn main() { + let v1 = addresses("examples/deadcat"); + let v3 = addresses("examples/deadcat_v3"); + + eprintln!("---- result ----"); + for ((type_name, state), (a, b)) in STATES.iter().zip(v1.iter().zip(v3.iter())) { + println!("state {state} ({type_name})"); + println!(" v1 = {a}"); + println!(" v3 = {b}"); + assert_ne!( + a, b, + "state {state}: v3 must NOT share an address with v1. If these ever match, the \ + fork stopped being a fork and tokens could be parked at an address whose \ + program is not the one this example compiles" + ); + } + assert_eq!( + v3.iter().collect::>().len(), + 4, + "v3's four states must still be four distinct addresses" + ); + + // The point of the fork: the OUTPUT factors are derived, so they stop being witnesses, + // while the INPUT ones stay — they are what binds the spent token to this market. + let names = v3_witness_names(); + println!("\nv3 witnesses ({}): {}", names.len(), names.join(", ")); + for gone in REMOVED_WITNESSES { + assert!( + !names.iter().any(|n| n == gone), + "{gone} is still a witness — the output check was not converted to the shifted form" + ); + } + for kept in KEPT_WITNESSES { + assert!(names.iter().any(|n| n == kept), "{kept} must survive the fork"); + } + for kept in ["ORACLE_SIGNATURE", "TOKENS_BURNED", "PAIRS_BURNED"] { + assert!(names.iter().any(|n| n == kept), "{kept} must survive the fork"); + } + + println!( + "\nOK: v3 moved all four addresses, dropped the 4 output blinding witnesses, \ + and kept the 4 input ones." + ); +} + +fn v3_witness_names() -> Vec { + use simplicityhl::ast::ElementsJetHinter; + use simplicityhl::{Arguments, CompiledProgram}; + + let (params, _, _) = fixture(); + let asset_arg = + |name: &str| format!(r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, params[name]); + let args_json = format!( + "{{{}, {}, {}, {}, {}, {}, {}, {}}}", + asset_arg("ORACLE_PUBLIC_KEY"), + asset_arg("COLLATERAL_ASSET_ID"), + asset_arg("YES_TOKEN_ASSET"), + asset_arg("NO_TOKEN_ASSET"), + asset_arg("YES_REISSUANCE_TOKEN"), + asset_arg("NO_REISSUANCE_TOKEN"), + r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, + r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, + ); + let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); + let source = std::fs::read_to_string("examples/deadcat_v3/prediction_market.simf") + .expect("read v3 simf"); + let abi = CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) + .expect("compile v3") + .generate_abi_meta() + .expect("abi"); + abi.witness_types.iter().map(|(n, _)| n.to_string()).collect() +} diff --git a/txmanifest_lib/examples/pset_balance.rs b/txmanifest_lib/examples/pset_balance.rs new file mode 100644 index 0000000..561d6b3 --- /dev/null +++ b/txmanifest_lib/examples/pset_balance.rs @@ -0,0 +1,382 @@ +// Dump a saved PSET's per-asset balance, to find out which asset does not add up. +// +// cargo run -p tx-manifest-lib --example pset_balance -- +// +// Elements rejects an unbalanced transaction with `bad-txns-in-ne-out`, which names no +// asset and no index. This prints every leg the PSET knows about — inputs, issuances, +// outputs — grouped by asset, so the missing or surplus amount is visible directly. +// Confidential legs are reported as such rather than guessed at. +use std::collections::BTreeMap; + +use lwk_wollet::elements::encode::Decodable; +use lwk_wollet::elements::pset::PartiallySignedTransaction; +use lwk_wollet::elements::AssetId; + +#[derive(Default)] +struct Leg { + inputs: u64, + issued: u64, + outputs: u64, + blinded_in: usize, + blinded_out: usize, +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: pset_balance "); + let hex = std::fs::read_to_string(&path).expect("read pset").trim().to_string(); + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex")) + .collect(); + let pset = PartiallySignedTransaction::consensus_decode(&bytes[..]).expect("decode pset"); + + let mut legs: BTreeMap = BTreeMap::new(); + let mut unknown_in = 0usize; + + println!("== inputs =="); + for (i, inp) in pset.inputs().iter().enumerate() { + match inp.witness_utxo.as_ref() { + Some(u) => println!( + " witness_utxo #{i}: asset={} value={}", + short(&format!("{:?}", u.asset)), + short(&format!("{:?}", u.value)) + ), + None => println!(" witness_utxo #{i}: MISSING"), + } + } + for (i, inp) in pset.inputs().iter().enumerate() { + let utxo = inp.witness_utxo.as_ref(); + let asset = inp.asset.map(|a| a.to_string()).or_else(|| { + utxo.and_then(|u| match u.asset { + lwk_wollet::elements::confidential::Asset::Explicit(a) => Some(a.to_string()), + _ => None, + }) + }); + let amount = inp.amount.or_else(|| { + utxo.and_then(|u| match u.value { + lwk_wollet::elements::confidential::Value::Explicit(v) => Some(v), + _ => None, + }) + }); + match (&asset, amount) { + (Some(a), Some(v)) => { + legs.entry(a.clone()).or_default().inputs += v; + println!(" #{i} {v:>12} {a}"); + } + _ => { + unknown_in += 1; + println!( + " #{i} {:>12} {}", + "confidential", + asset.clone().unwrap_or_else(|| "?".into()) + ); + if let Some(a) = asset { + legs.entry(a).or_default().blinded_in += 1; + } + } + } + + // Issuance legs. `issuance_value_amount` mints the asset; `issuance_inflation_keys` + // mints reissuance tokens. Both add to the input side of the balance. + let (asset_id, token_id) = inp.issuance_ids(); + if let Some(v) = inp.issuance_value_amount { + legs.entry(asset_id.to_string()).or_default().issued += v; + let kind = if inp.issuance_blinding_nonce.is_some() { "reissue" } else { "new" }; + println!(" + issuance ({kind}) {v:>10} {asset_id}"); + } + if let Some(v) = inp.issuance_inflation_keys { + legs.entry(token_id.to_string()).or_default().issued += v; + println!(" + inflation keys {v:>10} {token_id}"); + } + } + + println!("\n== outputs =="); + for (i, out) in pset.outputs().iter().enumerate() { + let asset = out.asset.map(|a: AssetId| a.to_string()); + match (&asset, out.amount) { + (Some(a), Some(v)) => { + legs.entry(a.clone()).or_default().outputs += v; + let kind = if out.script_pubkey.is_empty() { " (fee)" } else { "" }; + println!(" #{i} {v:>12} {a}{kind}"); + } + _ => { + println!(" #{i} {:>12} {}", "confidential", asset.clone().unwrap_or_else(|| "?".into())); + if let Some(a) = asset { + legs.entry(a).or_default().blinded_out += 1; + } + } + } + } + + println!("\n== balance by asset =="); + println!( + " {:<66} {:>12} {:>10} {:>12} {:>10}", + "asset", "in", "issued", "out", "delta" + ); + let mut any_bad = false; + for (asset, leg) in &legs { + let lhs = leg.inputs + leg.issued; + let delta = lhs as i128 - leg.outputs as i128; + let blinded = leg.blinded_in > 0 || leg.blinded_out > 0; + let note = if blinded { + format!(" <- {} blinded leg(s), delta not meaningful", leg.blinded_in + leg.blinded_out) + } else if delta != 0 { + any_bad = true; + " <== DOES NOT BALANCE".to_string() + } else { + String::new() + }; + println!( + " {:<66} {:>12} {:>10} {:>12} {:>10}{}", + asset, leg.inputs, leg.issued, leg.outputs, delta, note + ); + } + if unknown_in > 0 { + println!("\n ({unknown_in} input(s) had no explicit amount in the PSET)"); + } + if !any_bad { + println!("\n every fully-explicit asset balances; the imbalance is on a blinded leg"); + } + + // The PSET balancing is necessary but not sufficient: what consensus sees is the + // EXTRACTED transaction. If finalization dropped or altered the issuance legs, the + // PSET can add up while the broadcast transaction does not. + if let Some(tx_path) = std::env::args().nth(2) { + println!("\n== extracted transaction =="); + let hex = std::fs::read_to_string(&tx_path).expect("read tx").trim().to_string(); + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex")) + .collect(); + let tx = lwk_wollet::elements::Transaction::consensus_decode(&bytes[..]).expect("decode tx"); + for (i, inp) in tx.input.iter().enumerate() { + let iss = &inp.asset_issuance; + if iss.is_null() { + println!(" in #{i}: no issuance"); + } else { + println!( + " in #{i}: issuance amount={:?} inflation={:?} entropy={} nonce={}", + iss.amount, + iss.inflation_keys, + hex_le(&iss.asset_entropy), + hex_le(iss.asset_blinding_nonce.as_ref()), + ); + } + } + for (i, out) in tx.output.iter().enumerate() { + println!( + " out #{i}: asset={:?} value={:?}", + short(&format!("{:?}", out.asset)), + short(&format!("{:?}", out.value)) + ); + } + + verify_commitment_balance(&pset, &tx); + verify_reissuances(&pset, &tx); + verify_surjections(&pset, &tx); + } +} + +/// Verify each confidential output's surjection proof against the domain consensus builds: +/// every input's asset generator, plus a generator for each issuance leg, in input order. +/// +/// `VerifyAmounts` reports a surjection failure under the same `bad-txns-in-ne-out` string +/// as an arithmetic imbalance, so this is the remaining way to tell them apart. +fn verify_surjections( + pset: &PartiallySignedTransaction, + tx: &lwk_wollet::elements::Transaction, +) { + use lwk_wollet::elements::confidential::{Asset, Value}; + use lwk_wollet::elements::secp256k1_zkp::{Generator, Secp256k1, Tag}; + + let secp = Secp256k1::new(); + let unblinded = |a: lwk_wollet::elements::AssetId| { + Generator::new_unblinded(&secp, Tag::from(a.into_inner().to_byte_array())) + }; + + let mut domain: Vec = Vec::new(); + for (i, inp) in pset.inputs().iter().enumerate() { + if let Some(utxo) = inp.witness_utxo.as_ref() { + match utxo.asset { + Asset::Explicit(a) => domain.push(unblinded(a)), + Asset::Confidential(g) => domain.push(g), + _ => {} + } + } + let (asset_id, token_id) = inp.issuance_ids(); + let iss = &tx.input[i].asset_issuance; + if !matches!(iss.amount, Value::Null) { + domain.push(unblinded(asset_id)); + } + if !matches!(iss.inflation_keys, Value::Null) { + domain.push(unblinded(token_id)); + } + } + + println!("\n== surjection proofs =="); + println!(" domain: {} generator(s)", domain.len()); + for (i, out) in tx.output.iter().enumerate() { + let Asset::Confidential(out_gen) = out.asset else { continue }; + println!( + " out #{i}: rangeproof {} bytes, surjection {} bytes", + out.witness.rangeproof.as_ref().map(|p| p.serialize().len()).unwrap_or(0), + out.witness.surjection_proof.as_ref().map(|p| p.serialize().len()).unwrap_or(0), + ); + match &out.witness.surjection_proof { + None => println!(" out #{i}: CONFIDENTIAL BUT NO SURJECTION PROOF"), + Some(proof) => { + let ok = proof.verify(&secp, out_gen, &domain); + println!( + " out #{i}: surjection proof {}", + if ok { "verifies" } else { "FAILS <== this is the rejection" } + ); + } + } + } +} + +/// For every reissuance, check the two things consensus checks: that the entropy produces +/// the asset the transaction claims to be minting, and that the UTXO being spent really is +/// that entropy's reissuance token. +/// +/// The second is the one worth testing. The token id depends on whether the ORIGINAL +/// issuance blinded its amounts (`CalculateReissuanceToken(entropy, fBlinded)`), and this +/// engine hardcodes `false` when it derives the token. If the chain disagrees, the input is +/// simply not the token for this entropy and the reissuance is invalid. +fn verify_reissuances( + pset: &PartiallySignedTransaction, + tx: &lwk_wollet::elements::Transaction, +) { + use lwk_wollet::elements::confidential::Asset; + use lwk_wollet::elements::hashes::sha256; + use lwk_wollet::elements::AssetId; + + println!("\n== reissuance validity =="); + for (i, inp) in pset.inputs().iter().enumerate() { + let iss = &tx.input[i].asset_issuance; + if iss.is_null() || iss.asset_blinding_nonce.as_ref().iter().all(|b| *b == 0) { + continue; // not a reissuance + } + let entropy = sha256::Midstate::from_byte_array(iss.asset_entropy); + let derived_asset = AssetId::from_entropy(entropy); + + let spent = inp.witness_utxo.as_ref().map(|u| u.asset); + let spent_explicit = match spent { + Some(Asset::Explicit(a)) => Some(a), + _ => None, + }; + + println!(" in #{i}"); + println!(" entropy {}", hex_le(&iss.asset_entropy)); + println!(" derived asset {derived_asset}"); + for confidential in [false, true] { + let token = AssetId::reissuance_token_from_entropy(entropy, confidential); + let hit = spent_explicit.map(|a| a == token).unwrap_or(false); + println!( + " token (blinded={confidential:<5}) {token}{}", + if hit { " <== matches the UTXO being spent" } else { "" } + ); + } + match spent_explicit { + Some(a) => { + let ok = (0..2).any(|c| AssetId::reissuance_token_from_entropy(entropy, c == 1) == a); + println!(" spending {a}"); + if !ok { + println!( + " ^^ this UTXO is NOT the reissuance token for that entropy — the \ + reissuance is invalid regardless of how the amounts balance" + ); + } + } + None => println!(" spending (confidential asset)"), + } + } +} + +/// Check the Pedersen balance the way consensus does: sum(input commitments) + +/// sum(issuance commitments) == sum(output commitments). +/// +/// This is what separates the two candidate causes. If the commitments balance, the +/// transaction is arithmetically fine and Elements is rejecting it for an issuance-validity +/// reason (which it reports under the same `bad-txns-in-ne-out` string). If they do not, +/// the blinder produced the wrong final blinding factor. +fn verify_commitment_balance( + pset: &PartiallySignedTransaction, + tx: &lwk_wollet::elements::Transaction, +) { + use lwk_wollet::elements::confidential::{Asset, Value}; + use lwk_wollet::elements::secp256k1_zkp::{ + self, Generator, PedersenCommitment, Secp256k1, Tag, + }; + + let secp = Secp256k1::new(); + let gen_for = |asset: &Asset| -> Option { + match asset { + Asset::Explicit(a) => { + Some(Generator::new_unblinded(&secp, Tag::from(a.into_inner().to_byte_array()))) + } + Asset::Confidential(g) => Some(*g), + _ => None, + } + }; + let commit = |value: &Value, gen: Generator| -> Option { + match value { + Value::Explicit(v) => Some(PedersenCommitment::new_unblinded(&secp, *v, gen)), + Value::Confidential(c) => Some(*c), + _ => None, + } + }; + + let mut lhs: Vec = Vec::new(); + let mut rhs: Vec = Vec::new(); + let mut skipped = 0; + + for (i, inp) in pset.inputs().iter().enumerate() { + let Some(utxo) = inp.witness_utxo.as_ref() else { skipped += 1; continue }; + match gen_for(&utxo.asset).and_then(|g| commit(&utxo.value, g)) { + Some(c) => lhs.push(c), + None => skipped += 1, + } + // Explicit issuance legs enter the input side as value * H_asset, unblinded. + let (asset_id, token_id) = inp.issuance_ids(); + let tx_iss = &tx.input[i].asset_issuance; + if let Value::Explicit(v) = tx_iss.amount { + let g = Generator::new_unblinded(&secp, Tag::from(asset_id.into_inner().to_byte_array())); + lhs.push(PedersenCommitment::new_unblinded(&secp, v, g)); + } + if let Value::Explicit(v) = tx_iss.inflation_keys { + let g = Generator::new_unblinded(&secp, Tag::from(token_id.into_inner().to_byte_array())); + lhs.push(PedersenCommitment::new_unblinded(&secp, v, g)); + } + } + + for out in &tx.output { + match gen_for(&out.asset).and_then(|g| commit(&out.value, g)) { + Some(c) => rhs.push(c), + None => skipped += 1, + } + } + + let ok = secp256k1_zkp::verify_commitments_sum_to_equal(&secp, &lhs, &rhs); + println!( + "\n== pedersen balance ==\n {} input+issuance commitments vs {} output commitments{}", + lhs.len(), + rhs.len(), + if skipped > 0 { format!(" ({skipped} leg(s) skipped)") } else { String::new() } + ); + if ok { + println!(" BALANCES — the commitments sum to equal, so the arithmetic is fine and"); + println!(" Elements is rejecting this for an issuance-validity reason instead."); + } else { + println!(" DOES NOT BALANCE — the blinder produced a final blinding factor that does"); + println!(" not account for every leg. This is the cause of bad-txns-in-ne-out."); + } +} + +fn hex_le(b: &[u8; 32]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn short(s: &str) -> String { + if s.len() > 44 { format!("{}…", &s[..44]) } else { s.to_string() } +} diff --git a/txmanifest_lib/src/backend.rs b/txmanifest_lib/src/backend.rs index 5c333ce..f8216af 100644 --- a/txmanifest_lib/src/backend.rs +++ b/txmanifest_lib/src/backend.rs @@ -8,7 +8,8 @@ use anyhow::{anyhow, Result}; use lwk_wollet::blocking::BlockchainBackend; -use lwk_wollet::elements::{Transaction, Txid}; +use lwk_wollet::elements::confidential::{Asset, Value}; +use lwk_wollet::elements::{AssetId, Transaction, Txid}; use lwk_wollet::{blocking, ElectrumClient, ElectrumUrl, ElementsNetwork, Update, Wollet}; /// Which chain backend to connect to. @@ -75,6 +76,38 @@ impl Backend { .map_err(|e| anyhow!("Sync failed: {e}")) } + /// Read one on-chain output's explicit amount and asset. + /// + /// Returns `None` when the output is **confidential**: its value and asset are + /// commitments, and unblinding them needs a key this engine does not hold for a + /// covenant UTXO. Covenant outputs are explicit by construction (that is what lets a + /// Simplicity program introspect them), so in practice this resolves. + /// + /// The alternative — trusting the manifest or a `--input` flag for the amount — means + /// the value the sighash commits to is whatever an operator typed, and a mistyped one + /// produces a signature that is simply invalid against the real UTXO. + pub fn fetch_explicit_txout(&self, txid: Txid, vout: u32) -> Result> { + let txs = match self { + Backend::Esplora(c) => c.get_transactions(&[txid]), + Backend::Electrum(c) => c.get_transactions(&[txid]), + } + .map_err(|e| anyhow!("Cannot fetch transaction {txid}: {e}"))?; + + let tx = txs + .into_iter() + .next() + .ok_or_else(|| anyhow!("Transaction {txid} not found on chain"))?; + let out = tx + .output + .get(vout as usize) + .ok_or_else(|| anyhow!("{txid} has no output at index {vout}"))?; + + Ok(match (out.value, out.asset) { + (Value::Explicit(value), Asset::Explicit(asset)) => Some((value, asset)), + _ => None, + }) + } + /// Broadcast a finalized transaction, returning its txid. pub fn broadcast(&self, tx: &Transaction) -> Result { match self { diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 90fd508..d21121b 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -13,7 +13,9 @@ use simplicityhl::ast::ElementsJetHinter; use simplicityhl::simplicity::bit_machine::{ExecTracker, FrameIter, NodeOutput}; use simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use simplicityhl::simplicity::BitMachine; -use simplicityhl::{simplicity, Arguments, CompiledProgram, WitnessTypes, WitnessValues}; +use simplicityhl::{ + simplicity, Arguments, CompiledProgram, UnstableFeatures, WitnessTypes, WitnessValues, +}; /// Signs `(key_label, kind, sighash)` and returns a 64-byte Schnorr signature. type SigSigner = dyn Fn(&str, &str, &[u8; 32]) -> Result<[u8; 64]>; @@ -30,6 +32,67 @@ const NUMS_KEY_BYTES: [u8; 32] = [ 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, ]; +/// How a `.simf` program is compiled — the manifest's `simplicity_hl` block reduced to +/// what the compiler actually takes. Build it with +/// [`Manifest::compile_opts`](crate::manifest::Manifest::compile_opts). +/// +/// Every helper in this module takes `impl Into`, so callers that predate +/// unstable features (recon examples, tests) can keep passing a bare `bool` for debug +/// symbols and get "no unstable features" — the same thing the compiler defaults to. +#[derive(Debug, Clone)] +pub struct CompileOpts { + /// Include SimplicityHL debug symbols. Changes the CMR, and therefore every covenant + /// address; see [`SimplicityHl::debug_symbols`](crate::manifest::SimplicityHl::debug_symbols). + pub debug_symbols: bool, + /// Unstable compiler features the program may use (`simc -Z `). Purely a gate: + /// enabling a feature never changes generated code, so it never moves an address. + pub unstable_features: UnstableFeatures, +} + +impl Default for CompileOpts { + fn default() -> Self { + Self { + debug_symbols: false, + unstable_features: UnstableFeatures::none(), + } + } +} + +impl From for CompileOpts { + fn from(debug_symbols: bool) -> Self { + Self { + debug_symbols, + ..Self::default() + } + } +} + +impl From<&CompileOpts> for CompileOpts { + fn from(opts: &CompileOpts) -> Self { + opts.clone() + } +} + +/// The single place this crate hands a program to the SimplicityHL compiler. +/// +/// Every covenant address, CMR and dry-run in this crate goes through here, so the +/// toolchain settings cannot be applied on some paths and forgotten on others — which +/// for `debug_symbols` would mean deriving two different addresses for one covenant. +fn compile_program( + source: String, + arguments: Arguments, + opts: &CompileOpts, +) -> Result { + CompiledProgram::new_with_unstable( + source, + &opts.unstable_features, + arguments, + opts.debug_symbols, + Box::new(ElementsJetHinter::new()), + ) + .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}")) +} + /// Compile a `.simf` file and return the Simplicity tapleaf hash (32 bytes, natural byte order). /// /// This is an intermediate taproot value (TapLeafHash). To get the value that the Simplicity @@ -39,16 +102,15 @@ pub fn compute_tapleaf_hash( simf_path: &Path, compile_params: &HashMap, type_hints: &HashMap, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<[u8; 32]> { + let opts = opts.into(); let source = std::fs::read_to_string(simf_path) .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let compiled = - CompiledProgram::new(source, arguments, include_debug_symbols, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}"))?; + let compiled = compile_program(source, arguments, &opts)?; let commit = compiled.commit(); let cmr = commit.cmr(); let leaf_ver = simplicity_leaf_version(); @@ -67,9 +129,9 @@ pub fn compute_covenant_script_hash( compile_params: &HashMap, type_hints: &HashMap, network: lwk_wollet::ElementsNetwork, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<[u8; 32]> { - compute_covenant_script_hash_with_leaves(simf_path, compile_params, type_hints, &[], network, include_debug_symbols) + compute_covenant_script_hash_with_leaves(simf_path, compile_params, type_hints, &[], network, opts) } /// Like [`compute_covenant_script_hash`] but folds `extra_leaf_payloads` (taproot storage @@ -82,9 +144,9 @@ pub fn compute_covenant_script_hash_with_leaves( type_hints: &HashMap, extra_leaf_payloads: &[Vec], network: lwk_wollet::ElementsNetwork, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<[u8; 32]> { - let addr = compute_covenant_address(simf_path, compile_params, type_hints, extra_leaf_payloads, network, include_debug_symbols)?; + let addr = compute_covenant_address(simf_path, compile_params, type_hints, extra_leaf_payloads, network, opts)?; let spk = addr.script_pubkey(); Ok(sha256::Hash::hash(spk.as_bytes()).to_byte_array()) } @@ -96,15 +158,15 @@ pub fn check_compile( simf_path: &Path, compile_params: &HashMap, type_hints: &HashMap, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<()> { + let opts = opts.into(); let source = std::fs::read_to_string(simf_path) .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - CompiledProgram::new(source, arguments, include_debug_symbols, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}"))?; + compile_program(source, arguments, &opts)?; Ok(()) } @@ -120,14 +182,20 @@ pub fn compile_simf_function( fn_name: Option<&str>, compile_params: &HashMap, type_hints: &HashMap, + opts: impl Into, ) -> Result { + let opts = opts.into(); let source = std::fs::read_to_string(simf_path) .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let template = simplicityhl::TemplateProgram::new(source, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL parse/analyse failed: {e}"))?; + let template = simplicityhl::TemplateProgram::new_with_unstable( + source, + &opts.unstable_features, + Box::new(ElementsJetHinter::new()), + ) + .map_err(|e| anyhow::anyhow!("SimplicityHL parse/analyse failed: {e}"))?; template .compile_function(fn_name, arguments) .map_err(|e| anyhow::anyhow!("SimplicityHL function compile failed: {e}")) @@ -149,14 +217,20 @@ pub fn execute_simf_function( compile_params: &HashMap, type_hints: &HashMap, input_hex: &str, + opts: impl Into, ) -> Result { + let opts = opts.into(); let source = std::fs::read_to_string(simf_path) .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let template = simplicityhl::TemplateProgram::new(source, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL parse/analyse failed: {e}"))?; + let template = simplicityhl::TemplateProgram::new_with_unstable( + source, + &opts.unstable_features, + Box::new(ElementsJetHinter::new()), + ) + .map_err(|e| anyhow::anyhow!("SimplicityHL parse/analyse failed: {e}"))?; // Compile first to get the source type for input parsing. let compiled = template @@ -186,6 +260,7 @@ pub fn execute_simf_function( _compile_params: &HashMap, _type_hints: &HashMap, _input_hex: &str, + _opts: impl Into, ) -> Result { anyhow::bail!( "The `simf_fn` compute hook requires the `simplicity_eval` feature \ @@ -274,8 +349,9 @@ pub fn dry_run_covenant( input_index: u32, genesis_hash: BlockHash, debug_jets: bool, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<()> { + let opts = opts.into(); // Debug: all prints go to stdout so they interleave correctly with lifecycle output. use std::io::Write as _; let stdout = std::io::stdout(); @@ -344,9 +420,7 @@ pub fn dry_run_covenant( let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let compiled = - CompiledProgram::new(source, arguments, include_debug_symbols, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}"))?; + let compiled = compile_program(source, arguments, &opts)?; let abi_meta = compiled .generate_abi_meta() .map_err(|e| anyhow::anyhow!("Cannot get ABI metadata: {e}"))?; @@ -580,17 +654,16 @@ pub fn finalize_covenant_input( input_index: u32, genesis_hash: BlockHash, pset_input: &mut lwk_wollet::elements::pset::Input, - include_debug_symbols: bool, + opts: impl Into, ) -> Result<()> { + let opts = opts.into(); // Compile let source = std::fs::read_to_string(simf_path) .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; let args_json = build_args_json(compile_params, type_hints)?; let arguments: Arguments = serde_json::from_str(&args_json) .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let compiled = - CompiledProgram::new(source, arguments, include_debug_symbols, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}"))?; + let compiled = compile_program(source, arguments, &opts)?; let abi_meta = compiled .generate_abi_meta() .map_err(|e| anyhow::anyhow!("Cannot get ABI metadata: {e}"))?; @@ -688,8 +761,9 @@ pub fn compute_covenant_address( type_hints: &HashMap, extra_leaf_payloads: &[Vec], network: lwk_wollet::ElementsNetwork, - include_debug_symbols: bool, + opts: impl Into, ) -> Result
{ + let opts = opts.into(); eprintln!( "[covenant] compute_covenant_address: {} extra leaf(s), simf={}", extra_leaf_payloads.len(), @@ -721,9 +795,7 @@ pub fn compute_covenant_address( .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; eprintln!("[covenant] simf source loaded ({} bytes)", source.len()); - let compiled = - CompiledProgram::new(source, arguments, include_debug_symbols, Box::new(ElementsJetHinter::new())) - .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}"))?; + let compiled = compile_program(source, arguments, &opts)?; eprintln!("[covenant] SimplicityHL compilation OK"); // Get CMR; tapscript leaf = CMR (32 bytes) as required by Elements Simplicity validator @@ -782,7 +854,6 @@ fn build_witness_values_from_types( ) -> Result { use simplicityhl::parse::ParseFromStr as _; use simplicityhl::str::WitnessName; - use simplicityhl::value::Value; let obj = witnesses.and_then(|v| v.as_object()); @@ -803,9 +874,7 @@ fn build_witness_values_from_types( let Some(ty) = witness_types.get(&witness_name) else { continue; }; - let value = Value::parse_from_str(value_str, ty).map_err(|e| { - anyhow::anyhow!("Cannot parse witness '{name}' = '{value_str}': {e}") - })?; + let value = parse_witness_value(name, value_str, ty)?; map.insert(witness_name, value); } } @@ -822,6 +891,63 @@ fn build_witness_values_from_types( Ok(WitnessValues::from(map)) } +/// Parse one witness value against the type the compiled program declares for it, +/// accepting an unprefixed hex literal for the integer types. +/// +/// SimplicityHL literals need `0x`; without it a hash reads as an *identifier* and fails +/// with `Variable \`ea0b…\` is not defined`, which says nothing about the real problem. +/// Meanwhile every hash this crate produces — `script_hash_of_address`, `committed_output`, +/// a tapleaf compute — is bare hex, and `build_args_json` already normalizes exactly this +/// for compile params. Witnesses were the one path left where the same value had to be +/// spelled differently. +/// +/// The prefix is only tried **after** a plain parse fails, and only when the string is +/// hex of exactly the type's width. That ordering is what keeps it safe: `"100"` for a +/// u64 parses as decimal 100 and is never reinterpreted as 0x100. +fn parse_witness_value( + name: &str, + value_str: &str, + ty: &simplicityhl::ResolvedType, +) -> Result { + use simplicityhl::value::Value; + + let first_error = match Value::parse_from_str(value_str, ty) { + Ok(value) => return Ok(value), + Err(e) => e, + }; + + if let Some(hex_digits) = uint_hex_width(ty) { + let bare = value_str.trim(); + if bare.len() == hex_digits && bare.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(value) = Value::parse_from_str(&format!("0x{bare}"), ty) { + return Ok(value); + } + } + } + + Err(anyhow::anyhow!( + "Cannot parse witness '{name}' = '{value_str}': {first_error}" + )) +} + +/// Hex digits in a full-width literal of an unsigned integer type, or `None` for any +/// other type (a tuple, an enum, `Either` — where a bare hex string is meaningless). +fn uint_hex_width(ty: &simplicityhl::ResolvedType) -> Option { + use simplicityhl::types::{TypeInner, UIntType}; + + match ty.as_inner() { + TypeInner::UInt(uint_ty) => Some(match uint_ty { + UIntType::U1 | UIntType::U2 | UIntType::U4 | UIntType::U8 => 2, + UIntType::U16 => 4, + UIntType::U32 => 8, + UIntType::U64 => 16, + UIntType::U128 => 32, + UIntType::U256 => 64, + }), + _ => None, + } +} + /// Produce a structurally-valid zero/default value for a SimplicityHL `ResolvedType`. /// Used to satisfy `populate_witnesses` for witnesses on pruned branches. fn zero_value_for_type(ty: &simplicityhl::ResolvedType) -> simplicityhl::Value { @@ -1017,6 +1143,91 @@ fn network_to_params(network: lwk_wollet::ElementsNetwork) -> &'static AddressPa mod tests { use super::*; + /// A witness may be written as bare hex — but a decimal must never become hex. + /// + /// Every hash this crate hands to a manifest is unprefixed (`script_hash_of_address`, + /// `committed_output`), and compile params already accept that spelling, so a witness + /// carrying one used to die with `Variable \`ea0b…\` is not defined` — an error about + /// an undefined variable, for a value that was never meant to be a name. + /// + /// The second half is the one that could do damage: `"100"` is legal hex *and* legal + /// decimal, and reading it as `0x100` would silently substitute 256 for 100 in a + /// covenant. The prefix is only ever tried after a plain parse fails, which is what + /// makes that impossible. + #[test] + fn witness_values_accept_bare_hex_without_reinterpreting_decimals() { + use simplicityhl::types::{ResolvedType, TypeConstructible as _, UIntType}; + use simplicityhl::value::Value; + + let u256 = ResolvedType::from(UIntType::U256); + let hash = "ea0b31e6e0b85a6bcca7130df0ee49958670920378e5e31370ad885a157cfd46"; + let parsed = parse_witness_value("DEST_ADDR_SCRIPT_HASH", hash, &u256) + .expect("bare hex hash should parse"); + assert_eq!( + parsed, + Value::parse_from_str(&format!("0x{hash}"), &u256).unwrap(), + "bare hex must mean exactly what the 0x form means" + ); + // The explicit spelling keeps working. + assert_eq!( + parse_witness_value("W", &format!("0x{hash}"), &u256).unwrap(), + parsed + ); + + // The dangerous case: for a u8, "12" is both legal decimal and a legal 2-digit + // hex literal. It parses as decimal on the first attempt, so the retry never + // runs and the value stays 12 — not 0x12, which is 18. + let u8_ty = ResolvedType::from(UIntType::U8); + assert_eq!( + parse_witness_value("W", "12", &u8_ty).unwrap(), + Value::parse_from_str("12", &u8_ty).unwrap() + ); + assert_ne!( + parse_witness_value("W", "12", &u8_ty).unwrap(), + Value::parse_from_str("0x12", &u8_ty).unwrap() + ); + + // Hex of the wrong width is not silently padded, and the error still names the + // witness and its value rather than the retry. + let err = parse_witness_value("W", "ea0b31", &u256).unwrap_err(); + assert!(err.to_string().contains('W') && err.to_string().contains("ea0b31"), "{err}"); + + // Non-integer types never get the retry: a name is a name there. + let err = parse_witness_value("ACTION", "deadbeef", &ResolvedType::unit()).unwrap_err(); + assert!(err.to_string().contains("ACTION"), "{err}"); + } + + /// End-to-end proof that `simplicity_hl.unstable_features` reaches the compiler. + /// + /// Both directions matter. Compiling *with* the feature shows the manifest can enable + /// it at all; compiling *without* must still fail, or the manifest key would be + /// decorative — the gate silently open for every program in the repo. + /// + /// Manifest and program are both fixtures of this crate rather than an example under + /// `examples/`: a test about feature plumbing should not fail because an example was + /// edited, and should not stop working if one is ever moved or dropped. + #[test] + fn unstable_features_gate_a_program_that_uses_enums() { + let manifest = crate::manifest::Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "simplicity_hl": { "unstable_features": ["enums"] } }"#, + ) + .expect("manifest should parse"); + let simf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/unstable_enums.simf"); + let (params, hints) = (HashMap::new(), HashMap::new()); + + check_compile(&simf_path, ¶ms, &hints, manifest.compile_opts()) + .expect("the fixture should compile with the manifest's unstable features"); + + let err = check_compile(&simf_path, ¶ms, &hints, CompileOpts::default()) + .expect_err("gated syntax must not compile with no unstable features enabled"); + assert!( + err.to_string().contains("enums"), + "error should name the missing feature: {err}" + ); + } + /// The adapted last-will book example must compile with the three pubkey params /// wired in. Guards the tutorial's `.simf` against compiler/syntax drift. #[test] @@ -1576,3 +1787,4 @@ mod tests { ); } } + diff --git a/txmanifest_lib/src/eval.rs b/txmanifest_lib/src/eval.rs index 2750953..49a2c2e 100644 --- a/txmanifest_lib/src/eval.rs +++ b/txmanifest_lib/src/eval.rs @@ -151,11 +151,59 @@ pub fn encode_leaf_value( ) -> Result> { let value_ref = item.get("value").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("taproot leaf value item needs 'value': {item}"))?; - let resolved = resolve_ref(value_ref, ctx) - .unwrap_or_else(|| value_ref.trim_matches(['"', '\'']).to_string()); + let resolved = match resolve_ref(value_ref, ctx) { + Some(resolved) => resolved, + // An explicitly namespaced reference that does not resolve is a *missing value*, + // never a literal. Falling through to the literal made the encoder report + // `invalid hex 'params.x'`, which sends the reader to inspect their hex when the + // real problem is that the action being run never sets `x`. + None if NAMESPACED_REF_PREFIXES.iter().any(|p| value_ref.starts_with(p)) => { + bail!( + "taproot leaf value '{value_ref}' does not resolve in this action. Every \ + action that builds this utxo_type's address has to supply it — declare it \ + as a param (or an instance field) there, or give this leaf a constant." + ) + } + None => value_ref.trim_matches(['"', '\'']).to_string(), + }; encode_leaf_bytes(item, &resolved) } +/// Value prefixes that mark a string as a reference rather than a literal. A bare word is +/// deliberately absent: `resolve_ref` accepts one as a param name, so it stays ambiguous +/// with a literal and keeps the old fall-through. +const NAMESPACED_REF_PREFIXES: [&str; 4] = + ["params.", "instance.", "compile_params.", "inputs."]; + +/// Evaluate a site `args` expression in the action's scope. +/// +/// Like [`resolve_compile_param_value`], except an explicitly namespaced reference that +/// does not resolve is an error rather than its own name taken as a literal. A binding is +/// the one place a site states what it means; silently passing `"params.foo"` through as +/// the string `params.foo` would derive a real address for a value nobody supplied. +pub fn resolve_site_arg(expr: &str, ctx: &ExecutionContext) -> Result { + let expr = expr.trim(); + if let Some(resolved) = resolve_ref(expr, ctx) { + return Ok(resolved); + } + if !expr.contains('.') { + if let Some(cp) = ctx.get_compile_param(expr) { + return Ok(cp.to_string()); + } + } + if NAMESPACED_REF_PREFIXES.iter().any(|p| expr.starts_with(p)) { + bail!("'{expr}' does not resolve in this action"); + } + Ok(expr.trim_matches(['"', '\'']).to_string()) +} + +/// The `type` values a computed taproot leaf payload item may declare. +/// +/// Lives next to the encoder that implements them, and is what the published schema +/// enumerates, so the two cannot drift; `leaf_value_types_are_all_encodable` pins that. +pub const LEAF_VALUE_TYPES: [&str; 7] = + ["u8", "u16", "u32", "u64", "bytes32", "bytes", "pubkey"]; + /// Encode a taproot leaf payload item given its already-resolved `value` string. /// Split from [`encode_leaf_value`] so callers that resolve `value` themselves (e.g. /// against an in-progress `create_instance` field map) can reuse the typed encoding. @@ -411,6 +459,16 @@ fn resolve_ref(name: &str, ctx: &ExecutionContext) -> Option { return Some(v.to_string()); } } + // `inputs..` — the explicit, unambiguous spelling. The bare + // `.` form below still works, but it collides with every other + // namespace: an input called `params` or `instance` would shadow one, and an + // input's own field name can never be told apart from a namespace by shape + // alone. Prefer this form in new manifests. + if let Some(rest) = name.strip_prefix("inputs.") { + let (input_id, field) = rest.split_once('.')?; + return resolve_input_field(input_id, field, ctx); + } + // input_id.field (e.g. "collateral.amount_sat", "yes_rt.reissuance_token") if let Some(dot) = name.rfind('.') { let input_id = &name[..dot]; @@ -433,6 +491,55 @@ fn resolve_ref(name: &str, ctx: &ExecutionContext) -> Option { None } +/// Resolve any dotted reference (`instance.X`, `params.X`, `inputs..`) to its +/// STRING value, or `None` if it does not resolve. +/// +/// The public door onto the same resolver `eval_asset_label` uses. Callers that need a +/// 32-byte value — an asset id, an issuance entropy — must come through here rather than +/// [`eval_expr_str`], which is arithmetic and returns a `u64`. +pub fn resolve_value_ref(name: &str, ctx: &ExecutionContext) -> Option { + resolve_ref(name.trim(), ctx) +} + +/// Resolve a `.` reference — the tail of an `inputs.` / `$inputs.` path. +/// +/// Public so `create_instance` field expressions can take the same route: they need a +/// STRING, and the ordinary expression evaluator returns a u64. +pub fn resolve_input_ref(rest: &str, ctx: &ExecutionContext) -> Option { + let (input_id, field) = rest.split_once('.')?; + resolve_input_field(input_id, field, ctx) +} + +/// Resolve one field of a resolved input. +/// +/// ⚠️ `asset` is the asset of the UTXO being **spent**. On an input that carries a new +/// issuance those are two different things — a wallet L-BTC UTXO minting a new asset has +/// `asset` = L-BTC — so the newly created ids are exposed under distinct names, +/// `issued_asset` and `reissuance_token`, rather than shadowing `asset`. Getting this +/// wrong is silent and expensive: it writes L-BTC's id into a field meant to hold the +/// asset the transaction just created. +/// +/// Available fields: +/// +/// - `asset` — asset id of the spent UTXO +/// - `amount_sat` — its value +/// - `issued_asset` — asset id created by this input's issuance, if any +/// - `reissuance_token` — reissuance token id created by this input's issuance, if any +/// - `issuance_entropy` — the issuance entropy, hex. Set once the PSET is built, so a +/// constructor can persist it; a later reissuance of the same asset needs it and cannot +/// derive it from anything on chain. +fn resolve_input_field(input_id: &str, field: &str, ctx: &ExecutionContext) -> Option { + if let Some(inp) = ctx.get_input(input_id) { + match field { + "amount_sat" => return Some(inp.amount_sat.to_string()), + "asset" => return Some(inp.asset.clone()), + "issuance_entropy" => return inp.issuance_entropy.clone(), + _ => {} + } + } + ctx.get_input_attr(input_id, field).map(str::to_string) +} + // --------------------------------------------------------------------------- // SimplicityHL on_input_resolved hook evaluator // --------------------------------------------------------------------------- @@ -689,6 +796,58 @@ fn substitute_vars(expr: &str, ctx: &ExecutionContext) -> String { result } +#[cfg(test)] +mod leaf_value_type_tests { + use super::*; + + /// The published schema enumerates [`LEAF_VALUE_TYPES`], so a name listed there but + /// not handled by the encoder would be a schema that green-lights a manifest the + /// engine then rejects mid-run. + #[test] + fn leaf_value_types_are_all_encodable() { + for ty in LEAF_VALUE_TYPES { + // One value that is legal for every listed type: hex types want hex, integer + // types want digits, and "1" reads as both. + let value = if ty.starts_with('u') { "1" } else { "0x01" }; + let item = serde_json::json!({ "value": value, "type": ty }); + assert!( + encode_leaf_bytes(&item, value).is_ok(), + "schema advertises leaf value type '{ty}' but the encoder rejects it" + ); + } + // And the converse: a type the schema does not list is not quietly encodable. + let bogus = serde_json::json!({ "value": "1", "type": "u512" }); + assert!(encode_leaf_bytes(&bogus, "1").is_err()); + } + + /// An unresolved `params.X` in a leaf must say so, not be encoded as its own name. + /// + /// The literal fall-through is for genuine literals; for a namespaced reference it + /// produced `invalid hex 'params.dest_addr_script_hash'` — an error about hex, for a + /// problem that is entirely about a param the running action does not declare. + #[test] + fn unresolved_namespaced_leaf_reference_names_the_reference() { + let ctx = crate::context::ExecutionContext::new(); + let item = serde_json::json!({ "value": "params.dest_addr_script_hash", "type": "bytes32" }); + let err = encode_leaf_value(&item, &ctx).expect_err("unresolved ref must fail"); + let msg = err.to_string(); + assert!(msg.contains("params.dest_addr_script_hash"), "{msg}"); + assert!(msg.contains("does not resolve"), "should not blame the hex: {msg}"); + + // A resolved one still encodes... + let mut ctx = crate::context::ExecutionContext::new(); + ctx.set_param("dest_addr_script_hash", format!("0x{}", "11".repeat(32))); + assert_eq!(encode_leaf_value(&item, &ctx).unwrap().len(), 32); + + // ...and a real literal still falls through untouched. + let literal = serde_json::json!({ "value": "0x01", "type": "bytes" }); + assert_eq!( + encode_leaf_value(&literal, &ExecutionContext::new()).unwrap(), + vec![0x01] + ); + } +} + #[cfg(test)] mod fee_keyword_tests { use super::*; diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index b3e56cf..26e4a86 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -75,6 +75,118 @@ fn resolve_input_sequence(inp: &Input, ctx: &ExecutionContext) -> Result Option<(u64, String)> { + use crate::backend::{Backend, BackendKind}; + + let parsed = lwk_wollet::elements::Txid::from_str(txid).ok()?; + let cfg = crate::config::load(); + let kind = cfg.backend_kind(); + let url = match kind { + BackendKind::Esplora => cfg.esplora_url().to_string(), + BackendKind::Electrum => cfg.electrum_url().to_string(), + }; + + let result = Backend::connect(kind, &url, network) + .and_then(|backend| backend.fetch_explicit_txout(parsed, vout)); + match result { + Ok(Some((amount, asset))) => Some((amount, asset.to_string())), + Ok(None) => { + println!( + " {} {txid}:{vout} is confidential — falling back to the declared amount and asset.", + style("[warn]").yellow() + ); + None + } + Err(e) => { + println!( + " {} Cannot read {txid}:{vout} from the chain ({e}) — falling back to the declared amount and asset.", + style("[warn]").yellow() + ); + None + } + } +} + +/// The blinding key for an address destination, honouring the output's `confidential` flag. +/// +/// A confidential address carries its own blinding key, and using it is the right default. +/// But `confidential: false` has to win, because a covenant that reads the output amount +/// (`unwrap_right` on `Amount1`, the usual way to read a payout) can only do so when the +/// output is explicit — and the payee's address is usually confidential. Ignoring the flag +/// here, as this arm did, made that combination unspendable: the covenant hits the pruned +/// confidential branch and fails with `Execution reached a pruned branch`. +/// +/// Stripping the blinding key does not change the scriptPubKey, so a covenant committed to +/// `sha256(spk)` still matches the output — see `wallet::script_hash_of_address`. +fn address_blinding_key( + output: &crate::manifest::Output, + addr: &lwk_wollet::elements::Address, +) -> Option { + if output.confidential == Some(false) { + return None; + } + addr.blinding_pubkey + .map(|pk| lwk_wollet::elements::bitcoin::PublicKey { inner: pk, compressed: true }) +} + +/// Resolve a `script_hash` compute: `address` is a reference or a literal address, and the +/// result is `sha256(scriptPubKey)` — what the covenant's `output_script_hash` jet returns. +fn resolve_address_script_hash(address_ref: &str, ctx: &ExecutionContext) -> Result { + let address = eval::eval_destination_str(address_ref, ctx) + .unwrap_or_else(|| address_ref.trim().to_string()); + crate::wallet::script_hash_of_address(&address) +} + +/// The ids of declared inputs that never made it into the PSET. +/// +/// A dropped input is the most dangerous failure this engine has, because it is not a +/// failure anywhere the operator can see: resolution, the preview, the net-effect +/// summary and the state file all read the manifest's legs, while only the PSET reads +/// this list. A run that loses one still builds, signs and broadcasts — it just spends +/// different UTXOs than the manifest describes, leaving the covenant unspent while its +/// outputs are recreated out of wallet funds. +/// +/// So the collection code failing loudly per branch is not enough: this asserts the +/// result, independent of how many ways there are to miss. +fn inputs_missing_from_pset<'a>( + declared: &'a [crate::manifest::Input], + collected: &[pset_builder::PsetInput], +) -> Vec<&'a str> { + declared + .iter() + .map(|inp| inp.id.as_str()) + .filter(|id| !collected.iter().any(|got| got.input_id() == *id)) + .collect() +} + +/// True when an output declares wallet change (`destination: "change"`). +fn is_change_output(output: &crate::manifest::Output) -> bool { + output.destination.as_str() == Some("change") +} + +/// Whether an output that declares no `amount_sat` is skipped outright. +/// +/// Only `optional` outputs are. Change is the trap this exists to name: a +/// `destination: "change"` output has no `amount_sat` *by design* — it takes whatever is +/// left, which is why the validator does not require one — but skipping it drops its +/// asset from the set the action permits change in, and the build then rejects the +/// leftover with "this action does not permit L-BTC change" for a manifest that declared +/// exactly that. +fn skips_when_amount_absent(output: &crate::manifest::Output) -> bool { + !is_change_output(output) && output.optional.unwrap_or(false) +} + // --------------------------------------------------------------------------- // Run output // --------------------------------------------------------------------------- @@ -181,10 +293,11 @@ pub fn run( let manifest: Manifest = Manifest::from_json_str(&raw).with_context(|| { format!("Failed to parse manifest file: {}", manifest_file.display()) })?; - // Whether covenants compile with SimplicityHL debug symbols (affects every CMR/address). - // Sourced from the manifest so interop targets (e.g. simplicity-lending) can be matched - // without hardcoding; defaults to false. - let include_debug_symbols = manifest.include_debug_symbols(); + // How every `.simf` in this run compiles: debug symbols (which affect every CMR and + // address, so interop targets like simplicity-lending can be matched without + // hardcoding) and any unstable `-Z` features the programs need. Sourced from the + // manifest's `simplicity_hl` block once, then passed to every compile site. + let compile_opts = manifest.compile_opts(); // INPUT paths (instance load, state load) are NEVER auto-discovered: a run only // loads an instance/state if one is passed explicitly (`--instance` / `--state`). // This keeps a stale on-disk file from silently overriding `--params` and never @@ -364,6 +477,9 @@ pub fn run( if !params.is_empty() { println!(); println!(" {}", style("Action params:").bold()); + // `script_hash` params whose address was not resolvable on the first pass — + // typically because the address is a param prompted later in the map. + let mut deferred_script_hashes: Vec<(String, String)> = Vec::new(); for (name, def) in params { if let Some(expr) = def.compute.as_ref().and_then(|c| c.as_expr()) { println!( @@ -373,6 +489,21 @@ pub fn run( style(expr).yellow() ); } + // Hook params are filled by an on_resolved / on_pre_broadcast block later in + // the run. Nothing to prompt for and nothing to evaluate yet — but a + // --params override still wins, so a value can be pinned for a dry run. + if def.compute.as_ref().is_some_and(|c| c.is_hook()) + && overrides.get(name.as_str()).is_none() + { + println!( + " {} {} {}", + style("○").dim(), + style(name.as_str()).cyan(), + style("[will be set by a hook]").dim(), + ); + continue; + } + // SimfFn params are computed after inputs resolve (Step 3a). Skip here unless // an explicit override is provided via --params. if def.compute.as_ref().is_some_and(|c| c.is_simf_fn()) @@ -431,6 +562,28 @@ pub fn run( style("[from --params]").dim(), ); ov.to_string() + } else if let Some(address_ref) = + def.compute.as_ref().and_then(|c| c.as_script_hash_address()) + { + // The address may be a param declared later in the map, so a failure to + // resolve here is not final — collect it and retry once the loop ends, + // rather than making the value depend on alphabetical order. + match resolve_address_script_hash(address_ref, &ctx) { + Ok(hash) => { + println!( + " {} {} = {} {}", + style("✓").green(), + style(name).bold().cyan(), + style(&hash).yellow(), + style(format!("[script_hash of {address_ref}]")).dim(), + ); + hash + } + Err(_) => { + deferred_script_hashes.push((name.clone(), address_ref.to_string())); + continue; + } + } } else if let Some(expr) = def.compute.as_ref().and_then(|c| c.as_expr()) { let computed = eval::eval_expr_str(expr, &ctx)?; println!( @@ -451,6 +604,23 @@ pub fn run( // that may have been loaded from a previous instance file. ctx.set_compile_param(name, value); } + + // Second pass for the deferred `script_hash` params, now that everything they + // could name has been prompted or computed. + for (name, address_ref) in deferred_script_hashes { + let hash = resolve_address_script_hash(&address_ref, &ctx).with_context(|| { + format!("param '{name}' computes script_hash of '{address_ref}'") + })?; + println!( + " {} {} = {} {}", + style("✓").green(), + style(&name).bold().cyan(), + style(&hash).yellow(), + style(format!("[script_hash of {address_ref}]")).dim(), + ); + ctx.set_param(&name, hash.clone()); + ctx.set_compile_param(&name, hash); + } } } @@ -474,14 +644,27 @@ pub fn run( let state_utxo = contract_state.as_ref().and_then(|s| { s.utxos.iter().find(|u| u.txid == ov.txid && u.vout == ov.vout) }); - let asset = ov - .asset - .clone() + // The UTXO itself is the authority on what it holds. An outpoint is a + // fact about the chain, so reading the amount and asset off it beats any + // number the manifest or the operator supplies — those can be wrong, and + // a wrong amount is the value the sighash commits to. + let onchain = fetch_onchain_txout( + &ov.txid, + ov.vout, + loaded_wallet.as_ref().map(wallet::elements_network) + .unwrap_or(ElementsNetwork::LiquidTestnet), + ); + let asset = onchain + .as_ref() + .map(|(_, asset)| asset.clone()) + .or_else(|| ov.asset.clone()) .or_else(|| state_utxo.map(|u| u.asset.clone())) .or_else(|| input.asset.as_ref().and_then(|v| eval::eval_asset_label(v, &ctx).ok())) .unwrap_or_else(|| "lbtc".to_string()); - let amount_sat = ov - .amount_sat + let amount_sat = onchain + .as_ref() + .map(|(amount, _)| *amount) + .or(ov.amount_sat) .or_else(|| state_utxo.map(|u| u.amount_sat)) .or_else(|| input.amount_sat.as_ref().and_then(|v| eval::eval_amount(v, &ctx).ok())) .unwrap_or(0); @@ -493,7 +676,7 @@ pub fn run( ov.vout, style(amount_sat).yellow(), style(&asset).dim(), - style("[override]").cyan(), + style(if onchain.is_some() { "[override, on-chain]" } else { "[override]" }).cyan(), ); ResolvedInput { id: input.id.clone(), @@ -603,21 +786,21 @@ pub fn run( &resolved.txid, resolved.vout, ) { ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); + ctx.set_input_attr(&inp.id, "issued_asset", asset_id.to_string()); ctx.set_input_attr(&inp.id, "reissuance_token", token_id.to_string()); } } } Some("reissue") => { - let (rt_asset, entropy_hex_opt) = match ctx.get_input(&inp.id) { - Some(r) => (r.asset.clone(), r.issuance_entropy.clone()), + let rt_asset = match ctx.get_input(&inp.id) { + Some(r) => r.asset.clone(), None => continue, }; ctx.set_input_attr(&inp.id, "reissuance_token", &rt_asset); - if let Some(entropy_hex) = entropy_hex_opt { - if let Ok(entropy) = pset_builder::decode_entropy_hex(&entropy_hex) { - if let Ok(asset_id) = pset_builder::compute_asset_from_entropy(&entropy) { - ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); - } + if let Ok(Some(entropy)) = resolve_issuance_entropy(inp, &ctx) { + if let Ok(asset_id) = pset_builder::compute_asset_from_entropy(&entropy) { + ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); + ctx.set_input_attr(&inp.id, "issued_asset", asset_id.to_string()); } } } @@ -707,6 +890,7 @@ pub fn run( &cp_map, &compile_param_type_hints, input_hex, + &compile_opts, ) { Ok(result_hex) => { println!( @@ -849,7 +1033,7 @@ pub fn run( hints }; let pre_fields = eval_create_instance_fields( - ci, &ctx, manifest_file, &pre_hints, net_for_hash, false, include_debug_symbols, + ci, &ctx, manifest_file, &pre_hints, net_for_hash, false, &compile_opts, ); for (name, val) in pre_fields { ctx.set_compile_param(&name, val); @@ -898,21 +1082,21 @@ pub fn run( &resolved.txid, resolved.vout ) { ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); + ctx.set_input_attr(&inp.id, "issued_asset", asset_id.to_string()); ctx.set_input_attr(&inp.id, "reissuance_token", token_id.to_string()); } } } Some("reissue") => { - let (rt_asset, entropy_hex_opt) = match ctx.get_input(&inp.id) { - Some(r) => (r.asset.clone(), r.issuance_entropy.clone()), + let rt_asset = match ctx.get_input(&inp.id) { + Some(r) => r.asset.clone(), None => continue, }; ctx.set_input_attr(&inp.id, "reissuance_token", &rt_asset); - if let Some(entropy_hex) = entropy_hex_opt { - if let Ok(entropy) = pset_builder::decode_entropy_hex(&entropy_hex) { - if let Ok(asset_id) = pset_builder::compute_asset_from_entropy(&entropy) { - ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); - } + if let Ok(Some(entropy)) = resolve_issuance_entropy(inp, &ctx) { + if let Ok(asset_id) = pset_builder::compute_asset_from_entropy(&entropy) { + ctx.set_input_attr(&inp.id, "asset", asset_id.to_string()); + ctx.set_input_attr(&inp.id, "issued_asset", asset_id.to_string()); } } } @@ -956,25 +1140,24 @@ pub fn run( } None => 0, }; - let entropy = if let Some(resolved) = ctx.get_input(&inp.id) { - if let Some(hex) = &resolved.issuance_entropy.clone() { - match pset_builder::decode_entropy_hex(hex) { - Ok(e) => e, - Err(err) => { - println!(" {} Input '{}' entropy decode failed: {err}", style("[error]").red(), inp.id); - collect_inputs_ok = false; - break; - } - } - } else { - println!(" {} Input '{}' is a reissuance but has no issuance_entropy — add it to the instance file.", style("[error]").red(), inp.id); + let entropy = match resolve_issuance_entropy(inp, &ctx) { + Ok(Some(e)) => e, + Ok(None) => { + println!( + " {} Input '{}' is a reissuance but no entropy was found. Give the + issuance an \"entropy\" reference (e.g. \"instance.YES_ISSUANCE_ENTROPY\", + captured by the constructor as \"$inputs..issuance_entropy\"), or put + issuance_entropy on this input in the instance file's provided_inputs.", + style("[error]").red(), inp.id + ); + collect_inputs_ok = false; + break; + } + Err(e) => { + println!(" {} Input '{}': {e}", style("[error]").red(), inp.id); collect_inputs_ok = false; break; } - } else { - println!(" {} Input '{}' not resolved", style("[error]").red(), inp.id); - collect_inputs_ok = false; - break; }; Some(pset_builder::IssuanceKind::Reissue { asset_amount, entropy }) } @@ -1044,26 +1227,24 @@ pub fn run( break; } }; - let leaf_payloads = match inp_ut.resolve_extra_leaf_payloads(&ctx) { - Ok(p) => p, + let site = match resolve_utxo_site( + inp_ut, Some(&inp.utxo_source), &compile_params_map, + &compile_param_type_hints, action, &ctx, + ) { + Ok(s) => s, Err(e) => { - println!(" {} {e}", style("[error]").red()); + println!(" {} Input '{}' ({type_name}): {e}", style("[error]").red(), inp.id); collect_inputs_ok = false; break; } }; + let (leaf_payloads, inp_params, inp_hints) = + (site.leaf_payloads, site.compile_params, site.type_hints); let inp_simf_path = inp_ut.script.as_ref() .and_then(|s| s.source.as_deref()) .map(|src| manifest_file.parent().unwrap_or(std::path::Path::new(".")).join(src)) .unwrap_or_else(|| simf_path.clone()); - let (inp_params, inp_hints) = apply_utxo_compile_params(&compile_params_map, &compile_param_type_hints, inp_ut); - // Per-input `utxo_source.compile_params` overrides (resolved against action - // params), mirroring the output `destination.compile_params` form. - let (inp_params, inp_hints) = apply_site_compile_param_overrides( - inp_params, inp_hints, inp.utxo_source.get("compile_params"), - action, &compile_param_type_hints, &ctx, - ); - let script_pubkey = match pset_builder::covenant_script_pubkey(&inp_simf_path, &inp_params, &inp_hints, &leaf_payloads, net, include_debug_symbols) { + let script_pubkey = match pset_builder::covenant_script_pubkey(&inp_simf_path, &inp_params, &inp_hints, &leaf_payloads, net, &compile_opts) { Ok(s) => s, Err(e) => { println!(" {} Covenant address failed (input '{}'):", style("[error]").red(), inp.id); @@ -1082,7 +1263,12 @@ pub fn run( break; } }; - let asset_id = match lwk_wollet::elements::AssetId::from_str(&resolved.asset) { + // A resolved input's `asset` is a *label*, not necessarily a hex id: it comes + // from `eval_asset_label` and falls back to the literal "lbtc" (see input + // resolution above). Outputs have always gone through `resolve_asset_id`; + // this branch parsed the raw string, so a covenant input on L-BTC — + // `"asset": "lbtc"`, the obvious spelling — died with "failed to parse hex". + let asset_id = match resolve_asset_id(&resolved.asset, net) { Ok(a) => a, Err(e) => { println!(" {} Input '{}' asset parse failed: {e}", style("[error]").red(), inp.id); @@ -1108,11 +1294,48 @@ pub fn run( issuance: iso_spec, sequence: input_sequence, }); + } else { + // Neither `"wallet"` nor `{"utxo_type": "..."}`. This arm used to be + // absent, so an unrecognized source fell through and the input was simply + // never added — see `inputs_missing_from_pset` for why that is the worst + // failure mode available. + println!( + " {} Input '{}' has an unrecognized utxo_source: {}", + style("[error]").red(), inp.id, inp.utxo_source + ); + println!( + " Expected \"wallet\" or {{\"utxo_type\": \"\"}}. A bare \"\" \ + string is not a utxo_type reference." + ); + collect_inputs_ok = false; + break; + } + } + + // Backstop: every input the action declares must have reached the PSET. The + // branches above each fail loudly, but this is the invariant that actually + // matters, and it must not depend on having enumerated every way to miss it. + if collect_inputs_ok { + let declared = action.inputs.as_deref().unwrap_or_default(); + let missing = inputs_missing_from_pset(declared, &pset_inputs); + if !missing.is_empty() { + anyhow::bail!( + "Input(s) [{}] were declared by action '{action_name}' and resolved, but did \ + not reach the transaction.\n\ + Refusing to build: every other step (preview, net effect, state) reports the \ + action as declared, so a transaction built without them would spend and \ + create the wrong UTXOs while appearing correct.", + missing.join(", ") + ); } } // ---- Collect PSET outputs ---- let mut pset_outputs: Vec = Vec::new(); + // Assets the action declares a `"change"` output for. Anything left over in an asset + // absent from this set is an error, not an output the engine invents. + let mut change_assets: std::collections::HashSet = + std::collections::HashSet::new(); // (output id, amount formula) for each pushed output, aligned with pset_outputs // by index, so amounts referencing the `fee` keyword can be re-evaluated once // the fee is estimated below (and the covenant state metadata kept in sync). @@ -1126,13 +1349,17 @@ pub fn run( if collect_inputs_ok { for output in action.outputs.as_deref().unwrap_or_default() { let push_start = pset_outputs.len(); - let is_change = output.destination.as_str() == Some("change"); + let is_change = is_change_output(output); let dest_type = output.destination.get("type").and_then(|v| v.as_str()); let is_op_return = matches!(dest_type, Some("op_return") | Some("burn")); let amount = match &output.amount_sat { None => { - if output.optional.unwrap_or(false) || is_change { continue; } - if is_op_return { 0u64 } else { + if skips_when_amount_absent(output) { continue; } + // Change and OP_RETURN legs carry no declared amount. The + // placeholder is never spent for change — the destination arm + // below registers the asset and pushes no PSET output — and 0 is + // the real amount for OP_RETURN. + if is_change || is_op_return { 0u64 } else { anyhow::bail!("Output '{}' has no amount_sat and is not optional.", output.id); } } @@ -1150,7 +1377,9 @@ pub fn run( }, }; - if output.optional.unwrap_or(false) && amount == 0 { + // `!is_change`: the placeholder amount above is 0 by construction, and even + // an optional change output must still register its asset. + if output.optional.unwrap_or(false) && amount == 0 && !is_change { println!(" {} Output '{}' amount=0, optional — skipping.", style("·").dim(), output.id); continue; } @@ -1184,9 +1413,10 @@ pub fn run( } }; - match &output.destination { + match &*output.destination { serde_json::Value::String(dest) if dest == "change" => { println!(" {} Output '{}' → change (auto).", style("·").dim(), output.id); + change_assets.insert(asset_id); continue; } serde_json::Value::Object(m) @@ -1233,26 +1463,24 @@ pub fn run( } }; let confidential = ut.confidential; - let leaf_payloads = match ut.resolve_extra_leaf_payloads(&ctx) { - Ok(p) => p, + let site = match resolve_utxo_site( + ut, Some(&output.destination), &compile_params_map, + &compile_param_type_hints, action, &ctx, + ) { + Ok(s) => s, Err(e) => { - println!(" {} Output '{}' extra leaves error: {e}", style("[warn]").yellow(), output.id); + println!(" {} Output '{}' ({type_name}): {e}", style("[error]").red(), output.id); collect_outputs_ok = false; break; } }; + let (leaf_payloads, out_params, out_hints) = + (site.leaf_payloads, site.compile_params, site.type_hints); let out_simf_path = ut.script.as_ref() .and_then(|s| s.source.as_deref()) .map(|src| manifest_file.parent().unwrap_or(std::path::Path::new(".")).join(src)) .unwrap_or_else(|| simf_path.clone()); - let (out_params, out_hints) = apply_utxo_compile_params(&compile_params_map, &compile_param_type_hints, ut); - // Per-output `destination.compile_params` overrides (resolved against - // action params), so a covenant can be keyed by a runtime value. - let (out_params, out_hints) = apply_site_compile_param_overrides( - out_params, out_hints, m.get("compile_params"), - action, &compile_param_type_hints, &ctx, - ); - let script_pubkey = match pset_builder::covenant_script_pubkey(&out_simf_path, &out_params, &out_hints, &leaf_payloads, net, include_debug_symbols) { + let script_pubkey = match pset_builder::covenant_script_pubkey(&out_simf_path, &out_params, &out_hints, &leaf_payloads, net, &compile_opts) { Ok(s) => s, Err(e) => { println!(" {} Covenant address failed (output '{}'):", style("[error]").red(), output.id); @@ -1326,7 +1554,13 @@ pub fn run( continue; } }; - let bpk = addr.blinding_pubkey.map(|pk| lwk_wollet::elements::bitcoin::PublicKey { inner: pk, compressed: true }); + let bpk = address_blinding_key(output, &addr); + if bpk.is_none() && addr.blinding_pubkey.is_some() { + println!( + " {} Output '{}' pays a confidential address explicitly (confidential: false) — the amount and asset stay in the clear.", + style("·").dim(), output.id + ); + } println!( " {} Output '{}': {} sat {} → {}…", style("+").green(), output.id, style(amount).yellow(), asset_label, @@ -1388,16 +1622,32 @@ pub fn run( // ---- Build PSET ---- if collect_inputs_ok && collect_outputs_ok { - // Only build a change output if the action declared one. Otherwise the - // fee absorbs the surplus and the output count stays exact (recursive covenants). - let build_change = action.outputs.as_deref().unwrap_or_default().iter() - .any(|o| o.destination.as_str() == Some("change")); + // Change is permitted per ASSET: by an explicit `destination: "change"` + // output (collected while resolving outputs, above), or by the action's + // `allow_change` setting, which covers surpluses that cannot be predicted — + // chiefly the L-BTC left after a fee whose size is only known once the + // transaction is built. + let mut change_assets = change_assets; + match action.allow_change { + crate::manifest::AllowChange::None => {} + crate::manifest::AllowChange::LbtcOnly => { + change_assets.insert(net.policy_asset()); + } + crate::manifest::AllowChange::Any => { + for i in &pset_inputs { + if let pset_builder::PsetInput::Wallet { utxo, .. } = i { + change_assets.insert(utxo.unblinded.asset); + } + } + change_assets.insert(net.policy_asset()); + } + } let mut req = pset_builder::BuildPsetRequest { inputs: pset_inputs, outputs: pset_outputs, fee_rate: fee_rate as f32, policy_asset: net.policy_asset(), - build_change, + change_assets: change_assets.clone(), }; // Resolve the `fee` keyword: estimate the fee from the current (fee=0) @@ -1444,10 +1694,21 @@ pub fn run( } Ok(result) => { for iso in &result.issuances { - println!(" Issuance '{}': asset={}, token={}", iso.input_id, - style(&iso.asset_id.to_string()[..16]).yellow(), - style(&iso.token_id.to_string()[..16]).yellow()); + // Printed in full, not elided. These ids exist nowhere else yet: + // they are derived from this input's outpoint, and unless the action + // is a constructor that captures them via `on_resolved`, this line is + // the only record the operator will ever get. An elided id is not a + // record of anything. + println!(" Issuance '{}':", iso.input_id); + println!(" asset = {}", style(iso.asset_id.to_string()).yellow()); + println!(" reissuance_token = {}", style(iso.token_id.to_string()).yellow()); + if let Some(entropy_bytes) = &iso.entropy { + // Needed verbatim in the instance file's `provided_inputs` before + // any later reissuance of this asset can be built. + println!(" issuance_entropy = {}", style(hex_bytes(entropy_bytes)).yellow()); + } ctx.set_input_attr(&iso.input_id, "asset", iso.asset_id.to_string()); + ctx.set_input_attr(&iso.input_id, "issued_asset", iso.asset_id.to_string()); ctx.set_input_attr(&iso.input_id, "reissuance_token", iso.token_id.to_string()); if let Some(entropy_bytes) = &iso.entropy { let hex = entropy_bytes.iter().map(|b| format!("{b:02x}")).collect::(); @@ -1533,20 +1794,28 @@ pub fn run( manifest_file.parent().unwrap_or(std::path::Path::new(".")).join(src) })) .unwrap_or_else(|| simf_path.clone()); - let (check_params, check_hints) = check_ut - .map(|ut| apply_utxo_compile_params(&compile_params_map, &compile_param_type_hints, ut)) - .unwrap_or_else(|| (compile_params_map.clone(), compile_param_type_hints.clone())); - let (check_params, check_hints) = apply_site_compile_param_overrides( - check_params, check_hints, inp.utxo_source.get("compile_params"), - action, &compile_param_type_hints, &ctx, - ); + let check_site = check_ut.map(|ut| { + resolve_utxo_site( + ut, Some(&inp.utxo_source), &compile_params_map, + &compile_param_type_hints, action, &ctx, + ) + }); + let (check_params, check_hints) = match check_site { + Some(Ok(site)) => (site.compile_params, site.type_hints), + Some(Err(e)) => { + println!(" {} Input '{}' ({type_name}): {e}", style("[error]").red(), inp.id); + all_compiled = false; + continue; + } + None => (compile_params_map.clone(), compile_param_type_hints.clone()), + }; print!( " {} Input '{}' ({}) — compiling… ", style("·").dim(), inp.id, type_name ); use std::io::Write; let _ = std::io::stdout().flush(); - match covenant::check_compile(&check_simf_path, &check_params, &check_hints, include_debug_symbols) { + match covenant::check_compile(&check_simf_path, &check_params, &check_hints, &compile_opts) { Ok(()) => println!("{}", style("OK").green()), Err(e) => { println!("{}", style("FAILED").red()); @@ -1603,26 +1872,26 @@ pub fn run( continue; } }; - let leaf_payloads = match dry_ut.resolve_extra_leaf_payloads(&ctx) { - Ok(p) => p, + let dry_site = match resolve_utxo_site( + dry_ut, Some(&action_inp.utxo_source), &compile_params_map, + &compile_param_type_hints, action, &ctx, + ) { + Ok(s) => s, Err(e) => { println!( - " {} leaf_payloads for '{}': {e}", + " {} utxo_type site for '{}': {e}", style("[error]").red(), action_inp.id ); exec_all_ok = false; continue; } }; + let (leaf_payloads, dry_params, dry_hints) = + (dry_site.leaf_payloads, dry_site.compile_params, dry_site.type_hints); let dry_simf_path = dry_ut.script.as_ref() .and_then(|s| s.source.as_deref()) .map(|src| manifest_file.parent().unwrap_or(std::path::Path::new(".")).join(src)) .unwrap_or_else(|| simf_path.clone()); - let (dry_params, dry_hints) = apply_utxo_compile_params(&compile_params_map, &compile_param_type_hints, dry_ut); - let (dry_params, dry_hints) = apply_site_compile_param_overrides( - dry_params, dry_hints, action_inp.utxo_source.get("compile_params"), - action, &compile_param_type_hints, &ctx, - ); use std::io::Write; print!( @@ -1663,7 +1932,7 @@ pub fn run( pset_idx as u32, genesis_hash, debug_jets, - include_debug_symbols, + &compile_opts, ) { Ok(()) => println!("{}", style("OK").green()), Err(e) => { @@ -1761,17 +2030,22 @@ pub fn run( continue; } }; - let leaf_payloads = match fin_ut.resolve_extra_leaf_payloads(&ctx) { - Ok(p) => p, + let fin_site = match resolve_utxo_site( + fin_ut, Some(&action_inp.utxo_source), &compile_params_map, + &compile_param_type_hints, action, &ctx, + ) { + Ok(s) => s, Err(e) => { println!( - " {} leaf_payloads for '{}': {e}", + " {} utxo_type site for '{}': {e}", style("[error]").red(), action_inp.id ); all_finalized = false; continue; } }; + let (leaf_payloads, fin_params, fin_hints) = + (fin_site.leaf_payloads, fin_site.compile_params, fin_site.type_hints); let fin_simf_path = fin_ut.script.as_ref() .and_then(|s| s.source.as_deref()) .map(|src| { @@ -1780,13 +2054,6 @@ pub fn run( .join(src) }) .unwrap_or_else(|| simf_path.clone()); - let (fin_params, fin_hints) = apply_utxo_compile_params( - &compile_params_map, &compile_param_type_hints, fin_ut, - ); - let (fin_params, fin_hints) = apply_site_compile_param_overrides( - fin_params, fin_hints, action_inp.utxo_source.get("compile_params"), - action, &compile_param_type_hints, &ctx, - ); print!( " {} Input '{}' ({}) — finalizing… ", @@ -1831,7 +2098,7 @@ pub fn run( pset_idx as u32, genesis_hash, &mut pset.inputs_mut()[pset_idx], - include_debug_symbols, + &compile_opts, ) { Ok(()) => println!("{}", style("OK").green()), Err(e) => { @@ -1887,7 +2154,7 @@ pub fn run( println!(); println!("{}", step_header("Step 9b: Creating Instance")); let fields = eval_create_instance_fields( - ci, &ctx, manifest_file, &create_instance_hints, net_for_hash, true, include_debug_symbols, + ci, &ctx, manifest_file, &create_instance_hints, net_for_hash, true, &compile_opts, ); let inst = crate::instance::InstanceFile { instance: Some(crate::instance::InstanceData { @@ -2588,6 +2855,59 @@ fn issuance_kind(input: &crate::manifest::Input) -> Option<&str> { .and_then(|v| v.as_str()) } +/// Resolve the issuance entropy for a reissuance input. +/// +/// Two sources, in order: +/// 1. `issuance.entropy` — a reference into the run context, normally an instance field a +/// constructor captured with `$inputs..issuance_entropy`. This is the good path: the +/// value travels with the contract and nothing has to pin an outpoint to carry it. +/// 2. `provided_inputs..issuance_entropy` in the instance file — the older path, kept +/// working. It rides along with an outpoint override, which pins that input for *every* +/// action sharing the input id, long after the pin stops being correct. +/// +/// Returns `Ok(None)` when neither is present, so the caller can name the offending input. +fn resolve_issuance_entropy( + inp: &crate::manifest::Input, + ctx: &ExecutionContext, +) -> anyhow::Result> { + let spec = inp.issuance.as_ref(); + let entropy_ref = spec.and_then(|v| v.get("entropy")).and_then(|v| v.as_str()); + + let hex = match entropy_ref { + Some(r) => Some(eval::resolve_value_ref(r, ctx).ok_or_else(|| { + anyhow::anyhow!( + "issuance.entropy '{r}' does not resolve — a constructor should capture it \ + with \"$inputs..issuance_entropy\"" + ) + })?), + None => ctx.get_input(&inp.id).and_then(|r| r.issuance_entropy.clone()), + }; + + let Some(hex) = hex else { return Ok(None) }; + let entropy = pset_builder::decode_entropy_hex(&hex) + .with_context(|| format!("issuance entropy '{hex}' is not 32 bytes of hex"))?; + + // Optional cross-check. An entropy is opaque — a byte-reversed one (the order every + // block explorer prints) is still well-formed, and the transaction it builds still + // broadcasts; it just reissues a different asset. Re-deriving the asset id turns that + // into an error here rather than a wrong market later. + if let Some(expected_ref) = spec + .and_then(|v| v.get("issued_asset")) + .and_then(|v| v.as_str()) + { + if let Some(expected) = eval::resolve_value_ref(expected_ref, ctx) { + let derived = pset_builder::compute_asset_from_entropy(&entropy)?.to_string(); + anyhow::ensure!( + derived == expected, + "issuance entropy does not produce the declared asset:\n \ + entropy {hex}\n derives {derived}\n declared {expected} ({expected_ref})\n \ + note: an entropy copied from a block explorer is byte-reversed relative to this one" + ); + } + } + Ok(Some(entropy)) +} + fn hex_bytes(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -2668,6 +2988,114 @@ fn resolve_witness_signing_key<'a>( /// top-level compile param. The SimplicityHL type hint is carried from the /// referenced declaration — covenant compilation needs it to type the argument /// (many simf param names, e.g. `PUB_KEY`, are not inferable by convention). +/// Everything one mention of a `utxo_type` needs in order to derive its address. +#[derive(Debug)] +struct ResolvedUtxoSite { + compile_params: std::collections::HashMap, + type_hints: std::collections::HashMap, + leaf_payloads: Vec>, +} + +/// Resolve a `utxo_type` at one site — an output `destination` or an input `utxo_source`. +/// +/// This is the single place an address's inputs are assembled, because the alternative is +/// what this replaced: three mechanisms (ambient compile params, a remap table, per-site +/// overrides) applied in the same order at four call sites, any one of which could drift +/// and produce a valid address for the wrong covenant. +/// +/// A type that declares `params` is resolved in **closed scope**: its compile params are +/// exactly its bound params (plus `script.compile_params`, itself resolved against them), +/// and its leaves see those params and nothing else. A type that does not keeps the legacy +/// ambient behaviour, so this change is additive — see [`crate::manifest::UtxoType::params`]. +fn resolve_utxo_site( + ut: &crate::manifest::UtxoType, + site: Option<&serde_json::Value>, + base_params: &std::collections::HashMap, + base_hints: &std::collections::HashMap, + action: &crate::manifest::Action, + ctx: &ExecutionContext, +) -> Result { + if !ut.is_closed() { + let (params, hints) = apply_utxo_compile_params(base_params, base_hints, ut); + let (compile_params, type_hints) = apply_site_compile_param_overrides( + params, + hints, + site.and_then(|s| s.get("compile_params")), + action, + base_hints, + ctx, + ); + return Ok(ResolvedUtxoSite { + compile_params, + type_hints, + leaf_payloads: ut.resolve_extra_leaf_payloads(ctx)?, + }); + } + + // Args are expressions in the ACTION's scope — the site is the one place that knows + // what varies per run. + let eval_arg = |expr: &str| -> Result { eval::resolve_site_arg(expr, ctx) }; + // Defaults are declared on the type, so they may only read instance scope: values + // fixed when the contract was instantiated, identical at every site. + let eval_default = |expr: &str| -> Result { + let expr = expr.trim(); + if let Some(key) = expr + .strip_prefix("instance.") + .or_else(|| expr.strip_prefix("compile_params.")) + { + return base_params.get(key).cloned().ok_or_else(|| { + anyhow::anyhow!("no instance field '{key}' — defaults may only read instance scope") + }); + } + if expr.starts_with("params.") { + anyhow::bail!( + "a default may not read action scope ('{expr}'): it is declared once on the \ + utxo_type, so a value that varies per action has to be bound at the site \ + instead (\"args\")" + ); + } + Ok(expr.trim_matches(['"', '\'']).to_string()) + }; + + let (values, mut type_hints) = ut.bind_site_params(site, &eval_arg, &eval_default)?; + + // `script.compile_params` still maps simf param name → value, but in closed scope its + // right-hand side names one of THIS type's params (or is a literal). + let mut compile_params = values.clone(); + if let Some(map) = ut.script.as_ref().map(|s| &s.compile_params) { + for (simf_key, reference) in map { + let key = reference.trim(); + let key = key.strip_prefix("params.").unwrap_or(key); + match values.get(key) { + Some(v) => { + compile_params.insert(simf_key.clone(), v.clone()); + if let Some(h) = type_hints.get(key).cloned() { + type_hints.insert(simf_key.clone(), h); + } + } + // Literal, as in the legacy form (`"ASSET_AMOUNT": "1"`). + None => { + compile_params.insert(simf_key.clone(), key.to_string()); + } + } + } + } + + // Leaves see the type's params only. A stray `instance.X` in a leaf of a closed type + // now fails loudly rather than quietly picking up an ambient value. + let mut leaf_ctx = ExecutionContext::new(); + for (name, value) in &values { + leaf_ctx.set_param(name, value); + } + let leaf_payloads = ut.resolve_extra_leaf_payloads(&leaf_ctx)?; + + Ok(ResolvedUtxoSite { + compile_params, + type_hints, + leaf_payloads, + }) +} + fn apply_site_compile_param_overrides( mut params: std::collections::HashMap, mut hints: std::collections::HashMap, @@ -2874,7 +3302,7 @@ fn resolve_create_instance_leaves( let mut leaves = Vec::with_capacity(specs.len()); for leaf in specs { let mut bytes: Vec = Vec::new(); - for item in &leaf.payload { + for crate::manifest::TaprootLeafPayloadItem(item) in &leaf.payload { match item { serde_json::Value::String(s) => { match eval::encode_leaf_bytes(&serde_json::json!({ "type": "bytes", "value": s }), s) { @@ -2923,10 +3351,12 @@ fn eval_create_instance_fields( type_hints: &std::collections::HashMap, network: lwk_wollet::ElementsNetwork, verbose: bool, - include_debug_symbols: bool, + opts: impl Into, ) -> std::collections::HashMap { use crate::manifest::ComputeSpec; + let opts = opts.into(); + let mut fields: std::collections::HashMap = std::collections::HashMap::new(); // Track which field names still need evaluation; start with all of them. let mut remaining: Vec<&str> = ci.fields.keys().map(String::as_str).collect(); @@ -2947,15 +3377,24 @@ fn eval_create_instance_fields( let value: Option = match field_value { ComputeSpec::Expr(expr) => { - // $params.X / $instance.X → direct lookup; other → eval_expr_str + // `$`-prefixed forms are direct STRING lookups; everything else falls + // through to eval_expr_str, which is arithmetic and returns a u64. That + // is why a 32-byte id has to arrive by a `$` form: an asset id is not a + // number, and the numeric path rejects it. + // $params.X / $instance.X — a named value in the run context + // $inputs.. — straight off a resolved input expr - .strip_prefix("$params.") - .or_else(|| expr.strip_prefix("$instance.")) - .or_else(|| expr.strip_prefix("$compile_params.")) - .and_then(|name| { - ctx.get_param(name) - .or_else(|| ctx.get_compile_param(name)) - .map(str::to_string) + .strip_prefix("$inputs.") + .and_then(|rest| eval::resolve_input_ref(rest, ctx)) + .or_else(|| { + expr.strip_prefix("$params.") + .or_else(|| expr.strip_prefix("$instance.")) + .or_else(|| expr.strip_prefix("$compile_params.")) + .and_then(|name| { + ctx.get_param(name) + .or_else(|| ctx.get_compile_param(name)) + .map(str::to_string) + }) }) .or_else(|| eval::eval_expr_str(expr, ctx).ok()) } @@ -3047,7 +3486,7 @@ fn eval_create_instance_fields( match leaves_result { None => None, // a leaf ref not yet computed — retry in a later pass Some(leaves) => { - match covenant::compute_covenant_script_hash_with_leaves(&simf_path, &p, &hints, &leaves, network, include_debug_symbols) { + match covenant::compute_covenant_script_hash_with_leaves(&simf_path, &p, &hints, &leaves, network, &opts) { Ok(hash_bytes) => { Some(hash_bytes.iter().map(|b| format!("{b:02x}")).collect()) } @@ -3067,10 +3506,35 @@ fn eval_create_instance_fields( crate::manifest::ParamCompute::Expr { expr } => { eval::eval_expr_str(expr, ctx).ok() } + // Reproducible from the manifest and cheap, so it belongs on an + // instance field as much as on an action param: an instance that + // commits to a payout target commits to its hash. + crate::manifest::ParamCompute::ScriptHash { address } => { + let key = address + .strip_prefix("params.") + .or_else(|| address.strip_prefix("instance.")) + .unwrap_or(address); + let resolved = fields + .get(key) + .cloned() + .or_else(|| eval::eval_destination_str(address, ctx)) + .unwrap_or_else(|| address.trim().to_string()); + // `None` defers to a later pass, same as an unresolved leaf ref. + crate::wallet::script_hash_of_address(&resolved).ok() + } crate::manifest::ParamCompute::SimfFn { .. } => { // SimfFn is only valid on action params, not create_instance fields. None } + crate::manifest::ParamCompute::Hook {} => { + // A hook fills a PARAM, not an instance field. If a hook produced + // this value, name the param it wrote: "$params.NAME". + println!( + " {} create_instance field '{}' cannot be computed by a hook — reference the param the hook set (e.g. \"$params.NAME\"), or read the input directly (\"$inputs..issued_asset\").", + style("[error]").red(), field_name + ); + None + } crate::manifest::ParamCompute::Wallet { .. } => { // Wallet-derived values are resolved for action params in Step 1; // an instance field must be reproducible from the manifest, so a @@ -3254,6 +3718,257 @@ mod tests { use std::collections::BTreeMap; use crate::manifest::{ComputeSpec, InstanceCreate}; + /// `confidential: false` must beat a confidential address's own blinding key. + /// + /// The covenant case that forces it: a payout check does `unwrap_right` on + /// `jet::output_amount(0)`, which only exists when the output is explicit. Paying a + /// blinded output to a `tlq1…` address makes the program hit the pruned confidential + /// branch — "Execution reached a pruned branch" — with nothing pointing at blinding as + /// the cause. The flag was honoured for `wallet` destinations and ignored for address + /// ones, so the manifest could ask for this and be quietly overruled. + #[test] + fn confidential_false_strips_an_addresss_blinding_key() { + // Parsed from JSON rather than hand-built, so the test exercises the same + // `confidential` field an author actually writes. + let manifests: Vec = [None, Some(true), Some(false)] + .iter() + .map(|confidential| { + let extra = match confidential { + Some(v) => format!(r#", "confidential": {v}"#), + None => String::new(), + }; + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "1", "protocol": "t", "actions": {{ "A": {{ "outputs": [ + {{ "id": "o0", "amount_sat": "1", "destination": "params.a"{extra} }} ] }} }} }}"# + )) + .expect("manifest should parse") + }) + .collect(); + let output = |i: usize| &manifests[i].actions["A"].outputs.as_ref().unwrap()[0]; + let (default, yes, no) = (output(0), output(1), output(2)); + + let confidential: lwk_wollet::elements::Address = + "tlq1qq2tmze58e74rl0tw9cx23j47zxk0ddas95gdy0j2wzkcuejxwj8yypv93ju8yay2s3r4y6fwpuw0l322965qvse6u6zdd5mf5" + .parse() + .expect("valid address"); + assert!(confidential.blinding_pubkey.is_some()); + let explicit = confidential.to_unconfidential(); + + // The default and an explicit `true` both blind, as before. + assert!(address_blinding_key(default, &confidential).is_some()); + assert!(address_blinding_key(yes, &confidential).is_some()); + // `false` wins over the address's own key. + assert!(address_blinding_key(no, &confidential).is_none()); + // An unconfidential address has nothing to strip, whatever the flag says. + assert!(address_blinding_key(default, &explicit).is_none()); + assert!(address_blinding_key(yes, &explicit).is_none()); + + // Stripping must not move the scriptPubKey, or the covenant's committed + // sha256(spk) would stop matching the output that pays it. + assert_eq!(confidential.script_pubkey(), explicit.script_pubkey()); + } + + /// The end this boundary exists for: one closed `utxo_type`, two sites, two states — + /// and the state each site commits to comes from the site, not from whatever the + /// running action happens to have in scope. + /// + /// Also pins the closure itself. A leaf in a closed type reading `instance.X` must + /// fail rather than quietly pick up an ambient value, because "quietly picked up the + /// wrong value" is indistinguishable from success when the output is an address. + #[test] + fn closed_utxo_type_resolves_each_site_independently() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, + "utxo_types": { "prize": { + "description": "d", + "params": { "STATE": { "type": "bytes32", + "default": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, + "script": { "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ { "type": "tapdata", + "payload": [ { "value": "params.STATE", "type": "bytes32" } ] } ] } } } }"#, + ) + .expect("manifest should parse"); + let ut = manifest.utxo_type("prize").unwrap(); + let action = &manifest.actions["A"]; + let base = std::collections::HashMap::new(); + + let mut ctx = ExecutionContext::new(); + ctx.set_param("claim", format!("0x{}", "11".repeat(32))); + + // Site 1 says nothing → the declared default (the "no claim pending" state). + let creating = serde_json::json!({ "utxo_type": "prize" }); + let a = resolve_utxo_site(ut, Some(&creating), &base, &base, action, &ctx).unwrap(); + assert_eq!(a.leaf_payloads, vec![vec![0xffu8; 32]]); + + // Site 2 binds the action's param → a different leaf, hence a different address. + let claiming = + serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.claim" } }); + let b = resolve_utxo_site(ut, Some(&claiming), &base, &base, action, &ctx).unwrap(); + assert_eq!(b.leaf_payloads, vec![vec![0x11u8; 32]]); + assert_ne!(a.leaf_payloads, b.leaf_payloads); + + // The closed type's compile params are exactly its own params — the ambient map + // is no longer poured in wholesale. + let mut ambient = std::collections::HashMap::new(); + ambient.insert("SOMETHING_ELSE".to_string(), "0xdead".to_string()); + let c = resolve_utxo_site(ut, Some(&creating), &ambient, &base, action, &ctx).unwrap(); + assert_eq!(c.compile_params.keys().collect::>(), vec!["STATE"]); + + // An unbound arg naming an action param that does not exist fails here, rather + // than being encoded as the string "params.nope". + let bad = serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.nope" } }); + let err = resolve_utxo_site(ut, Some(&bad), &base, &base, action, &ctx).unwrap_err(); + assert!(err.to_string().contains("params.nope"), "{err}"); + } + + /// A closed type's leaves may not reach action scope, and its defaults may not either. + #[test] + fn closed_utxo_type_cannot_read_action_scope() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, + "utxo_types": { + "leaky_leaf": { + "description": "d", + "params": {}, + "script": { "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ { "type": "tapdata", + "payload": [ { "value": "params.claim", "type": "bytes32" } ] } ] } }, + "leaky_default": { + "description": "d", + "params": { "S": { "type": "bytes32", "default": "params.claim" } }, + "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, + ) + .unwrap(); + let action = &manifest.actions["A"]; + let base = std::collections::HashMap::new(); + let mut ctx = ExecutionContext::new(); + ctx.set_param("claim", format!("0x{}", "11".repeat(32))); + let site = serde_json::json!({ "utxo_type": "x" }); + + // The action HAS `claim` — the point is that a closed type cannot see it. + let err = resolve_utxo_site( + manifest.utxo_type("leaky_leaf").unwrap(), Some(&site), &base, &base, action, &ctx, + ) + .unwrap_err(); + assert!(err.to_string().contains("params.claim"), "{err}"); + + let err = resolve_utxo_site( + manifest.utxo_type("leaky_default").unwrap(), Some(&site), &base, &base, action, &ctx, + ) + .unwrap_err(); + // `{:#}` renders the whole chain: the outer context names the param, the cause + // says why a default may not read action scope. + let chain = format!("{err:#}"); + assert!(chain.contains("action scope"), "{chain}"); + assert!(chain.contains('S'), "should name the param: {chain}"); + } + + /// Inputs and outputs must agree on what an asset may be spelled as. + /// + /// A resolved input carries a *label* (`"lbtc"`), not necessarily a hex id — that is + /// what the manifest writes and what resolution defaults to. The covenant-input branch + /// used to parse it as raw hex while the output branch resolved it, so a covenant + /// input on L-BTC failed with "failed to parse hex" for the obvious spelling. + #[test] + fn asset_labels_and_hex_ids_resolve_the_same_everywhere() { + let net = lwk_wollet::ElementsNetwork::LiquidTestnet; + assert_eq!(resolve_asset_id("lbtc", net).unwrap(), net.policy_asset()); + assert_eq!(resolve_asset_id("bitcoin", net).unwrap(), net.policy_asset()); + // The hex form of the very same asset must still resolve to it. + assert_eq!( + resolve_asset_id(&net.policy_asset().to_string(), net).unwrap(), + net.policy_asset() + ); + // And a non-asset string is still an error, not a silent policy-asset fallback. + assert!(resolve_asset_id("not-an-asset", net).is_err()); + } + + /// Every declared input must reach the built transaction, or the run must fail. + /// + /// The live failure this pins: `"utxo_source": "prize_covenant"` (a bare string + /// rather than `{"utxo_type": "prize_covenant"}`) matched neither collection branch + /// and fell through a chain with no `else`, so the covenant input vanished from the + /// PSET. Step 2 still resolved it, the preview still showed it, and the run signed + /// and broadcast a transaction that never spent the covenant UTXO at all. + #[test] + fn declared_inputs_that_never_reach_the_pset_are_detected() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", "actions": { "A": { "inputs": [ + { "id": "contest_in", "utxo_source": "prize_covenant" }, + { "id": "fees_in", "utxo_source": "wallet" } ] } } }"#, + ) + .expect("manifest should parse"); + let declared = manifest.actions["A"].inputs.as_deref().unwrap(); + + let collected = |ids: &[&str]| -> Vec { + ids.iter() + .map(|id| pset_builder::PsetInput::Covenant { + input_id: (*id).to_string(), + outpoint: lwk_wollet::elements::OutPoint::new( + lwk_wollet::elements::Txid::from_str(&"a".repeat(64)).unwrap(), + 0, + ), + script_pubkey: lwk_wollet::elements::Script::new(), + asset: lwk_wollet::elements::AssetId::from_str(&"b".repeat(64)).unwrap(), + amount: 1, + issuance: None, + sequence: None, + }) + .collect() + }; + + // The bug: the unrecognized-source input is silently absent. + assert_eq!( + inputs_missing_from_pset(declared, &collected(&["fees_in"])), + vec!["contest_in"], + ); + // Both gone — reported in declaration order, not just the first. + assert_eq!( + inputs_missing_from_pset(declared, &collected(&[])), + vec!["contest_in", "fees_in"], + ); + // The healthy case must stay silent, whatever the ordering. + assert!(inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in"])).is_empty()); + assert!(inputs_missing_from_pset(declared, &collected(&["fees_in", "contest_in"])).is_empty()); + // Extra inputs the engine adds (not declared) are not this check's business. + assert!(inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in", "x"])).is_empty()); + } + + /// A declared change output must survive having no `amount_sat`. + /// + /// It reads like an incomplete output — no amount, nothing pushed to the PSET — and + /// was skipped for exactly that reason, which dropped its asset from the + /// change-permitted set and made a manifest that *declared* change fail with "this + /// action does not permit L-BTC change". The validator has always exempted change + /// from needing an amount, so the engine must agree. + #[test] + fn change_outputs_are_never_skipped_for_a_missing_amount() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", "actions": { "A": { "outputs": [ + { "id": "change_out", "asset": "lbtc", "destination": "change" }, + { "id": "opt_change", "asset": "lbtc", "destination": "change", "optional": true }, + { "id": "opt_wallet", "asset": "lbtc", "destination": "wallet", "optional": true }, + { "id": "plain_wallet", "asset": "lbtc", "destination": "wallet" } ] } } }"#, + ) + .expect("manifest should parse"); + let outputs = manifest.actions["A"].outputs.as_deref().unwrap(); + let by_id = |id: &str| outputs.iter().find(|o| o.id == id).expect(id); + + // Change: skipped for neither spelling, `optional` included — the asset has to be + // registered either way. + assert!(!skips_when_amount_absent(by_id("change_out"))); + assert!(!skips_when_amount_absent(by_id("opt_change"))); + assert!(is_change_output(by_id("change_out"))); + + // Everything else keeps the old behaviour: optional is skipped, required is not + // (and goes on to fail loudly for the missing amount). + assert!(skips_when_amount_absent(by_id("opt_wallet"))); + assert!(!skips_when_amount_absent(by_id("plain_wallet"))); + assert!(!is_change_output(by_id("plain_wallet"))); + } + #[test] fn outpoint_override_parses_txid_vout() { let ov = OutpointOverride::parse_outpoint( @@ -3339,6 +4054,7 @@ mod tests { }), asset: None, state_vars: None, + params: None, confidential: false, }; @@ -3426,6 +4142,182 @@ mod tests { "an unreferencing literal must pass through verbatim"); } + /// `$inputs..` reads an instance field straight off a resolved input, with + /// no hook in between (examples/deadcat's constructor). + /// + /// The assertion that matters is `issued_asset` != `asset`. On an input carrying a new + /// issuance those are different things — the spent UTXO is L-BTC, the created asset is + /// not — and confusing them writes L-BTC's id into a field meant to hold the asset the + /// transaction just created. Nothing downstream would notice: it is a well-formed + /// 32-byte id, so the covenant addresses simply come out wrong. + #[test] + fn create_instance_reads_input_refs_and_distinguishes_issued_asset() { + const LBTC: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const ISSUED: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + const TOKEN: &str = "3333333333333333333333333333333333333333333333333333333333333333"; + + let mut ctx = ExecutionContext::new(); + ctx.set_input(ResolvedInput { + id: "yes_defining_in".to_string(), + txid: "aa".repeat(32), + vout: 0, + amount_sat: 5_000, + asset: LBTC.to_string(), + issuance_entropy: None, + }); + ctx.set_input_attr("yes_defining_in", "issued_asset", ISSUED); + ctx.set_input_attr("yes_defining_in", "reissuance_token", TOKEN); + + let mut fields = BTreeMap::new(); + for (name, expr) in [ + ("YES_TOKEN_ASSET", "$inputs.yes_defining_in.issued_asset"), + ("YES_REISSUANCE_TOKEN", "$inputs.yes_defining_in.reissuance_token"), + ("SPENT_ASSET", "$inputs.yes_defining_in.asset"), + ] { + fields.insert(name.to_string(), ComputeSpec::Expr(expr.to_string())); + } + let ci = InstanceCreate { fields }; + + let result = eval_create_instance_fields( + &ci, + &ctx, + std::path::Path::new("/nonexistent"), + &std::collections::HashMap::new(), + lwk_wollet::ElementsNetwork::LiquidTestnet, + false, + false, + ); + + assert_eq!(result.get("YES_TOKEN_ASSET").map(String::as_str), Some(ISSUED)); + assert_eq!(result.get("YES_REISSUANCE_TOKEN").map(String::as_str), Some(TOKEN)); + assert_eq!( + result.get("SPENT_ASSET").map(String::as_str), + Some(LBTC), + "`asset` must stay the SPENT utxo's asset — if this ever aliases to the issued \ + asset, every manifest using `.asset` on a funding input changes meaning", + ); + } + + /// Build a reissuance input whose `issuance` block carries the given extra keys. + #[cfg(test)] + fn reissue_input(extra: serde_json::Value) -> crate::manifest::Input { + let mut iss = serde_json::json!({ "kind": "reissue", "asset_amount_sat": 10 }); + for (k, v) in extra.as_object().unwrap() { + iss[k] = v.clone(); + } + serde_json::from_value(serde_json::json!({ + "id": "yes_reissuance_in", + "utxo_source": "wallet", + "issuance": iss + })) + .expect("test input should deserialize") + } + + /// The entropy that mints the real testnet market's YES asset, and the asset id it + /// must produce. Taken from the live chain (mint tx 14369d64…), so this also pins the + /// byte order: a block explorer prints this entropy reversed. + #[cfg(test)] + const YES_ENTROPY: &str = "f8326827828ee2aab3c4d273fb573a8cf89a401bbc6643637d439ff36c60b6b9"; + #[cfg(test)] + const YES_ASSET: &str = "3a8b6b466346d9dbfcecd8c8d7b0c1873aee4e00fd5df0d95086a3f7eecd5a39"; + + #[test] + fn issuance_entropy_comes_from_the_reference() { + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); + + let inp = reissue_input(serde_json::json!({ "entropy": "instance.YES_ISSUANCE_ENTROPY" })); + let entropy = resolve_issuance_entropy(&inp, &ctx) + .expect("should resolve") + .expect("should be present"); + assert_eq!(hex_bytes(&entropy), YES_ENTROPY); + } + + #[test] + fn issuance_entropy_cross_check_rejects_a_reversed_value() { + // The failure this check exists for. A byte-reversed entropy — the order every + // block explorer prints — is still 32 well-formed bytes, still builds, still + // broadcasts, and reissues a completely different asset. + let mut reversed = pset_builder::decode_entropy_hex(YES_ENTROPY).unwrap(); + reversed.reverse(); + + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", hex_bytes(&reversed)); + ctx.set_compile_param("YES_TOKEN_ASSET", YES_ASSET); + + let inp = reissue_input(serde_json::json!({ + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + })); + let err = resolve_issuance_entropy(&inp, &ctx).expect_err("reversed entropy must fail"); + assert!( + err.to_string().contains("does not produce the declared asset"), + "unexpected error: {err}" + ); + + // …and the correct order passes the same check. + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); + assert!(resolve_issuance_entropy(&inp, &ctx).unwrap().is_some()); + } + + #[test] + fn issuance_entropy_falls_back_to_provided_inputs() { + // The old path stays working: no `entropy` reference, value from the instance + // file's provided_inputs. + let mut ctx = ExecutionContext::new(); + ctx.set_input(ResolvedInput { + id: "yes_reissuance_in".to_string(), + txid: "aa".repeat(32), + vout: 0, + amount_sat: 1, + asset: "bb".repeat(32), + issuance_entropy: Some(YES_ENTROPY.to_string()), + }); + let entropy = resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) + .expect("should resolve") + .expect("should be present"); + assert_eq!(hex_bytes(&entropy), YES_ENTROPY); + } + + #[test] + fn issuance_entropy_absent_is_reported_not_guessed() { + let ctx = ExecutionContext::new(); + assert!( + resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) + .expect("missing entropy is not an error, it is a None") + .is_none() + ); + } + + #[test] + fn an_unresolvable_entropy_reference_is_an_error() { + // Distinct from "absent": the manifest named a value and it was not there, which + // is a broken manifest rather than a missing instance field. + let ctx = ExecutionContext::new(); + let inp = reissue_input(serde_json::json!({ "entropy": "instance.NOT_SET" })); + let err = resolve_issuance_entropy(&inp, &ctx).expect_err("should error"); + assert!(err.to_string().contains("does not resolve"), "unexpected: {err}"); + } + + /// A 32-byte id can only travel by a `$` form. The bare-expression path is arithmetic + /// (`eval_expr` returns u64), which is exactly why `$inputs.` had to be added rather + /// than relying on the existing evaluator. + #[test] + fn a_bare_input_expression_cannot_carry_an_asset_id() { + let mut ctx = ExecutionContext::new(); + ctx.set_input_attr("in0", "issued_asset", &"22".repeat(32)); + + assert!( + eval::eval_expr_str("inputs.in0.issued_asset", &ctx).is_err(), + "the numeric evaluator must reject a 32-byte id rather than mangle it", + ); + assert_eq!( + eval::resolve_input_ref("in0.issued_asset", &ctx).as_deref(), + Some("22".repeat(32).as_str()), + "the string path must return it intact", + ); + } + /// `eval_create_instance_fields` with a `"$params.KEY"` expression must prefer /// `ctx.params` over `ctx.compile_params`, so the fresh wallet key is used even /// if `compile_params` still holds a stale value (extra regression guard for the @@ -3842,9 +4734,9 @@ mod tests { .expect("MakeOffer method exists"); let ci = action.create_instance.as_ref().expect("MakeOffer has create_instance"); - // Track the manifest's own setting — debug symbols change the CMR, so a hardcoded + // Track the manifest's own settings — debug symbols change the CMR, so a hardcoded // value here would verify a compilation mode the CLI never actually runs. - let debug = manifest.include_debug_symbols(); + let compile_opts = manifest.compile_opts(); let maker_pub_key = "e1512ae2f5b4ee8c12e9c57ccd0943273c6256f496516d3aefeaa16c32d3c05b"; let lbtc_testnet = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; let usdt_ish = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; @@ -3870,7 +4762,7 @@ mod tests { } } - let fields = eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, debug); + let fields = eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, &compile_opts); // MAKER_SPK is sha256 of the maker_payout covenant's scriptPubKey — verify against the // program itself rather than a copied constant. @@ -3880,7 +4772,7 @@ mod tests { let payout_hints: std::collections::HashMap = [("PUB_KEY".to_string(), "pubkey".to_string())].into_iter().collect(); let payout_addr = crate::covenant::compute_covenant_address( - &payout_simf, &payout_params, &payout_hints, &[], net, debug, + &payout_simf, &payout_params, &payout_hints, &[], net, &compile_opts, ) .expect("maker_payout covenant address compiles"); let expect_spk_hash: String = @@ -3918,7 +4810,7 @@ mod tests { ); let offer_simf = manifest_path.parent().unwrap().join("tessera.simf"); let offer_addr = crate::covenant::compute_covenant_address( - &offer_simf, &offer_params, &offer_hints, &[], net, debug, + &offer_simf, &offer_params, &offer_hints, &[], net, &compile_opts, ) .expect("tessera_offer covenant address compiles from create_instance output"); @@ -3926,7 +4818,7 @@ mod tests { let mut bumped = offer_params.clone(); bumped.insert("AMOUNT_B".to_string(), "50001".to_string()); let bumped_addr = crate::covenant::compute_covenant_address( - &offer_simf, &bumped, &offer_hints, &[], net, debug, + &offer_simf, &bumped, &offer_hints, &[], net, &compile_opts, ) .expect("bumped offer address compiles"); assert_ne!( @@ -3941,7 +4833,7 @@ mod tests { other_side.insert("OFFER_ASSET_ID".to_string(), lbtc_testnet.to_string()); other_side.insert("OFFER_AMOUNT".to_string(), "999".to_string()); let other_side_addr = crate::covenant::compute_covenant_address( - &offer_simf, &other_side, &offer_hints, &[], net, debug, + &offer_simf, &other_side, &offer_hints, &[], net, &compile_opts, ) .expect("offer address compiles with a different asset A"); assert_eq!( diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index 59e4129..a361e3e 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -1,9 +1,12 @@ #![allow(dead_code)] use std::collections::BTreeMap; +use std::str::FromStr; +use anyhow::Context as _; use schemars::JsonSchema; use serde::Deserialize; +use simplicityhl::{UnstableFeature, UnstableFeatures}; // --------------------------------------------------------------------------- // Top-level file @@ -50,6 +53,92 @@ pub struct SimplicityHl { /// Defaults to `false` (production; debug symbols are a transitional feature). #[serde(default)] pub debug_symbols: bool, + + /// Unstable SimplicityHL compiler features this manifest's programs are allowed to + /// use — the manifest form of `simc -Z `, one entry per feature: + /// + /// ```json + /// "simplicity_hl": { "unstable_features": ["enums"] } + /// ``` + /// + /// The compiler rejects gated syntax unless the feature is enabled, so a program + /// using `enum` fails to compile until `"enums"` is listed here. Enabling a feature + /// the programs don't use is harmless: this only lifts a restriction, it never + /// changes generated code, and therefore never changes a CMR or covenant address. + /// + /// Manifest-wide rather than per-`utxo_type`, mirroring `simc`'s own per-invocation + /// `-Z` flag — the whole point of a gate is that a reader can see, in one place, + /// which unstable syntax this protocol depends on. + /// + /// Defaults to empty: nothing unstable is enabled. + #[serde(default)] + pub unstable_features: Vec, +} + +/// One entry of [`SimplicityHl::unstable_features`], parsed straight into the compiler's +/// own [`UnstableFeature`] so the manifest and the toolchain cannot disagree about which +/// names exist. +/// +/// Both directions of drift are therefore load-time errors, which is the intent: a +/// misspelling (`"enum"`), and a feature that has since *stabilized* upstream — the +/// variant is deleted on stabilization, and the stale `-Z` name it leaves behind in a +/// manifest is no longer meaningful. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnstableFeatureName(pub UnstableFeature); + +impl<'de> Deserialize<'de> for UnstableFeatureName { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + UnstableFeature::from_str(&raw).map(Self).map_err(|_| { + serde::de::Error::custom(format!( + "unknown SimplicityHL unstable feature '{raw}'; known features: {}", + known_unstable_feature_names().join(", ") + )) + }) + } +} + +impl JsonSchema for UnstableFeatureName { + fn schema_name() -> String { + "UnstableFeatureName".to_string() + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + // Enumerated from the compiler's own list rather than hand-copied, so the + // published schema tracks the toolchain the same way the parser does. + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + enum_values: Some( + UnstableFeature::ALL + .iter() + .map(|feature| serde_json::Value::String(feature.to_string())) + .collect(), + ), + metadata: Some(Box::new(schemars::schema::Metadata { + description: Some(unstable_feature_descriptions()), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +/// Every `-Z` name the linked compiler accepts, for error messages. +fn known_unstable_feature_names() -> Vec { + UnstableFeature::ALL + .iter() + .map(UnstableFeature::to_string) + .collect() +} + +/// `name — what it enables` for each feature, as the schema's description. +fn unstable_feature_descriptions() -> String { + let mut out = String::from("Unstable SimplicityHL compiler feature (`simc -Z `)."); + for feature in UnstableFeature::ALL { + out.push_str(&format!("\n- {feature} — {}", feature.description())); + } + out } /// Structural keys an author may place in a manifest that carry no protocol meaning @@ -164,10 +253,24 @@ impl ComputeSpec { } } + /// The address whose script hash this param computes, if it is a `script_hash` spec. + pub fn as_script_hash_address(&self) -> Option<&str> { + match self.as_spec() { + Some(ParamCompute::ScriptHash { address }) => Some(address.as_str()), + _ => None, + } + } + /// True when this is a `simf_fn` spec, which resolves only after inputs do. pub fn is_simf_fn(&self) -> bool { matches!(self.as_spec(), Some(ParamCompute::SimfFn { .. })) } + + /// True when a hook supplies this value later in the run, so the user is never + /// prompted and there is nothing to evaluate up front. + pub fn is_hook(&self) -> bool { + matches!(self.as_spec(), Some(ParamCompute::Hook {})) + } } /// Auto-computation spec for a derived compile param or action param. @@ -217,6 +320,35 @@ pub enum ParamCompute { #[serde(default)] extra_leaves: Option>, }, + /// `sha256(scriptPubKey)` of an address — the exact value the Simplicity + /// `output_script_hash` / `input_script_hash` jets return for a UTXO paying it. + /// + /// An address and its script hash are two views of one destination: the covenant + /// commits to the hash, the transaction pays to the address, and if they ever + /// disagree the spend fails on-chain. Deriving one from the other is the only way to + /// keep that true — a manifest that asks for both separately is asking to be given + /// two values that must match and cannot be checked. + /// + /// Blinding is irrelevant here: a confidential address has the same scriptPubKey as + /// its unconfidential form, so both hash alike (`script_hash_of_address` pins this). + ScriptHash { + /// An address, or a reference resolving to one (`params.payout_address`). + address: String, + }, + /// A value a **hook** supplies later in this run — declared here, set by an + /// `on_resolved` / `on_pre_broadcast` block targeting `params.`. + /// + /// This exists so a hook cannot invent an identifier. Without it, `"set": { + /// "params.YES_TOKN_ASSET": "asset" }` is accepted, fills a slot nobody reads, and + /// surfaces as a wrong covenant address much later; with it, `validate` rejects the + /// typo and the declaration carries the `type` that byte-order handling depends on. + /// + /// It lives under `compute` rather than as a separate `deferred: true` flag because + /// `compute` already means exactly "this value is derived, do not prompt for it" — + /// the only thing that differs here is *who* derives it. A second flag would need its + /// own prompt-suppression path and would have to define what it means alongside a + /// `compute` that is also present. + Hook {}, /// A value taken from the executing wallet rather than the manifest. /// /// Grouped under one tag rather than spread across three so that "is this @@ -303,10 +435,62 @@ pub struct TapleafParam { // Action // --------------------------------------------------------------------------- +/// Which assets an action lets the engine return a surplus in, via a change output the +/// manifest did not declare. See [`Action::allow_change`]. +/// +/// Spelled as an enum rather than a boolean because the useful middle case — "return +/// leftover L-BTC, but never move a protocol asset I did not account for" — is the one +/// most funding actions want, and a boolean cannot say it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub enum AllowChange { + /// No undeclared change. A surplus in any asset fails the build. + #[default] + None, + /// Only the policy asset (L-BTC) may be returned. + LbtcOnly, + /// Any asset may be returned. + Any, +} + +impl AllowChange { + /// Whether a surplus in `asset` may be returned to the wallet. + pub fn permits(&self, asset: &lwk_wollet::elements::AssetId, policy_asset: &lwk_wollet::elements::AssetId) -> bool { + match self { + AllowChange::None => false, + AllowChange::LbtcOnly => asset == policy_asset, + AllowChange::Any => true, + } + } +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Action { pub description: Option, + /// Whether the engine may append a change output this action did not declare. + /// + /// **Every output a transaction carries must be written in the manifest. The network + /// fee is the single exception, because it has no manifest spelling.** A change output + /// is not an exception: its address and amount are chosen by the engine, so silently + /// adding one moves value to a destination the manifest never named, in an amount + /// nobody wrote down. That is how an oversized collateral input once turned 88,735 + /// satoshis into a miner's fee without a word of warning. + /// + /// So the default is [`AllowChange::None`]: a surplus in any asset — including L-BTC — + /// is an error, and the action must size its inputs to what it spends. Relax it only + /// where the surplus genuinely cannot be predicted: + /// + /// - `"none"` (default) — no change may be added; any surplus is an error. + /// - `"lbtc_only"` — the engine may return an L-BTC surplus to the wallet. Use this + /// for ordinary funding actions, where the fee is only known after the size is. + /// A surplus in any other asset is still an error. + /// - `"any"` — the engine may return a surplus in any asset. + /// + /// This governs *undeclared* change. An output with `"destination": "change"` is + /// declared, and permits change for its own asset regardless of this setting. + #[serde(default)] + pub allow_change: AllowChange, /// Runtime action parameters (Spec §5). Prompted, or set by hooks. pub params: Option>, pub inputs: Option>, @@ -454,6 +638,39 @@ pub struct Input { /// covenant's collateral is spent from the exact address whose hash it commits to. pub from_address: Option, pub amount_sat: Option, + /// An Elements asset issuance carried by this input. + /// + /// - `{"kind": "new", "asset_amount_sat": , "inflation_amount_sat": }` — + /// mint a brand-new asset, whose id is derived from this input's outpoint. Either + /// amount may be `0` (reissuance tokens only, or a fixed supply with no reissuance + /// rights). + /// - `{"kind": "reissue", "asset_amount_sat": , "entropy": }` — mint more of + /// an existing asset by spending its reissuance token. + /// + /// A reissuance needs the **issuance entropy** of the original mint — + /// `fast_merkle_root([sha256d(defining outpoint), contract_hash])`, the value the asset + /// id itself is derived from. It cannot be recovered from anything on chain: the + /// reissuance token UTXO carries no trace of the outpoint that created it. So a + /// constructor has to capture it at the one moment it exists, and hand it back later: + /// + /// ```json + /// // in the minting action's create_instance: + /// "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy" + /// // in the reissuing action's input: + /// "issuance": { "kind": "reissue", "asset_amount_sat": "params.PAIRS", + /// "entropy": "instance.YES_ISSUANCE_ENTROPY", + /// "issued_asset": "instance.YES_TOKEN_ASSET" } + /// ``` + /// + /// `issued_asset` is optional and is a **check**, not an input: the engine re-derives + /// the asset id from the entropy and refuses to build if the two disagree. An entropy + /// is opaque, and the byte order block explorers print is the reverse of the one used + /// here — without the check a transposed value still builds a broadcastable transaction + /// that reissues the wrong asset. + /// + /// Failing that, the entropy may come from `provided_inputs..issuance_entropy` + /// in the instance file. That works, but it travels with an outpoint override which + /// pins the input for *every* action sharing its id — long after the pin is correct. pub issuance: Option, /// Per-input `nSequence`. Drives BIP68 relative timelocks (the `check_lock_distance` /// / `check_lock_duration` Simplicity jets). Accepts: @@ -515,8 +732,8 @@ impl Input { pub struct Output { pub id: String, pub description: Option, - /// "change" | "params." | {"utxo_type": "..."} | {"type": "burn"} | conditional - pub destination: serde_json::Value, + /// Where this output's value goes. See [`OutputDestination`] for the accepted forms. + pub destination: OutputDestination, pub amount_sat: Option, pub asset: Option, pub optional: Option, @@ -537,6 +754,156 @@ pub struct Output { pub ui: Option, } +/// Where an [`Output`]'s value goes, in any of these forms: +/// +/// | form | meaning | +/// |---|---| +/// | `"change"` | wallet change; the amount is whatever is left, so `amount_sat` is omitted | +/// | `"wallet"` | a fresh receive address from this wallet | +/// | any other string | an address, or a `params.X` / `instance.X` reference resolving to one | +/// | `{"utxo_type": ""}` | the covenant address for that `utxo_type`; may carry per-site `compile_params` | +/// | `{"script_hash": ""}` | P2TR built from a 32-byte script hash | +/// | `{"type": "op_return"\|"burn"}` | data-carrying / burn output; bytes come from the output's own `data` field | +/// | `{"type": "fee"}` | the explicit fee leg — declares intent, produces no PSET output | +/// | `{"if": …}` | conditional — **parsed but NOT implemented**; the engine skips such an output | +/// +/// Kept as a raw [`serde_json::Value`] (the arms read their own keys), but the shape is +/// checked at load: an object matching none of these used to reach the build and be +/// skipped with a `[TODO]` line, which silently drops a declared output. +#[derive(Debug, Clone)] +pub struct OutputDestination(pub serde_json::Value); + +impl std::ops::Deref for OutputDestination { + type Target = serde_json::Value; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Destination `type` values the engine implements. Kept next to the parser so the schema, +/// the parser and `validate` cannot drift apart. +const DESTINATION_TYPES: [&str; 3] = ["op_return", "burn", "fee"]; + +impl<'de> Deserialize<'de> for OutputDestination { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match &value { + serde_json::Value::String(_) => Ok(Self(value)), + serde_json::Value::Object(map) => { + if let Some(ty) = map.get("type").and_then(|v| v.as_str()) { + if !DESTINATION_TYPES.contains(&ty) { + return Err(serde::de::Error::custom(format!( + "unknown destination type '{ty}'; expected one of: {}", + DESTINATION_TYPES.join(", ") + ))); + } + Ok(Self(value)) + } else if map.contains_key("utxo_type") + || map.contains_key("script_hash") + || map.contains_key("if") + { + Ok(Self(value)) + } else { + Err(serde::de::Error::custom(format!( + "unrecognized destination object {value}; expected \ + {{\"utxo_type\": ...}}, {{\"script_hash\": ...}}, or \ + {{\"type\": \"op_return\"|\"burn\"|\"fee\"}}" + ))) + } + } + other => Err(serde::de::Error::custom(format!( + "destination must be a string or an object, got {other}" + ))), + } + } +} + +impl JsonSchema for OutputDestination { + fn schema_name() -> String { + "OutputDestination".to_string() + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + subschema(serde_json::json!({ + "description": + "Where this output's value goes. A string is `change` (wallet change, \ + amount auto-computed), `wallet` (a fresh receive address), or an \ + address / `params.X` reference resolving to one.", + // `anyOf`, not `oneOf`: the string forms overlap by construction, and the + // schema must never reject what the parser accepts. + "anyOf": [ + { + "type": "string", + "description": + "`change`, `wallet`, a literal address, or a `params.X` / \ + `instance.X` reference that resolves to one.", + "examples": ["change", "wallet", "params.receive_address"] + }, + { + "type": "object", + "description": "The covenant address derived for a declared `utxo_type`.", + "required": ["utxo_type"], + "properties": { + "utxo_type": { "type": "string" }, + "args": { + "type": "object", + "description": + "Binds the utxo_type's declared `params` for this site. Values \ + are expressions in the ACTION's scope (`params.X`, \ + `instance.X`, a literal). Every param without a default has \ + to be bound here.", + "additionalProperties": { "type": "string" } + }, + "compile_params": { + "type": "object", + "description": + "Per-site compile-param overrides for this destination, \ + resolved against the action's params.", + "additionalProperties": { "type": "string" } + } + } + }, + { + "type": "object", + "description": "P2TR output built from a 32-byte script hash.", + "required": ["script_hash"], + "properties": { + "script_hash": { + "type": "string", + "description": "32-byte hex, or a reference resolving to it." + } + } + }, + { + "type": "object", + "description": + "`op_return` / `burn` embed the output's own `data` field (bare \ + OP_RETURN when absent). `fee` declares the fee leg and produces \ + no PSET output of its own.", + "required": ["type"], + "properties": { "type": { "enum": DESTINATION_TYPES } } + }, + { + "type": "object", + "description": + "Conditional destination. Parsed but NOT implemented — the engine \ + has no arm for it and skips the output entirely.", + "required": ["if"] + } + ] + })) + } +} + +/// Deserialize a hand-written JSON Schema fragment into schemars' representation. +/// +/// The alternative is assembling `SchemaObject`s field by field, which for a `anyOf` of +/// object shapes is several times the code and reads nothing like the schema it produces. +fn subschema(value: serde_json::Value) -> schemars::schema::Schema { + serde_json::from_value(value).expect("hand-written subschema is a valid JSON Schema") +} + impl Output { /// This output's short human-readable label, if it declares one. /// See [`Input::ui_label`] for why `description` is not a fallback here. @@ -551,7 +918,7 @@ impl Output { /// Human-readable summary of the destination. pub fn destination_summary(&self) -> String { - match &self.destination { + match &*self.destination { serde_json::Value::String(s) => s.clone(), serde_json::Value::Object(map) => { if let Some(ut) = map.get("utxo_type") { @@ -706,14 +1073,130 @@ pub struct UtxoScript { } /// Describes one additional taproot leaf appended to the Simplicity program leaf. +/// +/// Each leaf's payload is hashed as `tapdata` — `SHA256(SHA256("TapData") ‖ +/// SHA256("TapData") ‖ payload)`, which is the value a program computes with +/// `jet::tapdata_init()`, `sha_256_ctx_8_add_*` and `finalize` — then folded into the tap +/// tree with `TapBranch/elements` in declaration order, matching `jet::build_tapbranch`. +/// The payload's **width must match what the `.simf` hashes**: +/// `sha_256_ctx_8_add_32` wants exactly 32 bytes, `add_8` exactly 8. A mismatch yields a +/// perfectly valid address that the covenant then refuses to recognize as its own. #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct TaprootLeafSpec { + /// How the payload is hashed. Only `tapdata` is implemented, and it was previously + /// accepted as a free string — so any other spelling was silently hashed as tapdata + /// anyway, producing an address whose derivation nobody had written down. #[serde(rename = "type")] - pub type_: String, - /// Ordered payload items: each is either a hex literal string ("0x01") - /// or a state_var reference ({"state_var": "name"}). - pub payload: Vec, + pub type_: TaprootLeafKind, + /// Ordered payload items, concatenated into this leaf's byte string. + pub payload: Vec, +} + +/// The hashing scheme for a [`TaprootLeafSpec`]'s payload. +#[derive(Debug, Deserialize, JsonSchema)] +pub enum TaprootLeafKind { + /// Elements taproot data leaf — the only scheme the engine implements. + #[serde(rename = "tapdata")] + Tapdata, +} + +/// One item of a [`TaprootLeafSpec::payload`], in any of the three accepted forms: +/// +/// - a **hex literal** — `"0xff…"`, taken as raw bytes; +/// - a **computed value** — `{"value": , "type": "u64", "endian": "be", "pad_to": 32}`, +/// where `` resolves as `params.X` / `instance.X` / `.` / a bare +/// param name, falling back to the literal string (see [`crate::eval::encode_leaf_value`]); +/// - a **state var reference** — `{"state_var": "name"}`, that var's `default_value` as a +/// single `u8`. +/// +/// Kept as a raw [`serde_json::Value`] because the computed form's optional keys are read +/// by the encoder, but the *shape* is checked here: an item in none of these forms used to +/// parse happily and then fail mid-run, after prompting, with `Unsupported taproot payload +/// item`. +#[derive(Debug, Clone)] +pub struct TaprootLeafPayloadItem(pub serde_json::Value); + +impl<'de> Deserialize<'de> for TaprootLeafPayloadItem { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match &value { + serde_json::Value::String(_) => Ok(Self(value)), + serde_json::Value::Object(map) + if map.contains_key("value") || map.contains_key("state_var") => + { + Ok(Self(value)) + } + other => Err(serde::de::Error::custom(format!( + "taproot leaf payload item must be a hex literal (\"0x01\"), a computed \ + value ({{\"value\": ..., \"type\": ...}}), or a state var reference \ + ({{\"state_var\": \"name\"}}), got {other}" + ))), + } + } +} + +impl JsonSchema for TaprootLeafPayloadItem { + fn schema_name() -> String { + "TaprootLeafPayloadItem".to_string() + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + subschema(serde_json::json!({ + "description": + "One item of a taproot leaf payload. Items are concatenated, in order, \ + into the bytes that get hashed as the leaf.", + // `anyOf` rather than `oneOf`: an object carrying both `value` and `state_var` + // is nonsense, but the parser takes it (`value` wins), and the schema must not + // reject what the parser accepts. + "anyOf": [ + { + "type": "string", + "description": "Hex literal taken as raw bytes, e.g. \"0x01\". Whole bytes only.", + "pattern": "^(?:0[xX])?(?:[0-9a-fA-F]{2})*$" + }, + { + "type": "object", + "description": + "Computed value, resolved against the run's params/instance fields \ + and encoded per `type` / `endian` / `pad_to`.", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": + "`params.X`, `instance.X`, `.`, a bare param \ + name, or a literal." + }, + "type": { "enum": crate::eval::LEAF_VALUE_TYPES }, + "endian": { + "enum": ["be", "le"], + "description": "Byte order for the integer types. Defaults to little-endian." + }, + "pad_to": { + "type": "integer", + "description": + "Pad the encoded value to this width in bytes — 32 for a slot \ + the program hashes with `sha_256_ctx_8_add_32`." + }, + "align": { + "enum": ["left", "right"], + "description": + "Which end of the padded field the value occupies. Defaults to `right`." + } + } + }, + { + "type": "object", + "description": + "Reference to a `state_vars` entry; its `default_value` is encoded as a \ + single u8.", + "required": ["state_var"], + "properties": { "state_var": { "type": "string" } } + } + ] + })) + } } #[derive(Debug, Deserialize, JsonSchema)] @@ -723,13 +1206,114 @@ pub struct UtxoType { pub script: Option, pub asset: Option, pub state_vars: Option, + /// This type's parameter interface — everything the address derivation may read. + /// + /// Declaring it switches the type to a **closed scope**: `script.compile_params` and + /// `extra_leaves` resolve `params.X` against *these* params and nothing else. A site + /// binds them with `args` (`{"utxo_type": "t", "args": {"STATE": "params.x"}}`), whose + /// values are expressions evaluated in the *action's* scope. + /// + /// Without it, the type keeps the legacy behaviour: leaves and compile params resolve + /// against whatever is ambient at each mention. That is what makes one `utxo_type` + /// derive two different addresses in two actions — `params.foo` means one thing where + /// the action declares `foo` and something else where it does not — with no error, + /// because an address is a hash and a wrong one looks exactly like a right one. + pub params: Option>, /// Whether UTXOs of this type are confidential (blinded). Defaults to false — covenant /// UTXOs are explicit so the spending Simplicity program can introspect value and asset. #[serde(default)] pub confidential: bool, } +/// One entry of a [`UtxoType::params`] interface. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UtxoParamDef { + /// Manifest type, used as the compile-param type hint (`u64`, `bytes32`, + /// `liquid.asset_id`, …) — the same vocabulary action params use. + #[serde(rename = "type")] + pub type_: String, + pub description: Option, + /// Value to use when a site binds no `args` entry for this param. + /// + /// Evaluated in **instance scope**: a literal, or `instance.X` naming a field fixed + /// when the contract was instantiated. Action scope is deliberately unreachable — + /// a value that varies per run is exactly what a site must bind explicitly. + /// + /// Without a default, every site must bind it, and `validate` says which ones don't. + pub default: Option, +} + +/// The key a site uses to bind a [`UtxoType`]'s params. +pub const SITE_ARGS_KEY: &str = "args"; + impl UtxoType { + /// True when this type declares a parameter interface, and therefore resolves its + /// address from that interface alone. See [`UtxoType::params`]. + pub fn is_closed(&self) -> bool { + self.params.is_some() + } + + /// Bind this type's declared params for one site. + /// + /// `site` is the `destination` / `utxo_source` object the type was named from; its + /// `args` entries are expressions evaluated by `eval_arg` in the **action's** scope. + /// A param with no binding falls back to its `default`, evaluated by `eval_default` in + /// **instance** scope. A param with neither is an error naming the param — the failure + /// that used to be a silently different address. + /// + /// Returns the param values and their declared types (the compile-param type hints). + pub fn bind_site_params( + &self, + site: Option<&serde_json::Value>, + eval_arg: &dyn Fn(&str) -> anyhow::Result, + eval_default: &dyn Fn(&str) -> anyhow::Result, + ) -> anyhow::Result<( + std::collections::HashMap, + std::collections::HashMap, + )> { + let declared = self.params.as_ref().map(|p| p.iter().collect::>()).unwrap_or_default(); + let args = site + .and_then(|s| s.get(SITE_ARGS_KEY)) + .and_then(|a| a.as_object()); + + // A bound name that matches no declared param is a typo that would otherwise do + // nothing at all — the site would derive the default address and look fine. + if let Some(args) = args { + for name in args.keys() { + if !self.params.as_ref().is_some_and(|p| p.contains_key(name)) { + anyhow::bail!( + "'{name}' is not a param of this utxo_type; declared: [{}]", + declared.iter().map(|(n, _)| n.as_str()).collect::>().join(", ") + ); + } + } + } + + let mut values = std::collections::HashMap::new(); + let mut hints = std::collections::HashMap::new(); + for (name, def) in declared { + let raw = args.and_then(|a| a.get(name)); + let value = match (raw, &def.default) { + (Some(expr), _) => { + let expr = expr.as_str().ok_or_else(|| { + anyhow::anyhow!("arg '{name}' must be a string expression") + })?; + eval_arg(expr).with_context(|| format!("arg '{name}' = '{expr}'"))? + } + (None, Some(default)) => eval_default(default) + .with_context(|| format!("default for param '{name}' = '{default}'"))?, + (None, None) => anyhow::bail!( + "param '{name}' is not bound here and has no default — every site that \ + names this utxo_type has to supply it (\"args\": {{\"{name}\": …}})" + ), + }; + values.insert(name.clone(), value); + hints.insert(name.clone(), def.type_.clone()); + } + Ok((values, hints)) + } + /// Resolve `script.extra_leaves` to concrete byte vectors. /// /// Each payload item is one of: @@ -749,7 +1333,7 @@ impl UtxoType { let mut result = Vec::new(); for leaf in extra_leaves { let mut bytes: Vec = Vec::new(); - for item in &leaf.payload { + for TaprootLeafPayloadItem(item) in &leaf.payload { match item { serde_json::Value::String(s) => { let hex = s.trim_start_matches("0x").trim_start_matches("0X"); @@ -817,6 +1401,26 @@ impl Manifest { self.simplicity_hl.as_ref().is_some_and(|s| s.debug_symbols) } + /// The unstable SimplicityHL features this manifest enables for its `.simf` programs. + /// Empty by default; see [`SimplicityHl::unstable_features`]. + pub fn unstable_features(&self) -> UnstableFeatures { + UnstableFeatures::new( + self.simplicity_hl + .iter() + .flat_map(|hl| hl.unstable_features.iter().map(|name| name.0)), + ) + } + + /// Everything the SimplicityHL compiler needs from this manifest, in the shape the + /// [`crate::covenant`] helpers take. Build this once per run and pass it down — + /// deriving it per call site is how the two settings drift apart. + pub fn compile_opts(&self) -> crate::covenant::CompileOpts { + crate::covenant::CompileOpts { + debug_symbols: self.include_debug_symbols(), + unstable_features: self.unstable_features(), + } + } + /// Look up a named `utxo_type` entry. pub fn utxo_type(&self, name: &str) -> anyhow::Result<&UtxoType> { self.utxo_types @@ -1102,6 +1706,203 @@ mod tests { assert!(!absent.include_debug_symbols()); } + /// The destination forms the schema advertises are exactly the ones that parse, and + /// nothing else does. Before this, `destination` was an untyped `Value`: a typo like + /// `{"utxo_typ": …}` parsed happily and was then skipped at build time with a `[TODO]` + /// line, silently dropping a declared output from the transaction. + #[test] + fn destination_accepts_exactly_the_documented_forms() { + let parse = |dest: &str| { + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "1", "protocol": "t", "actions": {{ "A": {{ "outputs": [ + {{ "id": "o0", "amount_sat": "1", "destination": {dest} }} ] }} }} }}"# + )) + }; + + for ok in [ + r#""change""#, + r#""wallet""#, + r#""params.receive_address""#, + r#""tex1p0000""#, + r#"{ "utxo_type": "vault" }"#, + r#"{ "utxo_type": "vault", "compile_params": { "X": "params.x" } }"#, + r#"{ "script_hash": "instance.COV_HASH" }"#, + r#"{ "type": "op_return" }"#, + r#"{ "type": "burn" }"#, + r#"{ "type": "fee" }"#, + r#"{ "if": "params.flag" }"#, + ] { + assert!(parse(ok).is_ok(), "should parse: {ok}"); + } + + // A misspelled key is no longer an output that quietly disappears... + let err = parse(r#"{ "utxo_typ": "vault" }"#).expect_err("typo must not parse"); + assert!(err.to_string().contains("utxo_type"), "{err}"); + + // ...nor is a destination type the engine has no arm for. + let err = parse(r#"{ "type": "p2pkh" }"#).expect_err("unknown type must not parse"); + assert!(err.to_string().contains("p2pkh"), "{err}"); + + // Non-string, non-object destinations were never meaningful. + assert!(parse("42").is_err()); + assert!(parse(r#"["change"]"#).is_err()); + } + + /// A closed `utxo_type` derives its address from its own params — and only those. + /// + /// This is the whole point of the boundary: the same type mentioned in two actions + /// used to mean two different addresses, because a leaf reading `params.x` picked up + /// whichever action happened to declare `x`. Now the site says what it means, and a + /// site that says nothing gets the declared default, in every action alike. + #[test] + fn closed_utxo_type_binds_params_from_the_site_not_the_action() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", "utxo_types": { "vault": { + "description": "d", + "params": { + "STATE": { "type": "bytes32", "default": "0xff" }, + "OWNER": { "type": "bytes32", "default": "instance.OWNER_KEY" } + }, + "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, + ) + .expect("manifest should parse"); + let ut = manifest.utxo_type("vault").unwrap(); + assert!(ut.is_closed()); + + let arg = |expr: &str| Ok(format!("arg:{expr}")); + let default = |expr: &str| Ok(format!("default:{expr}")); + + // No site args: every param takes its default. + let (values, hints) = ut.bind_site_params(None, &arg, &default).unwrap(); + assert_eq!(values["STATE"], "default:0xff"); + assert_eq!(values["OWNER"], "default:instance.OWNER_KEY"); + assert_eq!(hints["STATE"], "bytes32"); + + // A site binds one and inherits the other. + let site = serde_json::json!({ "utxo_type": "vault", "args": { "STATE": "params.claim" } }); + let (values, _) = ut.bind_site_params(Some(&site), &arg, &default).unwrap(); + assert_eq!(values["STATE"], "arg:params.claim"); + assert_eq!(values["OWNER"], "default:instance.OWNER_KEY"); + + // Binding a name the type does not declare is a typo that would otherwise be a + // no-op — the site would silently derive the default address. + let typo = serde_json::json!({ "utxo_type": "vault", "args": { "STAT": "0x00" } }); + let err = ut.bind_site_params(Some(&typo), &arg, &default).unwrap_err(); + assert!(err.to_string().contains("STAT"), "{err}"); + } + + /// A param with no default must be bound at the site, and say so when it isn't. + #[test] + fn unbound_param_without_a_default_is_an_error_naming_it() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", "utxo_types": { "vault": { + "description": "d", + "params": { "DEBT": { "type": "u64" } }, + "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, + ) + .unwrap(); + let ut = manifest.utxo_type("vault").unwrap(); + let id = |expr: &str| Ok(expr.to_string()); + + let err = ut.bind_site_params(None, &id, &id).unwrap_err(); + assert!(err.to_string().contains("DEBT"), "{err}"); + assert!(err.to_string().contains("args"), "should say how to fix it: {err}"); + + let site = serde_json::json!({ "utxo_type": "vault", "args": { "DEBT": "1000" } }); + let (values, _) = ut.bind_site_params(Some(&site), &id, &id).unwrap(); + assert_eq!(values["DEBT"], "1000"); + } + + /// Same contract for taproot leaf payload items. + #[test] + fn leaf_payload_items_accept_exactly_the_documented_forms() { + let parse = |item: &str| { + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "1", "protocol": "t", "utxo_types": {{ "u": {{ + "description": "d", + "script": {{ "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ {{ "type": "tapdata", "payload": [{item}] }} ] }} }} }} }}"# + )) + }; + + for ok in [ + r#""0x01""#, + r#"{ "value": "instance.CURRENT_DEBT", "type": "u64", "endian": "be", "pad_to": 32 }"#, + r#"{ "state_var": "state" }"#, + ] { + assert!(parse(ok).is_ok(), "should parse: {ok}"); + } + + // Used to parse, then fail mid-run with "Unsupported taproot payload item". + assert!(parse("1").is_err()); + assert!(parse(r#"{ "val": "x" }"#).is_err()); + + // `tapdata` is the only hashing scheme implemented; anything else was silently + // hashed as tapdata anyway. + let err = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", "utxo_types": { "u": { + "description": "d", + "script": { "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ { "type": "tapscript", "payload": ["0x01"] } ] } } } }"#, + ) + .expect_err("unknown leaf kind must not parse"); + assert!(err.to_string().contains("tapdata"), "{err}"); + } + + #[test] + fn unstable_features_reach_the_compiler_and_default_to_none() { + // The point of the field is that the set handed to the compiler is exactly what + // the manifest listed — an entry that silently doesn't arrive shows up much later + // as an "unstable feature not enabled" compile error. + let enabled = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "simplicity_hl": { "unstable_features": ["enums"] } }"#, + ) + .expect("unstable_features should parse"); + assert_eq!( + enabled.unstable_features(), + UnstableFeatures::new([UnstableFeature::Enums]) + ); + // Purely a gate: it must not drag debug symbols (which move every address) along. + assert!(!enabled.include_debug_symbols()); + + // Absent block, empty block and empty list all mean "nothing unstable". + for json in [ + r#"{ "manifest_version": "1", "protocol": "t" }"#, + r#"{ "manifest_version": "1", "protocol": "t", "simplicity_hl": {} }"#, + r#"{ "manifest_version": "1", "protocol": "t", + "simplicity_hl": { "unstable_features": [] } }"#, + ] { + let m = Manifest::from_json_str(json).expect("should parse"); + assert_eq!(m.unstable_features(), UnstableFeatures::none(), "{json}"); + } + + // A name the compiler doesn't know is a load-time error, not a mystery compile + // failure later — and the message says which names exist. + let err = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "simplicity_hl": { "unstable_features": ["enum"] } }"#, + ) + .expect_err("a misspelled feature must not parse"); + let msg = err.to_string(); + assert!(msg.contains("enum"), "{msg}"); + assert!(msg.contains("enums"), "message should list known features: {msg}"); + + // Both settings travel together to the compile sites. + let both = Manifest::from_json_str( + r#"{ "manifest_version": "1", "protocol": "t", + "simplicity_hl": { "debug_symbols": true, "unstable_features": ["enums", "enums"] } }"#, + ) + .expect("both settings should parse"); + let opts = both.compile_opts(); + assert!(opts.debug_symbols); + // Duplicates collapse rather than being passed through twice. + assert_eq!( + opts.unstable_features, + UnstableFeatures::new([UnstableFeature::Enums]) + ); + } + #[test] fn wallet_computes_are_recognised_and_are_not_expressions() { let m = Manifest::from_json_str( diff --git a/txmanifest_lib/src/preview.rs b/txmanifest_lib/src/preview.rs index 4cd67d9..bdfcc58 100644 --- a/txmanifest_lib/src/preview.rs +++ b/txmanifest_lib/src/preview.rs @@ -502,7 +502,7 @@ fn build_net_effect(action: &Action, ctx: &ExecutionContext, fee_sat: Option bool { - matches!(&output.destination, serde_json::Value::String(s) if s == "change") + matches!(&*output.destination, serde_json::Value::String(s) if s == "change") } /// The display symbol for an output's asset (`tL-BTC`, `tUSD`, or a short id). @@ -528,7 +528,7 @@ fn input_bucket(input: &Input, _ctx: &ExecutionContext) -> String { } fn output_bucket(output: &Output, ctx: &ExecutionContext) -> String { - match &output.destination { + match &*output.destination { serde_json::Value::String(s) if s == "wallet" || s == "change" => WALLET_BUCKET.into(), serde_json::Value::String(other) => { // A `params.X` / `instance.X` destination resolves to an address. diff --git a/txmanifest_lib/src/pset_builder.rs b/txmanifest_lib/src/pset_builder.rs index 4cb90d2..75b10eb 100644 --- a/txmanifest_lib/src/pset_builder.rs +++ b/txmanifest_lib/src/pset_builder.rs @@ -79,11 +79,14 @@ pub struct BuildPsetRequest { pub outputs: Vec, pub fee_rate: f32, pub policy_asset: AssetId, - /// Whether the action declared an explicit `"change"` output. When `false`, - /// the builder never adds a change output — any L-BTC surplus is folded into - /// the fee instead. This keeps the output set to exactly the declared outputs - /// plus the fee, which recursive covenants (e.g. last-will Refresh) require. - pub build_change: bool, + /// The assets for which the action declared a `"change"` output. + /// + /// The builder adds a change output only for an asset in this set. Every other output + /// a transaction carries has to come from the manifest — the fee is the single + /// exception, because it has no manifest spelling. A surplus in an asset with no + /// declared change output is an error rather than a silently-invented output: the + /// alternative is a transaction that moves value the manifest never mentioned. + pub change_assets: std::collections::HashSet, } pub struct IssuanceResult { @@ -117,7 +120,7 @@ pub fn build_pset(wollet: &Wollet, network: ElementsNetwork, req: &BuildPsetRequ // First pass: temp fee=1 to estimate weight. let (temp_pset, temp_sec, _) = - build_inner(wollet, &secp, &mut rng, req, 1, wallet_blinding_pk_btc, network, false)?; + build_inner(wollet, &secp, &mut rng, req, 1, wallet_blinding_pk_btc, network, false, false)?; let fee = { let mut tmp = temp_pset.clone(); let mut tmp_rng = thread_rng(); @@ -136,7 +139,7 @@ pub fn build_pset(wollet: &Wollet, network: ElementsNetwork, req: &BuildPsetRequ // Second pass: real fee. let (mut pset, inp_txout_sec, issuances) = - build_inner(wollet, &secp, &mut rng, req, fee, wallet_blinding_pk_btc, network, false)?; + build_inner(wollet, &secp, &mut rng, req, fee, wallet_blinding_pk_btc, network, false, true)?; wollet .add_details(&mut pset) @@ -195,7 +198,7 @@ pub fn estimate_fee(wollet: &Wollet, network: ElementsNetwork, req: &BuildPsetRe let wallet_blinding_pk_btc = btc_pubkey(wallet_blinding_pk); let (draft_pset, draft_sec, _) = - build_inner(wollet, &secp, &mut rng, req, 0, wallet_blinding_pk_btc, network, true)?; + build_inner(wollet, &secp, &mut rng, req, 0, wallet_blinding_pk_btc, network, true, false)?; let mut tmp = draft_pset; if pset_has_confidential_output(&tmp) { tmp.blind_last(&mut rng, &secp, &draft_sec) @@ -226,6 +229,9 @@ fn build_inner( // Estimation pass: don't enforce balance or add change — the fee absorbs any // surplus (possibly 0). Used only to measure the resulting tx's vsize. draft: bool, + // Only the final pass checks the absorbed surplus. The weight-estimation pass runs with + // a placeholder fee of 1, against which every real surplus looks absurd. + enforce_fee_sanity: bool, ) -> Result<(PartiallySignedTransaction, HashMap, Vec)> { let mut pset = PartiallySignedTransaction::new_v2(); let mut inp_txout_sec: HashMap = HashMap::new(); @@ -303,7 +309,7 @@ fn build_inner( // plus the fee, as recursive covenants require. let (change, fee) = if draft { (0u64, total_lbtc_in.saturating_sub(total_lbtc_out)) - } else if req.build_change { + } else if req.change_assets.contains(&req.policy_asset) { let lbtc_needed = total_lbtc_out + fee; if total_lbtc_in < lbtc_needed { anyhow::bail!( @@ -319,7 +325,22 @@ fn build_inner( total_lbtc_in, total_lbtc_out ); } - (0u64, total_lbtc_in - total_lbtc_out) + let surplus = total_lbtc_in - total_lbtc_out; + // No change permitted for L-BTC, so the surplus IS the fee — and anything the fee + // does not account for is value leaving the wallet to no declared destination. + // Paying it to miners silently is exactly the failure `allow_change` exists to + // prevent, so the difference is an error, not a donation. + if enforce_fee_sanity && surplus != fee { + anyhow::bail!( + "L-BTC does not balance: inputs exceed outputs by {surplus} sat, but the fee \ + is {fee} sat, leaving {} sat unaccounted for.\n\ + This action does not permit L-BTC change, so there is nowhere for it to go. \ + Either set \"allow_change\": \"lbtc_only\" on the action, declare a change \ + output, or size the input to outputs + fee exactly.", + surplus.saturating_sub(fee) + ); + } + (0u64, surplus) }; // blinder_index must reference an input whose secrets are in inp_txout_sec (i.e. a wallet @@ -353,6 +374,20 @@ fn build_inner( .fold(HashMap::new(), |mut m, o| { *m.entry(o.asset).or_default() += o.amount; m }); for (asset, in_amt) in &wallet_asset_in { let out_amt = total_non_lbtc_out.get(asset).copied().unwrap_or(0); + if *in_amt > out_amt && !req.change_assets.contains(asset) { + // Only the fee may be an output the manifest did not declare. Inventing a + // change output here would move an asset to an address of the engine's + // choosing, in an amount nobody wrote down. + anyhow::bail!( + "Asset {asset} does not balance: inputs provide {} sat but the outputs \ + account for only {out_amt} sat.\n\ + This action does not permit change in that asset, so there is nowhere for \ + the remaining {} sat to go. Either set \"allow_change\" on the action, \ + declare a change output for it, or size the input to what the action spends.", + in_amt, + in_amt - out_amt + ); + } if *in_amt > out_amt { let surplus = in_amt - out_amt; let change_addr = wollet.change(None).context("Cannot derive change address")?.address().clone(); @@ -592,9 +627,9 @@ pub fn covenant_script_pubkey( type_hints: &HashMap, extra_leaf_payloads: &[Vec], network: ElementsNetwork, - include_debug_symbols: bool, + opts: impl Into, ) -> Result