diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c47a6c1..f67b775a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -163,6 +163,7 @@ jobs: timeout-minutes: 10 env: CARDANO_NODE_SOCKET_PATH: /run/cardano-node/node_preview.socket + CARGO_PROFILE_DEV_DEBUG: "0" BLOCKFROST_PREVIEW_PROJECT_ID: ${{ secrets.BLOCKFROST_PREVIEW_PROJECT_ID }} run: nix develop .# --command cargo test --verbose -p blockfrost-platform-integration-tests --no-fail-fast diff --git a/Cargo.lock b/Cargo.lock index 2c8322c2..714e2d1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -208,10 +208,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "amaru-uplc" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "b565727e99c072b9d29f7ddf9eafffd51ec69a653dad6589c36d4d1ca053603c" +dependencies = [ + "append-only-vec", + "blst", + "bumpalo", + "chumsky", + "cryptoxide", + "divan", + "hamming", + "hex", + "minicbor 0.25.1", + "num", + "num-bigint", + "num-integer", + "once_cell", + "secp256k1", + "thiserror 1.0.69", + "walkdir", +] [[package]] name = "android_system_properties" @@ -278,6 +296,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "append-only-vec" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114736faba96bcd79595c700d03183f61357b9fbce14852515e59f3bee4ed4a" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -367,7 +400,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.36.7", "rustc-demangle", "windows-targets", ] @@ -396,12 +429,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dabbe35f96fb9507f7330793dc490461b2962659ac5d427181e451a623751d1" -[[package]] -name = "bech32" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" - [[package]] name = "bech32" version = "0.11.1" @@ -410,12 +437,13 @@ checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" [[package]] name = "binary-layout" -version = "3.3.0" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5845e3504cf59b9588fff324710f27ee519515b8a8a9f1207042da9a9e64f819" +checksum = "66c7e8da2156abef3421f6226ef339ade8c0d157ec50932d5e624f1c6a5127b4" dependencies = [ "doc-comment", "paste", + "thiserror 1.0.69", ] [[package]] @@ -442,7 +470,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.13.0", "serde", "unicode-normalization", ] @@ -468,6 +496,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + [[package]] name = "bitcoin_hashes" version = "0.13.0" @@ -475,7 +509,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" dependencies = [ "bitcoin-internals", - "hex-conservative", + "hex-conservative 0.1.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", ] [[package]] @@ -501,7 +545,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -606,6 +650,7 @@ dependencies = [ "blockfrost-platform-common", "blockfrost-platform-data-node", "blockfrost-platform-node", + "blockfrost-platform-tx-evaluator", "cardano-serialization-lib", "chrono", "clap", @@ -625,7 +670,6 @@ dependencies = [ "pallas-codec", "pallas-network", "pretty_assertions", - "proptest", "reqwest 0.13.2", "rstest", "sentry", @@ -680,6 +724,7 @@ dependencies = [ "nix", "pallas-network", "pretty_assertions", + "proptest", "reqwest 0.13.2", "rstest", "serde", @@ -746,6 +791,7 @@ dependencies = [ "ntest", "pretty_assertions", "reqwest 0.13.2", + "rstest", "serde", "serde_json", "tokio", @@ -792,6 +838,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "blockfrost-platform-tx-evaluator" +version = "1.0.0" +dependencies = [ + "base64", + "blockfrost-platform-common", + "blockfrost-platform-node", + "blockfrost-platform-testgen", + "chrono", + "hex", + "pallas-addresses", + "pallas-codec", + "pallas-crypto", + "pallas-network", + "pallas-primitives", + "pallas-traverse", + "pallas-validate", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "blockfrost-sdk-bridge" version = "1.0.0" @@ -820,6 +890,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -929,19 +1011,43 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.1.1", + "windows-link 0.2.1", +] + +[[package]] +name = "chumsky" +version = "1.0.0-alpha.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e82d74e6c83060ec269fe9e0d408d6de4a1645d525f9a0bbbb841ba4efd91ac" +dependencies = [ + "hashbrown 0.15.5", + "regex-automata 0.3.9", + "serde", + "stacker", + "unicode-ident", + "unicode-segmentation", ] [[package]] @@ -985,6 +1091,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -1039,6 +1146,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "config-derive" version = "0.15.0" @@ -1091,6 +1204,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 = "crc" version = "3.2.1" @@ -1497,6 +1619,31 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -1839,6 +1986,7 @@ dependencies = [ "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -1908,6 +2056,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hamming" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65043da274378d68241eb9a8f8f8aa54e349136f7b8e12f63e3ef44043cc30e1" + [[package]] name = "hashbrown" version = "0.12.3" @@ -1929,6 +2083,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1982,6 +2138,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2406,6 +2571,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2603,7 +2777,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata", + "regex-automata 0.4.9", ] [[package]] @@ -2711,6 +2885,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0" +dependencies = [ + "minicbor-derive 0.15.3", +] + [[package]] name = "minicbor" version = "0.26.4" @@ -2718,7 +2901,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acb9d59e79ad66121ab441a0d1950890906a41e01ae14145ecf8401aa8894f50" dependencies = [ "half", - "minicbor-derive", + "minicbor-derive 0.16.2", +] + +[[package]] +name = "minicbor-derive" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", ] [[package]] @@ -2989,6 +3183,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3064,47 +3267,47 @@ dependencies = [ [[package]] name = "pallas-addresses" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "base58", - "bech32 0.9.1", + "bech32 0.11.1", "crc", "cryptoxide", "hex", "pallas-codec", "pallas-crypto", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "pallas-codec" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "hex", - "minicbor", + "minicbor 0.26.4", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "pallas-crypto" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "cryptoxide", "hex", "pallas-codec", - "rand_core 0.9.3", + "rand_core 0.10.1", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "pallas-hardano" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "binary-layout", "hex", @@ -3117,31 +3320,31 @@ dependencies = [ "serde_json", "serde_with", "tap", - "thiserror 1.0.69", + "thiserror 2.0.18", "tracing", ] [[package]] name = "pallas-network" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "byteorder", "hex", - "itertools 0.13.0", + "itertools 0.14.0", "pallas-codec", "pallas-crypto", - "rand 0.8.5", - "socket2 0.5.9", - "thiserror 1.0.69", + "rand 0.10.1", + "socket2 0.6.3", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "pallas-primitives" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "hex", "pallas-codec", @@ -3152,18 +3355,37 @@ dependencies = [ [[package]] name = "pallas-traverse" -version = "1.0.0-alpha.5" -source = "git+https://github.com/txpipe/pallas.git?rev=ed422bc0d5a09f77617d4882dd4c71089a8bcdb2#ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" dependencies = [ "hex", - "itertools 0.13.0", + "itertools 0.14.0", "pallas-addresses", "pallas-codec", "pallas-crypto", "pallas-primitives", "paste", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", +] + +[[package]] +name = "pallas-validate" +version = "1.0.0" +source = "git+https://github.com/blockfrost/pallas.git?rev=fb3a63791baa39b0f6ce996c141443ff7bf98ae0#fb3a63791baa39b0f6ce996c141443ff7bf98ae0" +dependencies = [ + "amaru-uplc", + "chrono", + "hex", + "itertools 0.14.0", + "pallas-addresses", + "pallas-codec", + "pallas-crypto", + "pallas-primitives", + "pallas-traverse", + "serde", + "thiserror 2.0.18", + "tracing", ] [[package]] @@ -3348,12 +3570,22 @@ dependencies = [ "rand 0.9.0", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax", + "regex-syntax 0.8.5", "rusty-fork", "tempfile", "unarray", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "ptr" version = "0.2.3" @@ -3418,6 +3650,17 @@ dependencies = [ "zerocopy 0.8.24", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3471,6 +3714,12 @@ dependencies = [ "getrandom 0.3.2", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_os" version = "0.1.3" @@ -3570,8 +3819,19 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", ] [[package]] @@ -3582,7 +3842,7 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] [[package]] @@ -3591,6 +3851,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -3827,6 +4093,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -3890,6 +4165,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.5", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4186,7 +4481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -4198,7 +4493,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -4210,7 +4505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -4330,6 +4625,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4449,6 +4757,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4499,6 +4817,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.47" @@ -4829,7 +5156,7 @@ dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex-automata", + "regex-automata 0.4.9", "sharded-slab", "smallvec", "thread_local", @@ -5062,6 +5389,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -5240,6 +5577,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 82ec6a66..dad77370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/api_provider", "crates/node", "crates/data_node", + "crates/tx_evaluator", "crates/testgen", "crates/error_decoder", "crates/gateway", @@ -34,6 +35,7 @@ bf-build-utils = { path = "crates/build_utils", package = "blockfrost-platform-b bf-common = { path = "crates/common", package = "blockfrost-platform-common" } bf-data-node = { path = "crates/data_node", package = "blockfrost-platform-data-node" } bf-node = { path = "crates/node", package = "blockfrost-platform-node" } +bf-tx-evaluator = { path = "crates/tx_evaluator", package = "blockfrost-platform-tx-evaluator" } bf-testgen = { path = "crates/testgen", package = "blockfrost-platform-testgen" } bip39 = "2.2.2" blockfrost = { version = "1.2.3", default-features = false, features = [ @@ -67,13 +69,16 @@ nix = { version = "0.30", default-features = false, features = ["signal"] } ntest = "0.9.5" num_cpus = "1" # FIXME: use a proper Pallas release after they merge : -pallas-addresses = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-codec = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-crypto = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-hardano = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-network = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-primitives = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } -pallas-traverse = { git = "https://github.com/txpipe/pallas.git", rev = "ed422bc0d5a09f77617d4882dd4c71089a8bcdb2" } +pallas-addresses = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-codec = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-crypto = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-hardano = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-network = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-primitives = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-traverse = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0" } +pallas-validate = { git = "https://github.com/blockfrost/pallas.git", rev = "fb3a63791baa39b0f6ce996c141443ff7bf98ae0", features = [ + "phase2", +] } pretty_assertions = "1.4.1" proptest = "1.10.0" reqwest = { version = "0.13.2", default-features = false, features = [ diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 823949de..66789ac1 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -35,6 +35,7 @@ tracing-journald.workspace = true [dev-dependencies] pretty_assertions.workspace = true +proptest.workspace = true rstest.workspace = true tokio.workspace = true diff --git a/crates/common/src/chain_config.rs b/crates/common/src/chain_config.rs new file mode 100644 index 00000000..c0eec5f1 --- /dev/null +++ b/crates/common/src/chain_config.rs @@ -0,0 +1,97 @@ +use crate::helpers::system_start_to_epoch_millis; +use pallas_network::miniprotocols::localstate::queries_v16::{CurrentProtocolParam, GenesisConfig}; +use serde::Serialize; + +/// Cached chain configuration queried from the Cardano node at startup and +/// refreshed at epoch boundaries by `ChainConfigWatch`. +pub struct ChainConfigCache { + /// Shelley genesis configuration + pub genesis_config: GenesisConfig, + /// Current protocol parameters + pub protocol_params: CurrentProtocolParam, + /// Slot timing derived from genesis + pub slot_config: SlotConfig, + /// Current Cardano era index (see [`Self::CONWAY_ERA`]) + pub era: u16, +} + +impl ChainConfigCache { + /// Conway era index used by Ouroboros + pub const CONWAY_ERA: u16 = 6; + + pub fn new( + genesis_config: GenesisConfig, + protocol_params: CurrentProtocolParam, + ) -> Result { + let slot_config = SlotConfig::by_network_magic(&genesis_config); + + Ok(Self { + genesis_config, + protocol_params, + slot_config, + era: Self::CONWAY_ERA, + }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SlotConfig { + /// Duration of a single slot in milliseconds + pub slot_length: u64, + /// Absolute slot number at the start of the Shelley era + pub zero_slot: u64, + /// Unix timestamp in milliseconds corresponding to `zero_slot` + pub zero_time: u64, + /// Number of slots per epoch + pub epoch_length: u64, +} + +impl SlotConfig { + pub fn mainnet() -> Self { + Self { + slot_length: 1000, + zero_slot: 4492800, + zero_time: 1596059091000, + epoch_length: 432000, + } + } + + pub fn preprod() -> Self { + Self { + slot_length: 1000, + zero_slot: 86400, + zero_time: 1655683200000, + epoch_length: 432000, + } + } + + pub fn preview() -> Self { + Self { + slot_length: 1000, + zero_slot: 0, + zero_time: 1666656000000, + epoch_length: 86400, + } + } + + pub fn by_network_magic(genesis_config: &GenesisConfig) -> Self { + match genesis_config.network_magic { + 764824073 => Self::mainnet(), + 1 => Self::preprod(), + 2 => Self::preview(), + _ => Self::from_genesis_config(genesis_config), + } + } + + /// Derive slot config from genesis for custom/unknown networks. + /// Assumes no Byron era (zero_slot = 0, zero_time = system_start). + fn from_genesis_config(genesis_config: &GenesisConfig) -> Self { + Self { + slot_length: genesis_config.slot_length as u64 * 1000, + zero_slot: 0, + zero_time: system_start_to_epoch_millis(&genesis_config.system_start), + epoch_length: genesis_config.epoch_length as u64, + } + } +} diff --git a/crates/common/src/errors.rs b/crates/common/src/errors.rs index d2a062d1..671f23eb 100644 --- a/crates/common/src/errors.rs +++ b/crates/common/src/errors.rs @@ -70,6 +70,12 @@ impl From for BlockfrostError { } } +impl From for BlockfrostError { + fn from(err: serde_json::Error) -> Self { + Self::internal_server_error(format!("JSON error: {err}")) + } +} + impl From for BlockfrostError { fn from(err: AppError) -> Self { match err { @@ -101,12 +107,6 @@ impl From for BlockfrostError { } } -impl From for BlockfrostError { - fn from(e: serde_json::Error) -> Self { - BlockfrostError::internal_server_error(format!("JSON error: {e}")) - } -} - impl From for BlockfrostError { fn from(e: url::ParseError) -> Self { BlockfrostError::internal_server_error(format!("URL error: {e}",)) @@ -220,6 +220,15 @@ impl BlockfrostError { } } + /// Error for 503 Service Unavailable + pub fn service_unavailable(message: String) -> Self { + Self { + error: "Service Unavailable".to_string(), + message, + status_code: 503, + } + } + /// This error is converted in middleware to internal_server_error_user pub fn internal_server_error(error: String) -> Self { Self { @@ -261,6 +270,7 @@ impl IntoResponse for BlockfrostError { 404 => StatusCode::NOT_FOUND, 405 => StatusCode::METHOD_NOT_ALLOWED, 500 => StatusCode::INTERNAL_SERVER_ERROR, + 503 => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, }; diff --git a/crates/common/src/helpers.rs b/crates/common/src/helpers.rs new file mode 100644 index 00000000..027f209d --- /dev/null +++ b/crates/common/src/helpers.rs @@ -0,0 +1,116 @@ +use crate::errors::BlockfrostError; +use pallas_network::miniprotocols::localstate::queries_v16::{BigInt, SystemStart}; + +/// This function allows us to take both hex-encoded and raw bytes. It has +/// to be a heuristic: if there are input bytes that are not `[0-9a-f]`, +/// then it must be a binary string. Otherwise, we assume it’s hex encoded. +/// +/// **Note**: there is a small probability that the user gave us a binary +/// string that only _looked_ like a hex-encoded one, but it’s rare enough +/// to ignore it. +pub fn binary_or_hex_heuristic(xs: &[u8]) -> Vec { + let even_length = xs.len().is_multiple_of(2); + let contains_non_hex = xs.iter().any(|&x| !x.is_ascii_hexdigit()); + + if !even_length || contains_non_hex { + xs.to_vec() + } else { + hex::decode(xs).unwrap_or_else(|_| unreachable!()) + } +} + +pub fn convert_bigint(bigint: &BigInt) -> Result { + match bigint { + BigInt::Int(big) => Ok(i128::from(*big)), + _ => Err(BlockfrostError::internal_server_error( + "Invalid/unsupported BigInt format".to_string(), + )), + } +} + +/// Convert a `SystemStart` to Unix epoch milliseconds. +/// +/// Date arithmetic is done manually to avoid adding a `chrono` dependency to `common`. +/// +/// # Panics +/// Panics if `SystemStart` contains an unsupported `BigInt` variant. +/// This should only be called during startup with data from the Cardano node. +pub fn system_start_to_epoch_millis(system_start: &SystemStart) -> u64 { + let year = i64::try_from( + convert_bigint(&system_start.year).expect("Failed to convert SystemStart year"), + ) + .expect("SystemStart year out of i64 range"); + let day_of_year = system_start.day_of_year; + let picos = i64::try_from( + convert_bigint(&system_start.picoseconds_of_day) + .expect("Failed to convert SystemStart picoseconds"), + ) + .expect("SystemStart picoseconds out of i64 range"); + + let epoch_days = days_from_year(year) + day_of_year - 1; + (epoch_days as u64) * 86_400_000 + (picos / 1_000_000_000) as u64 +} + +/// Days from Unix epoch (1970-01-01) to January 1 of the given year. +fn days_from_year(year: i64) -> i64 { + let y = year - 1; + 365 * (year - 1970) + (y / 4 - 492) - (y / 100 - 19) + (y / 400 - 4) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn proptest_binary_or_hex_heuristic( + binary in prop::collection::vec(any::(), 0..=128) + .prop_filter("exclude values made up only of hex digits", |xs| { + xs.iter().any(|&x| !x.is_ascii_hexdigit()) + }) + ) { + let hex_string = hex::encode(&binary); + assert_eq!( + binary_or_hex_heuristic(hex_string.as_bytes()), + binary_or_hex_heuristic(&binary) + ) + } + } + + #[test] + fn test_days_from_year_known_epochs() { + // 1970-01-01 is day 0 + assert_eq!(days_from_year(1970), 0); + // 2000-01-01 = 10957 days after epoch + assert_eq!(days_from_year(2000), 10957); + // 2017-01-01 = 17167 days after epoch (Cardano mainnet era) + assert_eq!(days_from_year(2017), 17167); + } + + #[test] + fn test_system_start_to_epoch_millis_mainnet() { + // Cardano mainnet SystemStart: 2017-09-23T21:44:51Z + // Unix timestamp: 1506203091000 ms + let system_start = SystemStart { + year: BigInt::Int(2017.into()), + day_of_year: 266, // September 23 = day 266 + picoseconds_of_day: BigInt::Int( + 78_291_000_000_000_000i64.into(), // 21:44:51 = 78291s = 78291e12 picos + ), + }; + assert_eq!(system_start_to_epoch_millis(&system_start), 1506203091000); + } + + #[test] + fn test_system_start_to_epoch_millis_preview() { + // Cardano preview SystemStart: 2022-11-01T00:00:00Z + // Unix timestamp: 1667260800000 ms + let system_start = SystemStart { + year: BigInt::Int(2022.into()), + day_of_year: 305, // November 1 = day 305 + picoseconds_of_day: BigInt::Int(0.into()), + }; + assert_eq!(system_start_to_epoch_millis(&system_start), 1667260800000); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 14c9f2a1..98f183d1 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,6 +1,8 @@ pub mod cardano_keys; +pub mod chain_config; pub mod errors; pub mod find_libexec; +pub mod helpers; pub mod hydra; pub mod json_client; pub mod pagination; diff --git a/crates/integration_tests/Cargo.toml b/crates/integration_tests/Cargo.toml index d31fc97b..b53a5780 100644 --- a/crates/integration_tests/Cargo.toml +++ b/crates/integration_tests/Cargo.toml @@ -15,7 +15,7 @@ blockfrost-platform.workspace = true bf-api-provider.workspace = true bf-common.workspace = true bf-data-node.workspace = true -bf-node.workspace = true +bf-node = { workspace = true, features = ["test-utils"] } anyhow.workspace = true governor.workspace = true @@ -45,6 +45,7 @@ hyper.workspace = true ntest.workspace = true pretty_assertions.workspace = true reqwest.workspace = true +rstest.workspace = true tokio-tungstenite.workspace = true tokio-util.workspace = true tower.workspace = true diff --git a/crates/integration_tests/src/gateway/mod.rs b/crates/integration_tests/src/gateway/mod.rs index e36dca4c..c8af8b60 100644 --- a/crates/integration_tests/src/gateway/mod.rs +++ b/crates/integration_tests/src/gateway/mod.rs @@ -244,7 +244,7 @@ pub async fn setup() -> (TestGateway, Client, String, ApiPrefix) { let gw = TestGateway::start().await; let gateway_url = format!("http://{}", gw.addr); - let (app, _, _, icebreakers_api, api_prefix) = + let (app, _, _, icebreakers_api, api_prefix, _) = crate::platform::build_app_non_solitary(Some(gateway_url)) .await .expect("Failed to build the application"); diff --git a/crates/integration_tests/src/platform/mod.rs b/crates/integration_tests/src/platform/mod.rs index d5170eb9..edd80c10 100644 --- a/crates/integration_tests/src/platform/mod.rs +++ b/crates/integration_tests/src/platform/mod.rs @@ -4,7 +4,7 @@ pub mod tx_builder; use axum::Router; use bf_common::types::{LogLevel, Network}; -use bf_node::pool::NodePool; +use bf_node::{chain_config_watch::ChainConfigWatch, pool::NodePool}; use blockfrost_platform::config::{Config, DataNodeConfig, IcebreakersConfig, Mode}; use blockfrost_platform::{ AppError, health_monitor, @@ -13,6 +13,17 @@ use blockfrost_platform::{ }; use std::{env, sync::Arc, time::Duration}; +pub async fn initialize_app() -> Router { + crate::initialize_logging(); + let (app, _, _, _, _, mut config_watch) = + build_app().await.expect("Failed to build the application"); + config_watch + .wait_ready() + .await + .expect("ChainConfigWatch closed before configuration became available"); + app +} + pub fn test_config(icebreakers_config: Option) -> Arc { dotenvy::dotenv().ok(); @@ -45,6 +56,7 @@ pub async fn build_app() -> Result< health_monitor::HealthMonitor, Option>, ApiPrefix, + ChainConfigWatch, ), AppError, > { @@ -62,6 +74,7 @@ pub async fn build_app_non_solitary( health_monitor::HealthMonitor, Option>, ApiPrefix, + ChainConfigWatch, ), AppError, > { @@ -116,6 +129,7 @@ pub async fn build_app_with_data_node( health_monitor::HealthMonitor, Option>, ApiPrefix, + ChainConfigWatch, ), AppError, > { diff --git a/crates/integration_tests/tests/api/utils.rs b/crates/integration_tests/tests/api/utils.rs new file mode 100644 index 00000000..ddc35f71 --- /dev/null +++ b/crates/integration_tests/tests/api/utils.rs @@ -0,0 +1,6 @@ +pub mod txs { + pub mod evaluate { + pub mod root; + pub mod utxos; + } +} diff --git a/crates/integration_tests/tests/api/utils/txs/evaluate/root.rs b/crates/integration_tests/tests/api/utils/txs/evaluate/root.rs new file mode 100644 index 00000000..dfe91994 --- /dev/null +++ b/crates/integration_tests/tests/api/utils/txs/evaluate/root.rs @@ -0,0 +1,123 @@ +mod tests { + use axum::body::to_bytes; + use axum::{body::Body, http::Request}; + use integration_tests::platform::initialize_app; + use reqwest::Method; + use rstest::rstest; + use serde_json::json; + use tower::ServiceExt; + + /// Conway tx with a single spend redeemer (a "Hello, World!" Plutus V3 script). + const CONWAY_TX_HEX: &str = "84A300818258204E9A66B7E310F004893EEF615E11F8AE6C3328CF2BFDB32F6E40063636D42D7C00018182581D70C40F9129C2684046EB02325B96CA2899A6FA6478C1DDE9B5C53206A51A00D59F800200A10581840000D8799F4D48656C6C6F2C20576F726C6421FF820000F5F6"; + + /// Pre-Alonzo (Mary era) tx, encoded as a 3-element CBOR array. + const MARY_TX_HEX: &str = "83a300818258200ac82ea5bc0967a17d4a60e2474b01df72440673429ff89b2802d3bd2a38ec3e01018282583900e2fbc47df26fcd065c074c451e792599ea8fc159f76163ca4c2b520b58adbef896164ee7456ccb4eaa965a87a602b0e3b2825d7b4ee789b01a000f4240825839003c77cd7f3c07b3b0ba72044848592d2e5687569ad25b93a926392f5e83892080b40900e146e1c68f12ef6811773bd8740196cd211f3211de1af9b0595d021a0002c5bda10081825820da818bbf3a082945884681d062147ca7dc3111d87fab415268749124a3ed1d31584059ca300a7d38abf454482a57281acdbbaab740b868978131f36117a224e6ba2be5248da0205296d7a8211506d6430a2873c201831e326e5db68ac9e1403e520ef6"; + + /// POST `/utils/txs/evaluate` with the given query string and CBOR-hex body, + /// assert a 2xx, and return the parsed JSON response. + async fn post_evaluate(query: &str, tx_hex: &str) -> serde_json::Value { + let app = initialize_app().await; + let uri = if query.is_empty() { + "/utils/txs/evaluate".to_string() + } else { + format!("/utils/txs/evaluate?{query}") + }; + let request = Request::builder() + .method(Method::POST) + .uri(uri) + .header("Content-Type", "application/cbor") + .body(Body::from(tx_hex.to_string())) + .unwrap(); + let response = app + .oneshot(request) + .await + .expect("Request to /utils/txs/evaluate failed"); + assert!( + response.status().is_success(), + "Response was not successful" + ); + let body_bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body"); + serde_json::from_slice(&body_bytes).unwrap() + } + + #[rstest] + #[case::no_version("")] + #[case::v5("version=5")] + #[case::unknown_version_falls_back("version=53")] + #[tokio::test] + #[ntest::timeout(120_000)] + async fn test_success_v5(#[case] query: &str) { + let body_json = post_evaluate(query, CONWAY_TX_HEX).await; + + assert_eq!(body_json["type"], "jsonwsp/response"); + assert_eq!(body_json["version"], "1.0"); + assert_eq!(body_json["servicename"], "ogmios"); + assert_eq!(body_json["methodname"], "EvaluateTx"); + assert!(body_json["reflection"]["id"].is_string()); + assert_eq!( + body_json["result"], + json!({"EvaluationResult": {"spend:0": {"memory": 15694, "steps": 3776164}}}) + ); + } + + #[tokio::test] + #[ntest::timeout(120_000)] + #[ignore = "pallas-validate does not generate same results with the external evaluator (ledger)"] + async fn test_success_v5_native() { + let body_json = post_evaluate("version=5&evaluator=native", CONWAY_TX_HEX).await; + + assert_eq!(body_json["type"], "jsonwsp/response"); + assert_eq!(body_json["servicename"], "ogmios"); + assert_eq!(body_json["methodname"], "EvaluateTx"); + assert!(body_json["reflection"]["id"].is_string()); + assert_eq!( + body_json["result"], + json!({"EvaluationResult": {"spend:0": {"memory": 15694, "steps": 3776164}}}) + ); + } + + #[tokio::test] + #[ntest::timeout(120_000)] + async fn test_success_v6() { + let body_json = post_evaluate("version=6", CONWAY_TX_HEX).await; + + assert_eq!(body_json["jsonrpc"], "2.0"); + assert_eq!(body_json["method"], "evaluateTransaction"); + assert!(body_json["id"].is_string()); + assert_eq!( + body_json["result"], + json!([{"budget": {"cpu": 3776164, "memory": 15694}, "validator": {"purpose": "spend", "index": 0}}]) + ); + } + + /// Pre-Alonzo (Mary era) transaction returns IncompatibleEra in v5 format + #[tokio::test] + #[ntest::timeout(120_000)] + async fn test_fail_incompatible_era_v5() { + let body_json = post_evaluate("version=5", MARY_TX_HEX).await; + + assert_eq!( + body_json.get("type").unwrap().as_str().unwrap(), + "jsonwsp/response" + ); + assert_eq!( + body_json["result"]["EvaluationFailure"]["IncompatibleEra"] + .as_str() + .unwrap(), + "Mary" + ); + } + + /// Pre-Alonzo (Mary era) transaction returns IncompatibleEra in v6 format (code 3000) + #[tokio::test] + #[ntest::timeout(120_000)] + async fn test_fail_incompatible_era_v6() { + let body_json = post_evaluate("version=6", MARY_TX_HEX).await; + + let error = body_json.get("error").unwrap(); + assert_eq!(error.get("code").unwrap().as_i64().unwrap(), 3000); + assert_eq!(error["data"]["incompatibleEra"].as_str().unwrap(), "mary"); + } +} diff --git a/crates/integration_tests/tests/api/utils/txs/evaluate/utxos.rs b/crates/integration_tests/tests/api/utils/txs/evaluate/utxos.rs new file mode 100644 index 00000000..e711a17c --- /dev/null +++ b/crates/integration_tests/tests/api/utils/txs/evaluate/utxos.rs @@ -0,0 +1,89 @@ +mod tests { + use axum::{ + body::{Body, to_bytes}, + http::Request, + }; + use integration_tests::platform::initialize_app; + use reqwest::Method; + use serde_json::json; + use tower::ServiceExt; + + /// Conway tx with a single spend redeemer (a "Hello, World!" Plutus V3 script). + const CONWAY_TX_HEX: &str = "84A300818258204E9A66B7E310F004893EEF615E11F8AE6C3328CF2BFDB32F6E40063636D42D7C00018182581D70C40F9129C2684046EB02325B96CA2899A6FA6478C1DDE9B5C53206A51A00D59F800200A10581840000D8799F4D48656C6C6F2C20576F726C6421FF820000F5F6"; + + /// POST `/utils/txs/evaluate/utxos` with the given JSON payload, assert a 2xx, + /// and return the parsed JSON response. + async fn post_evaluate_utxos(input: serde_json::Value) -> serde_json::Value { + let app = initialize_app().await; + let request = Request::builder() + .method(Method::POST) + .uri("/utils/txs/evaluate/utxos") + .header("Content-Type", "application/json") + .body(Body::from(input.to_string())) + .unwrap(); + let response = app + .oneshot(request) + .await + .expect("Request to /utils/txs/evaluate/utxos failed"); + assert!(response.status().is_success(), "Response should success"); + let body_bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read response body"); + serde_json::from_slice(&body_bytes).unwrap() + } + + /// This test is identical to the Blockfrost test: + /// https://github.com/blockfrost/blockfrost-tests/blob/dc33312126e9d7c49836d7605ab72224a337bc91/src/fixtures/preview/utils/txs-evaluate-utxos.ts#L5 + /// https://github.com/blockfrost/blockfrost-tests/blob/dc33312126e9d7c49836d7605ab72224a337bc91/src/fixtures/preview/utils/txs-evaluate-utxos.ts#L22 + #[tokio::test] + #[ntest::timeout(120_000)] + async fn success_cbor_only() { + let body_json = post_evaluate_utxos(json!({ "cbor": CONWAY_TX_HEX })).await; + + assert_eq!(body_json["type"], "jsonwsp/response"); + assert_eq!(body_json["servicename"], "ogmios"); + assert_eq!(body_json["methodname"], "EvaluateTx"); + assert!(body_json["reflection"]["id"].is_string()); + assert_eq!( + body_json["result"], + json!({"EvaluationResult": {"spend:0": {"memory": 15694, "steps": 3776164}}}) + ); + } + + /// Verifies that the mirror field sent by the client is NOT echoed back. + #[tokio::test] + #[ntest::timeout(120_000)] + async fn success_mirror_not_echoed() { + let body_json = post_evaluate_utxos(json!({ + "cbor": CONWAY_TX_HEX, + "mirror": {"id": "test-request-123"}, + })) + .await; + + assert_eq!(body_json["type"], "jsonwsp/response"); + assert!(body_json["reflection"]["id"].is_string()); + assert_ne!(body_json["reflection"]["id"], "test-request-123"); + } + + /// This test is identical to the Blockfrost test: + /// https://github.com/blockfrost/blockfrost-tests/blob/dc33312126e9d7c49836d7605ab72224a337bc91/src/fixtures/preview/utils/txs-evaluate-utxos.ts#L43 + #[tokio::test] + #[ntest::timeout(120_000)] + #[ignore = "not implemented yet"] + async fn fail_missing_input() { + let body_json = post_evaluate_utxos(json!({ + "cbor": "84A30081825820FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7C00018182581D70C40F9129C2684046EB02325B96CA2899A6FA6478C1DDE9B5C53206A51A00D59F800200A10581840000D8799F4D48656C6C6F2C20576F726C6421FF820000F5F6", + "additionalUtxoSet": [], + })) + .await; + + assert_eq!( + body_json, + json!({ + "error": "Bad Request", + "message": "Error evaluating transaction: resolved Input not found", + "status_code": 400, + }) + ); + } +} diff --git a/crates/integration_tests/tests/data/supported_endpoints.json b/crates/integration_tests/tests/data/supported_endpoints.json index 5804b336..048093ee 100644 --- a/crates/integration_tests/tests/data/supported_endpoints.json +++ b/crates/integration_tests/tests/data/supported_endpoints.json @@ -40,5 +40,7 @@ "/epochs/latest/parameters", "/genesis", "/addresses/{address}/transactions", - "/addresses/{address}/utxos" + "/addresses/{address}/utxos", + "/utils/txs/evaluate", + "/utils/txs/evaluate/utxos" ] diff --git a/crates/integration_tests/tests/e2e_websocket.rs b/crates/integration_tests/tests/e2e_websocket.rs index 1a2550ca..6e11ab41 100644 --- a/crates/integration_tests/tests/e2e_websocket.rs +++ b/crates/integration_tests/tests/e2e_websocket.rs @@ -116,7 +116,7 @@ async fn test_ws_invalid_credentials_rejected() { }; let config = test_config(Some(icebreakers_config)); - let (app, _, _, icebreakers_api, api_prefix) = + let (app, _, _, icebreakers_api, api_prefix, _) = build(config).await.expect("Failed to build app"); let icebreakers_api = icebreakers_api.expect("icebreakers_api should be Some"); diff --git a/crates/integration_tests/tests/eval_test.rs b/crates/integration_tests/tests/eval_test.rs new file mode 100644 index 00000000..30d231b7 --- /dev/null +++ b/crates/integration_tests/tests/eval_test.rs @@ -0,0 +1,3 @@ +pub mod api { + pub mod utils; +} diff --git a/crates/integration_tests/tests/platform_data_node.rs b/crates/integration_tests/tests/platform_data_node.rs index 34cd6958..7a6e82d8 100644 --- a/crates/integration_tests/tests/platform_data_node.rs +++ b/crates/integration_tests/tests/platform_data_node.rs @@ -34,7 +34,7 @@ async fn test_data_node_health_monitoring() { initialize_logging(); let mock = MockDataNode::healthy().await; - let (app, _, _, _, _) = build_app_with_data_node(mock.url) + let (app, _, _, _, _, _) = build_app_with_data_node(mock.url) .await .expect("Failed to build the application"); @@ -59,7 +59,7 @@ async fn test_data_node_unhealthy_status() { initialize_logging(); let mock = MockDataNode::unhealthy().await; - let (app, _, _, _, _) = build_app_with_data_node(mock.url) + let (app, _, _, _, _, _) = build_app_with_data_node(mock.url) .await .expect("Failed to build the application"); @@ -81,7 +81,7 @@ async fn test_data_node_unreachable() { initialize_logging(); let mock = MockDataNode::unreachable(); - let (app, _, _, _, _) = build_app_with_data_node(mock.url) + let (app, _, _, _, _, _) = build_app_with_data_node(mock.url) .await .expect("Failed to build the application"); diff --git a/crates/integration_tests/tests/platform_metrics.rs b/crates/integration_tests/tests/platform_metrics.rs index 339a342f..ee81c82c 100644 --- a/crates/integration_tests/tests/platform_metrics.rs +++ b/crates/integration_tests/tests/platform_metrics.rs @@ -12,7 +12,7 @@ use tower::ServiceExt; async fn test_route_metrics() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); // Test without trailing slash let response = app diff --git a/crates/integration_tests/tests/platform_root.rs b/crates/integration_tests/tests/platform_root.rs index d0fe198a..4dfcc0ba 100644 --- a/crates/integration_tests/tests/platform_root.rs +++ b/crates/integration_tests/tests/platform_root.rs @@ -14,7 +14,7 @@ use tower::ServiceExt; async fn test_route_root() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); let response = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) diff --git a/crates/integration_tests/tests/platform_submit.rs b/crates/integration_tests/tests/platform_submit.rs index 8401e95b..785265a8 100644 --- a/crates/integration_tests/tests/platform_submit.rs +++ b/crates/integration_tests/tests/platform_submit.rs @@ -15,7 +15,7 @@ use tower::ServiceExt; #[ntest::timeout(120_000)] async fn test_route_submit_cbor_error() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); let tx = "AAAAAA"; @@ -48,7 +48,7 @@ async fn test_route_submit_cbor_error() { #[ntest::timeout(120_000)] async fn test_route_submit_error() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); let tx = "84a300d90102818258205176274bef11d575edd6aa72392aaf993a07f736e70239c1fb22d4b1426b22bc01018282583900ddf1eb9ce2a1561e8f156991486b97873fb6969190cbc99ddcb3816621dcb03574152623414ed354d2d8f50e310f3f2e7d167cb20e5754271a003d09008258390099a5cb0fa8f19aba38cacf8a243d632149129f882df3a8e67f6bd512bcb0cde66a545e9fbc7ca4492f39bca1f4f265cc1503b4f7d6ff205c1b000000024f127a7c021a0002a2ada100d90102818258208b83e59abc9d7a66a77be5e0825525546a595174f8b929f164fcf5052d7aab7b5840709c64556c946abf267edd90b8027343d065193ef816529d8fa7aa2243f1fd2ec27036a677974199e2264cb582d01925134b9a20997d5a734da298df957eb002f5f6"; @@ -92,7 +92,7 @@ async fn test_route_submit_error() { #[ntest::timeout(120_000)] async fn test_route_submit_agent_dequeu() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); let tx = "84a800848258204c16d304e6d531c59afd87a9199b7bb4175bc131b3d6746917901046b662963c00825820893c3f630c0b2db16d041c388aa0d58746ccbbc44133b2d7a3127a72c79722f1018258200998adb591c872a241776e39fe855e04b2d7c361008e94c582f59b6b6ccc452c028258208380ce7240ba59187f6450911f74a70cf3d2749228badb2e7cd10fb6499355f503018482581d61e15900a9a62a8fb01f936a25bf54af209c7ed1248c4e5abd05ec4e76821a0023ba63a1581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235a145484f534b5900a300581d71cba5c6770fe7b30ebc1fa32f01938c150513211360ded23ac76e36b301821a006336d5a3581c239075b83c03c2333eacd0b0beac6b8314f11ce3dc0c047012b0cad4a144706f6f6c01581c3547b4325e495d529619335603ababde10025dceafa9ed34b1fb6611a158208b284793d3bd4967244a2ddd68410d56d06d36ac8d201429b937096a2e8234bc1b7ffffffffffade6b581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235a145484f534b59195e99028201d818583ad8799fd8799f4040ffd8799f581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c23545484f534b59ff1a006336d5195e99ff825839016d06090559d8ed2988aa5b2fff265d668cf552f4f62278c0128f816c0a48432e080280d0d9b15edb65563995f97ce236035afea568e660d1821a00118f32a1581c2f8b2d1f384485896f38406173fa11df2a4ce53b4b0886138b76597aa1476261746368657201825839016d06090559d8ed2988aa5b2fff265d668cf552f4f62278c0128f816c0a48432e080280d0d9b15edb65563995f97ce236035afea568e660d11a06d9f713021a000ab9e00b582027f17979d848d6472896266dd8bf39f7251ca23798713464bc407bf637286c230d81825820cf5de9189b958f8ad64c1f1837c2fa4711d073494598467a1c1a59589393eae20310825839016d06090559d8ed2988aa5b2fff265d668cf552f4f62278c0128f816c0a48432e080280d0d9b15edb65563995f97ce236035afea568e660d11a08666c75111a001016d01282825820bf93dc59c10c19c35210c2414779d7391ca19128cc7b13794ea85af5ff835f59008258201c37df764f8261edce8678b197767668a91d544b2b203fb5d0cf9acc10366e7600a200818258200eabfa083d7969681d2fc8e825a5f79e1c40f03aeac46ecd94bf5c5790db1bc058409a029ddd3cdde65598bb712c640ea63eeebfee526ce49bd0983b4d1fdca858481ddf931bf0354552cc0a7d3365e2f03fdb457c0466cea8b371b645f9b6d0c2010582840001d8799fd8799f011a006336d5195e991b7ffffffffffade6bd8799f1a000539e7ff01ffff821a000b46e41a0a7f3ca4840003d87d80821a002dccfe1a28868be8f5f6"; @@ -127,7 +127,7 @@ async fn test_route_submit_agent_dequeu() { #[ntest::timeout(120_000)] async fn test_route_submit_success() { initialize_logging(); - let (app, _, _, _, _) = build_app().await.expect("Failed to build the application"); + let (app, _, _, _, _, _) = build_app().await.expect("Failed to build the application"); let blockfrost_client = get_blockfrost_client(); let tx = build_tx(&blockfrost_client).await.unwrap(); diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index b28e4919..48cd7570 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -26,6 +26,7 @@ bf-build-utils.workspace = true [features] tarpaulin = [] +test-utils = [] [lints] workspace = true diff --git a/crates/node/src/cbor/validation.rs b/crates/node/src/cbor/validation.rs index c388890a..4fcbde4b 100644 --- a/crates/node/src/cbor/validation.rs +++ b/crates/node/src/cbor/validation.rs @@ -1,7 +1,7 @@ use bf_common::errors::BlockfrostError; use pallas_hardano::display::haskell_error::as_cbor_decode_failure; use pallas_primitives::{alonzo::Value, babbage::GenTransactionOutput, conway::Tx}; -use tracing::warn; +use tracing::info; /// Checks if the given transaction is a valid CBOR-encoded transaction, by trying to decode it. /// This function is used to validate the transaction before submitting it to the node. @@ -11,20 +11,26 @@ pub(crate) fn validate_tx_cbor(tx: &[u8]) -> Result<(), BlockfrostError> { match pallas_codec::minicbor::decode::(tx) { Ok(decoded) => { if _check_multiasset_zero(decoded) { - Err(BlockfrostError::custom_400( - as_cbor_decode_failure("MultiAsset cannot contain zeros".to_string(), 0) - .unwrap_or_else(|e| format!("Failed to format decode error: {e}")), - )) + let msg = as_cbor_decode_failure("MultiAsset cannot contain zeros".to_string(), 0) + .unwrap_or_else(|e| { + // pallas hardano failure, never expected to happen + tracing::warn!("Failed to format CBOR decode error: {e}"); + "transaction contains MultiAsset with zeros".to_string() + }); + Err(BlockfrostError::custom_400(msg)) } else { Ok(()) } }, Err(e) => { - warn!("Invalid TX CBOR: {:?}, CBOR: {}", e, hex::encode(tx)); - Err(BlockfrostError::custom_400( - as_cbor_decode_failure(e.to_string(), e.position().unwrap_or(0) as u64) - .unwrap_or_else(|e| format!("Failed to format decode error: {e}")), - )) + info!("Invalid TX CBOR: {:?}, CBOR: {}", e, hex::encode(tx)); + let msg = as_cbor_decode_failure(e.to_string(), e.position().unwrap_or(0) as u64) + .unwrap_or_else(|fmt_err| { + // pallas hardano failure, never expected to happen + tracing::warn!("Failed to format CBOR decode error: {fmt_err}"); + "transaction CBOR decode failure".to_string() + }); + Err(BlockfrostError::custom_400(msg)) }, } } diff --git a/crates/node/src/chain_config.rs b/crates/node/src/chain_config.rs new file mode 100644 index 00000000..aae8dfd0 --- /dev/null +++ b/crates/node/src/chain_config.rs @@ -0,0 +1,55 @@ +use bf_common::{ + chain_config::{ChainConfigCache, SlotConfig}, + errors::AppError, +}; + +use crate::pool::NodePool; + +/// inits the cache only if the chain tip is in the latest epoch +pub async fn init_caches(node_pool: NodePool) -> Result { + let mut node = node_pool.get().await?; + let (genesis_config, protocol_params, tip_slot) = + node.genesis_config_and_pp().await.map_err(|e| { + AppError::Server(format!( + "Could not fetch genesis and protocol parameters. Is the Cardano node running? {e}" + )) + })?; + + let config = + ChainConfigCache::new(genesis_config, protocol_params).map_err(AppError::Server)?; + + // Node's tip must be within the last epoch + let expected_slot = current_slot(&config.slot_config); + if expected_slot.saturating_sub(tip_slot) > config.slot_config.epoch_length { + return Err(AppError::Server(format!( + "Node tip slot ({tip_slot}) is more than one epoch behind expected slot ({expected_slot})" + ))); + } + + Ok(config) +} + +fn current_slot(slot_config: &SlotConfig) -> u64 { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let elapsed_ms = now_ms.saturating_sub(slot_config.zero_time); + slot_config.zero_slot + elapsed_ms / slot_config.slot_length +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_current_slot_future_zero_time_returns_zero_slot() { + let config = SlotConfig { + slot_length: 1000, + zero_slot: 100, + zero_time: u64::MAX, + epoch_length: 432000, + }; + assert_eq!(current_slot(&config), 100); + } +} diff --git a/crates/node/src/chain_config_watch.rs b/crates/node/src/chain_config_watch.rs new file mode 100644 index 00000000..f94b8d85 --- /dev/null +++ b/crates/node/src/chain_config_watch.rs @@ -0,0 +1,259 @@ +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bf_common::chain_config::ChainConfigCache; +use bf_common::errors::BlockfrostError; +use tokio::sync::watch; +use tokio::time; + +use crate::chain_config::init_caches; +use crate::pool::NodePool; + +const RETRY_INTERVAL: Duration = Duration::from_secs(60); +/// Buffer after computed epoch boundary before re-querying protocol params +const EPOCH_BOUNDARY_BUFFER: Duration = Duration::from_secs(30); + +/// Watches the Cardano node for sync readiness and epoch-boundary protocol +/// parameter changes, publishing [`ChainConfigCache`] updates via a +/// `tokio::sync::watch` channel. +#[derive(Clone)] +pub struct ChainConfigWatch { + rx: watch::Receiver>>, +} + +impl ChainConfigWatch { + /// Spawn the background monitor and return immediately. + /// + /// The watch value starts as `None` and is set to `Some(…)` once the node + /// is synced (tip within one epoch of expected slot). + /// + /// The monitor is supervised: if its task ends abnormally (e.g. a panic), + /// it is restarted after `RETRY_INTERVAL`. + pub fn spawn(node_pool: NodePool) -> Self { + let (tx, rx) = watch::channel(None); + + tokio::spawn(async move { + loop { + let node_pool = node_pool.clone(); + let tx_inner = tx.clone(); + let join = tokio::spawn(async move { + monitor_loop(node_pool, tx_inner).await; + }); + + match join.await { + Ok(()) => return, + Err(err) => { + tracing::error!( + "ChainConfigWatch: monitor_loop {err}. Restarting in {}s.", + RETRY_INTERVAL.as_secs() + ); + tokio::select! { + () = time::sleep(RETRY_INTERVAL) => {}, + () = tx.closed() => return, + } + }, + } + } + }); + + Self { rx } + } + + /// Subscribe to config updates. + pub fn subscribe(&self) -> watch::Receiver>> { + self.rx.clone() + } + + /// Returns the current config or a 503 if the node is not yet synced + pub fn get(&self) -> Result, BlockfrostError> { + self.rx.borrow().clone().ok_or_else(|| { + BlockfrostError::service_unavailable( + "Chain configuration is not yet available. The Cardano node may still be syncing." + .to_string(), + ) + }) + } + + /// Wait until the first config is available (node synced and init complete). + #[cfg(feature = "test-utils")] + pub async fn wait_ready(&mut self) -> Result<(), watch::error::RecvError> { + while self.rx.borrow().is_none() { + self.rx.changed().await.inspect_err(|err| { + tracing::error!( + "ChainConfigWatch: watch channel closed before configuration became available: {err}" + ); + })?; + } + Ok(()) + } +} + +/// The main background loop: init config, then watch for epoch changes. +async fn monitor_loop(node_pool: NodePool, tx: watch::Sender>>) { + // Phase 1: first successful init, retried until chain tip is in the latest epoch + let config = match init_until_success(&node_pool, &tx).await { + Some(c) => c, + None => return, + }; + let mut slot_config = config.slot_config.clone(); + if tx.send(Some(Arc::new(config))).is_err() { + return; + } + tracing::info!("ChainConfigWatch: chain configuration loaded"); + + // Phase 2: watch for epoch changes. + loop { + let sleep_dur = duration_until_next_epoch(&slot_config); + tokio::select! { + () = time::sleep(sleep_dur) => {}, + () = tx.closed() => { + tracing::info!("ChainConfigWatch: stopping monitor"); + return; + }, + } + + let new_config = loop { + match init_caches(node_pool.clone()).await { + Ok(c) => break c, + Err(e) => { + tracing::warn!( + "ChainConfigWatch: failed to refresh chain config: {e}. Retrying in {}s.", + RETRY_INTERVAL.as_secs() + ); + tokio::select! { + () = time::sleep(RETRY_INTERVAL) => {}, + () = tx.closed() => { + tracing::info!("ChainConfigWatch: stopping monitor"); + return; + }, + } + }, + } + }; + + slot_config = new_config.slot_config.clone(); + if tx.send(Some(Arc::new(new_config))).is_err() { + break; + } + } +} + +/// Retry `init_caches` until it succeeds +/// Returns `None` if all receivers were dropped (shutdown) +async fn init_until_success( + node_pool: &NodePool, + tx: &watch::Sender>>, +) -> Option { + loop { + match init_caches(node_pool.clone()).await { + Ok(config) => return Some(config), + Err(e) => { + tracing::debug!( + "ChainConfigWatch: failed to load chain config from node: {e}. Retrying in {}s.", + RETRY_INTERVAL.as_secs() + ); + tokio::select! { + () = time::sleep(RETRY_INTERVAL) => {}, + () = tx.closed() => return None, + } + }, + } + } +} + +/// Compute how long to sleep until the next epoch boundary (plus buffer). +fn duration_until_next_epoch(slot_config: &bf_common::chain_config::SlotConfig) -> Duration { + if slot_config.slot_length == 0 || slot_config.epoch_length == 0 { + return RETRY_INTERVAL; + } + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Current slot (may be approximate but close enough for scheduling). + let elapsed_ms = now_ms.saturating_sub(slot_config.zero_time); + let current_slot = slot_config.zero_slot + elapsed_ms / slot_config.slot_length; + + // Slots into the current epoch. + let slot_in_epoch = (current_slot - slot_config.zero_slot) % slot_config.epoch_length; + let slots_until_next = slot_config.epoch_length - slot_in_epoch; + + let ms_until_next = slots_until_next * slot_config.slot_length; + Duration::from_millis(ms_until_next) + EPOCH_BOUNDARY_BUFFER +} + +#[cfg(test)] +mod tests { + use super::*; + use bf_common::chain_config::SlotConfig; + + #[test] + fn test_zero_slot_length_returns_retry_interval() { + let config = SlotConfig { + slot_length: 0, + zero_slot: 0, + zero_time: 0, + epoch_length: 432000, + }; + assert_eq!(duration_until_next_epoch(&config), RETRY_INTERVAL); + } + + #[test] + fn test_zero_epoch_length_returns_retry_interval() { + let config = SlotConfig { + slot_length: 1000, + zero_slot: 0, + zero_time: 0, + epoch_length: 0, + }; + assert_eq!(duration_until_next_epoch(&config), RETRY_INTERVAL); + } + + #[test] + fn test_result_includes_buffer() { + let result = duration_until_next_epoch(&SlotConfig::preview()); + assert!(result >= EPOCH_BOUNDARY_BUFFER); + } + + #[test] + fn test_result_at_most_one_epoch() { + let config = SlotConfig::preview(); + let max = + Duration::from_millis(config.epoch_length * config.slot_length) + EPOCH_BOUNDARY_BUFFER; + let result = duration_until_next_epoch(&config); + assert!(result <= max, "result {result:?} exceeds max {max:?}"); + } + + #[test] + fn test_exact_epoch_boundary_waits_full_epoch() { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let config = SlotConfig { + slot_length: 1000, + zero_slot: 0, + zero_time: now_ms, // current time → current_slot = 0 → slot_in_epoch = 0 + epoch_length: 100, + }; + let result = duration_until_next_epoch(&config); + let full_epoch = Duration::from_millis(config.epoch_length * config.slot_length); + // Should be approximately full_epoch + buffer, not just buffer + assert!( + result > full_epoch, + "At epoch boundary, expected > {full_epoch:?}, got {result:?}" + ); + } + + #[test] + fn test_mainnet_config() { + let config = SlotConfig::mainnet(); + let max = + Duration::from_millis(config.epoch_length * config.slot_length) + EPOCH_BOUNDARY_BUFFER; + let result = duration_until_next_epoch(&config); + assert!(result >= EPOCH_BOUNDARY_BUFFER); + assert!(result <= max); + } +} diff --git a/crates/node/src/ledger.rs b/crates/node/src/ledger.rs new file mode 100644 index 00000000..6469026b --- /dev/null +++ b/crates/node/src/ledger.rs @@ -0,0 +1,29 @@ +use pallas_network::miniprotocols::localstate::{ + self, + queries_v16::{CurrentProtocolParam, GenesisConfig}, +}; + +use super::connection::NodeClient; + +use bf_common::errors::BlockfrostError; + +impl NodeClient { + pub async fn genesis_config_and_pp( + &mut self, + ) -> Result<(GenesisConfig, CurrentProtocolParam, u64), BlockfrostError> { + self.with_statequery(|generic_client: &mut localstate::GenericClient| { + Box::pin(async { + let era = localstate::queries_v16::get_current_era(generic_client).await?; + let genesis = + localstate::queries_v16::get_genesis_config(generic_client, era).await?; + let params = + localstate::queries_v16::get_current_pparams(generic_client, era).await?; + let tip_slot = localstate::queries_v16::get_chain_point(generic_client) + .await? + .slot_or_default(); + Ok((genesis, params, tip_slot)) + }) + }) + .await + } +} diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 77f47919..cafe7cb5 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1,7 +1,11 @@ pub mod cbor; +pub mod chain_config; +pub mod chain_config_watch; pub mod connection; +pub mod ledger; pub mod monitoring; pub mod pool; pub mod pool_manager; pub mod sync_progress; pub mod transactions; +pub mod utxo; diff --git a/crates/node/src/transactions.rs b/crates/node/src/transactions.rs index f19b6da5..6bb846a8 100644 --- a/crates/node/src/transactions.rs +++ b/crates/node/src/transactions.rs @@ -7,7 +7,7 @@ use pallas_network::miniprotocols::{ localstate, localtxsubmission::{EraTx, Response}, }; -use tracing::{error, info, warn}; +use tracing::{error, info}; impl NodeClient { /// Submits a transaction to the connected Cardano node. @@ -44,9 +44,15 @@ impl NodeClient { Ok(txid) }, Ok(Response::Rejected(reason)) => { - let haskell_display = as_node_submit_error(reason) - .unwrap_or_else(|e| format!("Failed to format submit error: {e}")); - warn!( + let haskell_display = as_node_submit_error(reason).unwrap_or_else(|e| { + tracing::warn!( + connection_id = self.connection_id, + "TxSubmitFail: failed to parse rejection reason: {e}, CBOR: {}", + hex::encode(&tx) + ); + "transaction rejected with unknown reason".to_string() + }); + info!( connection_id = self.connection_id, "TxSubmitFail: {}, CBOR: {}", haskell_display, diff --git a/crates/node/src/utxo.rs b/crates/node/src/utxo.rs new file mode 100644 index 00000000..ed1216c8 --- /dev/null +++ b/crates/node/src/utxo.rs @@ -0,0 +1,23 @@ +use pallas_network::miniprotocols::localstate::{ + self, + queries_v16::{TxIns, UTxOByTxin}, +}; + +use super::connection::NodeClient; + +use bf_common::errors::BlockfrostError; + +impl NodeClient { + pub async fn get_utxos_for_txins(&mut self, ins: TxIns) -> Result { + self.with_statequery(|generic_client: &mut localstate::GenericClient| { + Box::pin(async { + let era = localstate::queries_v16::get_current_era(generic_client).await?; + + let utxos: UTxOByTxin = + localstate::queries_v16::get_utxo_by_txin(generic_client, era, ins).await?; + Ok(utxos) + }) + }) + .await + } +} diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index f800eee4..9ef7f9ad 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -13,6 +13,7 @@ bf-common.workspace = true bf-node.workspace = true bf-data-node.workspace = true bf-api-provider.workspace = true +bf-tx-evaluator.workspace = true anyhow.workspace = true axum.workspace = true @@ -52,7 +53,6 @@ uuid.workspace = true [dev-dependencies] bf-api-provider.workspace = true pretty_assertions.workspace = true -proptest.workspace = true rstest.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/crates/platform/src/api/tx/submit.rs b/crates/platform/src/api/tx/submit.rs index 7ddab170..d02ece78 100644 --- a/crates/platform/src/api/tx/submit.rs +++ b/crates/platform/src/api/tx/submit.rs @@ -1,6 +1,6 @@ use crate::validation::validate_content_type; use axum::{Extension, Json, http::HeaderMap, response::IntoResponse}; -use bf_common::errors::BlockfrostError; +use bf_common::{errors::BlockfrostError, helpers::binary_or_hex_heuristic}; use bf_node::pool::NodePool; use metrics::counter; @@ -41,42 +41,3 @@ pub async fn route( Ok((response_headers, Json(response_body))) } - -/// This function allows us to take both hex-encoded and raw bytes. It has -/// to be a heuristic: if there are input bytes that are not `[0-9a-f]`, -/// then it must be a binary string. Otherwise, we assume it’s hex encoded. -/// -/// **Note**: there is a small probability that the user gave us a binary -/// string that only _looked_ like a hex-encoded one, but it’s rare enough -/// to ignore it. -pub fn binary_or_hex_heuristic(xs: &[u8]) -> Vec { - let even_length = xs.len().is_multiple_of(2); - - if !even_length || xs.iter().any(|&x| !x.is_ascii_hexdigit()) { - xs.to_vec() - } else { - hex::decode(xs).unwrap_or_else(|_| unreachable!()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn proptest_binary_or_hex_heuristic( - binary in prop::collection::vec(any::(), 0..=128) - .prop_filter("exclude values made up only of hex digits", |xs| { - xs.iter().any(|&x| !x.is_ascii_hexdigit()) - }) - ) { - let hex_string = hex::encode(&binary); - assert_eq!( - binary_or_hex_heuristic(hex_string.as_bytes()), - binary_or_hex_heuristic(&binary) - ) - } - } -} diff --git a/crates/platform/src/api/utils/txs/evaluate.rs b/crates/platform/src/api/utils/txs/evaluate.rs index b1ee3085..ad1bc5f6 100644 --- a/crates/platform/src/api/utils/txs/evaluate.rs +++ b/crates/platform/src/api/utils/txs/evaluate.rs @@ -1,2 +1,3 @@ +mod model; pub mod root; pub mod utxos; diff --git a/crates/platform/src/api/utils/txs/evaluate/model.rs b/crates/platform/src/api/utils/txs/evaluate/model.rs new file mode 100644 index 00000000..84a40a13 --- /dev/null +++ b/crates/platform/src/api/utils/txs/evaluate/model.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +fn default_version() -> u8 { + 5 +} + +#[derive(Deserialize)] +pub struct EvaluateQuery { + // The default version is 5, which represents Ogmios v5. + #[serde(default = "default_version")] + pub version: u8, +} diff --git a/crates/platform/src/api/utils/txs/evaluate/root.rs b/crates/platform/src/api/utils/txs/evaluate/root.rs index ba8d9950..36757769 100644 --- a/crates/platform/src/api/utils/txs/evaluate/root.rs +++ b/crates/platform/src/api/utils/txs/evaluate/root.rs @@ -1,6 +1,30 @@ -use crate::{BlockfrostError, api::ApiResult}; -use serde_json::Value; +use crate::validation::validate_content_type; +use crate::{BlockfrostError, api::utils::txs::evaluate::model::EvaluateQuery}; +use axum::{Extension, Json, extract::Query, response::IntoResponse}; +use bf_node::pool::NodePool; +use bf_tx_evaluator::external::ExternalEvaluator; +use hyper::HeaderMap; -pub async fn route() -> ApiResult { - Err(BlockfrostError::not_found()) +pub async fn route( + Extension(node): Extension, + Extension(evaluator): Extension, + Query(query): Query, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + validate_content_type(&headers, &["application/cbor"])?; + + match query.version { + 6 => Ok(Json( + evaluator + .evaluate_tx_payload_v6(node, body.as_ref(), None) + .await?, + )), + // Everything else is treated as v5 + _ => Ok(Json( + evaluator + .evaluate_tx_payload_v5(node, body.as_ref(), None) + .await?, + )), + } } diff --git a/crates/platform/src/api/utils/txs/evaluate/utxos.rs b/crates/platform/src/api/utils/txs/evaluate/utxos.rs index ba8d9950..a114a014 100644 --- a/crates/platform/src/api/utils/txs/evaluate/utxos.rs +++ b/crates/platform/src/api/utils/txs/evaluate/utxos.rs @@ -1,6 +1,41 @@ -use crate::{BlockfrostError, api::ApiResult}; -use serde_json::Value; +use crate::BlockfrostError; +use axum::{ + Extension, Json, + extract::{self}, + response::IntoResponse, +}; +use bf_node::pool::NodePool; +use bf_tx_evaluator::{external::ExternalEvaluator, model::api::TxEvaluationRequest}; -pub async fn route() -> ApiResult { - Err(BlockfrostError::not_found()) +pub async fn route( + Extension(node): Extension, + Extension(evaluator): Extension, + extract::Json(tx_request): extract::Json, +) -> Result { + // query.version is ignored on purpose + match tx_request { + TxEvaluationRequest::V6(request) => Ok(Json( + evaluator + .evaluate_tx_payload_v6( + node, + request.transaction.cbor.as_bytes(), + request.additional_utxo, + ) + .await?, + )), + TxEvaluationRequest::V5Cbor(request) => Ok(Json( + evaluator + .evaluate_tx_payload_v5(node, request.cbor.as_bytes(), request.additional_utxo_set) + .await?, + )), + TxEvaluationRequest::V5Evaluate(request) => Ok(Json( + evaluator + .evaluate_tx_payload_v5( + node, + request.evaluate.as_bytes(), + request.additional_utxo_set, + ) + .await?, + )), + } } diff --git a/crates/platform/src/main.rs b/crates/platform/src/main.rs index 934ad5e7..47439f39 100644 --- a/crates/platform/src/main.rs +++ b/crates/platform/src/main.rs @@ -35,7 +35,7 @@ async fn main() -> Result<(), AppError> { env!("GIT_REVISION") ); - let (app, _, health_monitor, icebreakers_api, api_prefix) = + let (app, _, health_monitor, icebreakers_api, api_prefix, _chain_config_watch) = build(config.clone().into()).await?; let address = std::net::SocketAddr::new(config.server_address, config.server_port); diff --git a/crates/platform/src/server.rs b/crates/platform/src/server.rs index 637e2e3a..d8389a82 100644 --- a/crates/platform/src/server.rs +++ b/crates/platform/src/server.rs @@ -8,10 +8,11 @@ use crate::{ icebreakers::api::IcebreakersAPI, middlewares::errors::error_middleware, }; -use axum::{Extension, Router, middleware::from_fn}; +use axum::{Extension, Router, extract::DefaultBodyLimit, middleware::from_fn}; use bf_common::errors::{AppError, BlockfrostError}; use bf_data_node::client::DataNode; -use bf_node::pool::NodePool; +use bf_node::{chain_config_watch::ChainConfigWatch, pool::NodePool}; +use bf_tx_evaluator::external::ExternalEvaluator; use metrics::{setup_metrics_recorder, spawn_process_collector}; use routes::{hidden::get_hidden_api_routes, nest_routes, regular::get_regular_api_routes}; use state::{ApiPrefix, AppState}; @@ -31,6 +32,7 @@ pub async fn build( health_monitor::HealthMonitor, Option>, ApiPrefix, + ChainConfigWatch, ), AppError, > { @@ -72,6 +74,12 @@ pub async fn build( // Set up optional Icebreakers API (solitary option in CLI) let icebreakers_api = IcebreakersAPI::new(&config, api_prefix.clone()).await?; + // Spawn the chain config watcher + let chain_config_watch = ChainConfigWatch::spawn(node_conn_pool.clone()); + + // Initialize the Haskell-based tx evaluator + let tx_evaluator = ExternalEvaluator::new(chain_config_watch.clone()); + // API routes that are always under / (and also under the UUID prefix, if we use it) let regular_api_routes = get_regular_api_routes(!config.no_metrics); let hidden_api_routes = get_hidden_api_routes(!config.no_metrics); @@ -94,6 +102,7 @@ pub async fn build( .with_state(app_state.clone()) .layer(Extension(health_monitor.clone())) .layer(Extension(node_conn_pool.clone())) + .layer(Extension(tx_evaluator.clone())) .layer(from_fn(error_middleware)) .fallback(BlockfrostError::not_found()); @@ -107,7 +116,8 @@ pub async fn build( let inner = NormalizePathLayer::trim_trailing_slash().layer(inner); let app = Router::new() .fallback_service(inner) - .layer(ConcurrencyLimitLayer::new(config.server_concurrency_limit)); + .layer(ConcurrencyLimitLayer::new(config.server_concurrency_limit)) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)); // 10 MB Ok(( app, @@ -115,5 +125,6 @@ pub async fn build( health_monitor, icebreakers_api, api_prefix, + chain_config_watch, )) } diff --git a/crates/platform/src/server/routes/hidden.rs b/crates/platform/src/server/routes/hidden.rs index f0aae598..a0dfe7c0 100644 --- a/crates/platform/src/server/routes/hidden.rs +++ b/crates/platform/src/server/routes/hidden.rs @@ -1,6 +1,7 @@ +use crate::api::utils; use crate::api::{ accounts, addresses, assets, blocks, epochs, governance, health, ledger, metadata, network, - pools, scripts, tx, txs, utils, + pools, scripts, tx, txs, }; use crate::middlewares::metrics::track_http_metrics; use crate::server::state::AppState; @@ -135,9 +136,9 @@ pub fn get_hidden_api_routes(enable_metrics: bool) -> Router { .route("/txs/{hash}/required_signers", get(txs::hash::required_signers::route)) .route("/txs/{hash}/cbor", get(txs::hash::cbor::route)) - // utils - .route("/utils/tx/evaluate", post(utils::txs::evaluate::root::route)) - .route("/utils/tx/evaluate/utxos", post(utils::txs::evaluate::utxos::route)); + // tx evaluate + .route("/utils/txs/evaluate",post(utils::txs::evaluate::root::route)) + .route("/utils/txs/evaluate/utxos",post(utils::txs::evaluate::utxos::route)); if enable_metrics { router = router.route_layer(from_fn(track_http_metrics)); diff --git a/crates/testgen/src/testgen.rs b/crates/testgen/src/testgen.rs index abf8ac73..61641fe9 100644 --- a/crates/testgen/src/testgen.rs +++ b/crates/testgen/src/testgen.rs @@ -30,12 +30,14 @@ struct TestgenRequest { #[non_exhaustive] pub enum Variant { DeserializeStream, + EvaluateStream, } impl Variant { fn as_arg(self) -> &'static str { match self { Self::DeserializeStream => "deserialize-stream", + Self::EvaluateStream => "evaluate-stream", } } } diff --git a/crates/tx_evaluator/Cargo.toml b/crates/tx_evaluator/Cargo.toml new file mode 100644 index 00000000..9aec3910 --- /dev/null +++ b/crates/tx_evaluator/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "blockfrost-platform-tx-evaluator" +version.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +bf-common.workspace = true +bf-testgen.workspace = true +bf-node.workspace = true +serde.workspace = true +hex.workspace = true +base64.workspace = true +serde_json.workspace = true +pallas-network.workspace = true +pallas-crypto.workspace = true +pallas-traverse.workspace = true +pallas-codec.workspace = true +pallas-primitives.workspace = true +pallas-addresses.workspace = true +pallas-validate.workspace = true +chrono.workspace = true +uuid.workspace = true +tracing.workspace = true +tokio.workspace = true + +[features] +tarpaulin = [] diff --git a/crates/tx_evaluator/README.md b/crates/tx_evaluator/README.md new file mode 100644 index 00000000..96f0a31f --- /dev/null +++ b/crates/tx_evaluator/README.md @@ -0,0 +1,151 @@ +# TX evaluator + +This package contains native and external tx evaluator. + +- Native is based on `pallas-validate`. +- External is using `ledger` codebase via `testgen` Haskell binary. + +`native` produces different evaluation results when compared to `external` at the moment. Knowing that `external` depends on the ledger codebase, we can consider `external` results accurate. + +## Versions + +Both `native` and `external` evaluator produces version 5 & 6 results. These versions are there for compatibility with [Blockfrost](https://docs.blockfrost.io/#tag/cardano--utilities/POST/utils/txs/evaluate). Blockfrost implementation is a proxy to Ogmios, so version 5 & 6 is Ogmios versions. + +### Version 5 Data Structure + +#### V5 Full Input for API + +```javascript +{ + "type": "jsonwsp/request", + "version": "1.0", + "servicename": "ogmios", + "methodname": "EvaluateTx", + "args": { + "evaluate": "string", + "additionalUtxoSet": [ + [ + { + "txId": "stringstringstringstringstringstringstringstringstringstringstri", + "index": 4294967295 + }, + { + "address": "addr_test1qz66ue36465w2qq40005h2hadad6pnjht8mu6sgplsfj74qdjnshguewlx4ww0eet26y2pal4xpav5prcydf28cvxtjqx46x7f", + "value": { + "coins": 2, + "assets": { + "3542acb3a64d80c29302260d62c3b87a742ad14abf855ebc6733081e": 42, + "b5ae663aaea8e500157bdf4baafd6f5ba0ce5759f7cd4101fc132f54.706174617465": 1337 + } + }, + "datumHash": null, + "datum": null, + "script": null + } + ] + ] + }, + "mirror": null +} +``` + +#### V5 Full Output for API + +```javascript +{ + "type": "jsonwsp/response", + "version": "1.0", + "servicename": "ogmios", + "methodname": "EvaluateTx", + "result": { + "EvaluationResult": { + "spend:1": { + "memory": 5236222, + "steps": 1212353 + }, + "mint:0": { + "memory": 5000, + "steps": 42 + } + } + }, + "reflection": { + "id": "3db92aa6-5765-46a8-a9a4-2e8a94ab2a1d" + } +} +``` + +### Version 6 Data Structure + +#### V6 Full Input for API + +```javascript +{ + "jsonrpc": "2.0", + "method": "evaluateTransaction", + "params": { + "transaction": { + "cbor": "string" + }, + "additionalUtxo": [ + { + "transaction": { + "id": "stringstringstringstringstringstringstringstringstringstringstri" + }, + "index": 4294967295, + "address": "addr1q9d34spgg2kdy47n82e7x9pdd6vql6d2engxmpj20jmhuc2047yqd4xnh7u6u5jp4t0q3fkxzckph4tgnzvamlu7k5psuahzcp", + "value": { + "ada": { + "lovelace": 0 + }, + "property1": { + "property1": 0, + "property2": 0 + }, + "property2": { + "property1": 0, + "property2": 0 + } + }, + "datumHash": "c248757d390181c517a5beadc9c3fe64bf821d3e889a963fc717003ec248757d", + "datum": "string", + "script": { + "language": "native", + "json": { + "clause": "signature", + "from": "3c07030e36bfff7cd2f004356ef320f3fe3c07030e7cd2f004356437" + }, + "cbor": "string" + } + } + ] + }, + "id": null +} +``` + +#### V6 Full Output for API + +```javascript +{ + "jsonrpc": "2.0", + "method": "evaluateTransaction", + "result": [ + { + "validator": "spend:1", + "budget": { + "memory": 5236222, + "cpu": 1212353 + } + }, + { + "validator": "mint:0", + "budget": { + "memory": 5000, + "cpu": 42 + } + } + ], + "id": "3db92aa6-5765-46a8-a9a4-2e8a94ab2a1d" +} +``` diff --git a/crates/tx_evaluator/src/external.rs b/crates/tx_evaluator/src/external.rs new file mode 100644 index 00000000..221dfed0 --- /dev/null +++ b/crates/tx_evaluator/src/external.rs @@ -0,0 +1,458 @@ +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use bf_node::chain_config_watch::ChainConfigWatch; +use bf_node::pool::NodePool; +use pallas_codec::{ + minicbor::to_vec, + utils::{AnyUInt, CborWrap}, +}; + +use pallas_network::miniprotocols::{ + localstate::queries_v16::{ + DatumOption, PostAlonsoTransactionOutput, TransactionOutput, UTxO, Value as NetworkValue, + }, + localtxsubmission::primitives::ScriptRef, +}; +use pallas_primitives::Bytes; +use pallas_primitives::KeyValuePairs; +use pallas_traverse::MultiEraTx; +use serde::Serialize; +use tokio::sync::watch; + +use bf_common::{ + chain_config::{ChainConfigCache, SlotConfig}, + errors::{AppError, BlockfrostError}, +}; +use bf_testgen::testgen::{Testgen, TestgenResponse, Variant}; + +use crate::{ + helper::{generate_reflection_v5, generate_reflection_v6, resolve_tx_body}, + model::api::{ + AdditionalUtxoSet, AdditionalUtxoV6, OgmiosError, OutputReferenceV6, TransactionIdV6, + }, + native::{ + convert_to_datum_option_network, convert_to_network_value, convert_to_network_value_v6, + create_address, extract_inputs, + }, + ogmios5_response::invalid_request_v5, + wrapper::{ + wrap_as_incompatible_era_v5, wrap_as_incompatible_era_v6, wrap_eval_output_v5, + wrap_eval_output_v6, wrap_ogmios_error_v6, + }, +}; + +/// How long the supervisor waits before retrying after a failed testgen-hs spawn. +const RESPAWN_RETRY_DELAY: Duration = Duration::from_secs(60); + +/// Evaluates transactions using the external testgen-hs Haskell binary. +#[derive(Clone)] +pub struct ExternalEvaluator { + testgen_rx: watch::Receiver>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InitPayload { + system_start: String, + protocol_params: String, + slot_config: SlotConfig, + era: u16, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EvalPayload { + tx: String, + utxos: String, +} + +/// Either a testgen-hs response +/// or a domain-level Ogmios compatible error detected before evaluation +pub enum EvalOutput { + Testgen(TestgenResponse), + Error(OgmiosError), +} + +/// Evaluates the given tx with utxos using the external testgen exe, which is a Haskell binary. +impl ExternalEvaluator { + pub fn new(config_watch: ChainConfigWatch) -> Self { + let (testgen_tx, testgen_rx) = watch::channel(None); + tokio::spawn(testgen_supervisor(config_watch, testgen_tx)); + Self { testgen_rx } + } + + /// Returns the current `Testgen` handle, or 503 if the supervisor hasn't + /// published one yet (node still syncing, or a respawn is in progress). + fn get_testgen(&self) -> Result { + self.testgen_rx.borrow().clone().ok_or_else(|| { + BlockfrostError::service_unavailable( + "testgen-hs is not yet ready. The Cardano node may still be syncing.".to_string(), + ) + }) + } + + pub async fn evaluate_binary_tx( + &self, + node_pool: NodePool, + tx_cbor_binary: &[u8], + additional_utxos: Vec<(UTxO, TransactionOutput)>, + ) -> Result { + let testgen = self.get_testgen()?; + let node = node_pool.get(); + + let multi_era_tx = match MultiEraTx::decode(tx_cbor_binary) { + Ok(tx) => tx, + Err(err) => { + let msg = format!( + "Invalid request: Deserialisation failure while decoding serialised transaction. CBOR failed with error: {}", + err + ); + return Ok(EvalOutput::Testgen(TestgenResponse::Err( + serde_json::Value::String(msg), + ))); + }, + }; + + // No redeemers = no scripts to evaluate. Return empty result + if multi_era_tx.redeemers().is_empty() { + return Ok(EvalOutput::Testgen(TestgenResponse::Ok(serde_json::json!( + [] + )))); + } + + let txins = extract_inputs(&multi_era_tx)?; + + let utxos_from_node = node.await?.get_utxos_for_txins(txins).await?; + + // Merge UTxOs from node (on-chain) and user-provided additional UTxOs. + // + // Conflict detection (follows Ogmios's OverlappingAdditionalUtxo approach): + // A TxIn deterministically identifies a unique TxOut on-chain. If a user + // provides a TxOut for a TxIn that already exists on-chain with a *different* + // value, the user's data is wrong — we reject it with a 400 error. User always loses + // against the node and mempool data(if we had it). + // See: https://github.com/CardanoSolutions/ogmios/blob/bdb1bad58506e9ac470796c5e1406cde49aebc1a/server/src/Ogmios/App/Protocol/TxSubmission.hs#L628-L649 + // + // Deduplication within additional_utxos: if the user sends the same TxIn twice, + // the last entry wins — matching Haskell's Data.Map.fromList semantics. + // + // Mempool gap: we do not have mempool support as agreed before (pallas has a txmonitor + // miniprotocol but it is not wired up in our node connection layer). Ogmios + // merges mempool UTxOs (via LocalTxMonitor) with user additional UTxOs before + // validating against network UTxOs. We only have network + user-provided. + type TxInKey = (pallas_crypto::hash::Hash<32>, u64); + let txin_key = |utxo: &UTxO| -> TxInKey { (utxo.transaction_id, u64::from(&utxo.index)) }; + + let node_utxo_map: HashMap = utxos_from_node + .to_vec() + .into_iter() + .map(|(txin, txout)| (txin_key(&txin), (txin, txout))) + .collect(); + + // Check for conflicts against on-chain UTxOs and deduplicate additional_utxos. + let mut overlapping: Vec = Vec::new(); + let mut additional_deduped: HashMap = HashMap::new(); + for (txin, txout) in additional_utxos { + let key = txin_key(&txin); + if let Some((_, node_txout)) = node_utxo_map.get(&key) { + if node_txout != &txout { + overlapping.push(OutputReferenceV6 { + transaction: TransactionIdV6 { + id: key.0.to_string(), + }, + index: key.1, + }); + } + // Same TxIn with identical TxOut: node already has it, skip. + } else { + // Not on-chain. Last entry wins for duplicate TxIns within additional_utxos. + additional_deduped.insert(key, (txin, txout)); + } + } + + if !overlapping.is_empty() { + return Ok(EvalOutput::Error(OgmiosError::overlapping_utxo( + overlapping, + ))); + } + + let utxos = KeyValuePairs::from_iter( + node_utxo_map + .into_values() + .chain(additional_deduped.into_values()), + ); + + let utxos_cbor = hex::encode(to_vec(&utxos).map_err(|err| { + BlockfrostError::internal_server_error(format!( + "ExternalEvaluator: Failed to serialize UTxOs: {err}" + )) + })?); + + // TODO(testgen-hs): CBOR with empty guarded fields (e.g. empty required_signers + // key 14 = 0x80) is rejected by testgen-hs's Conway decoder (fieldGuarded). + // Ogmios avoids this via Babbage fallback. Adding Babbage decode to testgen-hs + // would fix this. Until then, affected test fixtures should expect a fault response. + let payload = EvalPayload { + tx: hex::encode(tx_cbor_binary), + utxos: utxos_cbor, + }; + + let json = serde_json::to_string(&payload).map_err(|err| { + BlockfrostError::internal_server_error(format!( + "ExternalEvaluator: Failed to serialize payload: {err}" + )) + })?; + + let response = testgen.send(json).await.map_err(|err| { + BlockfrostError::internal_server_error(format!( + "ExternalEvaluator: Failed to send payload: {err}" + )) + })?; + Ok(EvalOutput::Testgen(response)) + } + + fn build_transaction_output( + txin: UTxO, + address: Bytes, + script_ref: Option>, + amount: NetworkValue, + inline_datum: Option, + ) -> (UTxO, TransactionOutput) { + let txout = TransactionOutput::Current(PostAlonsoTransactionOutput { + address, + script_ref, + amount, + inline_datum, + }); + (txin, txout) + } + + /// Decodes raw payload (hex/base64/binary) then evaluates. Used by application/cbor endpoint. + pub async fn evaluate_tx_payload_v5( + &self, + node_pool: NodePool, + tx_body: &[u8], + additional_utxos: Option, + ) -> Result { + let tx_cbor_binary = match resolve_tx_body(tx_body) { + Ok(decoded) => decoded, + Err(_) => return Ok(invalid_request_v5(&generate_reflection_v5())), + }; + self.evaluate_binary_tx_v5(node_pool, &tx_cbor_binary, additional_utxos) + .await + } + + /// Decodes raw payload (hex/base64/binary) then evaluates. Used by application/cbor endpoint. + pub async fn evaluate_tx_payload_v6( + &self, + node_pool: NodePool, + tx_body: &[u8], + additional_utxos: Option>, + ) -> Result { + let tx_cbor_binary = match resolve_tx_body(tx_body) { + Ok(decoded) => decoded, + Err(msg) => { + let oe = OgmiosError::invalid_request(msg); + return Ok(wrap_ogmios_error_v6(&oe, &generate_reflection_v6())); + }, + }; + self.evaluate_binary_tx_v6(node_pool, &tx_cbor_binary, additional_utxos) + .await + } + + /// Evaluates already-decoded CBOR binary. Used by JSON endpoints (utxos.rs). + pub async fn evaluate_binary_tx_v5( + &self, + node_pool: NodePool, + tx_cbor_binary: &[u8], + additional_utxos: Option, + ) -> Result { + // Only Alonzo/Babbage/Conway tx (4-element CBOR array) can be evaluated. + // TODO(dijkstra): Dijkstra's CDDL allows an optional 3-element transaction variant + // (no `is_valid` field). When adding Dijkstra support, pay attention. + if tx_cbor_binary.first() != Some(&0x84) { + return Ok(wrap_as_incompatible_era_v5("Mary".to_string())); + } + + let user_utxos = additional_utxos + .unwrap_or_default() + .iter() + .map( + |(utxo, tout)| -> Result<(UTxO, TransactionOutput), BlockfrostError> { + let txin = UTxO { + transaction_id: pallas_crypto::hash::Hash::<32>::from_str(&utxo.tx_id) + .map_err(|e| { + BlockfrostError::custom_400(format!( + "invalid tx_id '{}': {e}", + utxo.tx_id + )) + })?, + index: AnyUInt::U64(utxo.index), + }; + Ok(Self::build_transaction_output( + txin, + create_address(&tout.address)?, + tout.script + .as_ref() + .map(|s| ScriptRef::try_from(s.clone()).map(CborWrap)) + .transpose()?, + convert_to_network_value(&tout.value)?, + convert_to_datum_option_network(&tout.datum, &tout.datum_hash)?, + )) + }, + ) + .collect::, _>>()?; + + Ok(wrap_eval_output_v5( + self.evaluate_binary_tx(node_pool, tx_cbor_binary, user_utxos) + .await?, + )) + } + + /// Evaluates already-decoded CBOR binary. Used by JSON endpoints (utxos.rs). + pub async fn evaluate_binary_tx_v6( + &self, + node_pool: NodePool, + tx_cbor_binary: &[u8], + additional_utxos: Option>, + ) -> Result { + // Only Alonzo/Babbage/Conway tx (4-element CBOR array) can be evaluated. + // TODO(dijkstra): Dijkstra's CDDL allows an optional 3-element transaction variant + // (no `is_valid` field). When adding Dijkstra support, pay attention. + if tx_cbor_binary.first() != Some(&0x84) { + return Ok(wrap_as_incompatible_era_v6("mary".to_string())); + } + + let user_utxos = additional_utxos + .unwrap_or_default() + .iter() + .map( + |utxo| -> Result<(UTxO, TransactionOutput), BlockfrostError> { + let txin = UTxO { + transaction_id: pallas_crypto::hash::Hash::<32>::from_str( + utxo.transaction_id(), + ) + .map_err(|e| { + BlockfrostError::custom_400(format!( + "invalid transaction id '{}': {e}", + utxo.transaction_id() + )) + })?, + index: AnyUInt::U64(utxo.output_index()), + }; + Ok(Self::build_transaction_output( + txin, + create_address(&utxo.address)?, + utxo.script + .as_ref() + .map(|s| ScriptRef::try_from(s).map(CborWrap)) + .transpose()?, + convert_to_network_value_v6(&utxo.value)?, + convert_to_datum_option_network(&utxo.datum, &utxo.datum_hash)?, + )) + }, + ) + .collect::, _>>()?; + + Ok(wrap_eval_output_v6( + self.evaluate_binary_tx(node_pool, tx_cbor_binary, user_utxos) + .await?, + )) + } +} + +/// Background task that owns the testgen-hs (re)spawn loop. +async fn testgen_supervisor( + config_watch: ChainConfigWatch, + testgen_tx: watch::Sender>, +) { + let mut config_rx = config_watch.subscribe(); + let mut last_config: Option> = None; + + loop { + // Snapshot the current config; if not ready yet, wait for the first publish. + let snapshot = config_rx.borrow().clone(); + let current = match snapshot { + Some(c) => c, + None => { + if config_rx.changed().await.is_err() { + return; // ChainConfigWatch dropped + } + continue; + }, + }; + + let need_respawn = match &last_config { + Some(prev) => !Arc::ptr_eq(prev, ¤t), + None => true, + }; + + if need_respawn { + tracing::info!("ExternalEvaluator: (re)spawning testgen-hs"); + match spawn_and_init_testgen(¤t).await { + Ok(testgen) => { + if testgen_tx.send(Some(testgen)).is_err() { + return; // No more receivers + } + last_config = Some(current); + tracing::info!("ExternalEvaluator: testgen-hs ready"); + }, + Err(e) => { + tracing::error!( + "ExternalEvaluator: testgen-hs spawn failed: {e}. Retrying in {}s.", + RESPAWN_RETRY_DELAY.as_secs() + ); + // Keep any previously-published Testgen usable. + tokio::select! { + () = tokio::time::sleep(RESPAWN_RETRY_DELAY) => {}, + () = testgen_tx.closed() => return, + } + continue; + }, + } + } + + // Wait for the next config change. + if config_rx.changed().await.is_err() { + return; + } + } +} + +/// Spawn a fresh testgen-hs process and send the init payload +/// TODO(testgen-hs): Currently testgen only accepts init message as the first message. +/// See: https://github.com/blockfrost/blockfrost-platform/issues/573 +async fn spawn_and_init_testgen(config: &ChainConfigCache) -> Result { + let system_start = to_vec(&config.genesis_config.system_start).map_err(|err| { + AppError::Server(format!( + "ExternalEvaluator: failed to serialize genesis config: {err}" + )) + })?; + + let protocol_params = to_vec(&config.protocol_params).map_err(|err| { + AppError::Server(format!( + "ExternalEvaluator: failed to serialize protocol params: {err}" + )) + })?; + + let init_payload = InitPayload { + system_start: hex::encode(system_start), + protocol_params: hex::encode(protocol_params), + slot_config: config.slot_config.clone(), + era: config.era, + }; + + let payload = serde_json::to_string(&init_payload).map_err(|err| { + AppError::Server(format!( + "ExternalEvaluator: failed to serialize initial payload: {err}" + )) + })?; + + let testgen = Testgen::spawn_with_init(Variant::EvaluateStream, payload) + .map_err(|err| AppError::Server(format!("Failed to spawn ExternalEvaluator: {err}")))?; + + Ok(testgen) +} diff --git a/crates/tx_evaluator/src/helper.rs b/crates/tx_evaluator/src/helper.rs new file mode 100644 index 00000000..594ee343 --- /dev/null +++ b/crates/tx_evaluator/src/helper.rs @@ -0,0 +1,95 @@ +use base64::{Engine as _, engine::general_purpose}; +use serde_json::json; + +pub fn generate_reflection_v5() -> serde_json::Value { + json!({"id": uuid::Uuid::new_v4().to_string()}) +} +pub fn generate_reflection_v6() -> serde_json::Value { + serde_json::Value::String(uuid::Uuid::new_v4().to_string()) +} + +/// Decodes a CBOR payload from an `application/cbor` endpoint body. +/// Matches Blockfrost API behavior: +/// - Non-ASCII bytes → treat as raw binary CBOR +/// - Empty input → passed through as-is; the CBOR decoder downstream +/// would reject it with a "deserialisation failure" +/// - ASCII text → try hex decode, then base64 decode +/// - If neither works → error +pub fn resolve_tx_body(body: &[u8]) -> Result, String> { + let is_text = !body.is_empty() && body.iter().all(|b| b.is_ascii()); + if !is_text { + return Ok(body.to_vec()); + } + + // 1. Hex decode (even length, all hex chars) + let even_length = body.len().is_multiple_of(2); + let all_hex = body.iter().all(|b| b.is_ascii_hexdigit()); + if even_length + && all_hex + && let Ok(decoded) = hex::decode(body) + { + return Ok(decoded); + } + + // 2. Base64 decode + if let Ok(decoded) = general_purpose::STANDARD.decode(body) + && !decoded.is_empty() + { + return Ok(decoded); + } + + Err("Invalid request: failed to decode payload from base64 or base16.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Prod test cases verified via curl against cardano-preview.blockfrost.io + + #[test] + fn raw_binary_cbor() { + let cbor = hex::decode("84A300818258204E9A66B7").unwrap(); + assert_eq!(resolve_tx_body(&cbor).unwrap(), cbor); + } + + #[test] + fn hex_text_even_length() { + let hex = b"84A300818258204E9A66B7"; + let expected = hex::decode("84A300818258204E9A66B7").unwrap(); + assert_eq!(resolve_tx_body(hex).unwrap(), expected); + } + + #[test] + fn hex_text_odd_length_fails() { + let odd_hex = b"84a300818258204acdf8c67"; + assert!(resolve_tx_body(odd_hex).is_err()); + } + + #[test] + fn base64_text() { + let cbor = hex::decode("84A300818258204E9A66B7").unwrap(); + let b64 = general_purpose::STANDARD.encode(&cbor); + assert_eq!(resolve_tx_body(b64.as_bytes()).unwrap(), cbor); + } + + #[test] + fn garbage_text_fails() { + assert!(resolve_tx_body(b"hello world").is_err()); + } + + #[test] + fn empty_body_returns_empty() { + assert_eq!(resolve_tx_body(b"").unwrap(), Vec::::new()); + } + + #[test] + fn raw_0x80_binary() { + assert_eq!(resolve_tx_body(&[0x80]).unwrap(), vec![0x80]); + } + + #[test] + fn hex_text_80_decodes() { + assert_eq!(resolve_tx_body(b"80").unwrap(), vec![0x80]); + } +} diff --git a/crates/tx_evaluator/src/lib.rs b/crates/tx_evaluator/src/lib.rs new file mode 100644 index 00000000..7c3006bd --- /dev/null +++ b/crates/tx_evaluator/src/lib.rs @@ -0,0 +1,6 @@ +pub mod external; +mod helper; +pub mod model; +pub mod native; +pub mod ogmios5_response; +pub mod wrapper; diff --git a/crates/tx_evaluator/src/model/api.rs b/crates/tx_evaluator/src/model/api.rs new file mode 100644 index 00000000..4d6cb6b5 --- /dev/null +++ b/crates/tx_evaluator/src/model/api.rs @@ -0,0 +1,1175 @@ +use core::fmt; +use std::collections::HashMap; +use std::str::FromStr; + +use bf_common::errors::BlockfrostError; +use pallas_primitives::{Bytes, ExUnits, KeepRaw, conway::RedeemerTag}; +use pallas_validate::phase2::EvalReport; +use pallas_validate::phase2::tx::TxEvalResult; +use serde::de; +use serde::{Deserialize, Serialize, ser::SerializeMap}; + +fn decode_script_hex(s: &str) -> Result { + hex::decode(s) + .map(Bytes::from) + .map_err(|e| BlockfrostError::custom_400(format!("invalid hex-encoded script CBOR: {e}"))) +} + +fn decode_pubkey_hash(st: &str) -> Result<[u8; 28], BlockfrostError> { + let mut bytes = [0u8; 28]; + hex::decode_to_slice(st, &mut bytes).map_err(|e| { + BlockfrostError::custom_400(format!("invalid hex-encoded pubkey hash: {e}")) + })?; + Ok(bytes) +} + +// JSON request +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum TxEvaluationRequest { + V6(TxEvaluationRequestV6), + V5Cbor(TxEvaluationRequestV5Cbor), + V5Evaluate(TxEvaluationRequestV5Evaluate), +} + +#[derive(Debug, Deserialize)] +pub struct TxEvaluationRequestV5Cbor { + pub cbor: String, // @todo can be base16 or base64 CBOR + #[serde(rename = "additionalUtxoSet")] + pub additional_utxo_set: Option, + pub mirror: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TxEvaluationRequestV5Evaluate { + pub evaluate: String, + #[serde(rename = "additionalUtxoSet")] + pub additional_utxo_set: Option, + pub mirror: Option, +} +// JSON request + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TxEvaluationRequestV6 { + pub transaction: TransactionCborV6, + pub additional_utxo: Option>, + #[serde(default)] + pub mirror: Option, +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdditionalUtxoV6 { + #[serde(flatten)] + pub output_reference: OutputReferenceV6, + pub address: String, + pub value: ValueV6, + #[serde(rename = "datumHash")] + pub datum_hash: Option, + pub datum: Option, + pub script: Option, +} + +impl AdditionalUtxoV6 { + pub fn transaction_id(&self) -> &str { + &self.output_reference.transaction.id + } + + pub fn output_index(&self) -> u64 { + self.output_reference.index + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValueV6 { + pub ada: LovelaceV6, + #[serde(flatten)] + pub assets: HashMap>, +} + +/// If language is native, the json structure can be one of cbor or json. +/// If language is plutus, only cbor field is expected. +/// +/// ```json +/// { +/// "script": { +/// "language": "native", +/// "json": { +/// "clause": "signature", +/// "from": "3c07030e36bfff7cd2f004356ef320f3fe3c07030e7cd2f004356437" +/// }, +/// "cbor": "string" +/// } +/// } +/// ``` +#[derive(Debug, Deserialize)] +#[serde(tag = "language", rename_all = "camelCase")] +pub enum ScriptV6 { + #[serde(rename = "plutus:v1")] + PlutusV1 { cbor: String }, + #[serde(rename = "plutus:v2")] + PlutusV2 { cbor: String }, + #[serde(rename = "plutus:v3")] + PlutusV3 { cbor: String }, + Native { + json: Option, + cbor: Option, + }, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "clause", rename_all = "camelCase")] +pub enum ScriptNativeV6 { + Signature { + from: String, + }, + Any { + from: Vec, + }, + All { + from: Vec, + }, + Some { + at_least: u32, + from: Vec, + }, + Before { + slot: u64, + }, + After { + slot: u64, + }, +} + +#[derive(Deserialize, Debug)] +pub struct TxIn { + #[serde(rename = "txId")] + pub tx_id: String, + pub index: u64, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionCborV6 { + pub cbor: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TransactionIdV6 { + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OutputReferenceV6 { + pub transaction: TransactionIdV6, + pub index: u64, +} + +#[derive(Debug, Deserialize)] +pub struct LovelaceV6 { + pub lovelace: u64, +} + +#[derive(Deserialize, Debug)] +pub struct TxOut { + pub address: String, + pub value: Value, + #[serde(rename = "datumHash")] + pub datum_hash: Option, + pub datum: Option, + pub script: Option