From a0794738a6aba0a6afb595926d88757500f9b63f Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Mon, 24 Aug 2026 15:22:39 +0100 Subject: [PATCH] refactor: Wasm vm redesign (#8012) Co-authored-by: TimothyBanks --- .cspell.config.yaml | 8 + .github/scripts/strategy-matrix/generate.py | 1 - .../workflows/reusable-build-test-config.yml | 5 +- .github/workflows/reusable-clang-tidy.yml | 1 - BUILD.md | 29 +- CMakeLists.txt | 6 +- CONTRIBUTING.md | 2 +- cmake/XrplCore.cmake | 7 +- cmake/XrplSettings.cmake | 5 - conan.lock | 1 - conanfile.py | 6 +- crates/CMakeLists.txt | 9 +- crates/Cargo.lock | 214 +- crates/Cargo.toml | 10 +- crates/hello_world/src/lib.rs | 10 - crates/xrpl-host-functions-macros/Cargo.toml | 18 + .../xrpl-host-functions-macros/src/errors.rs | 12 + crates/xrpl-host-functions-macros/src/lib.rs | 405 ++++ .../src/parsed_host_function.rs | 859 ++++++++ crates/xrpl-host-functions/Cargo.toml | 7 + crates/xrpl-host-functions/src/lib.rs | 508 +++++ crates/xrpl-host-functions/src/macros.rs | 102 + .../tests/expansion_hygiene.rs | 34 + .../tests/generated_abi.rs | 1006 +++++++++ .../xrpl-host-functions/tests/host_errors.rs | 102 + .../Cargo.toml | 5 +- crates/xrpl-wasm-testkit/src/lib.rs | 49 + crates/xrpl-wasm-vm-ffi/Cargo.toml | 12 + crates/xrpl-wasm-vm-ffi/src/lib.rs | 1293 +++++++++++ crates/xrpl-wasm-vm/Cargo.toml | 11 + crates/xrpl-wasm-vm/src/abi.rs | 827 +++++++ crates/xrpl-wasm-vm/src/lib.rs | 28 + crates/xrpl-wasm-vm/src/preflight.rs | 407 ++++ crates/xrpl-wasm-vm/src/region.rs | 50 + crates/xrpl-wasm-vm/src/register.rs | 1215 +++++++++++ crates/xrpl-wasm-vm/src/vm.rs | 404 ++++ crates/xrpl-wasm-vm/tests/budgets.rs | 927 ++++++++ crates/xrpl-wasm-vm/tests/host_calls.rs | 1266 +++++++++++ crates/xrpl-wasm-vm/tests/memory_policy.rs | 621 ++++++ crates/xrpl-wasm-vm/tests/preflight.rs | 592 +++++ crates/xrpl-wasm-vm/tests/support/mod.rs | 1585 ++++++++++++++ crates/xrpl-wasm-vm/tests/vm_limits.rs | 642 ++++++ docs/build/environment.md | 28 +- docs/build/nix.md | 5 +- include/xrpl/tx/wasm/HostContext.h | 426 ++++ include/xrpl/tx/wasm/HostFunc.h | 27 - include/xrpl/tx/wasm/HostFuncWrapper.h | 244 --- include/xrpl/tx/wasm/README.md | 208 +- include/xrpl/tx/wasm/WasmCommon.h | 140 +- include/xrpl/tx/wasm/WasmImportsHelper.h | 126 -- include/xrpl/tx/wasm/WasmVM.h | 97 +- include/xrpl/tx/wasm/WasmiVM.h | 462 ---- src/libxrpl/tx/wasm/HostContext.cpp | 1249 +++++++++++ src/libxrpl/tx/wasm/HostFuncImplGetter.cpp | 8 +- src/libxrpl/tx/wasm/HostFuncWrapper.cpp | 1903 ----------------- src/libxrpl/tx/wasm/WasmVM.cpp | 325 ++- src/libxrpl/tx/wasm/WasmiVM.cpp | 958 --------- src/test/app/HostFuncImpl_test.cpp | 49 +- src/test/app/Wasm_test.cpp | 36 +- src/test/app/wasm_fixtures/fixtures.cpp | 6 + src/tests/libxrpl/CMakeLists.txt | 12 +- src/tests/libxrpl/basics/RustInterop.cpp | 9 - src/tests/libxrpl/tx/wasm/MockHostFunctions.h | 66 + src/tests/libxrpl/tx/wasm/Preflight.cpp | 248 +++ src/tests/libxrpl/tx/wasm/WasmFixture.h | 124 ++ src/tests/libxrpl/tx/wasm/WasmVM.cpp | 339 +++ .../wasm/host_calls/CurrentLedgerObjField.cpp | 74 + .../libxrpl/tx/wasm/host_calls/LedgerSqn.cpp | 69 + .../libxrpl/tx/wasm/host_calls/Sha512Half.cpp | 78 + .../libxrpl/tx/wasm/host_calls/Trace.cpp | 220 ++ 70 files changed, 16449 insertions(+), 4388 deletions(-) delete mode 100644 crates/hello_world/src/lib.rs create mode 100644 crates/xrpl-host-functions-macros/Cargo.toml create mode 100644 crates/xrpl-host-functions-macros/src/errors.rs create mode 100644 crates/xrpl-host-functions-macros/src/lib.rs create mode 100644 crates/xrpl-host-functions-macros/src/parsed_host_function.rs create mode 100644 crates/xrpl-host-functions/Cargo.toml create mode 100644 crates/xrpl-host-functions/src/lib.rs create mode 100644 crates/xrpl-host-functions/src/macros.rs create mode 100644 crates/xrpl-host-functions/tests/expansion_hygiene.rs create mode 100644 crates/xrpl-host-functions/tests/generated_abi.rs create mode 100644 crates/xrpl-host-functions/tests/host_errors.rs rename crates/{hello_world => xrpl-wasm-testkit}/Cargo.toml (57%) create mode 100644 crates/xrpl-wasm-testkit/src/lib.rs create mode 100644 crates/xrpl-wasm-vm-ffi/Cargo.toml create mode 100644 crates/xrpl-wasm-vm-ffi/src/lib.rs create mode 100644 crates/xrpl-wasm-vm/Cargo.toml create mode 100644 crates/xrpl-wasm-vm/src/abi.rs create mode 100644 crates/xrpl-wasm-vm/src/lib.rs create mode 100644 crates/xrpl-wasm-vm/src/preflight.rs create mode 100644 crates/xrpl-wasm-vm/src/region.rs create mode 100644 crates/xrpl-wasm-vm/src/register.rs create mode 100644 crates/xrpl-wasm-vm/src/vm.rs create mode 100644 crates/xrpl-wasm-vm/tests/budgets.rs create mode 100644 crates/xrpl-wasm-vm/tests/host_calls.rs create mode 100644 crates/xrpl-wasm-vm/tests/memory_policy.rs create mode 100644 crates/xrpl-wasm-vm/tests/preflight.rs create mode 100644 crates/xrpl-wasm-vm/tests/support/mod.rs create mode 100644 crates/xrpl-wasm-vm/tests/vm_limits.rs create mode 100644 include/xrpl/tx/wasm/HostContext.h delete mode 100644 include/xrpl/tx/wasm/HostFuncWrapper.h delete mode 100644 include/xrpl/tx/wasm/WasmImportsHelper.h delete mode 100644 include/xrpl/tx/wasm/WasmiVM.h create mode 100644 src/libxrpl/tx/wasm/HostContext.cpp delete mode 100644 src/libxrpl/tx/wasm/HostFuncWrapper.cpp delete mode 100644 src/libxrpl/tx/wasm/WasmiVM.cpp delete mode 100644 src/tests/libxrpl/basics/RustInterop.cpp create mode 100644 src/tests/libxrpl/tx/wasm/MockHostFunctions.h create mode 100644 src/tests/libxrpl/tx/wasm/Preflight.cpp create mode 100644 src/tests/libxrpl/tx/wasm/WasmFixture.h create mode 100644 src/tests/libxrpl/tx/wasm/WasmVM.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp diff --git a/.cspell.config.yaml b/.cspell.config.yaml index cc5214fc87..d7f1c5f737 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -107,6 +107,7 @@ words: - deleteme - demultiplexer - deserializaton + - desugars - desync - desynced - determ @@ -133,6 +134,7 @@ words: - gcov - gcovr - ghead + - gmock - Gnutella - godexsoft - gpgcheck @@ -142,7 +144,9 @@ words: - hwaddress - hwrap - ifndef + - impls - inequation + - initialiser - insuf - insuff - invasively @@ -252,6 +256,7 @@ words: - pyparsing - qalloc - qbsprofile + - qself - queuable - Raphson - rcflags @@ -350,6 +355,7 @@ words: - unflatten - unfund - unimpair + - unmetered - unroutable - unscalable - unserviced @@ -370,6 +376,8 @@ words: - vfalco - vinnie - wasmi + - wasmparser + - Werror - wextra - wptr - writeme diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7fef6643ff..35cf538e85 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -12,7 +12,6 @@ _BASE_CMAKE_ARGS = [ "-Dwerr=ON", "-Dxrpld=ON", "-Dwextra=ON", - "-Drust=ON", ] # Maps sanitizer names (as used in cmake) to short config-name suffixes. diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 656e6ec85b..ccf994de08 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -373,7 +373,10 @@ jobs: - name: Run Rust tests if: ${{ !inputs.build_only }} working-directory: crates - run: cargo nextest run --workspace --all-features --locked --no-tests=warn + # `xrpl-wasm-vm-ffi` is left out on Windows: its tests link as an executable, and + # MSVC - unlike the Unix linkers - will not dead-strip the never-called cxx wrappers + # whose C++ shims only the CMake build defines. The other runners cover these tests. + run: cargo nextest run --workspace --all-features --locked --no-tests=warn ${{ runner.os == 'Windows' && '--exclude xrpl-wasm-vm-ffi' || '' }} # Smoke-run every benchmark module with a single repetition to confirm the # benchmarks still build and execute. This is a correctness check, not a diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 6847ff9b57..ceda062604 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -87,7 +87,6 @@ jobs: -Dwerr=ON \ -Dxrpld=ON \ -Dverify_headers=ON \ - -Drust=ON \ .. - name: Build clang-tidy prerequisites diff --git a/BUILD.md b/BUILD.md index e98d204d0b..f39add176d 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,6 +1,6 @@ -| :warning: **WARNING** :warning: | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).

These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. | +| :warning: **WARNING** :warning: | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, Rust, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).

These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. | ## Minimum Requirements @@ -304,7 +304,6 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details. | ---------------- | ------------- | ----------------------------------------------------------------------------- | | `assert` | OFF | Force enabling assertions. | | `coverage` | OFF | Prepare the coverage report. | -| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. | | `tests` | OFF | Build tests. | | `unity` | OFF | Configure a unity build. | | `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. | @@ -319,23 +318,15 @@ builds may be faster for incremental builds, and can be helpful for detecting ### Rust crates -The Rust crates in `crates/` are only part of the build when `rust` is ON. With -`-Drust=OFF` (the default) the `crates` directory is not added to the build, no -cxxbridge bindings are generated, and the C++ tests that exercise the Rust -interop are not compiled — so no Rust toolchain is needed. CI builds always pass -`-Drust=ON`. - -With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`, -`rustc`) matching the channel pinned in -[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and -generates the cxxbridge bindings. It is provided by the -[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without -any extra setup; otherwise install it as described in -[Rust](./docs/build/environment.md#rust). +The build compiles the Rust workspace in `crates/` and generates the cxxbridge +bindings the C++ side includes, so it needs a Rust toolchain (`cargo`, `rustc`) +at the channel pinned in [`rust-toolchain.toml`](./rust-toolchain.toml). The +[Nix development shell](./docs/build/nix.md) provides one; otherwise install it +as described in [Rust](./docs/build/environment.md#rust). The crates also have their own Rust unit tests. Those are run with `cargo` and -need only the Rust toolchain, independently of CMake and of the `rust` option -(CI runs them with `cargo nextest`): +need only the Rust toolchain, independently of CMake (CI runs them with +`cargo nextest`): ```bash cargo test --manifest-path crates/Cargo.toml --workspace diff --git a/CMakeLists.txt b/CMakeLists.txt index 54a52cf21d..3e99d0e14c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,7 +114,6 @@ find_package(OpenSSL REQUIRED) find_package(secp256k1 REQUIRED) find_package(SOCI REQUIRED) find_package(SQLite3 REQUIRED) -find_package(wasmi REQUIRED) find_package(xxHash REQUIRED) target_link_libraries( @@ -161,11 +160,8 @@ endif() add_custom_target(tidy_prerequisites) -if(rust) - add_subdirectory(crates) -endif() +add_subdirectory(crates) include(XrplCore) - include(XrplProtocolAutogen) include(XrplInstall) include(XrplValidatorKeys) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35309a9824..de2aff5325 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -321,7 +321,7 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho ### Running clang-tidy locally -Before running clang-tidy, you must generate the files it depends on (protobuf headers, and, when the project is configured with `-Drust=ON`, the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them: +Before running clang-tidy, you must generate the files it depends on (protobuf headers and the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them: ```bash cmake --build build --target tidy_prerequisites diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 4cb251c857..e9e3af4c7c 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -69,7 +69,6 @@ target_link_libraries( Xrpl::opts Xrpl::syslibs secp256k1::secp256k1 - wasmi::wasmi xrpl.libpb xxHash::xxhash $<$:antithesis-sdk-cpp> @@ -208,7 +207,11 @@ target_link_libraries( ) add_module(xrpl tx) -target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger) +target_link_libraries( + xrpl.libxrpl.tx + PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge +) +add_dependencies(xrpl.libxrpl.tx xrpl_crates) add_module(xrpl consensus) target_link_libraries( diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake index 58b902baa1..be9bf1fda2 100644 --- a/cmake/XrplSettings.cmake +++ b/cmake/XrplSettings.cmake @@ -32,11 +32,6 @@ endif() option(benchmark "Build benchmarks" ON) -# When OFF, the crates directory is not added to the build at all: no Rust -# toolchain is required, no cxxbridge bindings are generated, and the C++ tests -# that consume those bindings are left out of the build tree. -option(rust "Build the Rust crates and the C++ code that depends on them" OFF) - # Enabled by default so every header is compiled on its own as the main file of # its own compile_commands.json entry - this is what lets clang-tidy (and clangd # and IDEs) analyse a header's own includes directly. The per-header objects are diff --git a/conan.lock b/conan.lock index 02016b83b7..176f0b27cb 100644 --- a/conan.lock +++ b/conan.lock @@ -3,7 +3,6 @@ "requires": [ "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708", "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688", - "wasmi/1.0.9#1fecdab9b90c96698eb35ea99ca4f5cb%1782307153.343419", "sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447", "soci/4.0.3#e726491a03468795453f7c83fc924a96%1782392402.679521", "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1782307151.633168", diff --git a/conanfile.py b/conanfile.py index d0cb95a0e6..ae677b99aa 100644 --- a/conanfile.py +++ b/conanfile.py @@ -36,7 +36,6 @@ class Xrpl(ConanFile): "nudb/2.0.9", "openssl/3.6.3", "soci/4.0.3", - "wasmi/1.0.9", "zlib/1.3.2", ] @@ -153,8 +152,12 @@ class Xrpl(ConanFile): "CMakeLists.txt", "cfg/*", "cmake/*", + "crates/*", + "crates/.cargo/*", + "!crates/target/*", "external/*", "include/*", + "rust-toolchain.toml", "src/*", ) @@ -225,7 +228,6 @@ class Xrpl(ConanFile): "soci::soci", "secp256k1::secp256k1", "sqlite3::sqlite", - "wasmi::wasmi", "xxhash::xxhash", "zlib::zlib", ] diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt index 3f83045cdb..f26cfeb4e3 100644 --- a/crates/CMakeLists.txt +++ b/crates/CMakeLists.txt @@ -101,4 +101,11 @@ function(add_xrpl_crate name) add_dependencies(xrpl_crates ${name}_cxxbridge) endfunction() -add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs) +add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs) + +add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs) + +target_include_directories( + xrpl_wasm_vm_ffi_cxxbridge + PRIVATE ${CMAKE_SOURCE_DIR}/include +) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 70247f8e19..53f6aad57c 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cc" version = "1.2.61" @@ -66,7 +78,7 @@ dependencies = [ "cxxbridge-cmd", "cxxbridge-flags", "cxxbridge-macro", - "foldhash", + "foldhash 0.2.0", "link-cplusplus", ] @@ -129,12 +141,27 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -148,9 +175,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "link-cplusplus" version = "1.0.12" @@ -160,6 +199,12 @@ dependencies = [ "cc", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -178,19 +223,18 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rs-hello_world" -version = "0.1.0" -dependencies = [ - "cxx", -] - [[package]] name = "scratch" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -227,6 +271,22 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -276,6 +336,99 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasmi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.239.0", +] + +[[package]] +name = "wasmi_collections" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "254.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" +dependencies = [ + "wast", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -299,3 +452,46 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "xrpl-host-functions" +version = "0.1.0" +dependencies = [ + "xrpl-host-functions-macros", +] + +[[package]] +name = "xrpl-host-functions-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "xrpl-host-functions", +] + +[[package]] +name = "xrpl-wasm-testkit" +version = "0.1.0" +dependencies = [ + "cxx", + "wat", +] + +[[package]] +name = "xrpl-wasm-vm" +version = "0.1.0" +dependencies = [ + "wasmi", + "wat", + "xrpl-host-functions", +] + +[[package]] +name = "xrpl-wasm-vm-ffi" +version = "0.1.0" +dependencies = [ + "cxx", + "xrpl-host-functions", + "xrpl-wasm-vm", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 0bb0e9c550..d08f4da233 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,9 +1,15 @@ [workspace] -members = ["hello_world"] +members = [ + "xrpl-wasm-vm-ffi", + "xrpl-wasm-vm", + "xrpl-wasm-testkit", + "xrpl-host-functions", + "xrpl-host-functions-macros", +] resolver = "3" [workspace.dependencies] -cxx = { version = "1.0.198", features = ["c++20"] } +cxx = { version = "1.0.199", features = ["c++20"] } [workspace.package] edition = "2024" diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs deleted file mode 100644 index b1cb121fa0..0000000000 --- a/crates/hello_world/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -#[cxx::bridge(namespace = "rs::hello_world")] -mod ffi { - extern "Rust" { - fn hello_world() -> String; - } -} - -pub fn hello_world() -> String { - "hello_world".to_string() -} diff --git a/crates/xrpl-host-functions-macros/Cargo.toml b/crates/xrpl-host-functions-macros/Cargo.toml new file mode 100644 index 0000000000..d24ca268d0 --- /dev/null +++ b/crates/xrpl-host-functions-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "xrpl-host-functions-macros" +version = "0.1.0" +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "3", features = ["full"] } +quote = "1" +proc-macro2 = "1" + +# The doctest declares host functions returning `HostResult`, which the facade +# crate hand-writes. Cargo allows this cycle because dev-dependencies are outside +# the library build graph. +[dev-dependencies] +xrpl-host-functions.path = "../xrpl-host-functions" diff --git a/crates/xrpl-host-functions-macros/src/errors.rs b/crates/xrpl-host-functions-macros/src/errors.rs new file mode 100644 index 0000000000..82d80eb56c --- /dev/null +++ b/crates/xrpl-host-functions-macros/src/errors.rs @@ -0,0 +1,12 @@ +/// Folds accumulated diagnostics into the single error a macro can return. +/// +/// `syn::Error` is itself a collection: `combine` appends, and +/// `into_compile_error` emits one `compile_error!` per recorded span. Folding +/// instead of returning the first error means every mistake in a +/// `host_functions!` block surfaces in one build rather than one per rebuild. +pub(crate) fn combine(errors: Vec) -> Option { + errors.into_iter().reduce(|mut first, next| { + first.combine(next); + first + }) +} diff --git a/crates/xrpl-host-functions-macros/src/lib.rs b/crates/xrpl-host-functions-macros/src/lib.rs new file mode 100644 index 0000000000..a8eb4ca7b6 --- /dev/null +++ b/crates/xrpl-host-functions-macros/src/lib.rs @@ -0,0 +1,405 @@ +mod errors; +mod parsed_host_function; + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + TraitItemFn, + parse::{Parse, ParseStream}, + parse2, +}; + +use parsed_host_function::ParsedHostFunction; + +/// Declares the wasm host ABI once, and generates everything that follows from it. +/// +/// The input is a block of `fn` declarations, each carrying the gas cost the host +/// charges before the call and the name the guest imports it under. Doc comments +/// are kept and appear on the generated items. +/// +/// This crate is an implementation detail of `xrpl-host-functions`, which +/// hand-writes the types the declarations refer to and holds the one declaration +/// block. +/// +/// # What it generates +/// +/// Three items, in the scope the block is written in: +/// +/// - `pub trait HostFunctions`: one method per declaration, emitted verbatim — +/// receiver, parameters, return type and doc comment exactly as written. An +/// execution environment implements it; the rest of the expansion does not +/// mention it. +/// - `pub enum HostFunctionSpec`: one variant per declaration, named by +/// PascalCasing the function name (`get_ledger_sqn` becomes `GetLedgerSqn`) and +/// carrying that declaration's doc comment. Its `const fn wasm_name` and +/// `const fn gas` are the ABI metadata, and `ALL` is every variant in +/// declaration order — what a wasm engine iterates to build its import table. +/// - `struct HostFnSpec`: private, one row of that metadata table. It exists only +/// so `wasm_name` and `gas` read from a single `match` over the declarations, +/// and never appears in a signature a caller can name. +/// +/// The expansion introduces no other name and reaches for none: the only paths in +/// it are `Self::Variant` and whatever the declarations themselves spell. So the +/// block compiles wherever the types it names — `HostResult` above — resolve. +/// +/// ``` +/// use xrpl_host_functions::HostResult; +/// use xrpl_host_functions_macros::host_functions; +/// +/// host_functions! { +/// /// The sequence number of the ledger being built, as 4 little-endian bytes. +/// #[gas = 60] +/// #[wasm_name = "ldgr_index"] +/// fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult; +/// +/// /// Writes `msg` to the trace log. +/// #[gas = 500] +/// #[wasm_name = "trace_num"] +/// fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>; +/// } +/// +/// // The trait's methods are the declarations, down to the `&self` receiver the +/// // VM calls the host through. +/// fn ledger_sqn(host: &dyn HostFunctions, out: &mut [u8]) -> HostResult { +/// host.get_ledger_sqn(out) +/// } +/// +/// // The metadata is a `const` table, so gas and import names are available at +/// // compile time rather than looked up at run time. +/// const TRACE_GAS: u64 = HostFunctionSpec::TraceNum.gas(); +/// assert_eq!(TRACE_GAS, 500); +/// +/// assert_eq!(HostFunctionSpec::GetLedgerSqn.wasm_name(), "ldgr_index"); +/// assert_eq!( +/// HostFunctionSpec::ALL, +/// &[HostFunctionSpec::GetLedgerSqn, HostFunctionSpec::TraceNum], +/// ); +/// ``` +/// +/// A declaration must be a plain `fn` taking `&self` and returning +/// `HostResult`, with no body and no generics: it maps to exactly one wasm +/// import signature. Two declarations may not share a `wasm_name`, nor collapse to +/// the same PascalCase variant. +#[proc_macro] +pub fn host_functions(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + expand(input.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand(input: TokenStream) -> syn::Result { + let HostFunctionsInput { functions } = parse2(input)?; + + let mut parsed = Vec::with_capacity(functions.len()); + let mut errors = Vec::new(); + for function in functions { + match ParsedHostFunction::parse(function) { + Ok(function) => parsed.push(function), + Err(error) => errors.push(error), + } + } + if let Some(error) = errors::combine(errors) { + return Err(error); + } + if let Some(error) = errors::combine(collisions(&parsed)) { + return Err(error); + } + + Ok(generate(&parsed)) +} + +/// Names two declarations may not share, because the generated code would then +/// fail to compile at a span the caller cannot see. +fn collisions(functions: &[ParsedHostFunction]) -> Vec { + let mut errors = Vec::new(); + let mut variants = HashSet::new(); + let mut wasm_names = HashSet::new(); + + for function in functions { + if !variants.insert(function.variant.to_string()) { + errors.push(syn::Error::new_spanned( + &function.variant, + format!( + "another host function already becomes the `{}` variant", + function.variant + ), + )); + } + if !wasm_names.insert(function.wasm_name.value()) { + errors.push(syn::Error::new_spanned( + &function.wasm_name, + format!( + "another host function is already imported as `{}`", + function.wasm_name.value() + ), + )); + } + } + + errors +} + +fn generate(functions: &[ParsedHostFunction]) -> TokenStream { + let trait_methods = functions.iter().map(ParsedHostFunction::trait_method); + let variants = functions + .iter() + .map(ParsedHostFunction::variant_declaration); + let spec_arms = functions.iter().map(ParsedHostFunction::spec_arm); + let all = functions.iter().map(|function| &function.variant); + + quote! { + /// The host side of the wasm ABI: one method per function a guest may + /// import. + /// + /// Implement it once per execution environment — the ledger host, a test + /// double, a benchmark fake — and a guest module cannot tell them apart. + /// Each method is one declaration from the `host_functions!` block, as + /// written; its `&self` receiver is not part of the ABI the guest sees, + /// so a host that must mutate does so behind interior mutability. + /// + /// # The output contract + /// + /// A method handed an `out` buffer **writes into it only when the whole + /// value fits, and returns the value's true length whether it fitted or + /// not.** + /// + /// The length is the value's, not the number of bytes written, because it + /// is how a guest that asked with too small a buffer learns the size to + /// ask for next time. The engine turns a length past the buffer into + /// `BufferTooSmall`, and one past the field cap into `DataFieldTooLarge`, + /// so a host needs to know neither. + /// + /// Writing nothing unless the value fits is the half only a host can hold + /// up. An engine can bound how many bytes are *writable* — and does, by + /// handing over a region clamped to the field cap — but it cannot take + /// back what a method already put there. A host that wrote a truncated + /// prefix and then reported the larger length would leave those bytes in + /// guest memory behind a refusal the guest is told to ignore. + pub trait HostFunctions { + #(#trait_methods)* + } + + /// One row of the ABI table: what [`HostFunctionSpec::wasm_name`] and + /// [`HostFunctionSpec::gas`] read from. + /// + /// Private, and the only reason it exists is to keep both of them fed + /// from a single `match` over the declarations. + struct HostFnSpec { + name: &'static str, + gas: u64, + } + + /// Identifies one host function, and is the compile-time source of its + /// ABI metadata. + /// + /// One variant per `host_functions!` declaration, named by converting the + /// function name to PascalCase. [`Self::ALL`] is the whole ABI, which is + /// what a wasm engine iterates to build its import table. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum HostFunctionSpec { + #(#variants,)* + } + + impl HostFunctionSpec { + /// Every host function, in the order declared. + /// + /// This is the complete import surface a guest may link against: a + /// function absent here cannot be called, and one present here must + /// be registered for a module that imports it to instantiate. + pub const ALL: &'static [Self] = &[#(Self::#all,)*]; + + /// This function's row of the ABI table. + const fn spec(self) -> HostFnSpec { + match self { + #(#spec_arms,)* + } + } + + /// The name a guest imports this function under. + /// + /// A guest's import name must match this exactly, or the module + /// fails to instantiate. Usable in `const` context, so import lists + /// can be built at compile time. + pub const fn wasm_name(self) -> &'static str { + self.spec().name + } + + /// Gas charged before the call runs, independent of its arguments. + /// + /// Consensus-relevant: two nodes that disagree on this value + /// disagree on transaction outcomes. Usable in `const` context, so + /// gas tables can be built at compile time. + pub const fn gas(self) -> u64 { + self.spec().gas + } + } + } +} + +struct HostFunctionsInput { + functions: Vec, +} + +impl Parse for HostFunctionsInput { + fn parse(input: ParseStream) -> syn::Result { + let mut functions = Vec::new(); + while !input.is_empty() { + functions.push(input.parse()?); + } + Ok(HostFunctionsInput { functions }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_an_empty_block() { + expand(quote! {}).unwrap(); + } + + #[test] + fn reports_mistakes_from_every_function() { + let error = expand(quote! { + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + + #[gas = 2000] + fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>; + }) + .expect_err("expected parsing to fail"); + + let messages: Vec<_> = error.into_iter().map(|error| error.to_string()).collect(); + assert_eq!(messages.len(), 2, "{messages:?}"); + assert!(messages[0].contains("missing `#[gas"), "{messages:?}"); + assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}"); + } + + #[test] + fn propagates_syntax_errors() { + let error = expand(quote! { fn missing_semicolon() }).expect_err("expected a syntax error"); + assert!(!error.to_string().is_empty()); + } + + /// The messages of every diagnostic recorded by one failed `expand`. + fn messages(input: TokenStream) -> Vec { + let Err(error) = expand(input) else { + panic!("expected expansion to fail"); + }; + error.into_iter().map(|error| error.to_string()).collect() + } + + #[test] + fn generates_the_trait_the_enum_and_the_table() { + let generated = expand(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + + #[gas = 500] + #[wasm_name = "trace_num"] + fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>; + }) + .unwrap() + .to_string(); + + for expected in [ + "pub trait HostFunctions", + "fn get_ledger_sqn (& self) -> HostResult < [u8 ; 4] > ;", + "fn trace_num (& self , msg : & str , number : i64) -> HostResult < () > ;", + "pub enum HostFunctionSpec { GetLedgerSqn , TraceNum , }", + "pub const ALL : & 'static [Self] = & [Self :: GetLedgerSqn , Self :: TraceNum ,]", + // The table's row type is generated too, and stays private. + "struct HostFnSpec { name : & 'static str , gas : u64 , }", + "const fn spec (self) -> HostFnSpec", + "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }", + "pub const fn wasm_name (self) -> & 'static str", + "pub const fn gas (self) -> u64", + ] { + assert!(generated.contains(expected), "missing {expected:?}"); + } + } + + /// The expansion stands alone: every name in it is either generated here or + /// written in the declarations, so it cannot depend on the crate it lands in. + #[test] + fn names_no_crate_of_its_own() { + let generated = expand(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }) + .unwrap() + .to_string(); + + assert!(!generated.contains("xrpl_host_functions"), "{generated}"); + + // `Self::Variant` is the only path the expansion may build: anything else + // would reach out of the generated code. Doc comments spell paths without + // spaces (`Self::ALL`), so they do not match. + for (index, _) in generated.match_indices(" :: ") { + assert!( + generated[..index].ends_with("Self"), + "path out of the expansion at {index}: {generated}" + ); + } + } + + /// `spec` is an implementation detail of the two accessors, so it must not + /// become part of the ABI crate's public surface. + #[test] + fn keeps_the_table_row_private() { + let generated = expand(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }) + .unwrap() + .to_string(); + + assert!(!generated.contains("pub struct HostFnSpec"), "{generated}"); + assert!(!generated.contains("pub const fn spec"), "{generated}"); + } + + #[test] + fn rejects_two_functions_that_share_a_wasm_name() { + let messages = messages(quote! { + #[gas = 60] + #[wasm_name = "trace"] + fn trace(&self, msg: &str) -> HostResult<()>; + + #[gas = 70] + #[wasm_name = "trace"] + fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("already imported as `trace`"), + "{messages:?}" + ); + } + + /// Names that differ only in underscores collapse to one enum variant. + #[test] + fn rejects_two_functions_that_share_a_variant() { + let messages = messages(quote! { + #[gas = 60] + #[wasm_name = "a"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + + #[gas = 70] + #[wasm_name = "b"] + fn get_ledger__sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("`GetLedgerSqn` variant"), + "{messages:?}" + ); + } +} diff --git a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs new file mode 100644 index 0000000000..77f126dfa3 --- /dev/null +++ b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs @@ -0,0 +1,859 @@ +use proc_macro2::TokenStream; +use quote::{ToTokens, format_ident, quote}; +use syn::{ + Attribute, Ident, LitInt, LitStr, PathArguments, ReceiverKind, ReturnType, Safety, Signature, + TraitItemFn, Type, TypePath, parse::Parse, +}; + +use crate::errors; + +/// `#[gas = N]`: the base gas charged before the call runs. +const GAS: &str = "gas"; +/// `#[wasm_name = "..."]`: the name the guest imports the function under. +const WASM_NAME: &str = "wasm_name"; +/// `///` desugars to `#[doc = "..."]` before macro expansion. +const DOC: &str = "doc"; +/// The alias every declaration returns its success type through. +const HOST_RESULT: &str = "HostResult"; + +/// One entry of a `host_functions!` block: its ABI metadata and its signature. +pub(crate) struct ParsedHostFunction { + pub(crate) gas: u64, + /// Kept as the literal the user wrote, so diagnostics and the generated + /// string both carry that span. + pub(crate) wasm_name: LitStr, + /// Doc comments, in source order, to re-emit on the generated items. + pub(crate) docs: Vec, + /// The enum variant this declaration becomes, spanned at the function name. + pub(crate) variant: Ident, + pub(crate) signature: Signature, +} + +impl ParsedHostFunction { + /// `#[doc …] fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;` + pub(crate) fn trait_method(&self) -> TokenStream { + let docs = &self.docs; + // The declaration is already a trait method: emitted verbatim, so what + // the block reads like is what the trait is. + let signature = &self.signature; + + quote! { + #(#docs)* + #signature; + } + } + + /// `#[doc …] GetLedgerSqn` + pub(crate) fn variant_declaration(&self) -> TokenStream { + let docs = &self.docs; + let variant = &self.variant; + quote! { + #(#docs)* + #variant + } + } + + /// `Self::GetLedgerSqn => HostFnSpec { name: "ldgr_index", gas: 60u64 }` + pub(crate) fn spec_arm(&self) -> TokenStream { + let Self { + gas, + wasm_name, + variant, + .. + } = self; + quote! { + Self::#variant => HostFnSpec { name: #wasm_name, gas: #gas } + } + } + + pub(crate) fn parse(function: TraitItemFn) -> syn::Result { + let mut gas = None; + let mut wasm_name = None; + let mut docs = Vec::new(); + let mut errors = Vec::new(); + + // Tracked separately from `gas`/`wasm_name` so a malformed attribute is + // not also reported as a missing one. + let mut saw_gas = false; + let mut saw_wasm_name = false; + + for attr in function.attrs { + if attr.path().is_ident(GAS) { + saw_gas = true; + if let Err(error) = int_value(&attr).and_then(|v| set_once(&mut gas, v, &attr)) { + errors.push(error); + } + } else if attr.path().is_ident(WASM_NAME) { + saw_wasm_name = true; + if let Err(error) = value::(&attr, "a string literal") + .and_then(|v| set_once(&mut wasm_name, v, &attr)) + { + errors.push(error); + } + } else if attr.path().is_ident(DOC) { + docs.push(attr); + } else { + errors.push(syn::Error::new_spanned( + &attr, + format!("unexpected attribute `{}`", path_name(&attr)), + )); + } + } + + if !saw_gas { + errors.push(syn::Error::new_spanned( + &function.sig.ident, + format!("missing `#[{GAS} = ...]` attribute"), + )); + } + if !saw_wasm_name { + errors.push(syn::Error::new_spanned( + &function.sig.ident, + format!("missing `#[{WASM_NAME} = \"...\"]` attribute"), + )); + } + if let Some(body) = &function.default { + errors.push(syn::Error::new_spanned( + body, + "a host function is implemented by the host, so it must not have a body", + )); + } + if !function.sig.generics.params.is_empty() || function.sig.generics.where_clause.is_some() + { + errors.push(syn::Error::new_spanned( + &function.sig.ident, + "a host function must not be generic: it maps to one wasm import signature", + )); + } + errors.extend(check_receiver(&function.sig).err()); + errors.extend(check_return_type(&function.sig).err()); + if let Some(name) = &wasm_name { + errors.extend(check_wasm_name(name).err()); + } + reject_modifiers(&function.sig, &mut errors); + + // A name whose PascalCase form is not a legal variant is reported here + // rather than emitted, which would either panic or fail downstream. + let variant = match variant_ident(&function.sig.ident) { + Ok(variant) => Some(variant), + Err(error) => { + errors.push(error); + None + } + }; + + if let Some(error) = errors::combine(errors) { + return Err(error); + } + + let (Some(gas), Some(wasm_name), Some(variant)) = (gas, wasm_name, variant) else { + unreachable!("every absent field is reported above"); + }; + + Ok(Self { + gas, + wasm_name, + docs, + variant, + signature: function.sig, + }) + } +} + +/// Every declaration carries a receiver, and it is always `&self`. +/// +/// `&self` is the only receiver that can work: the VM reaches the host through a +/// shared `&dyn HostFunctions` stored in the wasmi `Store`, and a host that needs +/// to mutate does so behind interior mutability. The receiver is not part of the +/// wasm ABI — the guest passes no `self` — so it is uniform across the block. +fn check_receiver(signature: &Signature) -> syn::Result<()> { + let Some(receiver) = signature.receiver() else { + return Err(syn::Error::new_spanned( + &signature.ident, + format!( + "a host function must declare its receiver: `fn {}(&self, ...)`", + signature.ident + ), + )); + }; + + // `&self` and nothing else: not `&mut self`, not `self`/`mut self`, not a + // typed `self: Box`, and not a spelled-out lifetime. + if !matches!(receiver.kind, ReceiverKind::Reference(_, None, None)) { + return Err(syn::Error::new_spanned( + receiver, + "a host function's receiver must be exactly `&self`: the VM calls the host \ + through a shared `&dyn HostFunctions`", + )); + } + Ok(()) +} + +/// Every declaration returns `HostResult`, including the ones that yield +/// nothing (`HostResult<()>`). +/// +/// One shape for every function is what lets a single dispatch adapter lower them +/// all: lift the arguments out of guest memory, call the host, then turn `Ok(T)` +/// into the wire's non-negative `i32` and `Err(e)` into a negative code or a trap. +/// A function returning a bare `T` would need its own arm. +fn check_return_type(signature: &Signature) -> syn::Result<()> { + const SHAPE: &str = "a host function must return `HostResult` — \ + `HostResult<()>` if it yields nothing"; + + let ReturnType::Type(_, returned) = &signature.output else { + return Err(syn::Error::new_spanned(&signature.ident, SHAPE)); + }; + + let Type::Path(TypePath { + qself: None, path, .. + }) = &**returned + else { + return Err(syn::Error::new_spanned(returned, SHAPE)); + }; + // The last segment only, so `HostResult` may be written qualified. + let Some(last) = path.segments.last() else { + return Err(syn::Error::new_spanned(returned, SHAPE)); + }; + if last.ident != HOST_RESULT { + return Err(syn::Error::new_spanned(returned, SHAPE)); + } + + // `HostResult` without its success type is `HostResult` the alias, which names + // no type; rustc's own message for that is unhelpfully far from the cause. + let PathArguments::AngleBracketed(arguments) = &last.arguments else { + return Err(syn::Error::new_spanned( + returned, + format!("`{HOST_RESULT}` needs its success type: `{HOST_RESULT}`"), + )); + }; + if arguments.args.len() != 1 { + return Err(syn::Error::new_spanned( + arguments, + format!("`{HOST_RESULT}` takes exactly one type: `{HOST_RESULT}`"), + )); + } + Ok(()) +} + +/// `const`, `async`, `unsafe`/`safe` and `extern "…"` have no meaning in the +/// wasm ABI, and would otherwise pass silently into the generated trait. +fn reject_modifiers(signature: &Signature, errors: &mut Vec) { + const PLAIN: &str = + "a host function must be a plain `fn`: this modifier is not part of the wasm ABI"; + + if let Some(constness) = &signature.constness { + errors.push(syn::Error::new_spanned(constness, PLAIN)); + } + if let Some(asyncness) = &signature.asyncness { + errors.push(syn::Error::new_spanned(asyncness, PLAIN)); + } + match &signature.safety { + Safety::Default => {} + Safety::Safe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)), + Safety::Unsafe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)), + } + if let Some(abi) = &signature.abi { + errors.push(syn::Error::new_spanned(abi, PLAIN)); + } +} + +/// The wasm import name reaches the engine's import table verbatim, so it is +/// held to what an import name can sanely be rather than to any string. +fn check_wasm_name(name: &LitStr) -> syn::Result<()> { + let value = name.value(); + if value.is_empty() { + return Err(syn::Error::new_spanned( + name, + "the wasm name must not be empty", + )); + } + if let Some(character) = value + .chars() + .find(|c| !c.is_ascii_alphanumeric() && *c != '_') + { + return Err(syn::Error::new_spanned( + name, + format!( + "a wasm name may only contain `A-Za-z0-9_`, but this one contains {character:?}" + ), + )); + } + Ok(()) +} + +/// The enum variant a declaration becomes: `get_ledger_sqn` -> `GetLedgerSqn`. +/// +/// The result carries `ident`'s span, so anything the compiler says about the +/// variant points at the declaration that produced it. +fn variant_ident(ident: &Ident) -> syn::Result { + // `to_string` spells raw identifiers `r#type`; the `r#` is not part of the name. + let name = ident.to_string(); + let name = name.strip_prefix("r#").unwrap_or(&name); + + let mut pascal = String::with_capacity(name.len()); + let mut capitalize = true; + for character in name.chars() { + if character == '_' { + capitalize = true; + } else if capitalize { + pascal.extend(character.to_uppercase()); + capitalize = false; + } else { + pascal.push(character); + } + } + + // A name of nothing but underscores leaves `pascal` empty; the original is + // already a legal identifier, so keep it. + if pascal.is_empty() { + return Ok(ident.clone()); + } + + // `Ident::new` panics on a leading digit (`_2fa` -> `2fa`) and silently + // accepts keyword spellings (`self_` -> `Self`), which then fails to parse + // where the variant is emitted. Parsing rejects both, without panicking. + if let Err(error) = syn::parse_str::(&pascal) { + return Err(syn::Error::new_spanned( + ident, + format!( + "this name becomes the enum variant `{pascal}`, which is not a valid \ + variant name ({error}); rename the host function" + ), + )); + } + Ok(format_ident!("{pascal}", span = ident.span())) +} + +/// Records `value`, or reports that the attribute appeared more than once. +fn set_once(slot: &mut Option, value: T, attr: &Attribute) -> syn::Result<()> { + if slot.replace(value).is_some() { + return Err(syn::Error::new_spanned( + attr, + format!("duplicate `{}` attribute", path_name(attr)), + )); + } + Ok(()) +} + +/// The value of `#[name = ]`, parsed as `T`. +/// +/// `expected` completes "`gas` expects …": syn's own message for the wrong kind +/// of literal names neither the attribute nor what it wanted. +fn value(attr: &Attribute, expected: &str) -> syn::Result { + let expr = &attr.meta.require_name_value()?.value; + syn::parse2(expr.to_token_stream()).map_err(|_| { + syn::Error::new_spanned(expr, format!("`{}` expects {expected}", path_name(attr))) + }) +} + +fn int_value(attr: &Attribute) -> syn::Result { + let int: LitInt = value(attr, "an integer literal")?; + // `LitInt` keeps the sign in its digits, so `base10_parse::` would + // report a negative value as "invalid digit found in string". + if int.base10_digits().starts_with('-') { + return Err(syn::Error::new_spanned( + int, + format!("`{}` must not be negative", path_name(attr)), + )); + } + int.base10_parse() +} + +/// The attribute's path as written, for diagnostics: `gas`, or `foo::bar`. +fn path_name(attr: &Attribute) -> String { + attr.path() + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::") +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::{Expr, ExprLit, Lit, parse_quote}; + + /// The message of every diagnostic recorded by one failed `parse`. + /// + /// `expect_err` is unavailable here: it needs `T: Debug`, and syn only + /// implements `Debug` for its AST types under the `extra-traits` feature. + fn messages(function: TraitItemFn) -> Vec { + let Err(error) = ParsedHostFunction::parse(function) else { + panic!("expected parsing to fail"); + }; + error.into_iter().map(|error| error.to_string()).collect() + } + + fn doc_text(attr: &Attribute) -> String { + match &attr.meta.require_name_value().unwrap().value { + Expr::Lit(ExprLit { + lit: Lit::Str(text), + .. + }) => text.value(), + _ => panic!("doc attribute is not a string literal"), + } + } + + #[test] + fn reads_gas_and_wasm_name() { + let parsed = ParsedHostFunction::parse(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }) + .unwrap(); + + assert_eq!(parsed.gas, 60); + assert_eq!(parsed.wasm_name.value(), "ldgr_index"); + assert_eq!(parsed.signature.ident.to_string(), "get_ledger_sqn"); + assert_eq!(parsed.variant.to_string(), "GetLedgerSqn"); + assert!(parsed.docs.is_empty()); + } + + #[test] + fn derives_variant_names_from_function_names() { + for (function, variant) in [ + ("get_ledger_sqn", "GetLedgerSqn"), + ("sha512_half", "Sha512Half"), + ("trace", "Trace"), + ("get_current_ledger_obj_field", "GetCurrentLedgerObjField"), + ("r#type", "Type"), + ("trace2", "Trace2"), + // Pathological, but must not panic: no letters to capitalize. + ("__", "__"), + ] { + let ident = format_ident!("{function}"); + assert_eq!( + variant_ident(&ident).map(|v| v.to_string()).ok(), + Some(variant.to_owned()), + "{function}" + ); + } + } + + /// `_2fa` would PascalCase to `2fa`; building that `Ident` panics, and a + /// panic in a proc macro is reported with no useful span at all. + #[test] + fn rejects_a_name_that_becomes_a_leading_digit() { + let messages = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "two_factor"] + fn _2fa(&self) -> HostResult<()>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("becomes the enum variant `2fa`"), + "{messages:?}" + ); + } + + /// `self_` PascalCases to `Self`, which `Ident::new` accepts and rustc then + /// rejects where the variant is emitted. `r#Self` is not a legal escape. + #[test] + fn rejects_a_name_that_becomes_a_keyword() { + for function in ["self_", "_self"] { + let ident = format_ident!("{function}"); + let Err(error) = variant_ident(&ident) else { + panic!("expected `{function}` to be rejected"); + }; + assert!( + error.to_string().contains("variant `Self`"), + "{}", + error.to_string() + ); + } + } + + #[test] + fn rejects_negative_gas() { + let messages = messages(parse_quote! { + #[gas = -5] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert_eq!(messages[0], "`gas` must not be negative"); + } + + #[test] + fn rejects_unusable_wasm_names() { + let empty = messages(parse_quote! { + #[gas = 60] + #[wasm_name = ""] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(empty.len(), 1, "{empty:?}"); + assert_eq!(empty[0], "the wasm name must not be empty"); + + let spaced = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(spaced.len(), 1, "{spaced:?}"); + assert!(spaced[0].contains("may only contain"), "{spaced:?}"); + } + + #[test] + fn rejects_signature_modifiers() { + for declaration in [ + quote! { unsafe fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; }, + quote! { async fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; }, + quote! { const fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; }, + quote! { extern "C" fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; }, + ] { + let function: TraitItemFn = syn::parse2(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + #declaration + }) + .unwrap(); + + let messages = messages(function); + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!(messages[0].contains("must be a plain `fn`"), "{messages:?}"); + } + } + + #[test] + fn trait_method_keeps_the_declared_receiver_and_ends_in_a_semicolon() { + let parsed = ParsedHostFunction::parse(parse_quote! { + /// Hashes `data`. + #[gas = 2000] + #[wasm_name = "sha512_half"] + fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>; + }) + .unwrap(); + + // `///` reaches the macro as `#[doc = r"..."]`: rustc's lexer spells doc + // comments as raw string literals. + let method = parsed.trait_method().to_string(); + assert!( + method.starts_with("# [doc = r\" Hashes `data`.\"]"), + "{method}" + ); + assert!( + method + .contains("fn sha512_half (& self , data : & [u8]) -> HostResult < [u8 ; 32] > ;"), + "{method}" + ); + } + + #[test] + fn spec_arm_carries_the_name_and_the_gas() { + let parsed = ParsedHostFunction::parse(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }) + .unwrap(); + + assert_eq!( + parsed.spec_arm().to_string(), + "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }" + ); + } + + #[test] + fn keeps_doc_comments_in_source_order() { + let parsed = ParsedHostFunction::parse(parse_quote! { + /// First line. + /// + /// Third line. + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }) + .unwrap(); + + let docs: Vec<_> = parsed.docs.iter().map(doc_text).collect(); + assert_eq!(docs, vec![" First line.", "", " Third line."]); + } + + #[test] + fn preserves_parameters_and_return_type() { + let traced = ParsedHostFunction::parse(parse_quote! { + #[gas = 500] + #[wasm_name = "trace"] + fn trace(&self, msg: &str, data: &[u8], as_hex: bool) -> HostResult<()>; + }) + .unwrap(); + // The receiver is `inputs[0]`; the three wasm parameters follow it. + assert_eq!(traced.signature.inputs.len(), 4); + assert_eq!( + traced.signature.output.to_token_stream().to_string(), + "-> HostResult < () >" + ); + + let hashed = ParsedHostFunction::parse(parse_quote! { + #[gas = 2000] + #[wasm_name = "sha512_half"] + fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; HASH_LEN]>; + }) + .unwrap(); + assert_eq!( + hashed.signature.output.to_token_stream().to_string(), + "-> HostResult < [u8 ; HASH_LEN] >" + ); + } + + #[test] + fn reports_both_missing_attributes_at_once() { + let messages = messages(parse_quote! { + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 2); + assert!(messages[0].contains("missing `#[gas"), "{messages:?}"); + assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}"); + } + + #[test] + fn names_the_unexpected_attribute() { + let messages = messages(parse_quote! { + #[gas = 60] + #[wsam_name = "typo"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + // The typo'd attribute, plus the `wasm_name` it failed to be. + assert_eq!(messages.len(), 2); + assert!( + messages.iter().any(|m| m.contains("`wsam_name`")), + "{messages:?}" + ); + } + + #[test] + fn rejects_wrong_literal_types() { + let gas = messages(parse_quote! { + #[gas = "60"] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(gas.len(), 1, "{gas:?}"); + assert!( + gas[0].contains("`gas` expects an integer literal"), + "{gas:?}" + ); + + let name = messages(parse_quote! { + #[gas = 60] + #[wasm_name = 7] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(name.len(), 1, "{name:?}"); + assert!( + name[0].contains("`wasm_name` expects a string literal"), + "{name:?}" + ); + } + + #[test] + fn rejects_gas_that_does_not_fit_in_u64() { + let messages = messages(parse_quote! { + #[gas = 99999999999999999999999] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!(messages[0].contains("number too large"), "{messages:?}"); + } + + #[test] + fn rejects_attribute_shapes_other_than_name_value() { + let bare = messages(parse_quote! { + #[gas] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(bare.len(), 1, "{bare:?}"); + assert!(bare[0].contains("gas = ..."), "{bare:?}"); + + let list = messages(parse_quote! { + #[gas(60)] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + assert_eq!(list.len(), 1, "{list:?}"); + } + + #[test] + fn rejects_duplicate_attributes() { + let messages = messages(parse_quote! { + #[gas = 60] + #[gas = 70] + #[wasm_name = "ldgr_index"] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 2, "{messages:?}"); + assert!(messages[0].contains("duplicate `gas`"), "{messages:?}"); + assert!( + messages[1].contains("duplicate `wasm_name`"), + "{messages:?}" + ); + } + + /// A malformed attribute must not also be reported as an absent one. + #[test] + fn does_not_report_a_malformed_attribute_as_missing() { + let messages = messages(parse_quote! { + #[gas = "60"] + #[wasm_name = 7] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 2, "{messages:?}"); + assert!( + !messages.iter().any(|m| m.contains("missing")), + "{messages:?}" + ); + } + + #[test] + fn rejects_a_body() { + let messages = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> { Ok([0; 4]) } + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!(messages[0].contains("must not have a body"), "{messages:?}"); + } + + #[test] + fn rejects_generics() { + let parameter = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult; + }); + assert_eq!(parameter.len(), 1, "{parameter:?}"); + assert!( + parameter[0].contains("must not be generic"), + "{parameter:?}" + ); + + let clause = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> where Self: Sized; + }); + assert_eq!(clause.len(), 1, "{clause:?}"); + } + + #[test] + fn requires_a_receiver() { + let messages = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn() -> HostResult<[u8; 4]>; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("must declare its receiver: `fn get_ledger_sqn(&self, ...)`"), + "{messages:?}" + ); + } + + /// Anything but `&self` would need a host the VM cannot hand out: it holds + /// one shared `&dyn HostFunctions` for the whole run. + #[test] + fn rejects_receivers_other_than_shared_self() { + for receiver in [ + quote! { &mut self }, + quote! { self }, + quote! { mut self }, + quote! { self: Box }, + quote! { &'a self }, + ] { + let function: TraitItemFn = syn::parse2(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(#receiver) -> HostResult<[u8; 4]>; + }) + .unwrap_or_else(|_| panic!("`{receiver}` should parse")); + + let messages = messages(function); + assert_eq!(messages.len(), 1, "`{receiver}`: {messages:?}"); + assert!( + messages[0].contains("must be exactly `&self`"), + "`{receiver}`: {messages:?}" + ); + } + } + + /// A bare `T` return would need its own lowering arm, so the uniform shape is + /// required rather than inferred. + #[test] + fn rejects_returns_that_are_not_host_result() { + for output in [ + quote! {}, + quote! { -> () }, + quote! { -> [u8; 4] }, + quote! { -> i32 }, + quote! { -> Result<[u8; 4], HostError> }, + quote! { -> impl Iterator }, + ] { + let function: TraitItemFn = syn::parse2(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) #output; + }) + .unwrap_or_else(|_| panic!("`{output}` should parse")); + + let messages = messages(function); + assert_eq!(messages.len(), 1, "`{output}`: {messages:?}"); + assert!( + messages[0].contains("must return `HostResult`"), + "`{output}`: {messages:?}" + ); + } + } + + /// `HostResult` may be written qualified, since the trait method keeps whatever + /// path resolves where the block is written. + #[test] + fn accepts_a_qualified_host_result() { + let parsed = ParsedHostFunction::parse(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> xrpl_host_functions::HostResult<[u8; 4]>; + }) + .unwrap(); + + assert!( + parsed + .trait_method() + .to_string() + .contains("xrpl_host_functions :: HostResult < [u8 ; 4] >"), + "{}", + parsed.trait_method() + ); + } + + /// `HostResult` with no success type names no type at all; rustc's own error + /// for that lands on the generated trait, far from the declaration. + #[test] + fn rejects_host_result_without_a_success_type() { + let messages = messages(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self) -> HostResult; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("needs its success type"), + "{messages:?}" + ); + } +} diff --git a/crates/xrpl-host-functions/Cargo.toml b/crates/xrpl-host-functions/Cargo.toml new file mode 100644 index 0000000000..c08bb7d62f --- /dev/null +++ b/crates/xrpl-host-functions/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "xrpl-host-functions" +version = "0.1.0" +edition.workspace = true + +[dependencies] +xrpl-host-functions-macros.path = "../xrpl-host-functions-macros" diff --git a/crates/xrpl-host-functions/src/lib.rs b/crates/xrpl-host-functions/src/lib.rs new file mode 100644 index 0000000000..9cb5a1d8b0 --- /dev/null +++ b/crates/xrpl-host-functions/src/lib.rs @@ -0,0 +1,508 @@ +//! The wasm host ABI: the one place it is declared. +//! +//! `host_functions!` turns the declaration block at the bottom of this file into the +//! [`HostFunctions`] trait a host implements and the [`HostFunctionSpec`] table a +//! wasm engine registers from. +//! +//! The split: hand-written here is the vocabulary the declarations are written in — +//! [`HostError`], [`TraceDataType`], [`HostResult`], [`HASH_LEN`] — and everything +//! derived from the declarations is generated. The expansion names nothing this file +//! does not, so the two sides meet only in the block below. +//! +//! So this file is lists — error codes, trace data types, functions. The `macro_rules!` +//! that expand the first two into enums live in `macros.rs`. + +#![no_std] + +#[macro_use] +mod macros; + +// Not re-exported: the ABI is declared once, here, and this is the only call site. +use xrpl_host_functions_macros::host_functions; + +host_errors! { + Unimplemented = -1, + FieldNotFound = -2, + BufferTooSmall = -3, + NoArray = -4, + NotLeafField = -5, + LocatorMalformed = -6, + SlotOutRange = -7, + SlotsFull = -8, + EmptySlot = -9, + LedgerObjNotFound = -10, + OutOfTransferLimit = -11, + DataFieldTooLarge = -12, + PointerOutOfBounds = -13, + NoMemExported = -14, + InvalidParams = -15, + InvalidAccount = -16, + InvalidField = -17, + IndexOutOfBounds = -18, + FloatInputMalformed = -19, + FloatComputationError = -20, + /// Internal fatal error. + /// User code will never see this error but keep it reserved to not rely on the value. + InternalFatal = -2147483648, +} + +/// Convenience alias for the trait's fallible returns. +pub type HostResult = Result; + +/// A `sha512Half` digest: the first 32 bytes of a SHA-512, as XRPL uses it. +pub const HASH_LEN: usize = 32; + +trace_data_types! { + /// 8 little-endian bytes, rendered as a signed decimal. + Int64 = 1, + /// 8 little-endian bytes, rendered as an unsigned decimal. + Uint64 = 2, + /// A serialized XRPL float: 12 bytes, mantissa then exponent. + Xfloat = 3, + /// A 20-byte account ID, rendered as base58. + Account = 4, + /// A serialized `STAmount`. + Amount = 5, + /// Raw bytes, hex-encoded. + AsHex = 6, + /// Bytes rendered verbatim as text. + AsText = 7, +} + +host_functions! { + /// The sequence number of the ledger being built, as 4 little-endian bytes. + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult; + + /// The close time of the parent (last-closed) ledger, as 4 little-endian bytes. + #[gas = 60] + #[wasm_name = "parent_ldgr_time"] + fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult; + + /// The hash of the parent (last-closed) ledger, as 32 bytes. + #[gas = 60] + #[wasm_name = "parent_ldgr_hash"] + fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult; + + /// The base fee of the ledger being built, in drops, as 4 little-endian bytes. + #[gas = 60] + #[wasm_name = "base_fee"] + fn get_base_fee(&self, out: &mut [u8]) -> HostResult; + + /// Whether an amendment is enabled. The input is either its 32-byte id or its name; + /// the answer is `1` if enabled and `0` if not. + #[gas = 100] + #[wasm_name = "amendment_enabled"] + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult; + + /// Load the ledger object with the given 32-byte id into a cache slot, so later + /// calls can read its fields. `cache_idx` selects the slot (1-based); `0` asks the + /// host to assign a free one. Answers the slot used. + #[gas = 5000] + #[wasm_name = "cache_le"] + fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult; + + /// The serialized bytes of one field of the transaction being executed, selected + /// by its `SField` code. + #[gas = 70] + #[wasm_name = "tx_field"] + fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult; + + /// The serialized bytes of one field of the current (escrow) ledger object. + #[gas = 70] + #[wasm_name = "home_le_field"] + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult; + + /// The serialized bytes of one field of a previously cached ledger object, + /// selected by its cache slot and the field's `SField` code. + #[gas = 70] + #[wasm_name = "le_field"] + fn get_ledger_obj_field(&self, cache_idx: i32, field: i32, out: &mut [u8]) -> HostResult; + + /// The serialized bytes of a nested field of the transaction, reached by a + /// `locator`: a path of little-endian `i32` steps (so its byte length is a non-zero + /// multiple of 4). + #[gas = 110] + #[wasm_name = "tx_inner"] + fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult; + + /// The serialized bytes of a nested field of the current (escrow) ledger object, + /// reached by a `locator`, as with [`HostFunctions::get_tx_nested_field`]. + #[gas = 110] + #[wasm_name = "home_le_inner"] + fn get_current_ledger_obj_nested_field( + &self, + locator: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The serialized bytes of a nested field of a previously cached ledger object, + /// selected by its cache slot and reached by a `locator`. + #[gas = 110] + #[wasm_name = "le_inner"] + fn get_ledger_obj_nested_field( + &self, + cache_idx: i32, + locator: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The number of elements in an array field of the transaction, selected by its + /// `SField` code. Answers the count directly; `NoArray` if the field is not an array. + #[gas = 40] + #[wasm_name = "tx_arr_len"] + fn get_tx_array_len(&self, field: i32) -> HostResult; + + /// The number of elements in an array field of the current (escrow) ledger + /// object, as with [`HostFunctions::get_tx_array_len`]. + #[gas = 40] + #[wasm_name = "home_le_arr_len"] + fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult; + + /// The number of elements in an array field of a previously cached ledger object, + /// selected by its cache slot and `SField` code. + #[gas = 40] + #[wasm_name = "le_arr_len"] + fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult; + + /// The number of elements in a nested array field of the transaction, reached by a + /// `locator`. + #[gas = 70] + #[wasm_name = "tx_inner_arr_len"] + fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult; + + /// The number of elements in a nested array field of the current (escrow) ledger + /// object, reached by a `locator`, as with [`HostFunctions::get_tx_nested_array_len`]. + #[gas = 70] + #[wasm_name = "home_le_inner_arr_len"] + fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult; + + /// The number of elements in a nested array field of a previously cached ledger + /// object, selected by its cache slot and reached by a `locator`. + #[gas = 70] + #[wasm_name = "le_inner_arr_len"] + fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult; + + /// Verify `signature` over `message` under `pubkey`. Answers `1` if the signature + /// is valid, `0` if not, or a negative error. + #[gas = 300] + #[wasm_name = "check_sig"] + fn check_signature( + &self, + message: &[u8], + signature: &[u8], + pubkey: &[u8], + ) -> HostResult; + + /// The 32-byte ledger key (keylet) of an account's `AccountRoot`, computed from a + /// 20-byte account id. + #[gas = 350] + #[wasm_name = "accountroot_id"] + fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of an AMM, computed from its two assets. Each asset is a byte + /// slice whose length selects its kind (24 = MPT, 20 = XRP, 40 = issued currency + + /// issuer). + #[gas = 450] + #[wasm_name = "amm_id"] + fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `Check`, computed from a 20-byte account id and its + /// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern. + #[gas = 350] + #[wasm_name = "check_id"] + fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `Credential`, computed from the 20-byte subject and + /// issuer account ids and a credential-type byte string. + #[gas = 350] + #[wasm_name = "credential_id"] + fn credential_keylet( + &self, + subject: &[u8], + issuer: &[u8], + credential_type: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of a `Delegate` object, computed from the 20-byte account and + /// the account it authorizes. + #[gas = 350] + #[wasm_name = "delegate_id"] + fn delegate_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of a `DepositPreauth`, computed from the 20-byte account and + /// the account it authorizes to deposit. + #[gas = 350] + #[wasm_name = "deposit_preauth_id"] + fn deposit_preauth_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of an account's `DID`, computed from its 20-byte account id. + #[gas = 350] + #[wasm_name = "did_id"] + fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of an `Escrow`, computed from the 20-byte owner account and + /// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit + /// pattern. + #[gas = 350] + #[wasm_name = "escrow_id"] + fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `RippleState` (trust line), computed from two 20-byte + /// account ids and a 20-byte currency. + #[gas = 400] + #[wasm_name = "trustline_id"] + fn trust_line_keylet( + &self, + account1: &[u8], + account2: &[u8], + currency: &[u8], + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of an `MPTokenIssuance`, computed from the 20-byte issuer + /// account and its sequence number. `seq` is the guest's `u32` carried as its `i32` + /// bit pattern. + #[gas = 350] + #[wasm_name = "mpt_issuance_id"] + fn mptoken_issuance_keylet( + &self, + issuer: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of an `MPToken`, computed from a 24-byte MPT issuance id and + /// the 20-byte holder account. + #[gas = 500] + #[wasm_name = "mptoken_id"] + fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of an `NFTokenOffer`, computed from the 20-byte owner account + /// and its sequence number. `seq` is the guest's `u32` carried as its `i32` bit + /// pattern. + #[gas = 350] + #[wasm_name = "nft_offer_id"] + fn nftoken_offer_keylet( + &self, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of an `Offer`, computed from the 20-byte owner account and + /// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit + /// pattern. + #[gas = 350] + #[wasm_name = "offer_id"] + fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of an `Oracle`, computed from the 20-byte owner account and + /// its document id. `doc_id` is the guest's `u32` carried as its `i32` bit pattern. + #[gas = 350] + #[wasm_name = "oracle_id"] + fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `PayChannel`, computed from the 20-byte source account, + /// the 20-byte destination account, and the channel's sequence number. `seq` is the + /// guest's `u32` carried as its `i32` bit pattern. + #[gas = 350] + #[wasm_name = "paychan_id"] + fn paychannel_keylet( + &self, + account: &[u8], + destination: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of a `PermissionedDomain`, computed from the 20-byte owner + /// account and its sequence number. `seq` is the guest's `u32` carried as its `i32` + /// bit pattern. + #[gas = 350] + #[wasm_name = "permissioned_domain_id"] + fn permissioned_domain_keylet( + &self, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult; + + /// The 32-byte keylet of a `SignerList`, computed from its 20-byte owner account. + #[gas = 350] + #[wasm_name = "signers_id"] + fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `Ticket`, computed from the 20-byte owner account and + /// its ticket sequence number. `seq` is the guest's `u32` carried as its `i32` bit + /// pattern. + #[gas = 350] + #[wasm_name = "ticket_id"] + fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult; + + /// The 32-byte keylet of a `Vault`, computed from the 20-byte owner account and its + /// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern. + #[gas = 350] + #[wasm_name = "vault_id"] + fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult; + + /// The XRPL `sha512Half` of `data`: the first [`HASH_LEN`] bytes of its SHA-512. + #[gas = 2000] + #[wasm_name = "sha512_half"] + fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult; + + /// Writes `msg` to the trace log, followed by `data` rendered as `data_type` says. + /// + /// The one declaration whose wasm function has **no result**: this node's own log + /// is its only effect, so a guest is told nothing. An `Err` from a host therefore + /// reaches it in no form, and only the host-fatal ones do anything at all. + /// + /// It is also the one declaration that is **not** the wasm parameter order. + /// `data_type` is the third wasm parameter, between the two regions, because that + /// is where the guest stdlib declares it; `register.rs` takes the arguments in wasm + /// order and calls this in declaration order. + #[gas = 30] + #[wasm_name = "trace"] + fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()>; + + /// Stores `data` as the current object's data field, replacing whatever was there, + /// and returns the number of bytes stored; `DataFieldTooLarge` if it exceeds the + /// host's limit. + #[gas = 1000] + #[wasm_name = "set_data"] + fn update_data(&self, data: &[u8]) -> HostResult; + + /// The URI of the `NFToken` with id `nft_id` (32 bytes) held by the 20-byte + /// `account`. + #[gas = 5000] + #[wasm_name = "nft_uri"] + fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult; + + /// The 20-byte issuer account encoded in the `NFToken` id `nft_id` (32 bytes). + #[gas = 70] + #[wasm_name = "nft_issuer"] + fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult; + + /// The taxon encoded in the `NFToken` id `nft_id` (32 bytes), as four little-endian + /// bytes. + #[gas = 60] + #[wasm_name = "nft_taxon"] + fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult; + + /// The flags encoded in the `NFToken` id `nft_id` (32 bytes). + #[gas = 60] + #[wasm_name = "nft_flags"] + fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult; + + /// The transfer fee encoded in the `NFToken` id `nft_id` (32 bytes). + #[gas = 60] + #[wasm_name = "nft_xfer_fee"] + fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult; + + /// The sequence number encoded in the `NFToken` id `nft_id` (32 bytes), as four + /// little-endian bytes. + #[gas = 60] + #[wasm_name = "nft_serial"] + fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult; + + // A "float" here is an XRPL `Number` in its serialized form: a byte blob the guest + // holds opaquely and hands back to these functions. Inputs and outputs that are + // floats are byte regions; `mode` is the rounding mode, a scalar the guest chooses. + + /// A float built from the signed integer `x` under rounding `mode`. + #[gas = 100] + #[wasm_name = "float_from_int"] + fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult; + + /// A float built from the unsigned integer in the 8-byte region `x` under rounding + /// `mode`. + #[gas = 130] + #[wasm_name = "float_from_uint"] + fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// A float built from the serialized `STAmount` in `amount` under rounding `mode`. + #[gas = 150] + #[wasm_name = "float_from_stamount"] + fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// A float built from the serialized `STNumber` in `number` under rounding `mode`. + #[gas = 150] + #[wasm_name = "float_from_stnumber"] + fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The float `x` rounded to a signed integer under rounding `mode`, as eight + /// little-endian bytes. + #[gas = 130] + #[wasm_name = "float_to_int"] + fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The float `x` split into its mantissa (eight little-endian bytes) and its exponent + /// (four little-endian bytes), each written to its own output region. + #[gas = 130] + #[wasm_name = "float_to_mant_exp"] + fn float_to_mant_exp( + &self, + x: &[u8], + mantissa_out: &mut [u8], + exponent_out: &mut [u8], + ) -> HostResult; + + /// A float built from `mantissa` and `exponent` under rounding `mode`. + #[gas = 100] + #[wasm_name = "float_from_mant_exp"] + fn float_from_mant_exp( + &self, + mantissa: i64, + exponent: i32, + mode: i32, + out: &mut [u8], + ) -> HostResult; + + /// Compares floats `x` and `y`, returning a negative, zero, or positive scalar as + /// `x` is less than, equal to, or greater than `y`. + #[gas = 80] + #[wasm_name = "float_cmp"] + fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult; + + /// The float sum `x + y` under rounding `mode`. + #[gas = 160] + #[wasm_name = "float_add"] + fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The float difference `x - y` under rounding `mode`. + #[gas = 160] + #[wasm_name = "float_sub"] + fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The float product `x * y` under rounding `mode`. + #[gas = 300] + #[wasm_name = "float_mult"] + fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The float quotient `x / y` under rounding `mode`. + #[gas = 300] + #[wasm_name = "float_div"] + fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult; + + /// The `n`-th root of the float `x` under rounding `mode`. + #[gas = 5500] + #[wasm_name = "float_root"] + fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult; + + /// The float `x` raised to the power `n` under rounding `mode`. + #[gas = 5500] + #[wasm_name = "float_pow"] + fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult; +} diff --git a/crates/xrpl-host-functions/src/macros.rs b/crates/xrpl-host-functions/src/macros.rs new file mode 100644 index 0000000000..b077526784 --- /dev/null +++ b/crates/xrpl-host-functions/src/macros.rs @@ -0,0 +1,102 @@ +//! The `macro_rules!` behind the two hand-listed enums, [`crate::HostError`] and +//! [`crate::TraceDataType`]. +//! +//! Each takes one list of `Variant = code,` and expands the enum together with the +//! `ALL`/`code`/`from_code` set that must not fall behind it. The lists themselves stay +//! in `lib.rs`, beside the `host_functions!` block. + +/// Declares [`crate::HostError`] from one list: the variants, `HostError::ALL` and +/// `HostError::from_code`'s table all expand from the codes given. +/// +/// One list is what makes `ALL` complete. Rust cannot enumerate an enum's +/// variants — an exhaustive `match` forces an arm per variant but gives nothing to +/// iterate — so a hand-written `ALL` beside a hand-written enum could only be kept +/// in step by review, and `ALL`'s whole purpose is to be the set a test can trust. +/// A code added to the list gains its `ALL` entry and its `from_code` arm by +/// construction. `HostFunctionSpec::ALL` is complete the same way, from the +/// `host_functions!` block. +macro_rules! host_errors { + ($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => { + /// Error codes a host function may return. + /// + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(i32)] + pub enum HostError { + $($(#[$doc])* $variant = $code,)+ + } + + impl HostError { + /// Every error a host function may return, in code order. + /// + /// The complete set, and complete by construction: a wasm engine's + /// split between the codes it hands the guest and the conditions it + /// traps on is a decision per variant, so the test that checks the + /// split iterates this and a code added to the ABI cannot slip past it. + pub const ALL: &'static [HostError] = &[$(HostError::$variant,)+]; + + /// The negative wire value a failed call returns. Every code but + /// `InternalFatal` is one a guest reads off that value. + #[inline] + pub const fn code(self) -> i32 { + self as i32 + } + + /// Reconstruct a `HostError` from its wire code. + /// + /// A code this ABI does not define is `InternalFatal`: an answer the + /// caller cannot act on is the call not having been served, and that is + /// the variant which says so. Positive values are not errors at all and go + /// the same way, since this is reached only once a negative return has + /// been read as a failure. + pub const fn from_code(code: i32) -> HostError { + match code { + $($code => HostError::$variant,)+ + _ => HostError::InternalFatal, + } + } + } + }; +} + +/// Declares [`crate::TraceDataType`] from one list, so `TraceDataType::ALL`, +/// `TraceDataType::code` and `TraceDataType::from_code` cannot fall behind the +/// variants — the reason `host_errors!` above is written this way. +macro_rules! trace_data_types { + ($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => { + /// How [`HostFunctions::trace`] is to read its data buffer. + /// + /// The discriminants are wire values shared with the guest stdlib: append only, + /// never renumber. They start at 1, so a zeroed argument names no type rather + /// than the first one. + /// + /// This is the declaration a guest and a host both compile against. The host + /// side needs a second one — `cxx` cannot be a dependency here, since this + /// crate also links into the guest — so `xrpl-wasm-vm-ffi` declares a shared + /// enum for C++ and converts, exhaustively, from this. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(i32)] + pub enum TraceDataType { + $($(#[$doc])* $variant = $code,)+ + } + + impl TraceDataType { + /// Every data type a guest may name, in code order. + pub const ALL: &'static [TraceDataType] = &[$(TraceDataType::$variant,)+]; + + /// The wire value a guest passes to name this type. + #[inline] + pub const fn code(self) -> i32 { + self as i32 + } + + /// The type `code` names, or `None`: the engine drops a call it cannot + /// read rather than guessing at a rendering the guest did not ask for. + pub const fn from_code(code: i32) -> Option { + match code { + $($code => Some(TraceDataType::$variant),)+ + _ => None, + } + } + } + }; +} diff --git a/crates/xrpl-host-functions/tests/expansion_hygiene.rs b/crates/xrpl-host-functions/tests/expansion_hygiene.rs new file mode 100644 index 0000000000..32854bfd72 --- /dev/null +++ b/crates/xrpl-host-functions/tests/expansion_hygiene.rs @@ -0,0 +1,34 @@ +//! `host_functions!` must work outside the crate that declares the ABI: the only +//! names its expansion needs are the ones the declarations themselves spell. + +use xrpl_host_functions::HostResult; +use xrpl_host_functions_macros::host_functions; + +host_functions! { + /// Answers with the number it was given. + #[gas = 7] + #[wasm_name = "ping"] + fn ping(&self, number: i32) -> HostResult; +} + +struct Host; + +impl HostFunctions for Host { + fn ping(&self, number: i32) -> HostResult { + Ok(number) + } +} + +#[test] +fn the_generated_table_stands_on_its_own() { + assert_eq!(HostFunctionSpec::ALL.len(), 1); + assert_eq!(HostFunctionSpec::Ping.wasm_name(), "ping"); + assert_eq!(HostFunctionSpec::Ping.gas(), 7); +} + +/// The generated trait is implementable from another crate, which is the point of +/// declaring the ABI in a library at all. +#[test] +fn the_generated_trait_is_implementable_here() { + assert_eq!(Host.ping(3), Ok(3)); +} diff --git a/crates/xrpl-host-functions/tests/generated_abi.rs b/crates/xrpl-host-functions/tests/generated_abi.rs new file mode 100644 index 0000000000..f2358195cb --- /dev/null +++ b/crates/xrpl-host-functions/tests/generated_abi.rs @@ -0,0 +1,1006 @@ +//! Exercises the API that `host_functions!` generates, not the macro itself: +//! the `HostFunctions` trait is implementable and callable both directly and +//! through `&dyn`, and the generated `HostFunctionSpec` and `TraceDataType` +//! tables agree with the declarations in `src/lib.rs`. The macro's own parsing +//! and diagnostics are covered by the unit tests in `xrpl-host-functions-macros`. + +use std::cell::RefCell; +use std::collections::HashSet; + +use xrpl_host_functions::{ + HASH_LEN, HostError, HostFunctionSpec, HostFunctions, HostResult, TraceDataType, +}; + +/// Records what it was asked to do; enough to prove the trait is usable. +/// +/// Every method takes `&self`, so a host that records anything keeps it behind +/// interior mutability. +#[derive(Default)] +struct FakeHost { + traced: RefCell>, +} + +/// The contract every byte-producing host function follows: write only if the +/// value fits, and report its true length either way, so the engine can turn a +/// value that doesn't fit into `BufferTooSmall` without the host knowing the +/// guest's buffer size. +fn put(out: &mut [u8], value: &[u8]) -> HostResult { + if let Some(dst) = out.get_mut(..value.len()) { + dst.copy_from_slice(value); + } + Ok(value.len()) +} + +impl HostFunctions for FakeHost { + fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult { + put(out, &7u32.to_le_bytes()) + } + + fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult { + put(out, &9u32.to_le_bytes()) + } + + fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult { + put(out, &[0xab; HASH_LEN]) + } + + fn get_base_fee(&self, out: &mut [u8]) -> HostResult { + put(out, &10u32.to_le_bytes()) + } + + /// Returns a flag rather than bytes, and reads its input: enabled unless empty. + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult { + Ok(i32::from(!amendment.is_empty())) + } + + /// Returns a slot: the requested one, or slot 1 when asked to pick. + fn cache_ledger_obj(&self, _obj_id: &[u8], cache_idx: i32) -> HostResult { + Ok(if cache_idx == 0 { 1 } else { cache_idx }) + } + + /// A field getter over the transaction; fails on a negative selector. + fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult { + if field < 0 { + return Err(HostError::FieldNotFound); + } + put(out, &[field as u8]) + } + + /// Fails on a field it doesn't know, so the error channel is exercised too. + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult { + if field < 0 { + return Err(HostError::FieldNotFound); + } + put(out, &[field as u8]) + } + + /// A field getter over a cached object, keyed by slot and selector. + fn get_ledger_obj_field( + &self, + cache_idx: i32, + field: i32, + out: &mut [u8], + ) -> HostResult { + if cache_idx <= 0 || field < 0 { + return Err(HostError::FieldNotFound); + } + put(out, &[cache_idx as u8, field as u8]) + } + + /// A nested-field getter over the transaction, keyed by the locator bytes. + fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult { + if locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + put(out, &[locator[0], locator.len() as u8]) + } + + /// The same, over the current ledger object. + fn get_current_ledger_obj_nested_field( + &self, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + if locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + put(out, &[locator.len() as u8, locator[0]]) + } + + /// The same, over a cached object keyed by slot. + fn get_ledger_obj_nested_field( + &self, + cache_idx: i32, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + if cache_idx <= 0 || locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + put(out, &[cache_idx as u8, locator[0]]) + } + + /// A scalar-in, scalar-out count; `NoArray` on a negative selector. + fn get_tx_array_len(&self, field: i32) -> HostResult { + if field < 0 { + return Err(HostError::NoArray); + } + Ok(field) + } + + /// The same, over the current ledger object. + fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult { + if field < 0 { + return Err(HostError::NoArray); + } + Ok(field + 1) + } + + /// The same, over a cached object keyed by slot. + fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult { + if cache_idx <= 0 || field < 0 { + return Err(HostError::NoArray); + } + Ok(cache_idx + field) + } + + /// A nested array-length getter, keyed by the locator bytes. + fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult { + if locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + Ok(locator.len() as i32) + } + + /// The same, over the current ledger object. + fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult { + if locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + Ok(locator.len() as i32 + 1) + } + + /// The same, over a cached object keyed by slot. + fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult { + if cache_idx <= 0 || locator.is_empty() { + return Err(HostError::LocatorMalformed); + } + Ok(cache_idx + locator.len() as i32) + } + + /// Reads three regions and returns a verdict: valid unless the signature is empty. + fn check_signature( + &self, + _message: &[u8], + signature: &[u8], + _pubkey: &[u8], + ) -> HostResult { + Ok(i32::from(!signature.is_empty())) + } + + /// A keylet getter: reads an account, writes a 32-byte keylet; `InvalidAccount` + /// on an empty account. + fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// A two-asset keylet getter; `InvalidParams` if the two assets are equal. + fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult { + if asset1 == asset2 { + return Err(HostError::InvalidParams); + } + put(out, &[asset1.len() as u8; HASH_LEN]) + } + + /// A keylet from an account and a sequence; `InvalidAccount` on an empty account. + fn check_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// A keylet from subject, issuer, and credential type; `InvalidAccount` if either + /// account is empty, `InvalidParams` if the type is empty. + fn credential_keylet( + &self, + subject: &[u8], + issuer: &[u8], + credential_type: &[u8], + out: &mut [u8], + ) -> HostResult { + if subject.is_empty() || issuer.is_empty() { + return Err(HostError::InvalidAccount); + } + if credential_type.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[subject[0]; HASH_LEN]) + } + + /// A keylet from two accounts; `InvalidAccount` if either is empty, `InvalidParams` + /// if they are equal. + fn delegate_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + if account.is_empty() || authorize.is_empty() { + return Err(HostError::InvalidAccount); + } + if account == authorize { + return Err(HostError::InvalidParams); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same two-account shape, for a `DepositPreauth`. + fn deposit_preauth_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + if account.is_empty() || authorize.is_empty() { + return Err(HostError::InvalidAccount); + } + if account == authorize { + return Err(HostError::InvalidParams); + } + put(out, &[authorize[0]; HASH_LEN]) + } + + /// A single-account keylet, for a `DID`. + fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The account-and-sequence shape, for an `Escrow`. + fn escrow_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// A keylet from two accounts and a currency; `InvalidAccount` if either account + /// is empty, `InvalidParams` if they are equal or the currency is empty. + fn trust_line_keylet( + &self, + account1: &[u8], + account2: &[u8], + currency: &[u8], + out: &mut [u8], + ) -> HostResult { + if account1.is_empty() || account2.is_empty() { + return Err(HostError::InvalidAccount); + } + if account1 == account2 || currency.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[account1[0]; HASH_LEN]) + } + + /// The issuer-and-sequence shape, for an `MPTokenIssuance`. + fn mptoken_issuance_keylet( + &self, + issuer: &[u8], + _seq: i32, + out: &mut [u8], + ) -> HostResult { + if issuer.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[issuer[0]; HASH_LEN]) + } + + /// A keylet from an MPT id and a holder; `InvalidParams` if the id is empty, + /// `InvalidAccount` if the holder is empty. + fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult { + if mptid.is_empty() { + return Err(HostError::InvalidParams); + } + if holder.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[mptid[0]; HASH_LEN]) + } + + /// The account-and-sequence shape, for an `NFTokenOffer`. + fn nftoken_offer_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same account-and-sequence shape, for an `Offer`. + fn offer_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same account-and-scalar shape, for an `Oracle` keyed by document id. + fn oracle_keylet(&self, account: &[u8], _doc_id: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// A two-account-and-sequence shape, for a `PayChannel`; `InvalidAccount` if + /// either account is empty. + fn paychannel_keylet( + &self, + account: &[u8], + destination: &[u8], + _seq: i32, + out: &mut [u8], + ) -> HostResult { + if account.is_empty() || destination.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same account-and-sequence shape, for a `PermissionedDomain`. + fn permissioned_domain_keylet( + &self, + account: &[u8], + _seq: i32, + out: &mut [u8], + ) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The account-only shape, for a `SignerList`. + fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same account-and-sequence shape, for a `Ticket`. + fn ticket_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// The same account-and-sequence shape, for a `Vault`. + fn vault_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult { + if account.is_empty() { + return Err(HostError::InvalidAccount); + } + put(out, &[account[0]; HASH_LEN]) + } + + fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult { + let mut digest = [0; HASH_LEN]; + digest[0] = data.len() as u8; + put(out, &digest) + } + + fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()> { + self.traced + .borrow_mut() + .push(format!("{msg}/{data_type:?}/{}", data.len())); + Ok(()) + } + + /// Reads a data blob and returns the count of bytes stored. + fn update_data(&self, data: &[u8]) -> HostResult { + Ok(data.len() as i32) + } + + /// Reads an account and an nft id, writes a byte value; `InvalidParams` if either + /// is empty. + fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult { + if account.is_empty() || nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[account[0]; HASH_LEN]) + } + + /// Reads an nft id, writes a byte value; `InvalidParams` on an empty id. + fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + if nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[nft_id[0]; HASH_LEN]) + } + + /// The same, for the taxon. + fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + if nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &nft_id[0].to_le_bytes()) + } + + /// Reads an nft id and returns a scalar; `InvalidParams` on an empty id. + fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult { + if nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + Ok(i32::from(nft_id[0])) + } + + /// The same, for the transfer fee. + fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult { + if nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + Ok(i32::from(nft_id[0])) + } + + /// The same byte-output shape, for the sequence number. + fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + if nft_id.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &nft_id[0].to_le_bytes()) + } + + /// A scalar-in float: writes the low byte of `x` as a stand-in float. + fn float_from_int(&self, x: i64, _mode: i32, out: &mut [u8]) -> HostResult { + put(out, &[x as u8]) + } + + /// A byte-in float; `InvalidParams` on an empty region. + fn float_from_uint(&self, x: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// The same, for a serialized amount. + fn float_from_stamount(&self, amount: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if amount.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[amount[0]]) + } + + /// The same, for a serialized number. + fn float_from_stnumber(&self, number: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if number.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[number[0]]) + } + + /// A float rounded to an integer, written as bytes. + fn float_to_int(&self, x: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// Writes a mantissa (its first byte) and an exponent (its first byte) to two + /// regions, returning their combined length. + fn float_to_mant_exp( + &self, + x: &[u8], + mantissa_out: &mut [u8], + exponent_out: &mut [u8], + ) -> HostResult { + if x.is_empty() { + return Err(HostError::InvalidParams); + } + let m = put(mantissa_out, &[x[0]])?; + let e = put(exponent_out, &[x[0]])?; + Ok(m + e) + } + + /// A two-scalar-in float. + fn float_from_mant_exp( + &self, + mantissa: i64, + _exponent: i32, + _mode: i32, + out: &mut [u8], + ) -> HostResult { + put(out, &[mantissa as u8]) + } + + /// Reads two floats and returns a scalar; `InvalidParams` if either is empty. + fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult { + if x.is_empty() || y.is_empty() { + return Err(HostError::InvalidParams); + } + Ok(i32::from(x[0]) - i32::from(y[0])) + } + + /// A binary float operator; `InvalidParams` if either operand is empty. + fn float_add(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() || y.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// The same shape, for subtraction. + fn float_subtract(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() || y.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// The same shape, for multiplication. + fn float_multiply(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() || y.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// The same shape, for division. + fn float_divide(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() || y.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// A one-float-and-integer operator; `InvalidParams` on an empty operand. + fn float_root(&self, x: &[u8], _n: i32, _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } + + /// The same shape, for exponentiation. + fn float_power(&self, x: &[u8], _n: i32, _mode: i32, out: &mut [u8]) -> HostResult { + if x.is_empty() { + return Err(HostError::InvalidParams); + } + put(out, &[x[0]]) + } +} + +#[test] +fn the_trait_is_implementable() { + let host = FakeHost::default(); + let mut out = [0u8; HASH_LEN]; + + assert_eq!(host.get_ledger_sqn(&mut out), Ok(4)); + assert_eq!(out[..4], [7, 0, 0, 0]); + assert_eq!(host.get_parent_ledger_time(&mut out), Ok(4)); + assert_eq!(out[..4], [9, 0, 0, 0]); + assert_eq!(host.get_parent_ledger_hash(&mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 0xab); + assert_eq!(host.get_base_fee(&mut out), Ok(4)); + assert_eq!(out[..4], [10, 0, 0, 0]); + assert_eq!(host.is_amendment_enabled(&[1; 32]), Ok(1)); + assert_eq!(host.is_amendment_enabled(&[]), Ok(0)); + assert_eq!(host.cache_ledger_obj(&[1; 32], 0), Ok(1)); + assert_eq!(host.cache_ledger_obj(&[1; 32], 5), Ok(5)); + assert_eq!(host.get_tx_field(5, &mut out), Ok(1)); + assert_eq!(out[0], 5); + assert_eq!(host.get_current_ledger_obj_field(3, &mut out), Ok(1)); + assert_eq!(out[0], 3); + assert_eq!(host.get_ledger_obj_field(2, 4, &mut out), Ok(2)); + assert_eq!(out[..2], [2, 4]); + assert_eq!(host.get_tx_nested_field(&[9, 0, 0, 0], &mut out), Ok(2)); + assert_eq!(out[..2], [9, 4]); + assert_eq!( + host.get_current_ledger_obj_nested_field(&[9, 0, 0, 0], &mut out), + Ok(2) + ); + assert_eq!(out[..2], [4, 9]); + assert_eq!( + host.get_ledger_obj_nested_field(3, &[9, 0, 0, 0], &mut out), + Ok(2) + ); + assert_eq!(out[..2], [3, 9]); + assert_eq!(host.get_tx_array_len(3), Ok(3)); + assert_eq!(host.get_tx_array_len(-1), Err(HostError::NoArray)); + assert_eq!(host.get_current_ledger_obj_array_len(3), Ok(4)); + assert_eq!( + host.get_current_ledger_obj_array_len(-1), + Err(HostError::NoArray) + ); + assert_eq!(host.get_ledger_obj_array_len(2, 3), Ok(5)); + assert_eq!(host.get_ledger_obj_array_len(0, 3), Err(HostError::NoArray)); + assert_eq!(host.get_tx_nested_array_len(&[9, 0, 0, 0]), Ok(4)); + assert_eq!( + host.get_tx_nested_array_len(&[]), + Err(HostError::LocatorMalformed) + ); + assert_eq!( + host.get_current_ledger_obj_nested_array_len(&[9, 0, 0, 0]), + Ok(5) + ); + assert_eq!( + host.get_current_ledger_obj_nested_array_len(&[]), + Err(HostError::LocatorMalformed) + ); + assert_eq!( + host.get_ledger_obj_nested_array_len(2, &[9, 0, 0, 0]), + Ok(6) + ); + assert_eq!( + host.get_ledger_obj_nested_array_len(0, &[9, 0, 0, 0]), + Err(HostError::LocatorMalformed) + ); + assert_eq!(host.check_signature(b"msg", b"sig", b"pk"), Ok(1)); + assert_eq!(host.check_signature(b"msg", b"", b"pk"), Ok(0)); + assert_eq!(host.account_keylet(&[7; 20], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.account_keylet(&[], &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.amm_keylet(&[1; 20], &[2; 40], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 20); + assert_eq!( + host.amm_keylet(&[1; 20], &[1; 20], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!(host.check_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.check_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.credential_keylet(&[7; 20], &[8; 20], b"cred", &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.credential_keylet(&[], &[8; 20], b"cred", &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.delegate_keylet(&[7; 20], &[8; 20], &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.delegate_keylet(&[], &[8; 20], &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.deposit_preauth_keylet(&[7; 20], &[8; 20], &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 8); + assert_eq!( + host.deposit_preauth_keylet(&[7; 20], &[7; 20], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!(host.did_keylet(&[7; 20], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.did_keylet(&[], &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.escrow_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.escrow_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.trust_line_keylet(&[7; 20], &[8; 20], &[1; 20], &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.trust_line_keylet(&[7; 20], &[7; 20], &[1; 20], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!( + host.mptoken_issuance_keylet(&[7; 20], 5, &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.mptoken_issuance_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.mptoken_keylet(&[9; 24], &[8; 20], &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 9); + assert_eq!( + host.mptoken_keylet(&[], &[8; 20], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!( + host.nftoken_offer_keylet(&[7; 20], 5, &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.nftoken_offer_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.offer_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.offer_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.oracle_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.oracle_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.paychannel_keylet(&[7; 20], &[8; 20], 5, &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.paychannel_keylet(&[7; 20], &[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!( + host.permissioned_domain_keylet(&[7; 20], 5, &mut out), + Ok(HASH_LEN) + ); + assert_eq!(out[0], 7); + assert_eq!( + host.permissioned_domain_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.signer_list_keylet(&[7; 20], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.signer_list_keylet(&[], &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.ticket_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.ticket_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.vault_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.vault_keylet(&[], 5, &mut out), + Err(HostError::InvalidAccount) + ); + assert_eq!(host.sha512_half(b"abc", &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 3); + assert_eq!(host.trace("hello", b"xy", TraceDataType::AsHex), Ok(())); + assert_eq!(host.update_data(b"abcd"), Ok(4)); + assert_eq!(host.get_nft(&[7; 20], &[9; 32], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 7); + assert_eq!( + host.get_nft(&[], &[9; 32], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!(host.get_nft_issuer(&[9; 32], &mut out), Ok(HASH_LEN)); + assert_eq!(out[0], 9); + assert_eq!( + host.get_nft_issuer(&[], &mut out), + Err(HostError::InvalidParams) + ); + assert_eq!(host.get_nft_taxon(&[9; 32], &mut out), Ok(1)); + assert_eq!(host.get_nft_flags(&[9; 32]), Ok(9)); + assert_eq!(host.get_nft_flags(&[]), Err(HostError::InvalidParams)); + assert_eq!(host.get_nft_transfer_fee(&[9; 32]), Ok(9)); + assert_eq!(host.get_nft_sequence(&[9; 32], &mut out), Ok(1)); + assert_eq!(host.float_from_int(5, 0, &mut out), Ok(1)); + assert_eq!(host.float_from_uint(&[3; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_from_stamount(&[3; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_from_stnumber(&[3; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_to_int(&[3; 8], 0, &mut out), Ok(1)); + let mut mant = [0u8; 8]; + let mut exp = [0u8; 4]; + assert_eq!(host.float_to_mant_exp(&[3; 8], &mut mant, &mut exp), Ok(2)); + assert_eq!(host.float_from_mant_exp(5, 0, 0, &mut out), Ok(1)); + assert_eq!(host.float_compare(&[9; 8], &[4; 8]), Ok(5)); + assert_eq!( + host.float_compare(&[], &[4; 8]), + Err(HostError::InvalidParams) + ); + assert_eq!(host.float_add(&[3; 8], &[4; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_subtract(&[3; 8], &[4; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_multiply(&[3; 8], &[4; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_divide(&[3; 8], &[4; 8], 0, &mut out), Ok(1)); + assert_eq!(host.float_root(&[3; 8], 2, 0, &mut out), Ok(1)); + assert_eq!(host.float_power(&[3; 8], 2, 0, &mut out), Ok(1)); + + assert_eq!(*host.traced.borrow(), ["hello/AsHex/2"]); +} + +/// The error channel every declaration carries: an `Err` the VM turns into the +/// wire's negative return code. +#[test] +fn a_failing_call_reports_its_error_code() { + let host = FakeHost::default(); + let mut out = [0u8; 8]; + + assert_eq!( + host.get_current_ledger_obj_field(-1, &mut out), + Err(HostError::FieldNotFound) + ); + assert_eq!(HostError::FieldNotFound.code(), -2); +} + +/// A host reports the value's true length even when it cannot write it, which is +/// what lets the engine answer `BufferTooSmall` on the guest's behalf. +#[test] +fn a_short_buffer_still_reports_the_true_length() { + let host = FakeHost::default(); + let mut out = [0u8; 2]; + + assert_eq!(host.get_ledger_sqn(&mut out), Ok(4)); + assert_eq!( + out, + [0, 0], + "nothing is written when the value does not fit" + ); +} + +/// The VM reaches the host as one shared trait object held in the wasmi `Store`, +/// which is what the `&self` receivers are for. +#[test] +fn the_trait_is_callable_through_a_shared_trait_object() { + let fake = FakeHost::default(); + let host: &dyn HostFunctions = &fake; + let mut out = [0u8; 4]; + + assert_eq!(host.get_ledger_sqn(&mut out), Ok(4)); + assert_eq!( + host.trace("count", &1i64.to_le_bytes(), TraceDataType::Int64), + Ok(()) + ); + + assert_eq!(*fake.traced.borrow(), ["count/Int64/8"]); +} + +/// The whole table, written out: the one place the ABI's wire names and gas costs +/// appear as literals, and a deliberate change-detector, since both are consensus +/// input. Everything else reads `HostFunctionSpec::gas()` instead. +/// +/// `ALL` is in declaration order, so comparing the whole vec pins the order and the +/// membership too. +#[test] +fn the_spec_table_matches_the_declarations() { + let table: Vec<(&str, u64)> = HostFunctionSpec::ALL + .iter() + .map(|function| (function.wasm_name(), function.gas())) + .collect(); + + assert_eq!( + table, + [ + ("ldgr_index", 60), + ("parent_ldgr_time", 60), + ("parent_ldgr_hash", 60), + ("base_fee", 60), + ("amendment_enabled", 100), + ("cache_le", 5000), + ("tx_field", 70), + ("home_le_field", 70), + ("le_field", 70), + ("tx_inner", 110), + ("home_le_inner", 110), + ("le_inner", 110), + ("tx_arr_len", 40), + ("home_le_arr_len", 40), + ("le_arr_len", 40), + ("tx_inner_arr_len", 70), + ("home_le_inner_arr_len", 70), + ("le_inner_arr_len", 70), + ("check_sig", 300), + ("accountroot_id", 350), + ("amm_id", 450), + ("check_id", 350), + ("credential_id", 350), + ("delegate_id", 350), + ("deposit_preauth_id", 350), + ("did_id", 350), + ("escrow_id", 350), + ("trustline_id", 400), + ("mpt_issuance_id", 350), + ("mptoken_id", 500), + ("nft_offer_id", 350), + ("offer_id", 350), + ("oracle_id", 350), + ("paychan_id", 350), + ("permissioned_domain_id", 350), + ("signers_id", 350), + ("ticket_id", 350), + ("vault_id", 350), + ("sha512_half", 2000), + ("trace", 30), + ("set_data", 1000), + ("nft_uri", 5000), + ("nft_issuer", 70), + ("nft_taxon", 60), + ("nft_flags", 60), + ("nft_xfer_fee", 60), + ("nft_serial", 60), + ("float_from_int", 100), + ("float_from_uint", 130), + ("float_from_stamount", 150), + ("float_from_stnumber", 150), + ("float_to_int", 130), + ("float_to_mant_exp", 130), + ("float_from_mant_exp", 100), + ("float_cmp", 80), + ("float_add", 160), + ("float_sub", 160), + ("float_mult", 300), + ("float_div", 300), + ("float_root", 5500), + ("float_pow", 5500), + ] + ); +} + +/// The other half of the wire vocabulary, and the same change-detector argument: the +/// codes are what a guest passes, so they are pinned as literals here. `ALL` is in code +/// order, so the round trip pins the discriminants and not just the membership. +#[test] +fn every_trace_data_type_survives_the_wire() { + let codes: Vec = TraceDataType::ALL.iter().map(|t| t.code()).collect(); + + assert_eq!(codes, [1, 2, 3, 4, 5, 6, 7]); + for &data_type in TraceDataType::ALL { + assert_eq!(TraceDataType::from_code(data_type.code()), Some(data_type)); + } +} + +/// A code no declaration names is refused rather than read as a neighbouring type. +/// Zero is the one worth naming: it is what a guest sends by omission. +#[test] +fn an_unnamed_trace_data_type_code_is_refused() { + for code in [0, -1, 8, i32::MAX, i32::MIN] { + assert_eq!(TraceDataType::from_code(code), None, "code {code}"); + } +} + +/// `ALL` is what a wasm engine iterates to register imports, so no two declarations +/// may collapse to the same wire name. The table above pins membership and order; +/// this adds only uniqueness, and restates nothing. +#[test] +fn every_variant_appears_in_all_exactly_once() { + let names: HashSet<&str> = HostFunctionSpec::ALL + .iter() + .map(|function| function.wasm_name()) + .collect(); + + assert_eq!(names.len(), HostFunctionSpec::ALL.len()); +} + +/// Both accessors are `const`, so an engine can build its import and gas tables at +/// compile time rather than on every invocation. The assertions sit in `const` +/// blocks so they are checked while compiling, which is the claim; the values +/// themselves are pinned above. +#[test] +fn the_table_is_usable_in_const_context() { + const NAME: &str = HostFunctionSpec::Trace.wasm_name(); + const GAS: u64 = HostFunctionSpec::Trace.gas(); + + const { assert!(!NAME.is_empty()) }; + const { assert!(GAS > 0) }; +} diff --git a/crates/xrpl-host-functions/tests/host_errors.rs b/crates/xrpl-host-functions/tests/host_errors.rs new file mode 100644 index 0000000000..7e77fcdc56 --- /dev/null +++ b/crates/xrpl-host-functions/tests/host_errors.rs @@ -0,0 +1,102 @@ +//! Exercises what `host_errors!` generates: the wire codes, the set +//! [`HostError::ALL`] names, and the round trip between them. +//! +//! The codes are consensus input — they are what a guest reads off a failed host +//! call — so they are pinned here as literals and derived everywhere else. + +use xrpl_host_functions::HostError; + +/// The whole set, written out in the order `ALL` gives it: the one place the wire +/// codes appear as literals, and a deliberate change-detector, since a code that +/// moves changes what every deployed guest is told. +#[test] +fn the_error_table_matches_the_declarations() { + let table: Vec<(HostError, i32)> = HostError::ALL + .iter() + .map(|&error| (error, error.code())) + .collect(); + + assert_eq!( + table, + [ + (HostError::Unimplemented, -1), + (HostError::FieldNotFound, -2), + (HostError::BufferTooSmall, -3), + (HostError::NoArray, -4), + (HostError::NotLeafField, -5), + (HostError::LocatorMalformed, -6), + (HostError::SlotOutRange, -7), + (HostError::SlotsFull, -8), + (HostError::EmptySlot, -9), + (HostError::LedgerObjNotFound, -10), + (HostError::OutOfTransferLimit, -11), + (HostError::DataFieldTooLarge, -12), + (HostError::PointerOutOfBounds, -13), + (HostError::NoMemExported, -14), + (HostError::InvalidParams, -15), + (HostError::InvalidAccount, -16), + (HostError::InvalidField, -17), + (HostError::IndexOutOfBounds, -18), + (HostError::FloatInputMalformed, -19), + (HostError::FloatComputationError, -20), + (HostError::InternalFatal, i32::MIN), + ] + ); +} + +/// The guest-facing set is `-1 ..= -20` and nothing else: those entries are xrpld's +/// `HostFunctionError`, and each is a code some contract may read. +/// +/// `InternalFatal` is the one deliberate exception, exempted by name rather than by +/// widening the range: a condition with no number a contract can act on needs no number +/// in the range a contract reads, and holding it at `i32::MIN` is what keeps it from +/// ever colliding with a code appended to xrpld's list. +#[test] +fn every_code_but_the_sentinel_is_in_the_shared_range() { + let shared: Vec = HostError::ALL + .iter() + .copied() + .filter(|&error| error != HostError::InternalFatal) + .collect(); + + let outside: Vec = shared + .iter() + .copied() + .filter(|error| !(-20..=-1).contains(&error.code())) + .collect(); + + assert!(outside.is_empty(), "outside -1..=-20: {outside:?}"); + assert_eq!(shared.len(), 20); + assert_eq!(HostError::InternalFatal.code(), i32::MIN); + assert_eq!(HostError::ALL.len(), 21); +} + +/// Every code a guest can be handed comes back as the error that produced it, so a +/// caller reading a negative return value recovers the condition and not a +/// neighbouring one. The table above pins the numbers; this adds only the round +/// trip. +#[test] +fn every_wire_code_round_trips_back_to_its_error() { + for &error in HostError::ALL { + assert_eq!(HostError::from_code(error.code()), error, "{error:?}"); + } +} + +/// A code from outside the set is `InternalFatal`: a host answering something this ABI +/// does not define has not served the call, whatever it meant by it, and success is not +/// an error at all. +/// +/// `-21` is the code xrpld would append next, so it is the one that decides whether a +/// list this crate has not caught up with reaches a guest or stops the run. `i32::MIN + +/// 1` is next to the sentinel and unassigned, which is what makes the sentinel a value +/// rather than a range. +#[test] +fn a_code_outside_the_set_is_internal_fatal() { + for code in [-21, i32::MIN + 1, 0, 1, i32::MAX] { + assert_eq!( + HostError::from_code(code), + HostError::InternalFatal, + "{code}" + ); + } +} diff --git a/crates/hello_world/Cargo.toml b/crates/xrpl-wasm-testkit/Cargo.toml similarity index 57% rename from crates/hello_world/Cargo.toml rename to crates/xrpl-wasm-testkit/Cargo.toml index 2e5a329c9a..06c1e7c366 100644 --- a/crates/hello_world/Cargo.toml +++ b/crates/xrpl-wasm-testkit/Cargo.toml @@ -1,10 +1,11 @@ [package] -name = "rs-hello_world" +name = "xrpl-wasm-testkit" version = "0.1.0" edition.workspace = true [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib", "rlib"] [dependencies] cxx.workspace = true +wat = "1" diff --git a/crates/xrpl-wasm-testkit/src/lib.rs b/crates/xrpl-wasm-testkit/src/lib.rs new file mode 100644 index 0000000000..f503294c59 --- /dev/null +++ b/crates/xrpl-wasm-testkit/src/lib.rs @@ -0,0 +1,49 @@ +//! Assembles WebAssembly text for the C++ test suite. **Test-only.** +//! +//! A crate of its own rather than an entry on `xrpl-wasm-vm-ffi`, and the separation is the +//! point. The engine pins `wasmi = { default-features = false }` precisely so a text +//! assembler cannot reach the consensus path — wasmi's `wat` feature is on by default and +//! makes `Module::new` accept text as readily as binary, which would make a transaction's +//! validity a build flag. Putting `compile_wat` on the production bridge would link `wat` +//! into xrpld even if nothing called it. +//! +//! Linked only into `xrpl_tests`, never into `libxrpl` or `xrpld`, so "no assembler in the +//! shipped node" is a property of the link graph rather than a flag someone can flip. +#![deny(rustdoc::broken_intra_doc_links)] + +#[cxx::bridge(namespace = "rs::wasm_testkit")] +mod ffi { + extern "Rust" { + /// Assemble `wat` to a wasm module. + /// + /// Throws `rust::Error` on invalid input, which is what a test wants: a typo in a + /// fixture should fail the test that holds it, at the line that holds it. + fn compile_wat(wat: &str) -> Result>; + } +} + +fn compile_wat(wat: &str) -> Result, wat::Error> { + wat::parse_str(wat) +} + +#[cfg(test)] +mod tests { + use super::compile_wat; + + #[test] + fn a_module_assembles_to_something_beginning_with_the_wasm_magic() { + let wasm = compile_wat("(module)").expect("assembles"); + + assert_eq!(&wasm[..4], b"\0asm"); + } + + #[test] + fn a_typo_is_an_error_rather_than_a_module() { + let error = compile_wat("(module (func (export").expect_err("must not assemble"); + + assert!( + !error.to_string().is_empty(), + "the error has to say something" + ); + } +} diff --git a/crates/xrpl-wasm-vm-ffi/Cargo.toml b/crates/xrpl-wasm-vm-ffi/Cargo.toml new file mode 100644 index 0000000000..c301eb707a --- /dev/null +++ b/crates/xrpl-wasm-vm-ffi/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "xrpl-wasm-vm-ffi" +version = "0.1.0" +edition.workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +cxx.workspace = true +xrpl-host-functions = { path = "../xrpl-host-functions" } +xrpl-wasm-vm = { path = "../xrpl-wasm-vm" } diff --git a/crates/xrpl-wasm-vm-ffi/src/lib.rs b/crates/xrpl-wasm-vm-ffi/src/lib.rs new file mode 100644 index 0000000000..0b2b965472 --- /dev/null +++ b/crates/xrpl-wasm-vm-ffi/src/lib.rs @@ -0,0 +1,1293 @@ +//! The cxx bridge between the escrow wasm engine and xrpld. +//! +//! Three crossings: +//! +//! - **In:** C++ calls `run_escrow`, once per escrow finish. +//! - **Back out:** that run's host calls leave through the C++ `HostContext`, which +//! `CxxHost` presents to the engine as an ordinary [`HostFunctions`] implementor. +//! - **In only:** C++ screens a module with `check_escrow`. Screening needs no host, +//! so nothing comes back out. +//! +//! The ABI the host calls speak is declared once, in `xrpl-host-functions`, so neither +//! side of this file gets to restate a signature. +//! +//! **Neither language may unwind into the other**, and the two halves of that are +//! not symmetric: +//! +//! - A **Rust panic** is caught here, by `guarded`. Letting one reach C++ is +//! undefined behaviour; `[profile.release]` turns overflow checks on, so this is a +//! live path and not a formality. +//! - A **C++ exception** is stopped on the C++ side: every `HostContext` method is +//! `noexcept` and catches its own. That is what makes `guarded` sufficient — see +//! its documentation. +//! +//! Everything hand-written here is private, so the names above are code spans rather +//! than links, and `cargo doc` needs `--document-private-items` to show any of it. +//! That is also why this crate, unlike `xrpl-wasm-vm`, does not +//! `deny(unreachable_pub)`: cxx's expansion is `pub` throughout by necessity, leaving +//! the lint nothing but generated code to fire on. +#![deny(rustdoc::broken_intra_doc_links)] + +use std::any::Any; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use xrpl_host_functions::{HostError, HostFunctions, HostResult, TraceDataType}; +use xrpl_wasm_vm::{CheckError, RunError, RunFailure, RunOutcome, check, run}; + +/// [`guarded`] must be able to stop an unwind. Under `panic = "abort"` it cannot, +/// and every arithmetic overflow in the engine becomes a node crash instead of a +/// `tecINTERNAL`. +#[cfg(panic = "abort")] +compile_error!( + "xrpl-wasm-vm-ffi requires panic=unwind: run_escrow catches panics rather than \ + letting them cross into C++" +); + +#[cxx::bridge(namespace = "rs::wasm_vm")] +mod ffi { + /// Which outcome a run had — one variant per way [`run`] can end, so the caller + /// maps a status to a TER rather than reading a message. + #[derive(Debug, Hash)] + #[repr(i32)] + enum RunStatus { + /// The entry point returned. + Ok, + /// `wasm` is not a valid module under this engine's configuration. + Compile, + /// The module would not instantiate. + Instantiate, + /// No export of that name with signature `() -> i32`. + EntryPoint, + /// Gas exhausted, by the guest's instructions or a host call's charge. + OutOfGas, + /// The host could not serve a call, including any exception it caught. + Internal, + /// A host call had no linear memory to work in. + NoMemory, + /// The guest trapped. + Trap, + /// The engine panicked. A defect in this crate or the one below it. + Panic, + } + + /// A run's outcome, flattened: cxx enums carry no payload, so the status, the + /// cost and the description travel side by side. + struct RunResult { + status: RunStatus, + /// What the entry point returned. Meaningful only when `status` is `Ok`. + result: i32, + /// Gas consumed. The whole limit when gas ran out; `0` when the module never + /// ran, or when the cost could not be trusted (`Internal`, `Panic`). + gas_used: u64, + /// The engine's own description of the outcome, for the log. Empty on `Ok`. + detail: String, + } + + /// Why a module cannot be run — one variant per way [`check`] can refuse it, + /// so the caller maps a status to a TER rather than reading a message. + #[derive(Debug, Hash)] + #[repr(i32)] + enum CheckStatus { + /// The module compiles, imports only what the engine serves, and exports + /// the entry point as `() -> i32`. + Ok, + /// `wasm` is not a valid module under this engine's configuration. + Compile, + /// An import the engine does not define: another module namespace, a name + /// that is not a host function, or one imported as something else. + Import, + /// No export of that name with signature `() -> i32`. + EntryPoint, + /// The module asks for more linear memory than the engine grants. + Memory, + /// The module asks for a larger table than the engine grants. + Table, + /// The engine panicked. A defect in this crate or the one below it, and + /// not a fault in the module — which is why it is a status of its own + /// rather than one more way a contract can be malformed. + Panic, + } + + /// A check's verdict. No cost, because nothing was executed. + struct CheckResult { + status: CheckStatus, + /// The engine's own description of the refusal, for the log. Empty on + /// `Ok`. + detail: String, + } + + /// How `HostContext::trace` is to read its data buffer. + /// + /// **Declared here so that C++ does not declare it.** A shared enum is emitted into + /// the generated header as `xrpl::TraceDataType`, which is the definition + /// `HostContext.cpp` switches on — so the variants and their wire values are + /// written once, in Rust, for both languages. + /// + /// It is not the same type as [`xrpl_host_functions::TraceDataType`], and cannot + /// be: the ABI crate is `no_std` with no dependencies so that it also links into + /// the guest, and `cxx` is neither. [`crossed`] converts, in a `match` that is + /// exhaustive over the ABI's enum — so a data type added there fails to compile + /// until it is added here, which is the drift check the hand-written C++ copy + /// never had. + #[namespace = "xrpl"] + #[derive(Debug, Hash)] + #[repr(i32)] + enum TraceDataType { + Int64 = 1, + Uint64 = 2, + Xfloat = 3, + Account = 4, + Amount = 5, + AsHex = 6, + AsText = 7, + } + + extern "Rust" { + /// Run `wasm`'s `function_name` export with `gas` fuel, servicing host calls + /// through `host`. + /// + /// Reports every outcome as a [`RunStatus`] and **never throws**: an + /// exception is a poor interface for a condition the caller has to turn into + /// a TER anyway, and a panic reaching C++ would be undefined behaviour. + /// + /// `gas` is the run's whole budget. `0` is a run that cannot execute an + /// instruction; the C++ front refuses it as `temBAD_AMOUNT` before calling + /// here, so it is not given a status of its own. + fn run_escrow(host: &HostContext, wasm: &[u8], gas: u64, function_name: &str) -> RunResult; + + /// Screen `wasm` before it can reach the ledger: whether [`run_escrow`] + /// would refuse it before the guest's first instruction. + /// + /// Takes no host, no gas and no store — the verdict comes from the + /// compiled module alone, which is what makes it callable from a + /// transaction's preflight, where there is no ledger to serve a host call + /// from. **Never throws**, for the same reason [`run_escrow`] does not. + fn check_escrow(wasm: &[u8], function_name: &str) -> CheckResult; + } + + unsafe extern "C++" { + include!("xrpl/tx/wasm/HostContext.h"); + + /// The C++ side of the ABI: one method per host function, forwarding to + /// `xrpl::HostFunctions`. + /// + /// Every method is `noexcept` and catches everything, so a host call cannot + /// unwind into the engine. + /// + /// `cxx_name` on each method below is not cosmetic: the declarations keep the + /// ABI's names here and rippled's camelBack over there, so neither side has + /// to spell the other's convention. + #[namespace = "xrpl"] + type HostContext; + + /// A byte-producing call is handed `out` and returns the value's **true + /// length**, writing it only if the whole value fits. Returning a length past + /// `out` is how a guest learns the size to ask for; the engine turns it into + /// `BufferTooSmall`, so C++ never needs to know the guest's capacity. + /// + /// A negative return is a `HostError` code. + #[namespace = "xrpl"] + #[cxx_name = "getLedgerSqn"] + fn get_ledger_sqn(self: &HostContext, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getParentLedgerTime"] + fn get_parent_ledger_time(self: &HostContext, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getParentLedgerHash"] + fn get_parent_ledger_hash(self: &HostContext, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getBaseFee"] + fn get_base_fee(self: &HostContext, out: &mut [u8]) -> i32; + + /// Reads the amendment (id or name) and answers `1`/`0`, or a negative + /// `HostError` code. + #[namespace = "xrpl"] + #[cxx_name = "isAmendmentEnabled"] + fn is_amendment_enabled(self: &HostContext, amendment: &[u8]) -> i32; + + /// Caches the object with `obj_id` in slot `cache_idx` (`0` = pick one) and + /// answers the slot used, or a negative `HostError` code. + #[namespace = "xrpl"] + #[cxx_name = "cacheLedgerObj"] + fn cache_ledger_obj(self: &HostContext, obj_id: &[u8], cache_idx: i32) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getTxField"] + fn get_tx_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getCurrentLedgerObjField"] + fn get_current_ledger_obj_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getLedgerObjField"] + fn get_ledger_obj_field( + self: &HostContext, + cache_idx: i32, + field: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getTxNestedField"] + fn get_tx_nested_field(self: &HostContext, locator: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getCurrentLedgerObjNestedField"] + fn get_current_ledger_obj_nested_field( + self: &HostContext, + locator: &[u8], + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getLedgerObjNestedField"] + fn get_ledger_obj_nested_field( + self: &HostContext, + cache_idx: i32, + locator: &[u8], + out: &mut [u8], + ) -> i32; + + /// Answers the array's element count directly, or a negative `HostError` code. + #[namespace = "xrpl"] + #[cxx_name = "getTxArrayLen"] + fn get_tx_array_len(self: &HostContext, field: i32) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getCurrentLedgerObjArrayLen"] + fn get_current_ledger_obj_array_len(self: &HostContext, field: i32) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getLedgerObjArrayLen"] + fn get_ledger_obj_array_len(self: &HostContext, cache_idx: i32, field: i32) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getTxNestedArrayLen"] + fn get_tx_nested_array_len(self: &HostContext, locator: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getCurrentLedgerObjNestedArrayLen"] + fn get_current_ledger_obj_nested_array_len(self: &HostContext, locator: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getLedgerObjNestedArrayLen"] + fn get_ledger_obj_nested_array_len( + self: &HostContext, + cache_idx: i32, + locator: &[u8], + ) -> i32; + + /// Answers `1`/`0` for a valid/invalid signature, or a negative `HostError`. + #[namespace = "xrpl"] + #[cxx_name = "checkSignature"] + fn check_signature( + self: &HostContext, + message: &[u8], + signature: &[u8], + pubkey: &[u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "accountKeylet"] + fn account_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "ammKeylet"] + fn amm_keylet(self: &HostContext, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "checkKeylet"] + fn check_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "credentialKeylet"] + fn credential_keylet( + self: &HostContext, + subject: &[u8], + issuer: &[u8], + credential_type: &[u8], + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "delegateKeylet"] + fn delegate_keylet( + self: &HostContext, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "depositPreauthKeylet"] + fn deposit_preauth_keylet( + self: &HostContext, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "didKeylet"] + fn did_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "escrowKeylet"] + fn escrow_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "trustLineKeylet"] + fn trust_line_keylet( + self: &HostContext, + account1: &[u8], + account2: &[u8], + currency: &[u8], + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "mptokenIssuanceKeylet"] + fn mptoken_issuance_keylet( + self: &HostContext, + issuer: &[u8], + seq: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "mptokenKeylet"] + fn mptoken_keylet(self: &HostContext, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "nftokenOfferKeylet"] + fn nftoken_offer_keylet( + self: &HostContext, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "offerKeylet"] + fn offer_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "oracleKeylet"] + fn oracle_keylet(self: &HostContext, account: &[u8], doc_id: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "paychannelKeylet"] + fn paychannel_keylet( + self: &HostContext, + account: &[u8], + destination: &[u8], + seq: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "permissionedDomainKeylet"] + fn permissioned_domain_keylet( + self: &HostContext, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "signerListKeylet"] + fn signer_list_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "ticketKeylet"] + fn ticket_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "vaultKeylet"] + fn vault_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "sha512Half"] + fn sha512_half(self: &HostContext, data: &[u8], out: &mut [u8]) -> i32; + + /// Renders `data` as `data_type` says and writes it to this node's log with + /// `msg`. Answers nothing at all: the guest's wasm function has no result, and + /// C++ swallows a malformed buffer rather than reporting it, so there is no + /// failure for this side to encode. + /// + /// The engine has already refused a code that names no type, so what crosses + /// here is always one of the variants. + #[namespace = "xrpl"] + fn trace(self: &HostContext, msg: &str, data: &[u8], data_type: TraceDataType); + + #[namespace = "xrpl"] + #[cxx_name = "updateData"] + fn update_data(self: &HostContext, data: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFT"] + fn get_nft(self: &HostContext, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFTIssuer"] + fn get_nft_issuer(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFTTaxon"] + fn get_nft_taxon(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFTFlags"] + fn get_nft_flags(self: &HostContext, nft_id: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFTTransferFee"] + fn get_nft_transfer_fee(self: &HostContext, nft_id: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "getNFTSequence"] + fn get_nft_sequence(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatFromInt"] + fn float_from_int(self: &HostContext, x: i64, mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatFromUint"] + fn float_from_uint(self: &HostContext, x: &[u8], mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatFromSTAmount"] + fn float_from_stamount(self: &HostContext, amount: &[u8], mode: i32, out: &mut [u8]) + -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatFromSTNumber"] + fn float_from_stnumber(self: &HostContext, number: &[u8], mode: i32, out: &mut [u8]) + -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatToInt"] + fn float_to_int(self: &HostContext, x: &[u8], mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatToMantExp"] + fn float_to_mant_exp( + self: &HostContext, + x: &[u8], + mantissa_out: &mut [u8], + exponent_out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatFromMantExp"] + fn float_from_mant_exp( + self: &HostContext, + mantissa: i64, + exponent: i32, + mode: i32, + out: &mut [u8], + ) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatCompare"] + fn float_compare(self: &HostContext, x: &[u8], y: &[u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatAdd"] + fn float_add(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatSubtract"] + fn float_subtract(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) + -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatMultiply"] + fn float_multiply(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) + -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatDivide"] + fn float_divide(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatRoot"] + fn float_root(self: &HostContext, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> i32; + + #[namespace = "xrpl"] + #[cxx_name = "floatPower"] + fn float_power(self: &HostContext, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> i32; + } +} + +/// Sized carrier for the [`HostFunctions`] implementation. +/// +/// [`ffi::HostContext`] is an opaque C++ type and therefore `!Sized`, so it cannot +/// be coerced to `&dyn HostFunctions` itself. +struct CxxHost<'a> { + ctx: &'a ffi::HostContext, +} + +/// A byte-producing call's answer: the value's true length, or its error code. +/// +/// The conversion *is* the sign test — it fails on exactly the negative values — so +/// there is no cast to argue about. +/// +/// A named function rather than a `From` impl, and not by preference: every type +/// involved — `i32`, `Result`, `HostError` — is foreign to this crate, so the orphan +/// rule forbids the impl. +fn bytes_written(n: i32) -> HostResult { + usize::try_from(n).map_err(|_| HostError::from_code(n)) +} + +/// The ABI's data type as the shared enum C++ was given a definition of. +/// +/// A `match` rather than a cast through `code()`: the cast would compile for a variant +/// nobody added to [`ffi::TraceDataType`] and hand C++ a value its `switch` does not +/// name. This is the whole reason the two lists cannot drift. +fn crossed(data_type: TraceDataType) -> ffi::TraceDataType { + match data_type { + TraceDataType::Int64 => ffi::TraceDataType::Int64, + TraceDataType::Uint64 => ffi::TraceDataType::Uint64, + TraceDataType::Xfloat => ffi::TraceDataType::Xfloat, + TraceDataType::Account => ffi::TraceDataType::Account, + TraceDataType::Amount => ffi::TraceDataType::Amount, + TraceDataType::AsHex => ffi::TraceDataType::AsHex, + TraceDataType::AsText => ffi::TraceDataType::AsText, + } +} + +/// A call whose answer is a scalar the guest reads directly (a flag, a slot index): +/// a non-negative value is that answer, a negative one its error code. +fn scalar(n: i32) -> HostResult { + if n < 0 { + return Err(HostError::from_code(n)); + } + Ok(n) +} + +impl HostFunctions for CxxHost<'_> { + fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_ledger_sqn(out)) + } + + fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_parent_ledger_time(out)) + } + + fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_parent_ledger_hash(out)) + } + + fn get_base_fee(&self, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_base_fee(out)) + } + + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult { + scalar(self.ctx.is_amendment_enabled(amendment)) + } + + fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult { + scalar(self.ctx.cache_ledger_obj(obj_id, cache_idx)) + } + + fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_tx_field(field, out)) + } + + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_current_ledger_obj_field(field, out)) + } + + fn get_ledger_obj_field( + &self, + cache_idx: i32, + field: i32, + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.get_ledger_obj_field(cache_idx, field, out)) + } + + fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_tx_nested_field(locator, out)) + } + + fn get_current_ledger_obj_nested_field( + &self, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.get_current_ledger_obj_nested_field(locator, out)) + } + + fn get_ledger_obj_nested_field( + &self, + cache_idx: i32, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written( + self.ctx + .get_ledger_obj_nested_field(cache_idx, locator, out), + ) + } + + fn get_tx_array_len(&self, field: i32) -> HostResult { + scalar(self.ctx.get_tx_array_len(field)) + } + + fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult { + scalar(self.ctx.get_current_ledger_obj_array_len(field)) + } + + fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult { + scalar(self.ctx.get_ledger_obj_array_len(cache_idx, field)) + } + + fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult { + scalar(self.ctx.get_tx_nested_array_len(locator)) + } + + fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult { + scalar(self.ctx.get_current_ledger_obj_nested_array_len(locator)) + } + + fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult { + scalar(self.ctx.get_ledger_obj_nested_array_len(cache_idx, locator)) + } + + fn check_signature(&self, message: &[u8], signature: &[u8], pubkey: &[u8]) -> HostResult { + scalar(self.ctx.check_signature(message, signature, pubkey)) + } + + fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.account_keylet(account, out)) + } + + fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.amm_keylet(asset1, asset2, out)) + } + + fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.check_keylet(account, seq, out)) + } + + fn credential_keylet( + &self, + subject: &[u8], + issuer: &[u8], + credential_type: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written( + self.ctx + .credential_keylet(subject, issuer, credential_type, out), + ) + } + + fn delegate_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.delegate_keylet(account, authorize, out)) + } + + fn deposit_preauth_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.deposit_preauth_keylet(account, authorize, out)) + } + + fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.did_keylet(account, out)) + } + + fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.escrow_keylet(account, seq, out)) + } + + fn trust_line_keylet( + &self, + account1: &[u8], + account2: &[u8], + currency: &[u8], + out: &mut [u8], + ) -> HostResult { + bytes_written( + self.ctx + .trust_line_keylet(account1, account2, currency, out), + ) + } + + fn mptoken_issuance_keylet( + &self, + issuer: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.mptoken_issuance_keylet(issuer, seq, out)) + } + + fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.mptoken_keylet(mptid, holder, out)) + } + + fn nftoken_offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.nftoken_offer_keylet(account, seq, out)) + } + + fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.offer_keylet(account, seq, out)) + } + + fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.oracle_keylet(account, doc_id, out)) + } + + fn paychannel_keylet( + &self, + account: &[u8], + destination: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.paychannel_keylet(account, destination, seq, out)) + } + + fn permissioned_domain_keylet( + &self, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.permissioned_domain_keylet(account, seq, out)) + } + + fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.signer_list_keylet(account, out)) + } + + fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.ticket_keylet(account, seq, out)) + } + + fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.vault_keylet(account, seq, out)) + } + + fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.sha512_half(data, out)) + } + + fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()> { + self.ctx.trace(msg, data, crossed(data_type)); + Ok(()) + } + + fn update_data(&self, data: &[u8]) -> HostResult { + scalar(self.ctx.update_data(data)) + } + + fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_nft(account, nft_id, out)) + } + + fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_nft_issuer(nft_id, out)) + } + + fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_nft_taxon(nft_id, out)) + } + + fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult { + scalar(self.ctx.get_nft_flags(nft_id)) + } + + fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult { + scalar(self.ctx.get_nft_transfer_fee(nft_id)) + } + + fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_nft_sequence(nft_id, out)) + } + + fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_from_int(x, mode, out)) + } + + fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_from_uint(x, mode, out)) + } + + fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_from_stamount(amount, mode, out)) + } + + fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_from_stnumber(number, mode, out)) + } + + fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_to_int(x, mode, out)) + } + + fn float_to_mant_exp( + &self, + x: &[u8], + mantissa_out: &mut [u8], + exponent_out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.float_to_mant_exp(x, mantissa_out, exponent_out)) + } + + fn float_from_mant_exp( + &self, + mantissa: i64, + exponent: i32, + mode: i32, + out: &mut [u8], + ) -> HostResult { + bytes_written(self.ctx.float_from_mant_exp(mantissa, exponent, mode, out)) + } + + fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult { + scalar(self.ctx.float_compare(x, y)) + } + + fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_add(x, y, mode, out)) + } + + fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_subtract(x, y, mode, out)) + } + + fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_multiply(x, y, mode, out)) + } + + fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_divide(x, y, mode, out)) + } + + fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_root(x, n, mode, out)) + } + + fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.float_power(x, n, mode, out)) + } +} + +fn run_escrow( + host: &ffi::HostContext, + wasm: &[u8], + gas: u64, + function_name: &str, +) -> ffi::RunResult { + guarded( + || { + let host = CxxHost { ctx: host }; + run(wasm, gas, &host, function_name).into() + }, + ffi::RunResult::panicked, + ) +} + +fn check_escrow(wasm: &[u8], function_name: &str) -> ffi::CheckResult { + guarded( + || check(wasm, function_name).into(), + ffi::CheckResult::panicked, + ) +} + +impl ffi::RunResult { + /// A run the engine panicked in. + /// + /// The cost is not reported: a panicking run's meter is not evidence of + /// anything, and `0` says "unknown" where a number would say "this is what it + /// owed". + fn panicked(detail: String) -> ffi::RunResult { + ffi::RunResult { + status: ffi::RunStatus::Panic, + result: 0, + gas_used: 0, + detail, + } + } +} + +impl ffi::CheckResult { + /// A check the engine panicked in. + fn panicked(detail: String) -> ffi::CheckResult { + ffi::CheckResult { + status: ffi::CheckStatus::Panic, + detail, + } + } +} + +/// Run `body`, handing a panic to `panicked` rather than letting it unwind into +/// C++. +/// +/// **Why catching here is enough.** An unwind can only be caught where every frame +/// between the panic and the catch is Rust, and every frame here is: the engine and +/// wasmi are Rust, and a host call cannot start a C++ unwind because each +/// `HostContext` method is `noexcept` and catches everything. So the only unwind +/// that can reach this frame started in Rust, and this stops it. +/// +/// [`AssertUnwindSafe`] is sound because nothing survives to be observed in a torn +/// state: the store, the linker and the host wrapper are all dropped on the way out, +/// and the one thing that outlives the call — the C++ `HostContext` — is only ever +/// touched through those `noexcept` methods, which either complete or report. +/// +/// Generic over the result so both crossings share the one catch: the two answer +/// with different structs, and a second `catch_unwind` is the last thing this file +/// should have two of. +fn guarded(body: impl FnOnce() -> T, panicked: impl FnOnce(String) -> T) -> T { + catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| panicked(panic_detail(&*payload))) +} + +/// The panic's message, for the log. +/// +/// A `panic!` payload is a `&str` or a `String`; anything else is a `panic_any` that +/// nothing below this crate makes, and it still has to produce a line. +fn panic_detail(payload: &(dyn Any + Send)) -> String { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("payload is not a string"); + format!("panicked: {message}") +} + +/// The engine's two-channel result on the one struct cxx can carry. +/// +/// A `From` rather than a named function because the mapping is total and there is +/// only one of it: every field of the wire struct is decided by the outcome, so +/// there is no second reading for a name to distinguish. +impl From> for ffi::RunResult { + fn from(result: Result) -> ffi::RunResult { + match result { + Ok(RunOutcome { result, fuel_used }) => ffi::RunResult { + status: ffi::RunStatus::Ok, + result, + gas_used: fuel_used, + detail: String::new(), + }, + // `fuel_used` is carried on both channels by construction, so a failed + // run reports its cost here without this having to decide what one is. + Err(RunFailure { error, fuel_used }) => ffi::RunResult { + status: ffi::RunStatus::from(&error), + result: 0, + gas_used: fuel_used, + detail: error.to_string(), + }, + } + } +} + +/// The status a [`RunError`] crosses as. +/// +/// Exhaustive rather than closed with a wildcard: an outcome added to the engine has +/// to be given a status — and therefore a TER on the far side — before this compiles. +impl From<&RunError> for ffi::RunStatus { + fn from(error: &RunError) -> ffi::RunStatus { + match error { + RunError::Compile(_) => ffi::RunStatus::Compile, + RunError::Instantiate(_) => ffi::RunStatus::Instantiate, + RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint, + RunError::OutOfGas => ffi::RunStatus::OutOfGas, + RunError::Internal => ffi::RunStatus::Internal, + RunError::NoMemory => ffi::RunStatus::NoMemory, + RunError::Trap(_) => ffi::RunStatus::Trap, + } + } +} + +/// A verdict on the wire. No cost to carry, so `Ok` is the empty description. +impl From> for ffi::CheckResult { + fn from(result: Result<(), CheckError>) -> ffi::CheckResult { + match result { + Ok(()) => ffi::CheckResult { + status: ffi::CheckStatus::Ok, + detail: String::new(), + }, + Err(error) => ffi::CheckResult { + status: ffi::CheckStatus::from(&error), + detail: error.to_string(), + }, + } + } +} + +/// The status a [`CheckError`] crosses as, exhaustive for the same reason +/// [`ffi::RunStatus`]'s conversion is. +impl From<&CheckError> for ffi::CheckStatus { + fn from(error: &CheckError) -> ffi::CheckStatus { + match error { + CheckError::Compile(_) => ffi::CheckStatus::Compile, + CheckError::Import(_) => ffi::CheckStatus::Import, + CheckError::EntryPoint(_) => ffi::CheckStatus::EntryPoint, + CheckError::Memory(_) => ffi::CheckStatus::Memory, + CheckError::Table(_) => ffi::CheckStatus::Table, + } + } +} + +/// These tests reach none of the `extern "C++"` methods, which is what lets the test +/// binary link at all: the C++ side of the bridge exists only in the CMake build, so +/// a test that called one would fail to link rather than fail. +#[cfg(test)] +mod tests { + use super::*; + + fn ok(result: i32, fuel_used: u64) -> ffi::RunResult { + let outcome: Result = Ok(RunOutcome { result, fuel_used }); + outcome.into() + } + + fn failed(error: RunError, fuel_used: u64) -> ffi::RunResult { + let outcome: Result = Err(RunFailure { error, fuel_used }); + outcome.into() + } + + #[test] + fn a_completed_run_carries_its_value_and_its_cost() { + let crossed = ok(5, 1234); + + assert_eq!(crossed.status, ffi::RunStatus::Ok); + assert_eq!(crossed.result, 5); + assert_eq!(crossed.gas_used, 1234); + assert_eq!(crossed.detail, "", "a completed run has nothing to explain"); + } + + /// The cost is the point: a contract that burns its gas and traps is charged. + #[test] + fn a_failed_run_carries_its_cost_and_the_engines_own_words() { + let crossed = failed(RunError::Trap("unreachable".to_string()), 900); + + assert_eq!(crossed.status, ffi::RunStatus::Trap); + assert_eq!(crossed.gas_used, 900); + assert_eq!(crossed.detail, "trap: unreachable"); + assert_eq!(crossed.result, 0, "a failed run returned no value"); + } + + /// The `RunError` set as the test *expects* it, not as the conversion reports it: + /// deriving it from the code under test would make the assertion vacuous. + fn every_run_error() -> Vec { + vec![ + RunError::Compile(String::new()), + RunError::Instantiate(String::new()), + RunError::EntryPoint(String::new()), + RunError::OutOfGas, + RunError::Internal, + RunError::NoMemory, + RunError::Trap(String::new()), + ] + } + + /// Distinct statuses, because the TER map on the far side reads nothing else. Two + /// outcomes sharing one status would silently collapse two TERs into one. + #[test] + fn every_run_error_crosses_as_a_status_of_its_own() { + let mut seen = Vec::new(); + for error in every_run_error() { + let status = ffi::RunStatus::from(&error); + assert!( + !seen.contains(&status), + "{error:?} shares {status:?} with an earlier outcome" + ); + seen.push(status); + } + } + + /// `Ok` is the one status no failure may take: the far side reads it as "the + /// contract returned", and would then read `result` off a run that produced none. + #[test] + fn no_failure_crosses_as_success() { + for error in every_run_error() { + assert_ne!( + ffi::RunStatus::from(&error), + ffi::RunStatus::Ok, + "{error:?}" + ); + } + } + + #[test] + fn a_panic_becomes_a_status_instead_of_an_unwind() { + let crossed = guarded(|| panic!("the engine came apart"), ffi::RunResult::panicked); + + assert_eq!(crossed.status, ffi::RunStatus::Panic); + assert_eq!(crossed.detail, "panicked: the engine came apart"); + assert_eq!(crossed.gas_used, 0, "a panicking run reports no cost"); + } + + /// A formatted `panic!` payload is a `String` rather than a `&str`, so both + /// downcasts are load-bearing. + #[test] + fn a_formatted_panic_keeps_its_message() { + let overflowed = 3; + let crossed = guarded( + || panic!("gas underflowed by {overflowed}"), + ffi::RunResult::panicked, + ); + + assert_eq!(crossed.detail, "panicked: gas underflowed by 3"); + } + + #[test] + fn a_panic_with_no_message_still_reports_one() { + let crossed = guarded(|| std::panic::panic_any(7u32), ffi::RunResult::panicked); + + assert_eq!(crossed.status, ffi::RunStatus::Panic); + assert_eq!(crossed.detail, "panicked: payload is not a string"); + } + + #[test] + fn a_run_that_does_not_panic_is_untouched() { + let crossed = guarded(|| ok(1, 2), ffi::RunResult::panicked); + + assert_eq!(crossed.status, ffi::RunStatus::Ok); + assert_eq!(crossed.result, 1); + assert_eq!(crossed.gas_used, 2); + } + + /// [`crossed`] being exhaustive makes the two lists hold the same *variants*; + /// this makes them hold the same *numbers*, which is what actually crosses. A + /// `match` arm pointed at the wrong variant would pass the compiler and fail + /// here. + /// + /// Over `TraceDataType::ALL`, so it is the whole set rather than a sample: a data + /// type added to the ABI arrives already asserted against the shared enum. + #[test] + fn every_data_type_crosses_as_the_same_wire_value() { + for &data_type in TraceDataType::ALL { + assert_eq!( + crossed(data_type).repr, + data_type.code(), + "{data_type:?} crosses as a different value than the ABI gives it" + ); + } + } + + #[test] + fn a_negative_answer_is_an_error_code_and_a_length_is_a_length() { + assert_eq!(bytes_written(32), Ok(32)); + assert_eq!(bytes_written(0), Ok(0)); + assert_eq!(bytes_written(-3), Err(HostError::BufferTooSmall)); + assert_eq!(bytes_written(-14), Err(HostError::NoMemExported)); + assert_eq!(scalar(1), Ok(1)); + assert_eq!(scalar(0), Ok(0)); + assert_eq!(scalar(-2), Err(HostError::FieldNotFound)); + } + + #[test] + fn a_caught_cxx_exception_arrives_as_internal_fatal() { + assert_eq!(bytes_written(i32::MIN), Err(HostError::InternalFatal)); + } + + /// A code the ABI does not define goes the same way, so a C++ list this crate has + /// not caught up with stops the run rather than reaching the guest. + #[test] + fn an_undefined_code_arrives_as_internal_fatal() { + assert_eq!(bytes_written(-21), Err(HostError::InternalFatal)); + } + + // ----------------------------------------------------------------------- + // The check crossing + // + // `check_escrow` takes no host, so unlike `run_escrow` it can be called + // outright here — the modules are hand-written bytes because this crate has + // no assembler and needs none for two of them. + // ----------------------------------------------------------------------- + + /// The smallest valid module: the eight-byte header and nothing else. It + /// compiles and imports nothing, so it reaches the entry-point stage. + const EMPTY_MODULE: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + + #[test] + fn a_module_that_does_not_compile_crosses_as_compile() { + let crossed = check_escrow(b"not wasm", "escrow_finish"); + + assert_eq!(crossed.status, ffi::CheckStatus::Compile); + assert!( + crossed.detail.starts_with("compile: "), + "{}", + crossed.detail + ); + } + + /// The whole crossing, end to end: a real module through the real engine, with + /// the refusal the C++ side will log. + #[test] + fn a_module_without_the_entry_point_crosses_as_entry_point() { + let crossed = check_escrow(&EMPTY_MODULE, "escrow_finish"); + + assert_eq!(crossed.status, ffi::CheckStatus::EntryPoint); + assert_eq!(crossed.detail, "no entry point 'escrow_finish'"); + } + + /// The `CheckError` set as the test *expects* it, not as the conversion reports + /// it: deriving it from the code under test would make the assertion vacuous. + fn every_check_error() -> Vec { + vec![ + CheckError::Compile(String::new()), + CheckError::Import(String::new()), + CheckError::EntryPoint(String::new()), + CheckError::Memory(String::new()), + CheckError::Table(String::new()), + ] + } + + /// Distinct statuses, because the TER map on the far side reads nothing else. + #[test] + fn every_check_error_crosses_as_a_status_of_its_own() { + let mut seen = Vec::new(); + for error in every_check_error() { + let status = ffi::CheckStatus::from(&error); + assert!( + !seen.contains(&status), + "{error:?} shares {status:?} with an earlier refusal" + ); + seen.push(status); + } + } + + /// `Ok` is the one status no refusal may take: the far side reads it as + /// `tesSUCCESS` and would let the module through. + #[test] + fn no_refusal_crosses_as_success() { + for error in every_check_error() { + assert_ne!( + ffi::CheckStatus::from(&error), + ffi::CheckStatus::Ok, + "{error:?}" + ); + } + } + + /// A panic during a check is its own status rather than one more malformed + /// module: the far side answers a node-local failure, not `temBAD_WASM`. + #[test] + fn a_panic_during_a_check_becomes_a_status_instead_of_an_unwind() { + let crossed = guarded( + || panic!("the checker came apart"), + ffi::CheckResult::panicked, + ); + + assert_eq!(crossed.status, ffi::CheckStatus::Panic); + assert_eq!(crossed.detail, "panicked: the checker came apart"); + } +} diff --git a/crates/xrpl-wasm-vm/Cargo.toml b/crates/xrpl-wasm-vm/Cargo.toml new file mode 100644 index 0000000000..21a5a6f608 --- /dev/null +++ b/crates/xrpl-wasm-vm/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "xrpl-wasm-vm" +version = "0.1.0" +edition.workspace = true + +[dependencies] +wasmi = { version = "1.1.0", default-features = false, features = ["std"] } +xrpl-host-functions = { path = "../xrpl-host-functions" } + +[dev-dependencies] +wat = "1" diff --git a/crates/xrpl-wasm-vm/src/abi.rs b/crates/xrpl-wasm-vm/src/abi.rs new file mode 100644 index 0000000000..da7108d2c4 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/abi.rs @@ -0,0 +1,827 @@ +use crate::region::Region; +use crate::vm::{MAX_FIELD_BYTES, VmState}; +use core::ops::Range; +use wasmi::{Caller, Memory}; +use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult}; + +/// A condition that stops the run. It is a property of the run rather than an answer +/// to a call, so it reaches no guest and carries no wire code — which is why it is +/// not a [`HostError`]: no host can report one and no contract can read one. +/// +/// The three are the outcomes a host call can end a run with, and +/// `From for RunError` in `vm.rs` is where each gets its name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Fault { + /// This call's charge would take the meter below zero. The guest exhausting the + /// meter with its own instructions reaches [`crate::vm::RunError::OutOfGas`] by + /// wasmi's `OutOfFuel` trap instead, never through here. + OutOfGas, + /// The call could not be served: either the host said so, or this engine's own + /// fuel meter did not answer. + Internal, + /// There is no linear memory to work in — the module exports none, or the call + /// came from a start section, which runs before there is an instance. + NoMemory, +} + +/// How a host call fails: with a code the guest reads off the return value, or with a +/// [`Fault`] that stops the run. +/// +/// **The variant picks the channel.** [`to_wire`] reads it rather than asking a +/// predicate, so the two cannot disagree, and a [`FatalHostError`] cannot be built +/// around something a guest was supposed to see. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CallError { + Code(HostError), + Fatal(Fault), +} + +/// A host call's result inside the engine: [`HostResult`] plus the faults only the +/// engine can raise. +pub(crate) type CallResult = Result; + +/// Which channel a host's answer takes, decided once, here. +/// +/// Three codes stop the run instead of reaching the contract that asked. Each says the +/// call was not served at all — the host could not do it, it has not been wired, or +/// there is nowhere to put the answer — and a contract has no business interpreting +/// any of them, so it is told nothing and the run ends. Every other code is the +/// contract's to read. +impl From for CallError { + fn from(error: HostError) -> CallError { + match error { + HostError::InternalFatal => CallError::Fatal(Fault::Internal), + HostError::Unimplemented => CallError::Fatal(Fault::Internal), + HostError::NoMemExported => CallError::Fatal(Fault::NoMemory), + code => CallError::Code(code), + } + } +} + +/// The payload a trap carries so [`crate::vm::run`] can name the outcome without +/// parsing a message. Holds a [`Fault`], so by construction no guest-visible code can +/// leave through this channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct FatalHostError(pub(crate) Fault); + +impl wasmi::errors::HostError for FatalHostError {} + +impl core::fmt::Display for FatalHostError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "host call refused: {:?}", self.0) + } +} + +/// Charge the call's gas, run its body, put the result on the wire. The one path +/// every registered closure takes, so gas cannot be forgotten. +pub(crate) fn charged( + caller: &mut Caller<'_, VmState<'_>>, + op: HostFunctionSpec, + body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult, +) -> Result { + to_wire(charge(caller, op.gas()).and_then(|()| body(caller))) +} + +/// [`charged`] for a call the guest gets no answer from: its wasm function has no +/// result, so a soft error has nowhere to go and is dropped. The gas is charged first +/// and charged whatever happens after, so the cost is all such a call leaves behind. +/// +/// Only `trace` takes this path. +pub(crate) fn charged_unreported( + caller: &mut Caller<'_, VmState<'_>>, + op: HostFunctionSpec, + body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult<()>, +) -> Result<(), wasmi::Error> { + dropped(charge(caller, op.gas()).and_then(|()| body(caller))) +} + +/// [`to_wire`] for a call with no result: there is no return value to encode a code +/// in, so it is dropped. A [`Fault`] still stops the run — that is a property of the +/// run, not an answer to the call. +fn dropped(result: CallResult<()>) -> Result<(), wasmi::Error> { + match result { + Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))), + _ => Ok(()), + } +} + +fn to_wire(result: CallResult) -> Result { + match result { + Ok(value) => Ok(value), + Err(CallError::Code(error)) => Ok(error.code()), + Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))), + } +} + +/// Deduct `cost` fuel; [`Fault::OutOfGas`] if it would go negative. +/// +/// A meter that will not answer is this crate's own defect, not the contract's, so it +/// is [`Fault::Internal`] rather than a number a guest could act on. +fn charge(caller: &mut Caller<'_, T>, cost: u64) -> CallResult<()> { + let remaining = caller + .get_fuel() + .map_err(|_| CallError::Fatal(Fault::Internal))?; + match remaining.checked_sub(cost) { + Some(left) => caller + .set_fuel(left) + .map_err(|_| CallError::Fatal(Fault::Internal)), + None => { + let _ = caller.set_fuel(0); + Err(CallError::Fatal(Fault::OutOfGas)) + } + } +} + +fn charge_transfer(state: &VmState<'_>, n: usize) -> Result<(), HostError> { + let n = n as u64; + let remaining = state.transfer_budget.get(); + match remaining.checked_sub(n) { + Some(left) => { + state.transfer_budget.set(left); + Ok(()) + } + None => Err(HostError::OutOfTransferLimit), + } +} + +fn memory(caller: &Caller<'_, VmState<'_>>) -> CallResult { + caller + .data() + .memory + .ok_or(CallError::Fatal(Fault::NoMemory)) +} + +/// [`Region::read`] of the guest's memory, for a call that reads and writes nothing +/// back (`trace`). +pub(crate) fn read_borrowed<'a>( + caller: &'a Caller<'_, VmState<'_>>, + input: Region, +) -> CallResult<&'a [u8]> { + let mem = memory(caller)?; + Ok(input.read(mem.data(caller))?) +} + +/// Decode a guest `u32` argument — a keylet's sequence number or document id — from +/// its four little-endian bytes, carried on to the host as its `i32` bit pattern. +/// +/// The ABI transports these as a 4-byte region rather than a wasm scalar (the guest +/// SDK passes `seq.to_le_bytes()`), so the region must be exactly four bytes; +/// `InvalidParams` otherwise. +pub(crate) fn read_u32_arg(bytes: &[u8]) -> HostResult { + let arr: [u8; 4] = bytes.try_into().map_err(|_| HostError::InvalidParams)?; + Ok(i32::from_le_bytes(arr)) +} + +/// Service a call whose answer is bytes, written straight into the guest's output +/// region. +/// +/// **`fill` returns the value's true length, not what it wrote**: a host holding 64 +/// bytes and offered room for 4 writes nothing and answers `64`, which is how the +/// guest learns the size to ask for. So `n` is bounded by neither the region, the +/// cap, nor the budget, and all three checks below are reachable. +pub(crate) fn write_into( + caller: &mut Caller<'_, VmState<'_>>, + out: Region, + fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult, +) -> CallResult { + let range = out.range()?; + let cap = range.len(); + let mem = memory(caller)?; + let host: &dyn HostFunctions = caller.data().host; + let budget = usize::try_from(caller.data().transfer_budget.get()).unwrap_or(usize::MAX); + let buf = mem + .data_mut(&mut *caller) + .get_mut(range) + .ok_or(HostError::PointerOutOfBounds)?; + let buf = &mut buf[..cap.min(MAX_FIELD_BYTES).min(budget)]; + + let n = fill(host, buf)?; + + if n > MAX_FIELD_BYTES { + return Err(HostError::DataFieldTooLarge.into()); + } + if n > cap { + return Err(HostError::BufferTooSmall.into()); + } + charge_transfer(caller.data(), n)?; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32" + )] + let n = n as i32; + Ok(n) +} + +/// Service a call that reads guest memory and writes bytes back to it: the host +/// fills the run's output buffer, which is copied to the guest once every rule has +/// passed. +/// +/// `call` gets the guest's whole memory, so it can borrow any number of input +/// regions with [`Region::read`] — which a `&mut` view of that memory would forbid. +/// That is why the answer goes through a buffer instead of straight into the guest +/// as [`write_into`]'s does. +/// +/// **The host is never told the guest's capacity**: it is offered the whole buffer +/// and reports the value's true length, so the fit is decided here, with nothing yet +/// in guest memory. A refused value therefore reaches it in no part. +/// +/// The output is judged after the inputs, so a call with both bad reports the +/// input's verdict. `NoMemExported` precedes both: there is no memory to validate a +/// region against. +pub(crate) fn write_buffered( + caller: &mut Caller<'_, VmState<'_>>, + out: Region, + call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8]) -> HostResult, +) -> CallResult { + let mem = memory(caller)?; + // One borrow split in two: the guest's bytes for the inputs, the store data for + // the output buffer. Taking them together is what keeps the inputs borrowed + // rather than copied out. + let (data, state) = mem.data_and_store_mut(&mut *caller); + let host: &dyn HostFunctions = state.host; + + let n = call(host, data, &mut state.out_buffer[..])?; + + // `out` is checked here rather than before the call: the inputs are judged + // first, so a call with both malformed reports the input's verdict. + let range = out.range()?; + let cap = range.len(); + if n > MAX_FIELD_BYTES { + return Err(HostError::DataFieldTooLarge.into()); + } + let buf = data.get_mut(range).ok_or(HostError::PointerOutOfBounds)?; + if n > cap { + return Err(HostError::BufferTooSmall.into()); + } + charge_transfer(state, n)?; + buf[..n].copy_from_slice(&state.out_buffer[..n]); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32" + )] + let n = n as i32; + Ok(n) +} + +/// The mantissa and exponent widths `float_to_mant_exp` writes: an `i64` and an `i32`. +/// Fixed by the ABI, not the guest, so the split is a constant rather than a reported +/// length. +const MANTISSA_BYTES: usize = 8; +const EXPONENT_BYTES: usize = 4; + +fn check_fits(data: &[u8], range: &Range, width: usize) -> HostResult<()> { + let region = data + .get(range.clone()) + .ok_or(HostError::PointerOutOfBounds)?; + if region.len() < width { + return Err(HostError::BufferTooSmall); + } + Ok(()) +} + +/// Service `float_to_mant_exp`, the one call that writes two output regions: the host +/// fills the run's output buffer with the mantissa followed by the exponent, and each +/// is copied to its own guest region once every rule has passed. +/// +/// Like [`write_buffered`], the host reads its input from the guest's memory and writes +/// to a scratch buffer, so the input stays borrowed rather than copied. The two output +/// regions are judged after the input, and the mantissa's region before the exponent's, +/// so the first fault reported is the leftmost. +/// +/// The two widths are the ABI's rather than the guest's, so the length the host reports +/// is checked against their sum for equality rather than as a bound, and ahead of the +/// output regions: a wrong total means there is no answer to place, whatever the guest +/// declared. That is a fatal error and not a status, since the guest asked for nothing +/// wrong. +pub(crate) fn write_mant_exp( + caller: &mut Caller<'_, VmState<'_>>, + mantissa_out: Region, + exponent_out: Region, + call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8], &mut [u8]) -> HostResult, +) -> CallResult { + let mem = memory(caller)?; + let (data, state) = mem.data_and_store_mut(&mut *caller); + let host: &dyn HostFunctions = state.host; + + // The scratch buffer is split at the fixed mantissa width: the host fills the first + // eight bytes with the mantissa and the next four with the exponent. + let (mant_buf, exp_buf) = state.out_buffer.split_at_mut(MANTISSA_BYTES); + let mant_buf = &mut mant_buf[..MANTISSA_BYTES]; + let exp_buf = &mut exp_buf[..EXPONENT_BYTES]; + + let total = call(host, data, mant_buf, exp_buf)?; + + // Both buffers are fixed-width and were offered whole, so the only length the host + // can correctly report is their sum. Anything else is the host contradicting the + // ABI: with the widths in doubt, part of what would be copied out is whatever the + // previous call left in the buffer, so none of it is copied. + if total != MANTISSA_BYTES + EXPONENT_BYTES { + return Err(HostError::InternalFatal.into()); + } + + let mant_range = mantissa_out.range()?; + check_fits(data, &mant_range, MANTISSA_BYTES)?; + let exp_range = exponent_out.range()?; + check_fits(data, &exp_range, EXPONENT_BYTES)?; + + charge_transfer(state, MANTISSA_BYTES + EXPONENT_BYTES)?; + + let mant_dst = data + .get_mut(mant_range) + .ok_or(HostError::PointerOutOfBounds)?; + mant_dst[..MANTISSA_BYTES].copy_from_slice(&state.out_buffer[..MANTISSA_BYTES]); + let exp_dst = data + .get_mut(exp_range) + .ok_or(HostError::PointerOutOfBounds)?; + exp_dst[..EXPONENT_BYTES] + .copy_from_slice(&state.out_buffer[MANTISSA_BYTES..MANTISSA_BYTES + EXPONENT_BYTES]); + + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "a total other than 12 returned above, and 12 is far inside i32" + )] + let total = total as i32; + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm::TRANSFER_LIMIT_BYTES; + use std::cell::Cell; + use wasmi::StoreLimitsBuilder; + use xrpl_host_functions::TraceDataType; + + /// `charge_transfer` takes the store data, which has to hold a host. + struct UncalledHost; + + impl HostFunctions for UncalledHost { + fn get_ledger_sqn(&self, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_parent_ledger_time(&self, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_parent_ledger_hash(&self, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_base_fee(&self, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn is_amendment_enabled(&self, _amendment: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn cache_ledger_obj(&self, _obj_id: &[u8], _cache_idx: i32) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_tx_field(&self, _field: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_current_ledger_obj_field(&self, _field: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_ledger_obj_field( + &self, + _cache_idx: i32, + _field: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_tx_nested_field(&self, _locator: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_current_ledger_obj_nested_field( + &self, + _locator: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_ledger_obj_nested_field( + &self, + _cache_idx: i32, + _locator: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_tx_array_len(&self, _field: i32) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_current_ledger_obj_array_len(&self, _field: i32) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_ledger_obj_array_len(&self, _cache_idx: i32, _field: i32) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_tx_nested_array_len(&self, _locator: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_current_ledger_obj_nested_array_len(&self, _locator: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_ledger_obj_nested_array_len( + &self, + _cache_idx: i32, + _locator: &[u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn check_signature( + &self, + _message: &[u8], + _signature: &[u8], + _pubkey: &[u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn account_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn amm_keylet(&self, _asset1: &[u8], _asset2: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn check_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn credential_keylet( + &self, + _subject: &[u8], + _issuer: &[u8], + _credential_type: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn delegate_keylet( + &self, + _account: &[u8], + _authorize: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn deposit_preauth_keylet( + &self, + _account: &[u8], + _authorize: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn did_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn escrow_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn trust_line_keylet( + &self, + _account1: &[u8], + _account2: &[u8], + _currency: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn mptoken_issuance_keylet( + &self, + _issuer: &[u8], + _seq: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn mptoken_keylet( + &self, + _mptid: &[u8], + _holder: &[u8], + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn nftoken_offer_keylet( + &self, + _account: &[u8], + _seq: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn offer_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn oracle_keylet( + &self, + _account: &[u8], + _doc_id: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn paychannel_keylet( + &self, + _account: &[u8], + _destination: &[u8], + _seq: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn permissioned_domain_keylet( + &self, + _account: &[u8], + _seq: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn signer_list_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn ticket_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn vault_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn sha512_half(&self, _data: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn trace(&self, _msg: &str, _data: &[u8], _data_type: TraceDataType) -> HostResult<()> { + unreachable!("no unit test in this module calls the host") + } + fn update_data(&self, _data: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft(&self, _account: &[u8], _nft_id: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft_issuer(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft_taxon(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft_flags(&self, _nft_id: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft_transfer_fee(&self, _nft_id: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn get_nft_sequence(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_from_int(&self, _x: i64, _mode: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_from_uint(&self, _x: &[u8], _mode: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_from_stamount( + &self, + _amount: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_from_stnumber( + &self, + _number: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_to_int(&self, _x: &[u8], _mode: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_to_mant_exp( + &self, + _x: &[u8], + _mantissa_out: &mut [u8], + _exponent_out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_from_mant_exp( + &self, + _mantissa: i64, + _exponent: i32, + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_compare(&self, _x: &[u8], _y: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_add( + &self, + _x: &[u8], + _y: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_subtract( + &self, + _x: &[u8], + _y: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_multiply( + &self, + _x: &[u8], + _y: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_divide( + &self, + _x: &[u8], + _y: &[u8], + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_root(&self, _x: &[u8], _n: i32, _mode: i32, _out: &mut [u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + fn float_power( + &self, + _x: &[u8], + _n: i32, + _mode: i32, + _out: &mut [u8], + ) -> HostResult { + unreachable!("no unit test in this module calls the host") + } + } + + fn state(budget: u64) -> VmState<'static> { + VmState { + host: &UncalledHost, + mem_limits: StoreLimitsBuilder::new().build(), + transfer_budget: Cell::new(budget), + memory: None, + out_buffer: [0u8; MAX_FIELD_BYTES], + } + } + + /// `wasmi::Error` is not `PartialEq`, so a test expecting the guest-visible + /// channel says so by going through here. + fn wire(result: CallResult) -> i32 { + to_wire(result) + .unwrap_or_else(|trap| panic!("expected a guest-visible status, got a trap: {trap}")) + } + + #[test] + fn a_success_becomes_the_value_and_an_error_becomes_its_code() { + assert_eq!(wire(Ok(0)), 0); + assert_eq!(wire(Ok(32)), 32); + assert_eq!(wire(Err(HostError::BufferTooSmall.into())), -3); + } + + /// The codes a host may answer that a contract must not see, and the fault each + /// becomes. Written out rather than derived from `From`, which is what + /// they are asserting. + const STOPS_THE_RUN: [(HostError, Fault); 3] = [ + (HostError::InternalFatal, Fault::Internal), + (HostError::Unimplemented, Fault::Internal), + (HostError::NoMemExported, Fault::NoMemory), + ]; + + /// Every fault, so the two tests below are the whole set and not a sample. + /// `From for RunError` is what forces a fault added later to be + /// considered; this is what forces it to be tested. + const ALL_FAULTS: [Fault; 3] = [Fault::OutOfGas, Fault::Internal, Fault::NoMemory]; + + #[test] + fn a_code_that_stops_the_run_converts_to_its_fault() { + for (error, fault) in STOPS_THE_RUN { + assert_eq!(CallError::from(error), CallError::Fatal(fault), "{error:?}"); + } + } + + /// Over `HostError::ALL`, so it is the whole ABI and not a sample: a code added + /// to the ABI arrives already asserted to reach the guest as itself, and stopping + /// the run on it is then a change someone has to come and make. + /// + /// `OutOfTransferLimit` is the row worth reading twice: the one budget a + /// contract can be expected to handle, so it is told no rather than killed. + #[test] + fn every_other_code_reaches_the_guest_as_itself() { + for &error in HostError::ALL { + if STOPS_THE_RUN.iter().any(|&(stops, _)| stops == error) { + continue; + } + assert_eq!(CallError::from(error), CallError::Code(error), "{error:?}"); + assert_eq!(wire(Err(error.into())), error.code(), "{error:?}"); + } + } + + /// The trap carries the fault, so `run` can name the outcome without parsing a + /// message. + #[test] + fn a_fault_becomes_a_trap_carrying_it() { + for fault in ALL_FAULTS { + let trap = to_wire(Err(CallError::Fatal(fault))) + .expect_err("a fault must not reach the guest as a code"); + let payload = trap.downcast_ref::().unwrap_or_else(|| { + panic!("{fault:?}: expected a FatalHostError payload, got: {trap}") + }); + assert_eq!(*payload, FatalHostError(fault)); + } + } + + /// The result-less path splits the same two channels differently: a fault still + /// stops the run, and every code is dropped, since `trace` has no return value to + /// carry it. Over `HostError::ALL` for the reason above — a code added to the ABI + /// arrives asserted against both paths. + #[test] + fn a_call_with_no_result_drops_a_code_and_traps_on_a_fault() { + assert!(dropped(Ok(())).is_ok()); + + for &error in HostError::ALL { + if let CallError::Code(code) = CallError::from(error) { + assert!( + dropped(Err(CallError::Code(code))).is_ok(), + "{error:?} has no channel to the guest and must be dropped" + ); + } + } + + for fault in ALL_FAULTS { + let trap = + dropped(Err(CallError::Fatal(fault))).expect_err("a fault must stop the run"); + let payload = trap.downcast_ref::().unwrap_or_else(|| { + panic!("{fault:?}: expected a FatalHostError payload, got: {trap}") + }); + assert_eq!(*payload, FatalHostError(fault)); + } + } + + #[test] + fn a_transfer_spends_the_budget() { + let state = state(100); + + assert_eq!(charge_transfer(&state, 30), Ok(())); + assert_eq!(state.transfer_budget.get(), 70); + assert_eq!(charge_transfer(&state, 70), Ok(())); + assert_eq!(state.transfer_budget.get(), 0); + } + + /// The budget bounds the total, so the transfer that would overrun it is + /// refused whole rather than partially charged. + #[test] + fn a_transfer_past_the_budget_is_refused_and_charges_nothing() { + let state = state(100); + + assert_eq!( + charge_transfer(&state, 101), + Err(HostError::OutOfTransferLimit) + ); + assert_eq!( + state.transfer_budget.get(), + 100, + "a refusal must not charge" + ); + assert_eq!(charge_transfer(&state, 100), Ok(())); + assert_eq!( + charge_transfer(&state, 1), + Err(HostError::OutOfTransferLimit) + ); + } + + #[test] + fn transferring_nothing_costs_nothing() { + let state = state(0); + + assert_eq!(charge_transfer(&state, 0), Ok(())); + assert_eq!(state.transfer_budget.get(), 0); + } + + /// The field cap holds one call to a small share of the run's budget, so the + /// budget bounds a run rather than a call. An inequality, not the two values: + /// those are pinned in `vm.rs`. + #[test] + fn no_single_value_can_exhaust_the_run_budget() { + assert!( + (MAX_FIELD_BYTES as u64) * 64 <= TRANSFER_LIMIT_BYTES, + "one {MAX_FIELD_BYTES}-byte value against a {TRANSFER_LIMIT_BYTES}-byte budget" + ); + } +} diff --git a/crates/xrpl-wasm-vm/src/lib.rs b/crates/xrpl-wasm-vm/src/lib.rs new file mode 100644 index 0000000000..b67690825e --- /dev/null +++ b/crates/xrpl-wasm-vm/src/lib.rs @@ -0,0 +1,28 @@ +//! The escrow wasm VM: compile a contract, meter it, and serve its host calls. +//! +//! Every guest access goes through `abi.rs` and reaches linear memory only by +//! wasmi's bounds-checked slice operations; `forbid(unsafe_code)` makes that a +//! property rather than a claim. The cast lints are on for the same reason — on a +//! consensus path a truncating or sign-losing cast changes what a contract is +//! charged or told, so each one is argued for at its site. +#![forbid(unsafe_code)] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(unreachable_pub)] +#![deny( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + clippy::cast_lossless +)] + +mod abi; +mod preflight; +mod region; +mod register; +mod vm; + +pub use preflight::{CheckError, check}; +pub use vm::{ + MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError, RunFailure, + RunOutcome, TRANSFER_LIMIT_BYTES, run, +}; diff --git a/crates/xrpl-wasm-vm/src/preflight.rs b/crates/xrpl-wasm-vm/src/preflight.rs new file mode 100644 index 0000000000..3c4dd53317 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/preflight.rs @@ -0,0 +1,407 @@ +//! Screening a contract before it reaches the ledger. +//! +//! [`check`] answers whether [`crate::run`] would refuse a module before the +//! guest's first instruction — the three stages a caller maps to a malformed +//! transaction rather than to a failed one. It needs **no host, no store and no +//! gas**: everything it reads is a property of the compiled module. That is what +//! makes it callable from a transaction's preflight, which has no ledger to serve +//! host calls from. +//! +//! Two things it deliberately does not screen. A module exporting **no** linear +//! memory passes: a contract that makes no host call needs none, and one that +//! does is refused at the call and charged for what it burned. A start section +//! passes: it is guest code, and executing it is the one thing a check must not do +//! — a trap in one is charged to the contract like any other trap. +//! +//! Two things it screens that a run can only discover: an exported memory, or an +//! exported table, larger than the engine grants. Both read the same export list, so +//! [`check_exported_resources`] is one pass — see it for what stays invisible, and +//! why the table case leaves much more of it there. + +use std::fmt; +use wasmi::{ExternType, FuncType, Module, ValType}; +use xrpl_host_functions::HostFunctionSpec; + +use crate::register::HOST_MODULE; +use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile}; + +/// Why a module cannot be run. One variant per stage, since the caller maps the +/// stages separately. +#[derive(Debug)] +pub enum CheckError { + /// `wasm` is not a valid module under this engine's configuration. + Compile(String), + /// An import no engine of this ABI defines: another module namespace, a name + /// that is not a host function, or one imported as something other than a + /// function. + Import(String), + /// No export named `function_name` with signature `() -> i32`. + EntryPoint(String), + /// The module asks for more linear memory than the engine grants. + Memory(String), + /// The module asks for a larger table than the engine grants. + Table(String), +} + +impl fmt::Display for CheckError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CheckError::Compile(detail) => write!(f, "compile: {detail}"), + CheckError::Import(detail) => write!(f, "import: {detail}"), + // The detail says which of the entry point's failures this is, since + // "no entry point" would be wrong for an export of the wrong type. + CheckError::EntryPoint(detail) => write!(f, "{detail}"), + CheckError::Memory(detail) => write!(f, "memory: {detail}"), + CheckError::Table(detail) => write!(f, "table: {detail}"), + } + } +} + +/// Screen `wasm`: it must compile, import only what the engine serves, export +/// `function_name` as `() -> i32`, and ask for no more memory or table than it may +/// have. +/// +/// The stages are ordered by how much of the module each explains. An import fault +/// is reported before a missing entry point because the imports are what the rest of +/// the module is built on; the resource caps come last, being a request rather than a +/// mistake about the ABI. +pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> { + let module = compile(wasm).map_err(CheckError::Compile)?; + check_imports(&module)?; + check_entry_point(&module, function_name)?; + check_exported_resources(&module) +} + +/// Every import must be one the linker defines. The first that is not ends the +/// check, so a module with several faults reports the earliest. +fn check_imports(module: &Module) -> Result<(), CheckError> { + for import in module.imports() { + check_import(import.module(), import.name(), import.ty()).map_err(CheckError::Import)?; + } + Ok(()) +} + +/// Whether the engine defines this one import. +/// +/// The set of names is [`HostFunctionSpec::ALL`], which is also what +/// [`crate::register::register_host_functions`] iterates — so a check and a run +/// cannot disagree about which names exist, and adding a host function extends +/// both at once. The one thing this does not compare is `ty`'s *signature*, which +/// still parts a module from the engine at instantiation; the kind is compared +/// because the engine defines these names as functions and as nothing else. +/// +/// The rules are ordered, not merely alternatives: a guest importing `env::malloc` +/// is told about the namespace rather than that `malloc` is not a host function, +/// because the namespace is the one that explains every other import it has too. +fn check_import(module: &str, name: &str, ty: &ExternType) -> Result<(), String> { + if module != HOST_MODULE { + return Err(format!("'{module}::{name}' is not from '{HOST_MODULE}'")); + } + if !HostFunctionSpec::ALL + .iter() + .any(|op| op.wasm_name() == name) + { + return Err(format!("no host function '{name}'")); + } + if !matches!(ty, ExternType::Func(_)) { + return Err(format!("'{HOST_MODULE}::{name}' is not a function")); + } + Ok(()) +} + +fn check_entry_point(module: &Module, name: &str) -> Result<(), CheckError> { + match module.get_export(name) { + Some(ExternType::Func(ty)) if is_entry_point(&ty) => Ok(()), + found => Err(CheckError::EntryPoint(entry_point_fault(found, name))), + } +} + +/// The entry point's type: nothing in, one `i32` out — what [`crate::run`]'s +/// `get_typed_func::<(), i32>` accepts. +fn is_entry_point(ty: &FuncType) -> bool { + ty.params().is_empty() && matches!(ty.results(), [ValType::I32]) +} + +/// A module may declare no more linear memory, and no larger a table, than the +/// engine grants. One pass over the exports, since both rules read the same list and +/// the export table is the only place either is visible. +/// +/// **A memory or table the module keeps to itself is therefore not screened**: it is +/// absent from the exports, and the store's limiter is what refuses it, at +/// instantiation. That gap is wide for tables — Rust exports +/// `__indirect_function_table` only under `--export-table`, so unexported is the +/// normal shape — and narrow for memories, since a contract needs an exported one to +/// make any host call at all. +/// +/// A module faulting on both is reported by whichever it declares first. Neither +/// fault explains the other, so there is no precedence to preserve — only the need +/// for every node to reach the same verdict, which export order already gives. +fn check_exported_resources(module: &Module) -> Result<(), CheckError> { + for export in module.exports() { + match export.ty() { + ExternType::Memory(ty) => { + check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?; + } + ExternType::Table(ty) => { + check_initial_elements(ty.minimum()).map_err(CheckError::Table)?; + } + _ => {} + } + } + Ok(()) +} + +/// Whether the engine will grant a memory of this declared initial size. +/// +/// The *minimum* only: a declared maximum past the cap is legal and simply +/// unreachable, which `vm_limits::a_declared_maximum_past_the_cap_is_allowed_but_ +/// unreachable` pins on the run side. Refusing it here would turn a runnable +/// contract away. +fn check_initial_pages(pages: u64) -> Result<(), String> { + if pages > u64::from(MAX_MEMORY_PAGES) { + return Err(format!( + "initial memory of {pages} pages is past the {MAX_MEMORY_PAGES}-page cap" + )); + } + Ok(()) +} + +/// Whether the engine will grant a table of this declared initial size. +/// +/// The *minimum* is the whole question: `table.grow` belongs to the reference-types +/// proposal, which [`crate::vm`]'s engine turns off, so a table never becomes larger +/// than it was declared and a declared maximum past the cap is simply unreachable. +fn check_initial_elements(elements: u64) -> Result<(), String> { + let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("the cap is a small constant"); + if elements > cap { + return Err(format!( + "initial table of {elements} elements is past the {MAX_TABLE_ELEMENTS}-element cap" + )); + } + Ok(()) +} + +/// How an entry-point lookup failed, in the words both stages use: a check and a +/// run describe the same module the same way, and "no entry point" would send a +/// contract author looking for a function they already have. +pub(crate) fn entry_point_fault(found: Option, name: &str) -> String { + match found { + Some(ExternType::Func(_)) => { + format!("entry point '{name}' has the wrong signature, expected '() -> i32'") + } + Some(_) => format!("export '{name}' is not a function"), + None => format!("no entry point '{name}'"), + } +} + +/// The rules, one by one, on inputs built directly rather than parsed out of a +/// module. `tests/preflight.rs` runs real modules through [`check`]; what is here is +/// what a module cannot state precisely — which rule fires, in which order, and in +/// what words the caller logs it. +/// +/// `wat` is a dev-dependency, so the one test here that does need a module writes it +/// as text like every other test in the crate. What the library must not gain is a +/// text *entry point* — `check` and `run` take binaries — and a `cfg(test)` caller +/// cannot give it one. +#[cfg(test)] +mod tests { + use super::*; + use wasmi::{GlobalType, MemoryType, Mutability}; + + /// A host function as a guest declares it. Any function type will do: the + /// signature is not what [`check_import`] compares. + fn a_function() -> ExternType { + ExternType::Func(FuncType::new([ValType::I32], [ValType::I32])) + } + + /// A name every one of these tests can use, taken from the ABI rather than + /// spelled, so it stays a real host function as the ABI changes. + fn a_host_function_name() -> &'static str { + HostFunctionSpec::ALL[0].wasm_name() + } + + // ----------------------------------------------------------------------- + // Imports + // ----------------------------------------------------------------------- + + /// Every name the ABI declares is served. Derived from `ALL` rather than + /// listed, so a host function added to the ABI is covered the day it lands. + #[test] + fn every_declared_host_function_is_served() { + for op in HostFunctionSpec::ALL { + assert_eq!( + check_import(HOST_MODULE, op.wasm_name(), &a_function()), + Ok(()), + "{}", + op.wasm_name() + ); + } + } + + #[test] + fn an_import_from_another_namespace_is_refused() { + for namespace in ["env", "host", "host_lib2", ""] { + let refusal = check_import(namespace, a_host_function_name(), &a_function()) + .expect_err(namespace); + assert!( + refusal.contains("is not from 'host_lib'"), + "{namespace}: {refusal}" + ); + } + } + + #[test] + fn an_unknown_name_is_refused() { + let refusal = + check_import(HOST_MODULE, "no_such_function", &a_function()).expect_err("unknown name"); + assert_eq!(refusal, "no host function 'no_such_function'"); + } + + /// The engine defines these names as functions and as nothing else, so a module + /// importing one as a global or a memory does not link either. + #[test] + fn a_host_function_imported_as_anything_else_is_refused() { + for ty in [ + ExternType::Global(GlobalType::new(ValType::I32, Mutability::Const)), + ExternType::Memory(MemoryType::new(1, None)), + ] { + let name = a_host_function_name(); + let refusal = check_import(HOST_MODULE, name, &ty).expect_err("not a function"); + assert_eq!(refusal, format!("'host_lib::{name}' is not a function")); + } + } + + /// The rules are ordered. An import that breaks two of them is reported by the + /// first, so the message a contract author reads is the one that explains the + /// rest of their imports too. + #[test] + fn the_namespace_is_reported_before_the_name() { + let refusal = check_import("env", "no_such_function", &a_function()) + .expect_err("neither the namespace nor the name is served"); + + assert!(refusal.contains("is not from 'host_lib'"), "{refusal}"); + assert!( + !refusal.contains("no host function"), + "the namespace explains it: {refusal}" + ); + } + + /// Both halves of the type are load-bearing, and neither is checked anywhere + /// a module cannot reach. + #[test] + fn the_entry_point_type_is_nothing_in_and_one_i32_out() { + assert!(is_entry_point(&FuncType::new([], [ValType::I32]))); + + for wrong in [ + FuncType::new([], []), + FuncType::new([], [ValType::I64]), + FuncType::new([ValType::I32], [ValType::I32]), + FuncType::new([], [ValType::I32, ValType::I32]), + ] { + assert!(!is_entry_point(&wrong), "{wrong:?}"); + } + } + + /// Three faults, three descriptions. A run reports these too, with wasmi's own + /// error appended, so a swapped arm would mislead at both stages at once. + #[test] + fn each_entry_point_fault_is_described_as_itself() { + assert_eq!( + entry_point_fault(Some(a_function()), "finish"), + "entry point 'finish' has the wrong signature, expected '() -> i32'" + ); + assert_eq!( + entry_point_fault( + Some(ExternType::Global(GlobalType::new( + ValType::I32, + Mutability::Const + ))), + "finish" + ), + "export 'finish' is not a function" + ); + assert_eq!( + entry_point_fault(None, "finish"), + "no entry point 'finish'", + "an absent export must not be reported as a wrong signature" + ); + } + + /// The cap itself is granted; one page past it is not. The boundary is the whole + /// rule, and it is the same boundary the store's limiter applies at + /// instantiation. + #[test] + fn the_initial_memory_may_reach_the_cap_but_not_pass_it() { + assert_eq!(check_initial_pages(0), Ok(())); + assert_eq!(check_initial_pages(u64::from(MAX_MEMORY_PAGES)), Ok(())); + + let past = u64::from(MAX_MEMORY_PAGES) + 1; + let refusal = check_initial_pages(past).expect_err("one page past the cap"); + assert_eq!( + refusal, + format!("initial memory of {past} pages is past the {MAX_MEMORY_PAGES}-page cap") + ); + } + + /// The cap itself is granted; one element past it is not. The boundary is the + /// whole rule, and it is the same boundary the store's limiter applies at + /// instantiation. + #[test] + fn the_initial_table_may_reach_the_cap_but_not_pass_it() { + let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("fits"); + assert_eq!(check_initial_elements(0), Ok(())); + assert_eq!(check_initial_elements(cap), Ok(())); + + let past = cap + 1; + let refusal = check_initial_elements(past).expect_err("one element past the cap"); + assert_eq!( + refusal, + format!( + "initial table of {past} elements is past the {MAX_TABLE_ELEMENTS}-element cap" + ) + ); + } + + /// The bridge logs this string and the C++ tests match on it, so the stage's + /// prefix is part of the interface rather than a debugging aid. + #[test] + fn a_refusal_names_its_stage() { + assert_eq!( + CheckError::Compile("bad magic".to_string()).to_string(), + "compile: bad magic" + ); + assert_eq!( + CheckError::Memory("initial memory of 129 pages".to_string()).to_string(), + "memory: initial memory of 129 pages" + ); + assert_eq!( + CheckError::Table("initial table of 1025 elements".to_string()).to_string(), + "table: initial table of 1025 elements" + ); + assert_eq!( + CheckError::Import("no host function 'x'".to_string()).to_string(), + "import: no host function 'x'" + ); + // The entry point's detail already says which of its three faults it is, + // so a prefix would only repeat it. + assert_eq!( + CheckError::EntryPoint("no entry point 'finish'".to_string()).to_string(), + "no entry point 'finish'" + ); + } + + #[test] + fn the_stages_run_in_order() { + assert!( + matches!(check(b"not wasm", "finish"), Err(CheckError::Compile(_))), + "nothing is screened until the module compiles" + ); + + // A module that compiles and imports nothing, so it reaches the entry point. + let empty = wat::parse_str("(module)").expect("assembles"); + assert!( + matches!(check(&empty, "finish"), Err(CheckError::EntryPoint(_))), + "a module that compiles and imports nothing reaches the entry point" + ); + } +} diff --git a/crates/xrpl-wasm-vm/src/region.rs b/crates/xrpl-wasm-vm/src/region.rs new file mode 100644 index 0000000000..06268396c8 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/region.rs @@ -0,0 +1,50 @@ +use crate::vm::MAX_FIELD_BYTES; +use core::ops::Range; +use xrpl_host_functions::{HostError, HostResult}; + +/// A byte region as the guest declared it: the `(ptr, len)` pair off the wire, not +/// yet checked. +/// +/// Every byte parameter in this ABI is such a pair, so pairing them once at the wire +/// boundary is what keeps the helpers in `abi.rs` from each taking two loose integers +/// they could be handed in either order. +/// +/// It lives in a module of its own so that the fields are out of reach and +/// [`range`](Region::range) is the *only* way to indices — the check cannot be +/// skipped, only deferred. Construction is infallible for that reason: a call whose +/// output region is malformed is then refused in the order its own helper chooses, +/// rather than at the moment the pair happened to be formed. +#[derive(Copy, Clone)] +pub(crate) struct Region { + ptr: i32, + len: i32, +} + +impl Region { + pub(crate) fn new(ptr: i32, len: i32) -> Region { + Region { ptr, len } + } + + /// `start..end` as indices. The conversion is the negativity check — it fails on + /// exactly the negative values — and the addition guards a 32-bit `usize`, where + /// two `i32`s can sum past the end. + pub(crate) fn range(self) -> HostResult> { + let (Ok(start), Ok(len)) = (usize::try_from(self.ptr), usize::try_from(self.len)) else { + return Err(HostError::InvalidParams); + }; + let end = start + .checked_add(len) + .ok_or(HostError::PointerOutOfBounds)?; + Ok(start..end) + } + + /// The region's bytes, refused past the field cap. No copy: the slice aliases + /// `data`. + pub(crate) fn read(self, data: &[u8]) -> HostResult<&[u8]> { + let range = self.range()?; + if range.len() > MAX_FIELD_BYTES { + return Err(HostError::DataFieldTooLarge); + } + data.get(range).ok_or(HostError::PointerOutOfBounds) + } +} diff --git a/crates/xrpl-wasm-vm/src/register.rs b/crates/xrpl-wasm-vm/src/register.rs new file mode 100644 index 0000000000..7a31a34b9c --- /dev/null +++ b/crates/xrpl-wasm-vm/src/register.rs @@ -0,0 +1,1215 @@ +use crate::abi::{ + charged, charged_unreported, read_borrowed, read_u32_arg, write_buffered, write_into, + write_mant_exp, +}; +use crate::region::Region; +use crate::vm::VmState; +use wasmi::{Caller, Linker}; +use xrpl_host_functions::{HostError, HostFunctionSpec, TraceDataType}; + +/// The module name the guest imports under (`(import "host_lib" "ldgr_index" …)`), +/// as the guest SDK and this fork's fixtures spell it. +pub(crate) const HOST_MODULE: &str = "host_lib"; + +/// Register the host functions on `linker`, one per [`HostFunctionSpec`] variant. +/// +/// The `match` is exhaustive over [`HostFunctionSpec::ALL`], so a variant added to +/// the ABI will not compile until it has an arm here — the "cannot forget to +/// register" guarantee. Every arm goes through [`charged`], which is what makes the +/// gas charge and the wire encoding unforgettable too. +pub(crate) fn register_host_functions( + linker: &mut Linker>, +) -> Result<(), wasmi::errors::LinkerError> { + // The arms are hand-written and repetitive by decision, not by neglect: + // generating them needs the typed `link_*` shims, deferred until the C header + // is generated from the same table. + for &op in HostFunctionSpec::ALL { + match op { + HostFunctionSpec::GetLedgerSqn => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetLedgerSqn, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.get_ledger_sqn(out)) + }) + }, + ), + HostFunctionSpec::GetParentLedgerTime => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetParentLedgerTime, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.get_parent_ledger_time(out)) + }) + }, + ), + HostFunctionSpec::GetParentLedgerHash => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetParentLedgerHash, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.get_parent_ledger_hash(out)) + }) + }, + ), + HostFunctionSpec::GetBaseFee => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetBaseFee, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.get_base_fee(out)) + }) + }, + ), + HostFunctionSpec::IsAmendmentEnabled => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + ptr: i32, + len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::IsAmendmentEnabled, |c| { + let host = c.data().host; + let amendment = read_borrowed(c, Region::new(ptr, len))?; + Ok(host.is_amendment_enabled(amendment)?) + }) + }, + ), + HostFunctionSpec::CacheLedgerObj => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + id_ptr: i32, + id_len: i32, + cache_idx: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::CacheLedgerObj, |c| { + let host = c.data().host; + let obj_id = read_borrowed(c, Region::new(id_ptr, id_len))?; + Ok(host.cache_ledger_obj(obj_id, cache_idx)?) + }) + }, + ), + HostFunctionSpec::GetTxField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + field: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetTxField, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.get_tx_field(field, out)) + }) + }, + ), + HostFunctionSpec::GetCurrentLedgerObjField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + field: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::GetCurrentLedgerObjField, + |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| { + host.get_current_ledger_obj_field(field, out) + }) + }, + ) + }, + ), + HostFunctionSpec::GetLedgerObjField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + cache_idx: i32, + field: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetLedgerObjField, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| { + host.get_ledger_obj_field(cache_idx, field, out) + }) + }) + }, + ), + HostFunctionSpec::GetTxNestedField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + loc_ptr: i32, + loc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetTxNestedField, |c| { + let out = Region::new(out_ptr, out_len); + let locator = Region::new(loc_ptr, loc_len); + write_buffered(c, out, |host, data, buf| { + host.get_tx_nested_field(locator.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::GetCurrentLedgerObjNestedField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + loc_ptr: i32, + loc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::GetCurrentLedgerObjNestedField, + |c| { + let out = Region::new(out_ptr, out_len); + let locator = Region::new(loc_ptr, loc_len); + write_buffered(c, out, |host, data, buf| { + host.get_current_ledger_obj_nested_field(locator.read(data)?, buf) + }) + }, + ) + }, + ), + HostFunctionSpec::GetLedgerObjNestedField => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + cache_idx: i32, + loc_ptr: i32, + loc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::GetLedgerObjNestedField, + |c| { + let out = Region::new(out_ptr, out_len); + let locator = Region::new(loc_ptr, loc_len); + write_buffered(c, out, |host, data, buf| { + host.get_ledger_obj_nested_field( + cache_idx, + locator.read(data)?, + buf, + ) + }) + }, + ) + }, + ), + HostFunctionSpec::GetTxArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, field: i32| -> Result { + charged(&mut caller, HostFunctionSpec::GetTxArrayLen, |c| { + Ok(c.data().host.get_tx_array_len(field)?) + }) + }, + ), + HostFunctionSpec::GetCurrentLedgerObjArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, field: i32| -> Result { + charged( + &mut caller, + HostFunctionSpec::GetCurrentLedgerObjArrayLen, + |c| Ok(c.data().host.get_current_ledger_obj_array_len(field)?), + ) + }, + ), + HostFunctionSpec::GetLedgerObjArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + cache_idx: i32, + field: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetLedgerObjArrayLen, |c| { + Ok(c.data().host.get_ledger_obj_array_len(cache_idx, field)?) + }) + }, + ), + HostFunctionSpec::GetTxNestedArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + loc_ptr: i32, + loc_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetTxNestedArrayLen, |c| { + let host = c.data().host; + let locator = read_borrowed(c, Region::new(loc_ptr, loc_len))?; + Ok(host.get_tx_nested_array_len(locator)?) + }) + }, + ), + HostFunctionSpec::GetCurrentLedgerObjNestedArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + loc_ptr: i32, + loc_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::GetCurrentLedgerObjNestedArrayLen, + |c| { + let host = c.data().host; + let locator = read_borrowed(c, Region::new(loc_ptr, loc_len))?; + Ok(host.get_current_ledger_obj_nested_array_len(locator)?) + }, + ) + }, + ), + HostFunctionSpec::GetLedgerObjNestedArrayLen => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + cache_idx: i32, + loc_ptr: i32, + loc_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::GetLedgerObjNestedArrayLen, + |c| { + let host = c.data().host; + let locator = read_borrowed(c, Region::new(loc_ptr, loc_len))?; + Ok(host.get_ledger_obj_nested_array_len(cache_idx, locator)?) + }, + ) + }, + ), + HostFunctionSpec::CheckSignature => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + msg_ptr: i32, + msg_len: i32, + sig_ptr: i32, + sig_len: i32, + pk_ptr: i32, + pk_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::CheckSignature, |c| { + let host = c.data().host; + let message = read_borrowed(c, Region::new(msg_ptr, msg_len))?; + let signature = read_borrowed(c, Region::new(sig_ptr, sig_len))?; + let pubkey = read_borrowed(c, Region::new(pk_ptr, pk_len))?; + Ok(host.check_signature(message, signature, pubkey)?) + }) + }, + ), + HostFunctionSpec::AccountKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::AccountKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + write_buffered(c, out, |host, data, buf| { + host.account_keylet(account.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::AmmKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + a1_ptr: i32, + a1_len: i32, + a2_ptr: i32, + a2_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::AmmKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let asset1 = Region::new(a1_ptr, a1_len); + let asset2 = Region::new(a2_ptr, a2_len); + write_buffered(c, out, |host, data, buf| { + host.amm_keylet(asset1.read(data)?, asset2.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::CheckKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::CheckKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.check_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::CredentialKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + subj_ptr: i32, + subj_len: i32, + iss_ptr: i32, + iss_len: i32, + ct_ptr: i32, + ct_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::CredentialKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let subject = Region::new(subj_ptr, subj_len); + let issuer = Region::new(iss_ptr, iss_len); + let cred_type = Region::new(ct_ptr, ct_len); + write_buffered(c, out, |host, data, buf| { + host.credential_keylet( + subject.read(data)?, + issuer.read(data)?, + cred_type.read(data)?, + buf, + ) + }) + }) + }, + ), + HostFunctionSpec::DelegateKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + auth_ptr: i32, + auth_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::DelegateKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let authorize = Region::new(auth_ptr, auth_len); + write_buffered(c, out, |host, data, buf| { + host.delegate_keylet(account.read(data)?, authorize.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::DepositPreauthKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + auth_ptr: i32, + auth_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::DepositPreauthKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let authorize = Region::new(auth_ptr, auth_len); + write_buffered(c, out, |host, data, buf| { + host.deposit_preauth_keylet( + account.read(data)?, + authorize.read(data)?, + buf, + ) + }) + }) + }, + ), + HostFunctionSpec::DidKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::DidKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + write_buffered(c, out, |host, data, buf| { + host.did_keylet(account.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::EscrowKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::EscrowKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.escrow_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::TrustLineKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + a1_ptr: i32, + a1_len: i32, + a2_ptr: i32, + a2_len: i32, + cur_ptr: i32, + cur_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::TrustLineKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account1 = Region::new(a1_ptr, a1_len); + let account2 = Region::new(a2_ptr, a2_len); + let currency = Region::new(cur_ptr, cur_len); + write_buffered(c, out, |host, data, buf| { + host.trust_line_keylet( + account1.read(data)?, + account2.read(data)?, + currency.read(data)?, + buf, + ) + }) + }) + }, + ), + HostFunctionSpec::MptokenIssuanceKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::MptokenIssuanceKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let issuer = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let issuer = issuer.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.mptoken_issuance_keylet(issuer, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::MptokenKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + mpt_ptr: i32, + mpt_len: i32, + holder_ptr: i32, + holder_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::MptokenKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let mptid = Region::new(mpt_ptr, mpt_len); + let holder = Region::new(holder_ptr, holder_len); + write_buffered(c, out, |host, data, buf| { + host.mptoken_keylet(mptid.read(data)?, holder.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::NftokenOfferKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::NftokenOfferKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.nftoken_offer_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::OfferKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::OfferKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.offer_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::OracleKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + doc_ptr: i32, + doc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::OracleKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let doc_id = Region::new(doc_ptr, doc_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let doc_id = read_u32_arg(doc_id.read(data)?)?; + host.oracle_keylet(account, doc_id, buf) + }) + }) + }, + ), + HostFunctionSpec::PaychannelKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + dst_ptr: i32, + dst_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::PaychannelKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let destination = Region::new(dst_ptr, dst_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + host.paychannel_keylet( + account.read(data)?, + destination.read(data)?, + read_u32_arg(seq.read(data)?)?, + buf, + ) + }) + }) + }, + ), + HostFunctionSpec::PermissionedDomainKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged( + &mut caller, + HostFunctionSpec::PermissionedDomainKeylet, + |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.permissioned_domain_keylet(account, seq, buf) + }) + }, + ) + }, + ), + HostFunctionSpec::SignerListKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::SignerListKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + write_buffered(c, out, |host, data, buf| { + host.signer_list_keylet(account.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::TicketKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::TicketKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.ticket_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::VaultKeylet => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + seq_ptr: i32, + seq_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::VaultKeylet, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let seq = Region::new(seq_ptr, seq_len); + write_buffered(c, out, |host, data, buf| { + let account = account.read(data)?; + let seq = read_u32_arg(seq.read(data)?)?; + host.vault_keylet(account, seq, buf) + }) + }) + }, + ), + HostFunctionSpec::Sha512Half => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + data_ptr: i32, + data_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::Sha512Half, |c| { + let out = Region::new(out_ptr, out_len); + let input = Region::new(data_ptr, data_len); + write_buffered(c, out, |host, data, buf| { + host.sha512_half(input.read(data)?, buf) + }) + }) + }, + ), + // The one arm with no result: the wasm function is `(param i32 i32 i32 i32 + // i32)` and nothing more, so a malformed call is dropped rather than + // answered — an unreadable region, a `msg` that is not UTF-8 and a + // `data_type` naming no rendering all leave the guest none the wiser, and + // the host uncalled. + // + // Also the one arm whose parameters are not the declaration's order: + // `data_type` arrives third, between the two regions, as xrpld and the + // guest stdlib spell it. The wasm order is this closure's; the declaration + // order is the call's. + HostFunctionSpec::Trace => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + msg_ptr: i32, + msg_len: i32, + data_type: i32, + data_ptr: i32, + data_len: i32| + -> Result<(), wasmi::Error> { + charged_unreported(&mut caller, HostFunctionSpec::Trace, |c| { + let host = c.data().host; + let msg = read_borrowed(c, Region::new(msg_ptr, msg_len))?; + let msg = + core::str::from_utf8(msg).map_err(|_| HostError::InvalidParams)?; + let data_type = + TraceDataType::from_code(data_type).ok_or(HostError::InvalidParams)?; + let data = read_borrowed(c, Region::new(data_ptr, data_len))?; + Ok(host.trace(msg, data, data_type)?) + }) + }, + ), + HostFunctionSpec::UpdateData => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + ptr: i32, + len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::UpdateData, |c| { + let host = c.data().host; + let data = read_borrowed(c, Region::new(ptr, len))?; + Ok(host.update_data(data)?) + }) + }, + ), + HostFunctionSpec::GetNft => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + acc_ptr: i32, + acc_len: i32, + nft_ptr: i32, + nft_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNft, |c| { + let out = Region::new(out_ptr, out_len); + let account = Region::new(acc_ptr, acc_len); + let nft_id = Region::new(nft_ptr, nft_len); + write_buffered(c, out, |host, data, buf| { + host.get_nft(account.read(data)?, nft_id.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::GetNftIssuer => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + nft_ptr: i32, + nft_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNftIssuer, |c| { + let out = Region::new(out_ptr, out_len); + let nft_id = Region::new(nft_ptr, nft_len); + write_buffered(c, out, |host, data, buf| { + host.get_nft_issuer(nft_id.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::GetNftTaxon => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + nft_ptr: i32, + nft_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNftTaxon, |c| { + let out = Region::new(out_ptr, out_len); + let nft_id = Region::new(nft_ptr, nft_len); + write_buffered(c, out, |host, data, buf| { + host.get_nft_taxon(nft_id.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::GetNftFlags => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + nft_ptr: i32, + nft_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNftFlags, |c| { + let host = c.data().host; + let nft_id = read_borrowed(c, Region::new(nft_ptr, nft_len))?; + Ok(host.get_nft_flags(nft_id)?) + }) + }, + ), + HostFunctionSpec::GetNftTransferFee => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + nft_ptr: i32, + nft_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNftTransferFee, |c| { + let host = c.data().host; + let nft_id = read_borrowed(c, Region::new(nft_ptr, nft_len))?; + Ok(host.get_nft_transfer_fee(nft_id)?) + }) + }, + ), + HostFunctionSpec::GetNftSequence => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + nft_ptr: i32, + nft_len: i32, + out_ptr: i32, + out_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::GetNftSequence, |c| { + let out = Region::new(out_ptr, out_len); + let nft_id = Region::new(nft_ptr, nft_len); + write_buffered(c, out, |host, data, buf| { + host.get_nft_sequence(nft_id.read(data)?, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatFromInt => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x: i64, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatFromInt, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| host.float_from_int(x, mode, out)) + }) + }, + ), + HostFunctionSpec::FloatFromUint => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatFromUint, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_from_uint(x.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatFromStamount => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatFromStamount, |c| { + let out = Region::new(out_ptr, out_len); + let amount = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_from_stamount(amount.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatFromStnumber => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatFromStnumber, |c| { + let out = Region::new(out_ptr, out_len); + let number = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_from_stnumber(number.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatToInt => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatToInt, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_to_int(x.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatToMantExp => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + mant_ptr: i32, + mant_len: i32, + exp_ptr: i32, + exp_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatToMantExp, |c| { + let mantissa = Region::new(mant_ptr, mant_len); + let exponent = Region::new(exp_ptr, exp_len); + let x = Region::new(in_ptr, in_len); + write_mant_exp(c, mantissa, exponent, |host, data, mant, exp| { + host.float_to_mant_exp(x.read(data)?, mant, exp) + }) + }) + }, + ), + HostFunctionSpec::FloatFromMantExp => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + mantissa: i64, + exponent: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatFromMantExp, |c| { + let out = Region::new(out_ptr, out_len); + write_into(c, out, |host, out| { + host.float_from_mant_exp(mantissa, exponent, mode, out) + }) + }) + }, + ), + HostFunctionSpec::FloatCompare => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x_ptr: i32, + x_len: i32, + y_ptr: i32, + y_len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatCompare, |c| { + let host = c.data().host; + let x = read_borrowed(c, Region::new(x_ptr, x_len))?; + let y = read_borrowed(c, Region::new(y_ptr, y_len))?; + Ok(host.float_compare(x, y)?) + }) + }, + ), + HostFunctionSpec::FloatAdd => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x_ptr: i32, + x_len: i32, + y_ptr: i32, + y_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatAdd, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(x_ptr, x_len); + let y = Region::new(y_ptr, y_len); + write_buffered(c, out, |host, data, buf| { + host.float_add(x.read(data)?, y.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatSubtract => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x_ptr: i32, + x_len: i32, + y_ptr: i32, + y_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatSubtract, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(x_ptr, x_len); + let y = Region::new(y_ptr, y_len); + write_buffered(c, out, |host, data, buf| { + host.float_subtract(x.read(data)?, y.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatMultiply => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x_ptr: i32, + x_len: i32, + y_ptr: i32, + y_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatMultiply, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(x_ptr, x_len); + let y = Region::new(y_ptr, y_len); + write_buffered(c, out, |host, data, buf| { + host.float_multiply(x.read(data)?, y.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatDivide => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + x_ptr: i32, + x_len: i32, + y_ptr: i32, + y_len: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatDivide, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(x_ptr, x_len); + let y = Region::new(y_ptr, y_len); + write_buffered(c, out, |host, data, buf| { + host.float_divide(x.read(data)?, y.read(data)?, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatRoot => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + n: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatRoot, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_root(x.read(data)?, n, mode, buf) + }) + }) + }, + ), + HostFunctionSpec::FloatPower => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + in_ptr: i32, + in_len: i32, + n: i32, + out_ptr: i32, + out_len: i32, + mode: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::FloatPower, |c| { + let out = Region::new(out_ptr, out_len); + let x = Region::new(in_ptr, in_len); + write_buffered(c, out, |host, data, buf| { + host.float_power(x.read(data)?, n, mode, buf) + }) + }) + }, + ), + }?; + } + Ok(()) +} diff --git a/crates/xrpl-wasm-vm/src/vm.rs b/crates/xrpl-wasm-vm/src/vm.rs new file mode 100644 index 0000000000..c9a2592378 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/vm.rs @@ -0,0 +1,404 @@ +use std::cell::Cell; +use std::fmt; +use std::sync::LazyLock; +use wasmi::{ + Config, Engine, Export, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, + TrapCode, +}; +use xrpl_host_functions::HostFunctions; + +use crate::abi::{FatalHostError, Fault}; +use crate::preflight::entry_point_fault; +use crate::register::register_host_functions; + +/// wasm linear-memory page size, fixed by the wasm spec (64 KiB). +const WASM_PAGE_BYTES: u32 = 64 * 1024; + +/// Linear-memory page cap. +pub const MAX_MEMORY_PAGES: u32 = 128; + +/// [`MAX_MEMORY_PAGES`] in bytes: 8 MiB. +pub const MAX_MEMORY_BYTES: usize = (MAX_MEMORY_PAGES * WASM_PAGE_BYTES) as usize; + +/// Cap on a table's element count. +/// +/// A table entry is 8 bytes and wasmi materializes every one of them inside +/// `instantiate_and_start` — before the guest's first instruction, so no gas charge +/// can reach the cost. Without this cap the ceiling is the validator's, `u32::MAX` +/// entries, which a module asks for in five bytes of LEB128 and pays for in ~34 GiB. +pub const MAX_TABLE_ELEMENTS: usize = 1024; + +/// Total bytes the host may write into guest memory in one [`run`], separate from +/// gas. +/// +/// One direction only. What the guest passes in is not charged: it reaches the host +/// as a borrowed slice of guest memory, capped per value at [`MAX_FIELD_BYTES`] by +/// `Region::read` and in number by gas, and a host that keeps a copy (`update_data`) +/// bounds it on its own side. +pub const TRANSFER_LIMIT_BYTES: u64 = 1 << 20; + +/// Size cap on any single value crossing the boundary, in either direction; over +/// it is `DataFieldTooLarge`. +/// +/// A protocol limit: `kMaxWasmDataLength` in `include/xrpl/protocol/Protocol.h`. +pub const MAX_FIELD_BYTES: usize = 1024; + +/// State threaded through every host call, stored in the wasmi [`Store`]. +pub(crate) struct VmState<'h> { + pub(crate) host: &'h dyn HostFunctions, + /// Enforces [`store_limits`] via `Store::limiter`, which needs a `&mut` into it + /// from `&mut VmState` — hence a field rather than a local. + pub(crate) mem_limits: StoreLimits, + /// Remaining transfer budget for this run ([`TRANSFER_LIMIT_BYTES`]). + /// + /// A `Cell` because it is decremented from a shared `&Caller`. One thread per + /// invocation touches the store, so the lack of `Sync` costs nothing. + /// + /// TODO: the extra charge for an unaligned field copy has nothing to attach to + /// until this ABI gains a `FieldLocator` host function. + pub(crate) transfer_budget: Cell, + /// The guest's linear memory, resolved once by [`run`] after instantiation so + /// no host call pays for an export lookup. + /// + /// Caching the handle is sound because a [`Memory`] is an arena index, not a + /// pointer to the bytes: it survives `memory.grow`, and `data`/`data_mut` + /// re-derive the slice per call. + /// + /// The handle is scoped to one store, so this assumes **one module, one + /// instance, one store per `run`**. Module linking or nested execution would + /// have to resolve per instance: a cached handle would serve a call against the + /// wrong instance's memory, which is a wrong answer rather than an error. + pub(crate) memory: Option, + /// Where a host writes a value before [`crate::abi::write_buffered`] copies it + /// to the guest. One buffer per run, so no call zero-fills one of its own. + /// + /// Inline rather than boxed: the store's data is built once and then only + /// borrowed, so a kilobyte in it costs a move where a `Box` costs an + /// allocation. A local would cost neither, but `forbid(unsafe_code)` means a + /// stack buffer is zero-filled — per call, which is the cost this removes. + pub(crate) out_buffer: [u8; MAX_FIELD_BYTES], +} + +/// Outcome of running an escrow contract to completion. +#[derive(Debug)] +pub struct RunOutcome { + /// The value returned by the exported entry point (`finish`): `> 0` means + /// allow the escrow to finish. + pub result: i32, + /// Fuel (gas) consumed by the whole invocation — guest instructions plus + /// the per-call host charges. + pub fuel_used: u64, +} + +/// Why a run produced no result. Each variant is one outcome for the caller to +/// map to a TER. +#[derive(Debug)] +pub enum RunError { + /// `wasm` is not a valid module under this engine's configuration. + Compile(String), + /// The module compiled but the engine would not accept it: an import the + /// linker does not define, or an initial memory past the page cap. Not guest + /// code failing — a start section that traps is [`RunError::Trap`]. + Instantiate(String), + /// No export named `function_name` with signature `() -> i32`: absent, not a + /// function, or a function of another type — which the detail tells apart. + EntryPoint(String), + /// Gas exhausted — by the guest's own instructions or by a host call's + /// charge. [`RunFailure::fuel_used`] is the whole limit. + OutOfGas, + /// The host could not serve a call. + Internal, + /// A host call had no linear memory to work in: the module exports none, or + /// the call came from a start section, which runs before there is an instance + /// to resolve the memory from. + NoMemory, + /// The guest trapped: `unreachable`, division by zero, an out-of-bounds + /// access, or `memory.grow` past the page cap. Wherever the guest was + /// executing, including a start section during instantiation. + Trap(String), +} + +impl fmt::Display for RunError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RunError::Compile(detail) => write!(f, "compile: {detail}"), + RunError::Instantiate(detail) => write!(f, "instantiate: {detail}"), + // The detail says which of the entry point's failures this is, since + // "no entry point" would be wrong for an export of the wrong type. + RunError::EntryPoint(detail) => write!(f, "{detail}"), + RunError::OutOfGas => write!(f, "out of gas"), + RunError::Internal => write!(f, "internal error"), + RunError::NoMemory => write!(f, "no exported memory"), + RunError::Trap(detail) => write!(f, "trap: {detail}"), + } + } +} + +/// A failed run, with the gas it still owes: a contract that traps or exhausts +/// its gas is charged for what it burned. +#[derive(Debug)] +pub struct RunFailure { + pub error: RunError, + /// Fuel consumed before the failure. The whole limit when gas ran out; `0` + /// when the module never ran. + pub fuel_used: u64, +} + +impl fmt::Display for RunFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} (fuel used: {})", self.error, self.fuel_used) + } +} + +impl RunFailure { + /// A failure with no fuel accounted: it stopped the run at or before the guest's + /// first instruction, or under a store with no meter to read. + fn owing_nothing(error: RunError) -> RunFailure { + RunFailure { + error, + fuel_used: 0, + } + } +} + +/// Fuel spent out of `gas`: the one place a run's cost is measured, so success, +/// trap and refusal all report it the same way. +/// +/// `Store::get_fuel` fails only on a store without fuel metering, which +/// [`build_wasm_engine`] rules out and `run`'s `set_fuel` would already have +/// caught — so a failure here is a defect in this crate. It must not become a +/// number: `0` forgives a run its whole cost, `gas` charges an untouched one for +/// everything. [`RunError::Internal`] instead. +fn fuel_used(store: &Store>, gas: u64) -> Result { + store + .get_fuel() + .map(|remaining| gas.saturating_sub(remaining)) + .map_err(|_| RunError::Internal) +} + +/// Report `error` with the run's cost attached. A cost that cannot be read replaces +/// the outcome rather than being invented — see [`fuel_used`]. +fn failed(store: &Store>, gas: u64, error: RunError) -> RunFailure { + match fuel_used(store, gas) { + Ok(fuel_used) => RunFailure { error, fuel_used }, + Err(unmetered) => RunFailure::owing_nothing(unmetered), + } +} + +/// The outcome a `wasmi::Error` names for itself, if any, rather than leaving it to +/// the stage that raised it. +/// +/// Two ways a run halts mid-flight: a host call that could not be served, which +/// carries a [`FatalHostError`] saying which condition it was, and the guest's own +/// instructions exhausting the meter, which wasmi raises as `OutOfFuel`. +/// +/// Both can happen anywhere the guest executes — including a start section, which +/// is guest code running during instantiation — so every stage from there on asks +/// this before naming a failure after itself. +fn guest_halted(error: &wasmi::Error) -> Option { + if let Some(fatal) = error.downcast_ref::() { + return Some(fatal.0.into()); + } + (error.as_trap_code() == Some(TrapCode::OutOfFuel)).then_some(RunError::OutOfGas) +} + +/// Why instantiation failed, once [`guest_halted`] has ruled out the two conditions +/// that can arise anywhere. +/// +/// A start section is guest code, so it can trap on its own — `unreachable`, a +/// division by zero, an out-of-bounds access — and a trap is the guest's fault +/// wherever it happens. Naming that after the *stage* would file it beside the +/// module faults a caller treats as its own defect, and charge nothing for +/// instructions the contract burned. What is left for [`RunError::Instantiate`] is a +/// module the linker or the store would not accept at all. +fn instantiation_failure(error: &wasmi::Error) -> RunError { + match error.as_trap_code() { + Some(_) => RunError::Trap(error.to_string()), + None => RunError::Instantiate(error.to_string()), + } +} + +/// The outcome a [`Fault`] is: the one place a stopped call becomes a stopped run. +/// +/// Total and one arm each, because a `Fault` is only ever a condition that stops the +/// run — the guest-visible codes cannot reach here, which is what +/// [`crate::abi::CallError`] buys. A fault added later has no arm and does not +/// compile. +impl From for RunError { + fn from(fault: Fault) -> RunError { + match fault { + Fault::OutOfGas => RunError::OutOfGas, + Fault::Internal => RunError::Internal, + Fault::NoMemory => RunError::NoMemory, + } + } +} + +/// The process-wide wasmi engine, built once on first use. +/// +/// The configuration is consensus-fixed and identical for every invocation, and an +/// [`Engine`] is an internally `Arc`ed `Send + Sync` handle, so one shared engine +/// serves concurrent [`run`] calls. +pub(crate) fn wasm_engine() -> &'static Engine { + static ENGINE: LazyLock = LazyLock::new(build_wasm_engine); + &ENGINE +} + +/// Build the wasmi engine the escrow VM requires: deterministic, minimal +/// features, fuel metering on. +fn build_wasm_engine() -> Engine { + let mut config = Config::default(); + config.consume_fuel(true); + config.ignore_custom_sections(true); + config.wasm_mutable_global(false); + config.wasm_multi_value(false); + config.wasm_sign_extension(false); + config.wasm_saturating_float_to_int(false); + config.wasm_bulk_memory(false); + config.wasm_reference_types(false); + config.wasm_tail_call(false); + config.wasm_extended_const(false); + config.floats(false); + config.wasm_multi_memory(false); + config.wasm_custom_page_sizes(false); + config.wasm_memory64(false); + config.wasm_wide_arithmetic(false); + // TODO: enable option to reject wasm code containing start section after wasmi 2.0 release + Engine::new(&config) +} + +/// Every resource ceiling a run is given, in one place. +/// +/// The two *size* caps are what a contract can reach today. The three *count* caps +/// are set to 1 although [`build_wasm_engine`] already forces each: turning +/// `wasm_reference_types` on would let a module declare up to +/// `wasmparser::MAX_WASM_TABLES` tables, `wasm_multi_memory` likewise for memories, +/// and both size caps are **per table and per memory, not aggregate** — so a feature +/// flag flipped in isolation would multiply the ceiling by a hundred rather than +/// leave it be. The counts are what keeps those two decisions independent. +/// +/// wasmi enforces the counts by asking the limiter before it allocates +/// (`can_create_more_instances`/`_memories`/`_tables`); they default to 10000, so +/// leaving them unset is not the same as their being unreachable. +fn store_limits() -> StoreLimits { + StoreLimitsBuilder::new() + .memory_size(MAX_MEMORY_BYTES) + .table_elements(MAX_TABLE_ELEMENTS) + .instances(1) + .tables(1) + .memories(1) + .trap_on_grow_failure(true) + .build() +} + +/// Compile `wasm` for this engine. +/// +/// The one path to a [`Module`]: the configuration is what decides whether a +/// contract is valid at all, so [`run`] and [`crate::check`] must not be able to +/// compile against different ones. +pub(crate) fn compile(wasm: &[u8]) -> Result { + Module::new(wasm_engine(), wasm).map_err(|e| e.to_string()) +} + +/// Run a contract: compile `wasm`, give it `gas` fuel, service its host +/// calls through `host`, and call the exported `function_name`. +pub fn run<'h>( + wasm: &[u8], + gas: u64, + host: &'h dyn HostFunctions, + function_name: &str, +) -> Result { + let engine = wasm_engine(); + let module = + compile(wasm).map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?; + + let mut store = Store::new( + engine, + VmState { + host, + mem_limits: store_limits(), + transfer_budget: Cell::new(TRANSFER_LIMIT_BYTES), + memory: None, + out_buffer: [0u8; MAX_FIELD_BYTES], + }, + ); + + store + .set_fuel(gas) + .map_err(|_| RunFailure::owing_nothing(RunError::Internal))?; + store.limiter(|state| &mut state.mem_limits); + + let mut linker = Linker::>::new(engine); + register_host_functions(&mut linker) + .map_err(|_| RunFailure::owing_nothing(RunError::Internal))?; + + let instance = match linker.instantiate_and_start(&mut store, &module) { + Ok(instance) => instance, + Err(e) => { + let error = guest_halted(&e).unwrap_or_else(|| instantiation_failure(&e)); + return Err(failed(&store, gas, error)); + } + }; + store.data_mut().memory = instance.exports(&store).find_map(Export::into_memory); + + let function = match instance.get_typed_func::<(), i32>(&store, function_name) { + Ok(function) => function, + Err(e) => { + let found = instance + .get_export(&store, function_name) + .map(|export| export.ty(&store)); + let error = + RunError::EntryPoint(format!("{}: {e}", entry_point_fault(found, function_name))); + return Err(failed(&store, gas, error)); + } + }; + + let result = match function.call(&mut store, ()) { + Ok(result) => result, + Err(e) => { + let error = guest_halted(&e).unwrap_or_else(|| RunError::Trap(e.to_string())); + return Err(failed(&store, gas, error)); + } + }; + + let fuel_used = fuel_used(&store, gas).map_err(RunFailure::owing_nothing)?; + Ok(RunOutcome { result, fuel_used }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_engine_is_one_engine() { + assert!(Engine::same(wasm_engine(), wasm_engine())); + } + + /// One instance, one table, one memory — asserted here rather than through a + /// module, because no module can reach these. `wasm_reference_types(false)` and + /// `wasm_multi_memory(false)` make a module declaring a second table or memory + /// fail *validation*, so a run never gets far enough to consult the limiter. + /// That is exactly why the counts are worth pinning: they are the ceiling that + /// survives one of those flags being turned on, and nothing else would fail if + /// they were silently dropped. + #[test] + fn the_store_grants_one_of_each_thing_a_module_can_own() { + use wasmi::ResourceLimiter; + + let limits = store_limits(); + assert_eq!(limits.instances(), 1); + assert_eq!(limits.tables(), 1); + assert_eq!(limits.memories(), 1); + } + + /// The only place these numbers appear as literals; every other test derives + /// them from the constants. + #[test] + fn the_limits_are_the_protocol_limits() { + assert_eq!(MAX_MEMORY_PAGES, 128, "linear-memory page cap"); + assert_eq!(MAX_MEMORY_BYTES, 8 * 1024 * 1024, "page cap in bytes"); + assert_eq!(MAX_TABLE_ELEMENTS, 1024, "table-element cap"); + assert_eq!(MAX_FIELD_BYTES, 1024, "kMaxWasmDataLength"); + assert_eq!(TRANSFER_LIMIT_BYTES, 1 << 20, "kWasmTransferLimit"); + } +} diff --git a/crates/xrpl-wasm-vm/tests/budgets.rs b/crates/xrpl-wasm-vm/tests/budgets.rs new file mode 100644 index 0000000000..2e9e1f86c6 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/budgets.rs @@ -0,0 +1,927 @@ +//! The two budgets a run spends: gas (fuel), and the transfer limit on bytes +//! crossing the boundary. Both are consensus input, so several of these tests +//! assert exact numbers. + +mod support; + +use support::{ + Answer, EMPTY_REGION, FakeHost, ONE_PAGE, PLENTY_OF_GAS, code, import, module, run, + run_with_gas, trace_call, +}; +use xrpl_host_functions::{HASH_LEN, HostError, HostFunctionSpec, TraceDataType}; +use xrpl_wasm_vm::{MAX_FIELD_BYTES, RunError, TRANSFER_LIMIT_BYTES}; + +// --------------------------------------------------------------------------- +// Gas +// --------------------------------------------------------------------------- + +/// The fuel a module of `body` burns, given gas to spare. +fn fuel_for(body: &str, parts: &[&str], host: &FakeHost) -> u64 { + let wat = module(parts, body); + run(&wat, host).expect("the module should run").fuel_used +} + +/// The fuel a module burns doing nothing but returning a constant; every figure +/// below builds on it. wasmi's number, pinned deliberately because wasmi's fuel +/// table is consensus input. +const EMPTY_MODULE_FUEL: u64 = 30; + +/// wasmi's own fuel for a host call whose operands are all constants under 64: 14 +/// per `*.const`, plus 1 for the call. Our gas sits on top. +/// +/// The formula holds only under 64, because wasmi widens a constant's encoding +/// above that, each tier costing 7 more. Every call in [`call_for`] keeps its +/// operands small for that reason; one with a larger constant fails here by a +/// multiple of 7. +fn wasmi_call_fuel(small_const_operands: u64) -> u64 { + 14 * small_const_operands + 1 +} + +/// What wasmi charges on top of that for a call to a function with no result — +/// `trace`'s shape, and nothing else in the ABI. Per call, not per module. Measured +/// and pinned like the figures above. +const WASMI_NO_RESULT_FUEL: u64 = 14; + +/// wasmi's fuel for one `(drop …)`, which is how a module makes more than one call +/// and keeps only the last result. Pinned like the two above. +const WASMI_DROP_FUEL: u64 = 21; + +/// The wasm a test needs in order to call one host function: the `(import …)` +/// declaration, a call with small-constant operands, and how many it pushes. +struct Call { + import: &'static str, + call: &'static str, + operands: u64, + /// Whether the call leaves an `i32` behind. `trace` does not, which is why + /// [`Call::body`] ends every module with a constant instead of the call. + yields: bool, +} + +impl Call { + /// `n` calls in a row, leaving one `i32` for the module to return: the last + /// answer where there is one, and a constant where the call has none. + fn body(&self, n: usize) -> String { + if self.yields { + format!( + "{}{}", + format!("(drop {}) ", self.call).repeat(n - 1), + self.call + ) + } else { + format!("{}(i32.const 0)", format!("{} ", self.call).repeat(n)) + } + } + + /// What [`Call::body`] burns beside the calls' own gas and the module's floor: + /// one `drop` between consecutive answers, or wasmi's own surcharge on a call + /// that has none. + fn overhead(&self, n: u64) -> u64 { + if self.yields { + (n - 1) * WASMI_DROP_FUEL + } else { + n * WASMI_NO_RESULT_FUEL + } + } +} + +/// The test wasm for each host function. The `match` is exhaustive, so a function +/// added to the ABI fails to compile until it has wasm here, and iterating +/// [`HostFunctionSpec::ALL`] then covers the whole ABI. +fn call_for(op: HostFunctionSpec) -> Call { + let (import, call, operands) = match op { + HostFunctionSpec::GetLedgerSqn => ( + import::LDGR_INDEX, + "(call $ldgr_index (i32.const 0) (i32.const 4))", + 2, + ), + HostFunctionSpec::GetParentLedgerTime => ( + import::PARENT_LDGR_TIME, + "(call $parent_ldgr_time (i32.const 0) (i32.const 4))", + 2, + ), + HostFunctionSpec::GetParentLedgerHash => ( + import::PARENT_LDGR_HASH, + "(call $parent_ldgr_hash (i32.const 0) (i32.const 32))", + 2, + ), + HostFunctionSpec::GetBaseFee => ( + import::BASE_FEE, + "(call $base_fee (i32.const 0) (i32.const 4))", + 2, + ), + HostFunctionSpec::IsAmendmentEnabled => ( + import::AMENDMENT_ENABLED, + "(call $amendment_enabled (i32.const 0) (i32.const 32))", + 2, + ), + HostFunctionSpec::CacheLedgerObj => ( + import::CACHE_LE, + "(call $cache_le (i32.const 0) (i32.const 32) (i32.const 0))", + 3, + ), + HostFunctionSpec::GetTxField => ( + import::TX_FIELD, + "(call $tx_field (i32.const 1) (i32.const 0) (i32.const 4))", + 3, + ), + HostFunctionSpec::GetCurrentLedgerObjField => ( + import::HOME_LE_FIELD, + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4))", + 3, + ), + HostFunctionSpec::GetLedgerObjField => ( + import::LE_FIELD, + "(call $le_field (i32.const 1) (i32.const 1) (i32.const 0) (i32.const 4))", + 4, + ), + HostFunctionSpec::GetTxNestedField => ( + import::TX_INNER, + "(call $tx_inner (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))", + 4, + ), + HostFunctionSpec::GetCurrentLedgerObjNestedField => ( + import::HOME_LE_INNER, + "(call $home_le_inner (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))", + 4, + ), + HostFunctionSpec::GetLedgerObjNestedField => ( + import::LE_INNER, + "(call $le_inner (i32.const 1) (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))", + 5, + ), + HostFunctionSpec::GetTxArrayLen => { + (import::TX_ARR_LEN, "(call $tx_arr_len (i32.const 1))", 1) + } + HostFunctionSpec::GetCurrentLedgerObjArrayLen => ( + import::HOME_LE_ARR_LEN, + "(call $home_le_arr_len (i32.const 1))", + 1, + ), + HostFunctionSpec::GetLedgerObjArrayLen => ( + import::LE_ARR_LEN, + "(call $le_arr_len (i32.const 1) (i32.const 1))", + 2, + ), + HostFunctionSpec::GetTxNestedArrayLen => ( + import::TX_INNER_ARR_LEN, + "(call $tx_inner_arr_len (i32.const 0) (i32.const 4))", + 2, + ), + HostFunctionSpec::GetCurrentLedgerObjNestedArrayLen => ( + import::HOME_LE_INNER_ARR_LEN, + "(call $home_le_inner_arr_len (i32.const 0) (i32.const 4))", + 2, + ), + HostFunctionSpec::GetLedgerObjNestedArrayLen => ( + import::LE_INNER_ARR_LEN, + "(call $le_inner_arr_len (i32.const 1) (i32.const 0) (i32.const 4))", + 3, + ), + HostFunctionSpec::CheckSignature => ( + import::CHECK_SIG, + "(call $check_sig (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0))", + 6, + ), + HostFunctionSpec::AccountKeylet => ( + import::ACCOUNTROOT_ID, + "(call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))", + 4, + ), + HostFunctionSpec::AmmKeylet => ( + import::AMM_ID, + "(call $amm_id (i32.const 0) (i32.const 20) (i32.const 24) (i32.const 40) (i32.const 0) (i32.const 32))", + 6, + ), + HostFunctionSpec::CheckKeylet => ( + import::CHECK_ID, + "(call $check_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::CredentialKeylet => ( + import::CREDENTIAL_ID, + "(call $credential_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 4) (i32.const 44) (i32.const 20))", + 8, + ), + HostFunctionSpec::DelegateKeylet => ( + import::DELEGATE_ID, + "(call $delegate_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 32))", + 6, + ), + HostFunctionSpec::DepositPreauthKeylet => ( + import::DEPOSIT_PREAUTH_ID, + "(call $deposit_preauth_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 32))", + 6, + ), + HostFunctionSpec::DidKeylet => ( + import::DID_ID, + "(call $did_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))", + 4, + ), + HostFunctionSpec::EscrowKeylet => ( + import::ESCROW_ID, + "(call $escrow_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::TrustLineKeylet => ( + import::TRUSTLINE_ID, + "(call $trustline_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 20) (i32.const 60) (i32.const 32))", + 8, + ), + HostFunctionSpec::MptokenIssuanceKeylet => ( + import::MPT_ISSUANCE_ID, + "(call $mpt_issuance_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::MptokenKeylet => ( + import::MPTOKEN_ID, + "(call $mptoken_id (i32.const 0) (i32.const 24) (i32.const 24) (i32.const 20) (i32.const 44) (i32.const 20))", + 6, + ), + HostFunctionSpec::NftokenOfferKeylet => ( + import::NFT_OFFER_ID, + "(call $nft_offer_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::OfferKeylet => ( + import::OFFER_ID, + "(call $offer_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::OracleKeylet => ( + import::ORACLE_ID, + "(call $oracle_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::PaychannelKeylet => ( + import::PAYCHAN_ID, + "(call $paychan_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 40) (i32.const 20))", + 8, + ), + HostFunctionSpec::PermissionedDomainKeylet => ( + import::PERMISSIONED_DOMAIN_ID, + "(call $permissioned_domain_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::SignerListKeylet => ( + import::SIGNERS_ID, + "(call $signers_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))", + 4, + ), + HostFunctionSpec::TicketKeylet => ( + import::TICKET_ID, + "(call $ticket_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::VaultKeylet => ( + import::VAULT_ID, + "(call $vault_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))", + 6, + ), + HostFunctionSpec::Sha512Half => ( + import::SHA512_HALF, + "(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 0) (i32.const 32))", + 4, + ), + HostFunctionSpec::Trace => ( + import::TRACE, + "(call $trace (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 0))", + 5, + ), + HostFunctionSpec::UpdateData => ( + import::SET_DATA, + "(call $set_data (i32.const 0) (i32.const 8))", + 2, + ), + HostFunctionSpec::GetNft => ( + import::NFT_URI, + "(call $nft_uri (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 32) (i32.const 52) (i32.const 12))", + 6, + ), + HostFunctionSpec::GetNftIssuer => ( + import::NFT_ISSUER, + "(call $nft_issuer (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 20))", + 4, + ), + HostFunctionSpec::GetNftTaxon => ( + import::NFT_TAXON, + "(call $nft_taxon (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 4))", + 4, + ), + HostFunctionSpec::GetNftFlags => ( + import::NFT_FLAGS, + "(call $nft_flags (i32.const 0) (i32.const 32))", + 2, + ), + HostFunctionSpec::GetNftTransferFee => ( + import::NFT_XFER_FEE, + "(call $nft_xfer_fee (i32.const 0) (i32.const 32))", + 2, + ), + HostFunctionSpec::GetNftSequence => ( + import::NFT_SERIAL, + "(call $nft_serial (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 4))", + 4, + ), + HostFunctionSpec::FloatFromInt => ( + import::FLOAT_FROM_INT, + "(call $float_from_int (i64.const 0) (i32.const 0) (i32.const 8) (i32.const 0))", + 4, + ), + HostFunctionSpec::FloatFromUint => ( + import::FLOAT_FROM_UINT, + "(call $float_from_uint (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))", + 5, + ), + HostFunctionSpec::FloatFromStamount => ( + import::FLOAT_FROM_STAMOUNT, + "(call $float_from_stamount (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))", + 5, + ), + HostFunctionSpec::FloatFromStnumber => ( + import::FLOAT_FROM_STNUMBER, + "(call $float_from_stnumber (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))", + 5, + ), + HostFunctionSpec::FloatToInt => ( + import::FLOAT_TO_INT, + "(call $float_to_int (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))", + 5, + ), + HostFunctionSpec::FloatToMantExp => ( + import::FLOAT_TO_MANT_EXP, + "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 4))", + 6, + ), + HostFunctionSpec::FloatFromMantExp => ( + import::FLOAT_FROM_MANT_EXP, + "(call $float_from_mant_exp (i64.const 0) (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 0))", + 5, + ), + HostFunctionSpec::FloatCompare => ( + import::FLOAT_CMP, + "(call $float_cmp (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))", + 4, + ), + HostFunctionSpec::FloatAdd => ( + import::FLOAT_ADD, + "(call $float_add (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))", + 7, + ), + HostFunctionSpec::FloatSubtract => ( + import::FLOAT_SUB, + "(call $float_sub (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))", + 7, + ), + HostFunctionSpec::FloatMultiply => ( + import::FLOAT_MULT, + "(call $float_mult (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))", + 7, + ), + HostFunctionSpec::FloatDivide => ( + import::FLOAT_DIV, + "(call $float_div (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))", + 7, + ), + HostFunctionSpec::FloatRoot => ( + import::FLOAT_ROOT, + "(call $float_root (i32.const 0) (i32.const 8) (i32.const 2) (i32.const 8) (i32.const 8) (i32.const 0))", + 6, + ), + HostFunctionSpec::FloatPower => ( + import::FLOAT_POW, + "(call $float_pow (i32.const 0) (i32.const 8) (i32.const 2) (i32.const 8) (i32.const 8) (i32.const 0))", + 6, + ), + }; + Call { + import, + call, + operands, + yields: !matches!(op, HostFunctionSpec::Trace), + } +} + +#[test] +fn an_empty_module_burns_a_fixed_amount_of_fuel() { + let fuel = fuel_for("(i32.const 0)", &[ONE_PAGE], &FakeHost::new()); + assert_eq!(fuel, EMPTY_MODULE_FUEL); +} + +/// Calling a host function `n` times costs `n` times its gas, to the unit. Every +/// other term is known — the module's floor, wasmi's fuel per call, one `drop` per +/// answered call — so the total is a closed form, with the gas read from the spec +/// table rather than restated. `n = 1` pins the charge, `n > 1` pins that it lands +/// on every call rather than once per run. +#[test] +fn a_host_call_costs_its_gas_every_time_it_is_called() { + let host = FakeHost::new().answering_field(1, Answer::bytes([0xaa])); + + for &op in HostFunctionSpec::ALL { + let call = call_for(op); + let per_call = wasmi_call_fuel(call.operands) + op.gas(); + + for n in 1..=3 { + let body = call.body(n); + let n = n as u64; + + assert_eq!( + fuel_for(&body, &[call.import, ONE_PAGE], &host), + EMPTY_MODULE_FUEL + n * per_call + call.overhead(n), + "{n} x {}", + call.call + ); + } + } +} + +/// The gas charge precedes the call's body, so a failing call costs exactly what a +/// successful one costs. Field 1 is answered and field 7 is not; the two modules +/// are otherwise identical, so their totals are comparable. +#[test] +fn a_failing_host_call_costs_exactly_what_a_successful_one_costs() { + let host = FakeHost::new().answering_field(1, Answer::bytes([0xaa])); + let call = |field: i32| { + module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!("(call $home_le_field (i32.const {field}) (i32.const 0) (i32.const 4))"), + ) + }; + + let answered = run(&call(1), &host).expect("the module should run"); + let refused = run(&call(7), &host).expect("the module should run"); + + assert_eq!(answered.result, 1); + assert_eq!(refused.result, code(HostError::FieldNotFound)); + assert_eq!(refused.fuel_used, answered.fuel_used); +} + +/// `fuel_used` is `gas - remaining`: what the run spent, not what was left or what +/// it was handed. The gas figures are derived from the run's cost, so the boundary +/// — exactly enough, and one short — is among the cases. +#[test] +fn fuel_used_is_what_was_spent_not_what_was_supplied() { + let host = FakeHost::new(); + let op = HostFunctionSpec::GetLedgerSqn; + let call = call_for(op); + let wat = module(&[call.import, ONE_PAGE], call.call); + let cost = EMPTY_MODULE_FUEL + wasmi_call_fuel(call.operands) + op.gas(); + + // Exactly its cost is enough, and no amount above it changes the figure. The + // result is checked too, so the figure belongs to a run that did the work + // rather than to one that was cut short. + for gas in [cost, cost + 1, cost * 100, PLENTY_OF_GAS] { + let outcome = run_with_gas(&wat, gas, &host).expect("should run"); + assert_eq!( + outcome.result, 4, + "gas {gas}: the call should have succeeded" + ); + assert_eq!(outcome.fuel_used, cost, "gas {gas}"); + } + + // One fuel short: the run ends at the call it cannot pay for and still owes the + // whole limit, because `charge` spends what is left. + let short = run_with_gas(&wat, cost - 1, &host).expect_err("one fuel short must not complete"); + assert!( + matches!(short.error, RunError::OutOfGas), + "expected the run to end out of gas, got: {short}" + ); + assert_eq!(short.fuel_used, cost - 1); +} + +/// Fuel is metered, so the same module burns the same fuel every time — a +/// property consensus depends on. +#[test] +fn the_same_run_burns_the_same_fuel() { + let call = call_for(HostFunctionSpec::Trace); + let wat = module(&[call.import, ONE_PAGE], &call.body(1)); + + let first = run(&wat, &FakeHost::new()).expect("should run").fuel_used; + for _ in 0..4 { + assert_eq!( + run(&wat, &FakeHost::new()).expect("should run").fuel_used, + first + ); + } + assert!(first > HostFunctionSpec::Trace.gas()); +} + +/// Too little gas to finish stops the run: the meter refuses the guest's own +/// instructions before it ever reaches the host call. +#[test] +fn a_run_that_cannot_afford_itself_fails() { + let host = FakeHost::new(); + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + "(call $ldgr_index (i32.const 0) (i32.const 4))", + ); + + for gas in [0, 1, 10] { + let Err(failure) = run_with_gas(&wat, gas, &host) else { + panic!("gas {gas} should not have completed"); + }; + assert!( + matches!(failure.error, RunError::OutOfGas), + "gas {gas}: expected the run to end out of gas, got: {failure}" + ); + } +} + +/// A guest looping forever is stopped by gas rather than running away, and owes +/// the gas it burned doing it. +#[test] +fn an_endless_loop_is_stopped_by_gas() { + const GAS: u64 = 100_000; + + let host = FakeHost::new(); + let wat = module(&[ONE_PAGE], "(loop $l (br $l)) (i32.const 0)"); + + let failure = run_with_gas(&wat, GAS, &host).expect_err("an endless loop must not complete"); + assert!( + matches!(failure.error, RunError::OutOfGas), + "expected the meter to stop it, got: {failure}" + ); + assert_eq!( + failure.fuel_used, GAS, + "a runaway guest burns the whole limit" + ); +} + +/// A host call refused its gas stops the run: the guest never gets a chance to +/// ignore the refusal and carry on, and it is charged the whole limit. +/// +/// The gas range is every amount that reaches the call and cannot pay for it, so +/// the case is the whole boundary rather than one number. `trace` is the call under +/// it because it is the one that could not report a refusal even if it wanted to: +/// stopping the run is the whole of what the guest sees. +#[test] +fn a_host_call_refused_its_gas_stops_the_run() { + let host = FakeHost::new(); + let op = HostFunctionSpec::Trace; + let call = call_for(op); + let wat = module(&[call.import, ONE_PAGE], &call.body(1)); + // Measured rather than derived: the whole run's cost, less the call's own gas, + // is the least a guest can be given and still reach the call. Below that the + // meter stops the guest's own instructions instead, which is + // `a_run_that_cannot_afford_itself_fails`'s case, not this one. + let cost = run(&wat, &FakeHost::new()) + .expect("the module should run") + .fuel_used; + + for gas in cost - op.gas()..cost { + let Err(failure) = run_with_gas(&wat, gas, &host) else { + panic!("gas {gas}: the run completed, so the guest was handed the refusal"); + }; + assert!( + matches!(failure.error, RunError::OutOfGas), + "gas {gas}: expected the run to end out of gas, got: {failure}" + ); + assert_eq!( + failure.fuel_used, gas, + "gas {gas}: a call it cannot afford burns the whole limit" + ); + } + assert!(host.traces().is_empty(), "the host body must not have run"); +} + +// --------------------------------------------------------------------------- +// The transfer limit +// --------------------------------------------------------------------------- + +/// A module that repeats `call` while `keep_going` holds, then returns the last +/// status, so a budget can be run to exhaustion inside one invocation. +fn until_refused(imports: &str, call: &str, keep_going: &str) -> String { + module( + &[imports, ONE_PAGE], + &format!( + "(local $r i32) + (loop $l + (local.set $r {call}) + (br_if $l {keep_going})) + (local.get $r)" + ), + ) +} + +/// For a call whose success is a positive byte count. +const WHILE_POSITIVE: &str = "(i32.gt_s (local.get $r) (i32.const 0))"; + +/// Bytes written into guest memory are charged against the run's budget, and the +/// budget is a per-run total: 1 MiB of 1 KiB values exhausts it. +#[test] +fn writes_spend_the_transfer_budget() { + let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES)); + let wat = until_refused( + import::HOME_LE_FIELD, + &format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"), + WHILE_POSITIVE, + ); + + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, code(HostError::OutOfTransferLimit)); + assert_eq!( + host.fields_asked.borrow().len() as u64, + TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 + 1, + "one call per 1 KiB of budget, plus the one that was refused" + ); +} + +/// The budget is per run, not per call: a fresh run starts with a full budget. +#[test] +fn each_run_gets_its_own_budget() { + let wat = until_refused( + import::HOME_LE_FIELD, + &format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"), + WHILE_POSITIVE, + ); + + for _ in 0..2 { + let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES)); + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, code(HostError::OutOfTransferLimit)); + assert_eq!( + host.fields_asked.borrow().len() as u64, + TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 + 1 + ); + } +} + +/// A run well inside the budget never sees it. +#[test] +fn a_modest_run_never_meets_the_budget() { + let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES)); + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"), + ); + + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, MAX_FIELD_BYTES as i32); +} + +/// A write the budget refuses is a write that did not happen. `float_to_mant_exp` is +/// the case worth pinning: its two regions are charged as one, so a call that cannot +/// pay for both must leave both alone rather than place the mantissa and refuse. +#[test] +fn a_write_the_budget_refuses_reaches_guest_memory_in_no_part() { + let host = FakeHost::new() + .answering_field(1, Answer::filler(MAX_FIELD_BYTES)) + .answering_float_mant_exp(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![9, 10, 11, 12]); + + // Spend the budget on 1 KiB fields at offset 0, then ask for a mantissa and an + // exponent at offsets well clear of them. + let call = "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 2048) (i32.const 8) (i32.const 2064) (i32.const 4))"; + let spent = |tail: &str| { + module( + &[import::HOME_LE_FIELD, import::FLOAT_TO_MANT_EXP, ONE_PAGE], + &format!( + "(local $r i32) + (loop $l + (local.set $r (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))) + (br_if $l {WHILE_POSITIVE})) + {tail}" + ), + ) + }; + + let refused = run(&spent(call), &host).expect("the module should run"); + assert_eq!(refused.result, code(HostError::OutOfTransferLimit)); + + let wat = spent(&format!( + "(drop {call}) + (i32.or (i32.load8_u (i32.const 2048)) (i32.load8_u (i32.const 2064)))" + )); + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, 0, "neither region should be written"); +} + +/// The same rule on the path that writes straight into guest memory: `write_into` +/// hands the host a slice *of the guest's own buffer*, so a value the budget cannot +/// pay for has to be kept out of that slice before the host fills it. +/// +/// The probe region is one the spending loop never writes to, so anything found there +/// came from the refused call. +#[test] +fn a_straight_write_the_budget_refuses_reaches_guest_memory_in_no_part() { + /// Clear of the offset the spending loop writes to. + const PROBE: usize = 2048; + /// Every byte of the value, so the fold sees a prefix as readily as the whole. + const MARK: u8 = 0xff; + + let host = FakeHost::new().answering_field(1, Answer::bytes(vec![MARK; MAX_FIELD_BYTES])); + let call = format!( + "(call $home_le_field (i32.const 1) (i32.const {PROBE}) (i32.const {MAX_FIELD_BYTES}))" + ); + + // Every local the tails below use is declared here: wasm wants them all ahead of + // the first instruction. + let spent = |tail: &str| { + module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!( + "(local $r i32) (local $i i32) (local $seen i32) + (loop $l + (local.set $r (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))) + (br_if $l {WHILE_POSITIVE})) + {tail}" + ), + ) + }; + + let refused = run(&spent(&call), &host).expect("the module should run"); + assert_eq!(refused.result, code(HostError::OutOfTransferLimit)); + + // Guest memory starts zero-filled, so or-ing the region together reports whether + // any byte of it was written. + let wat = spent(&format!( + "(drop {call}) + (loop $l + (local.set $seen (i32.or (local.get $seen) + (i32.load8_u (i32.add (i32.const {PROBE}) (local.get $i))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {MAX_FIELD_BYTES})))) + (local.get $seen)" + )); + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, 0, "not one byte should have been written"); +} + +/// What a write may deliver is what is *left* of the budget, to the byte. +/// +/// The prologue spends all but `LEFT`, and field 3's host answers with as much as it +/// is offered — so the window `write_into` opened is what it reports and what it +/// leaves in guest memory, and both are read off as `LEFT`. A mark is a 1, so the +/// fold over the probe's whole buffer counts the bytes that reached it. +/// +/// `LEFT` is under [`MAX_FIELD_BYTES`] and the buffer is wider than both probes' +/// values, so it is the budget answering and neither the field cap nor the guest's +/// capacity. Field 4 is the byte past it: a host whose value is one larger than what +/// is left, which no window can hold. +#[test] +fn a_write_may_deliver_what_is_left_of_the_budget_and_not_a_byte_more() { + /// Full-cap writes, all the prologue can make without overshooting. + const BULK: u64 = TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 - 1; + /// What the prologue leaves unspent. + const LEFT: usize = MAX_FIELD_BYTES / 2; + /// The write that trims what [`BULK`] leaves down to [`LEFT`]. + const TRIM: usize = MAX_FIELD_BYTES - LEFT; + /// Clear of the offset the prologue writes to. + const PROBE: usize = 2048; + const BUFFER: usize = MAX_FIELD_BYTES; + /// One per byte written, so the fold below sums to how many there were. + const MARK: u8 = 1; + + assert_eq!( + BULK * MAX_FIELD_BYTES as u64 + TRIM as u64 + LEFT as u64, + TRANSFER_LIMIT_BYTES, + "the prologue must spend all but LEFT of the budget" + ); + + let host = FakeHost::new() + .answering_field(1, Answer::filler(MAX_FIELD_BYTES)) + .answering_field(2, Answer::filler(TRIM)) + .answering_field(3, Answer::as_much_as_offered(MARK)) + .answering_field(4, Answer::claiming(LEFT + 1)); + + let probe = |field: i32| { + format!( + "(call $home_le_field (i32.const {field}) (i32.const {PROBE}) (i32.const {BUFFER}))" + ) + }; + // Every local the tails use, declared where wasm wants them. + let after_prologue = |tail: String| { + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!( + "(local $i i32) (local $marks i32) + (loop $l + (drop (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {BULK})))) + (drop (call $home_le_field (i32.const 2) (i32.const 0) (i32.const {TRIM}))) + (local.set $i (i32.const 0)) + {tail}" + ), + ); + run(&wat, &host).expect("the module should run").result + }; + + assert_eq!( + after_prologue(probe(3)), + LEFT as i32, + "the host should be offered exactly what is left" + ); + + // Guest memory starts zero-filled, so summing the probe's whole buffer counts the + // marks in it. + assert_eq!( + after_prologue(format!( + "(drop {}) + (loop $l + (local.set $marks (i32.add (local.get $marks) + (i32.load8_u (i32.add (i32.const {PROBE}) (local.get $i))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {BUFFER})))) + (local.get $marks)", + probe(3) + )), + LEFT as i32, + "and that many marks, no more, should reach guest memory" + ); + + assert_eq!( + after_prologue(probe(4)), + code(HostError::OutOfTransferLimit), + "a value one byte past what is left fits no window" + ); +} + +/// Reads leave the budget alone: `read_borrowed` hands the host a slice *aliasing* +/// guest memory, so there are no copied bytes to charge. What bounds how many reads +/// a run can make is gas, which every host call pays before its body runs. +/// +/// The observation is the write at the end, not the reads: the module reads four +/// times the whole budget first, so a rule that charged reads would have nothing +/// left, and the write would answer `OutOfTransferLimit` instead of a byte count. +#[test] +fn reads_do_not_spend_the_transfer_budget() { + /// 1 KiB reads, four times over the budget. + const READS: u64 = 4 * TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64; + + let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES)); + let read = trace_call( + TraceDataType::AsHex, + EMPTY_REGION, + &format!("(i32.const 0) (i32.const {MAX_FIELD_BYTES})"), + ); + let wat = module( + &[import::TRACE, import::HOME_LE_FIELD, ONE_PAGE], + &format!( + "(local $i i32) + (loop $l + {read} + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {READS})))) + (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))" + ), + ); + + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!( + host.traces().len() as u64, + READS, + "every read should have been served" + ); + assert_eq!( + outcome.result, MAX_FIELD_BYTES as i32, + "the write after {READS} reads of {MAX_FIELD_BYTES} bytes should still have its budget" + ); +} + +/// Only the output half of a read-write call spends the budget. `sha512_half`'s +/// input is a borrowed read like any other, aliasing guest memory rather than +/// crossing the boundary, so a run may hash far more bytes than the budget holds as +/// long as the digests it writes fit inside it. +/// +/// The two totals are asserted, so the arithmetic that makes the case is in the +/// test rather than in a comment: the inputs alone would overrun the budget, the +/// digests alone are a small fraction of it. +#[test] +fn only_the_output_half_of_a_read_write_spends_the_budget() { + /// Enough 1 KiB inputs to overrun the budget twice over. + const CALLS: u64 = 2 * TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64; + + assert!( + CALLS * MAX_FIELD_BYTES as u64 > TRANSFER_LIMIT_BYTES, + "the inputs alone must overrun the budget" + ); + assert!( + CALLS * HASH_LEN as u64 <= TRANSFER_LIMIT_BYTES / 2, + "the digests alone must stay well inside it" + ); + + let host = FakeHost::new().answering_digest(Answer::filler(HASH_LEN)); + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(local $i i32) + (local $r i32) + (loop $l + (local.set $r (call $sha512_half (i32.const 0) (i32.const {MAX_FIELD_BYTES}) + (i32.const 0) (i32.const {HASH_LEN}))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {CALLS})))) + (local.get $r)" + ), + ); + + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!( + host.digested.borrow().len() as u64, + CALLS, + "every call should have been served" + ); + assert_eq!( + outcome.result, HASH_LEN as i32, + "only the digests are charged, and they fit" + ); +} diff --git a/crates/xrpl-wasm-vm/tests/host_calls.rs b/crates/xrpl-wasm-vm/tests/host_calls.rs new file mode 100644 index 0000000000..6f2b4010ec --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/host_calls.rs @@ -0,0 +1,1266 @@ +//! What each registered host function passes in each direction: the scalars the +//! guest supplies reach the host unchanged, and the bytes the host produces land +//! where the guest asked for them. + +mod support; + +use support::{ + COMPLETED, EMPTY_REGION, FakeHost, ONE_PAGE, Trace, code, failure, import, module, run, status, + traced, +}; +use xrpl_host_functions::{HASH_LEN, HostError, TraceDataType}; +use xrpl_wasm_vm::RunError; + +/// A value the host writes must be readable by the guest at the pointer it gave, +/// and the call's status is the byte count. +#[test] +fn ldgr_index_writes_the_sequence_number_where_the_guest_asked() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + "(drop (call $ldgr_index (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 7, "the 4 LE bytes the host wrote"); + + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + "(call $ldgr_index (i32.const 64) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 4, "the byte count"); +} + +/// A second scalar getter travels the same path: the value the host supplies lands +/// where the guest asked, and the status is the byte count. The default parent +/// ledger time is distinct from the sequence number, so this cannot pass by reading +/// the wrong one. +#[test] +fn parent_ldgr_time_writes_the_close_time_where_the_guest_asked() { + let host = FakeHost::new(); + + let wat = module( + &[import::PARENT_LDGR_TIME, ONE_PAGE], + "(drop (call $parent_ldgr_time (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 9, "the 4 LE bytes the host wrote"); + + let wat = module( + &[import::PARENT_LDGR_TIME, ONE_PAGE], + "(call $parent_ldgr_time (i32.const 64) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 4, "the byte count"); +} + +/// A 32-byte value (a ledger hash) travels the same getter path as the 4-byte +/// scalars: every byte lands where the guest asked, and the status is the length. +#[test] +fn parent_ldgr_hash_writes_all_32_bytes_where_the_guest_asked() { + let host = FakeHost::new(); + + let wat = module( + &[import::PARENT_LDGR_HASH, ONE_PAGE], + "(call $parent_ldgr_hash (i32.const 64) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 32, "the byte count"); + + // The default hash is 0, 1, 2, ..., so its first four bytes load as 0x03020100. + let wat = module( + &[import::PARENT_LDGR_HASH, ONE_PAGE], + "(drop (call $parent_ldgr_hash (i32.const 64) (i32.const 32))) + (i32.load (i32.const 64))", + ); + assert_eq!( + status(&wat, &host), + 0x03020100, + "the first four bytes the host wrote" + ); +} + +/// A third scalar getter, to pin the pattern rather than a single instance of it. +#[test] +fn base_fee_writes_the_fee_where_the_guest_asked() { + let host = FakeHost::new(); + + let wat = module( + &[import::BASE_FEE, ONE_PAGE], + "(drop (call $base_fee (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 10, "the 4 LE bytes the host wrote"); + + let wat = module( + &[import::BASE_FEE, ONE_PAGE], + "(call $base_fee (i32.const 64) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 4, "the byte count"); +} + +/// A call that reads an input region and returns a scalar flag, rather than writing +/// bytes to an output region: the amendment reaches the host, and its verdict comes +/// back as the call's status. +#[test] +fn amendment_enabled_reads_the_input_and_returns_the_flag() { + let host = FakeHost::new(); // enabled by default + + let wat = module( + &[import::AMENDMENT_ENABLED, ONE_PAGE], + "(call $amendment_enabled (i32.const 64) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 1, "the enabled flag"); + assert_eq!( + *host.amendments_asked.borrow(), + [vec![0u8; 32]], + "the 32-byte region reached the host" + ); + + // A host that reports the amendment disabled answers 0 — a value, not an error. + let host = FakeHost::new().answering_amendment_enabled(Ok(0)); + let wat = module( + &[import::AMENDMENT_ENABLED, ONE_PAGE], + "(call $amendment_enabled (i32.const 0) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 0, "the disabled flag"); +} + +/// A call that reads an input region and takes a second scalar arg: both the object +/// id and the requested slot reach the host, and the slot it chose comes back as the +/// status. +#[test] +fn cache_le_passes_the_object_id_and_slot_through() { + let host = FakeHost::new().answering_cache_slot(Ok(4)); + + let wat = module( + &[import::CACHE_LE, ONE_PAGE], + "(call $cache_le (i32.const 64) (i32.const 32) (i32.const 7))", + ); + assert_eq!(status(&wat, &host), 4, "the slot the host chose"); + assert_eq!( + *host.cached.borrow(), + [(vec![0u8; 32], 7)], + "the id region and the requested slot reached the host" + ); +} + +/// The output region is wherever the guest points, not a fixed address. +#[test] +fn the_output_region_is_the_pointer_the_guest_gave() { + let host = FakeHost::new(); + + for offset in [0, 1, 7, 4096, 65532] { + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + &format!( + "(drop (call $ldgr_index (i32.const {offset}) (i32.const 4))) + (i32.load (i32.const {offset}))" + ), + ); + assert_eq!(status(&wat, &host), 7, "at offset {offset}"); + } +} + +/// A field getter over the transaction: the selector reaches the host, and the bytes +/// it answers land where the guest asked. It has its own answer set, distinct from +/// the current-object field getter's. +#[test] +fn tx_field_passes_the_selector_and_writes_the_field() { + let host = FakeHost::new().answering_tx_field(17, support::Answer::bytes([0xab, 0xcd])); + + let wat = module( + &[import::TX_FIELD, ONE_PAGE], + "(call $tx_field (i32.const 17) (i32.const 0) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 2); + assert_eq!(*host.tx_fields_asked.borrow(), vec![17]); +} + +/// A field getter over a cached object: both the slot and the selector reach the +/// host, keyed together, and the answered bytes land where the guest asked. +#[test] +fn le_field_passes_the_slot_and_selector_through() { + let host = + FakeHost::new().answering_le_field(2, 17, support::Answer::bytes([0xab, 0xcd, 0xef])); + + let wat = module( + &[import::LE_FIELD, ONE_PAGE], + "(call $le_field (i32.const 2) (i32.const 17) (i32.const 0) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 3); + assert_eq!(*host.le_fields_asked.borrow(), vec![(2, 17)]); +} + +/// A nested-field getter: the locator is read from one region and the answer written +/// to another — the read-input-write-output path. The guest lays the locator down in +/// memory, and the bytes the host answers land where it asked. +#[test] +fn tx_inner_reads_the_locator_and_writes_the_field() { + // An eight-byte, two-step locator, as it lands in little-endian guest memory. + let locator = vec![17u8, 0, 0, 0, 2, 0, 0, 0]; + let host = + FakeHost::new().answering_tx_nested(locator.clone(), support::Answer::bytes([0xaa, 0xbb])); + + let wat = module( + &[import::TX_INNER, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 17)) + (i32.store (i32.const 4) (i32.const 2)) + (call $tx_inner (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 2, "the field bytes the host wrote"); + assert_eq!(*host.tx_nested_asked.borrow(), vec![locator]); +} + +/// The same read-input-write-output path over the current object, with its own +/// answer set distinct from the transaction's nested getter. +#[test] +fn home_le_inner_reads_the_locator_and_writes_the_field() { + let locator = vec![5u8, 0, 0, 0]; + let host = FakeHost::new() + .answering_home_le_nested(locator.clone(), support::Answer::bytes([0xcc, 0xdd, 0xee])); + + let wat = module( + &[import::HOME_LE_INNER, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 5)) + (call $home_le_inner (i32.const 0) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 3, "the field bytes the host wrote"); + assert_eq!(*host.home_le_nested_asked.borrow(), vec![locator]); +} + +/// The nested getter over a cached object: the slot leads, the locator is read from +/// memory, and the two reach the host keyed together. +#[test] +fn le_inner_reads_the_slot_and_locator_and_writes_the_field() { + let locator = vec![5u8, 0, 0, 0]; + let host = FakeHost::new().answering_le_nested( + 3, + locator.clone(), + support::Answer::bytes([0x11, 0x22]), + ); + + let wat = module( + &[import::LE_INNER, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 5)) + (call $le_inner (i32.const 3) (i32.const 0) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 2, "the field bytes the host wrote"); + assert_eq!(*host.le_nested_asked.borrow(), vec![(3, locator)]); +} + +/// A scalar-in, scalar-out call — no memory regions at all: the field selector +/// reaches the host and the array length comes back as the status. +#[test] +fn tx_arr_len_passes_the_selector_and_returns_the_count() { + let host = FakeHost::new().answering_tx_arr_len(17, 5); + + let wat = module( + &[import::TX_ARR_LEN, ONE_PAGE], + "(call $tx_arr_len (i32.const 17))", + ); + assert_eq!(status(&wat, &host), 5, "the array length"); + assert_eq!(*host.tx_arr_lens_asked.borrow(), vec![17]); +} + +/// The same scalar-in, scalar-out count over the current object, with its own answer +/// set distinct from the transaction's. +#[test] +fn home_le_arr_len_passes_the_selector_and_returns_the_count() { + let host = FakeHost::new().answering_home_le_arr_len(17, 8); + + let wat = module( + &[import::HOME_LE_ARR_LEN, ONE_PAGE], + "(call $home_le_arr_len (i32.const 17))", + ); + assert_eq!(status(&wat, &host), 8, "the array length"); + assert_eq!(*host.home_le_arr_lens_asked.borrow(), vec![17]); +} + +/// The scalar count over a cached object: the slot leads, and both it and the +/// selector reach the host keyed together. +#[test] +fn le_arr_len_passes_the_slot_and_selector_and_returns_the_count() { + let host = FakeHost::new().answering_le_arr_len(2, 17, 9); + + let wat = module( + &[import::LE_ARR_LEN, ONE_PAGE], + "(call $le_arr_len (i32.const 2) (i32.const 17))", + ); + assert_eq!(status(&wat, &host), 9, "the array length"); + assert_eq!(*host.le_arr_lens_asked.borrow(), vec![(2, 17)]); +} + +/// A nested array-length getter: the locator is read from memory and the count comes +/// back as the status — read-input, scalar-out, no output buffer. +#[test] +fn tx_inner_arr_len_reads_the_locator_and_returns_the_count() { + let locator = vec![5u8, 0, 0, 0]; + let host = FakeHost::new().answering_tx_nested_arr_len(locator.clone(), 6); + + let wat = module( + &[import::TX_INNER_ARR_LEN, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 5)) + (call $tx_inner_arr_len (i32.const 0) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 6, "the array length"); + assert_eq!(*host.tx_nested_arr_lens_asked.borrow(), vec![locator]); +} + +/// The same read-input, scalar-out count over the current object, with its own answer +/// set distinct from the transaction's. +#[test] +fn home_le_inner_arr_len_reads_the_locator_and_returns_the_count() { + let locator = vec![5u8, 0, 0, 0]; + let host = FakeHost::new().answering_home_le_nested_arr_len(locator.clone(), 7); + + let wat = module( + &[import::HOME_LE_INNER_ARR_LEN, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 5)) + (call $home_le_inner_arr_len (i32.const 0) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 7, "the array length"); + assert_eq!(*host.home_le_nested_arr_lens_asked.borrow(), vec![locator]); +} + +/// The nested array-length getter over a cached object: the slot leads, the locator +/// is read from memory, and the two reach the host keyed together. +#[test] +fn le_inner_arr_len_reads_the_slot_and_locator_and_returns_the_count() { + let locator = vec![5u8, 0, 0, 0]; + let host = FakeHost::new().answering_le_nested_arr_len(3, locator.clone(), 8); + + let wat = module( + &[import::LE_INNER_ARR_LEN, ONE_PAGE], + "(i32.store (i32.const 0) (i32.const 5)) + (call $le_inner_arr_len (i32.const 3) (i32.const 0) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 8, "the array length"); + assert_eq!(*host.le_nested_arr_lens_asked.borrow(), vec![(3, locator)]); +} + +/// A call that reads three input regions and returns a scalar verdict: the message, +/// signature, and pubkey all reach the host, and the verdict comes back as the status. +#[test] +fn check_sig_reads_all_three_regions_and_returns_the_verdict() { + let host = FakeHost::new(); // valid by default + + // message @0 len 3, signature @8 len 4, pubkey @16 len 5 — memory is zeroed. + let wat = module( + &[import::CHECK_SIG, ONE_PAGE], + "(call $check_sig + (i32.const 0) (i32.const 3) + (i32.const 8) (i32.const 4) + (i32.const 16) (i32.const 5))", + ); + assert_eq!(status(&wat, &host), 1, "the valid verdict"); + assert_eq!( + *host.sigs_checked.borrow(), + [(vec![0u8; 3], vec![0u8; 4], vec![0u8; 5])], + "the three regions reached the host at their declared lengths" + ); + + // An invalid signature comes back as 0 — a value, not an error. + let host = FakeHost::new().answering_check_sig(Ok(0)); + assert_eq!(status(&wat, &host), 0, "the invalid verdict"); +} + +/// A keylet getter: reads an account region and writes a 32-byte keylet back — the +/// read-input-write-output path. The account reaches the host and the keylet lands +/// where the guest asked. +#[test] +fn accountroot_id_reads_the_account_and_writes_the_keylet() { + // Guest memory is zeroed, so a 20-byte account read is all zeros. + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_account_keylet(account.clone(), support::Answer::filler(32)); + + let wat = module( + &[import::ACCOUNTROOT_ID, ONE_PAGE], + "(call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.account_keylets_asked.borrow(), vec![account]); + + // The keylet bytes land at the output pointer: filler is 0, 1, 2, ..., so the + // first four load as 0x03020100. + let wat = module( + &[import::ACCOUNTROOT_ID, ONE_PAGE], + "(drop (call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 64) (i32.const 64))) + (i32.load (i32.const 64))", + ); + assert_eq!( + status(&wat, &host), + 0x03020100, + "the first four keylet bytes" + ); +} + +/// A keylet getter that reads two input regions: both assets reach the host as a +/// pair, and the keylet lands where the guest asked. +#[test] +fn amm_id_reads_two_assets_and_writes_the_keylet() { + // Two distinct all-zero assets of different lengths (20 and 40 bytes). + let asset1 = vec![0u8; 20]; + let asset2 = vec![0u8; 40]; + let host = FakeHost::new().answering_amm_keylet( + asset1.clone(), + asset2.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::AMM_ID, ONE_PAGE], + "(call $amm_id + (i32.const 0) (i32.const 20) + (i32.const 64) (i32.const 40) + (i32.const 128) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.amm_keylets_asked.borrow(), vec![(asset1, asset2)]); +} + +/// A keylet getter that reads an account region and also takes a scalar seq: both +/// reach the host keyed together, and the keylet lands where the guest asked. +#[test] +fn check_id_reads_the_account_and_seq_and_writes_the_keylet() { + // Guest memory is zeroed, so a 20-byte account read is all zeros. + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_check_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::CHECK_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $check_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.check_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// A keylet getter that reads three input regions — two accounts and a credential +/// type: all three reach the host keyed together, and the keylet lands where asked. +#[test] +fn credential_id_reads_subject_issuer_and_type() { + // Guest memory is zeroed, so the two 20-byte accounts and the 4-byte type read + // as zeros of their declared lengths. + let subject = vec![0u8; 20]; + let issuer = vec![0u8; 20]; + let cred_type = vec![0u8; 4]; + let host = FakeHost::new().answering_credential_keylet( + subject.clone(), + issuer.clone(), + cred_type.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::CREDENTIAL_ID, ONE_PAGE], + "(call $credential_id + (i32.const 0) (i32.const 20) + (i32.const 20) (i32.const 20) + (i32.const 40) (i32.const 4) + (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!( + *host.credential_keylets_asked.borrow(), + vec![(subject, issuer, cred_type)] + ); +} + +/// A two-account keylet getter: both accounts reach the host as a pair, and the +/// keylet lands where the guest asked. +#[test] +fn delegate_id_reads_both_accounts_and_writes_the_keylet() { + let account = vec![0u8; 20]; + let authorize = vec![0u8; 20]; + let host = FakeHost::new().answering_delegate_keylet( + account.clone(), + authorize.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::DELEGATE_ID, ONE_PAGE], + "(call $delegate_id + (i32.const 0) (i32.const 20) + (i32.const 20) (i32.const 20) + (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!( + *host.delegate_keylets_asked.borrow(), + vec![(account, authorize)] + ); +} + +/// The same two-account keylet shape as delegate, with its own answer set. +#[test] +fn deposit_preauth_id_reads_both_accounts_and_writes_the_keylet() { + let account = vec![0u8; 20]; + let authorize = vec![0u8; 20]; + let host = FakeHost::new().answering_deposit_preauth_keylet( + account.clone(), + authorize.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::DEPOSIT_PREAUTH_ID, ONE_PAGE], + "(call $deposit_preauth_id + (i32.const 0) (i32.const 20) + (i32.const 20) (i32.const 20) + (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!( + *host.deposit_preauth_keylets_asked.borrow(), + vec![(account, authorize)] + ); +} + +/// A single-account keylet getter (like accountroot), with its own answer set. +#[test] +fn did_id_reads_the_account_and_writes_the_keylet() { + let account = vec![0u8; 20]; + let host = FakeHost::new().answering_did_keylet(account.clone(), support::Answer::filler(32)); + + let wat = module( + &[import::DID_ID, ONE_PAGE], + "(call $did_id (i32.const 0) (i32.const 20) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.did_keylets_asked.borrow(), vec![account]); +} + +/// The account-and-sequence keylet shape (like check), with its own answer set. +#[test] +fn escrow_id_reads_the_account_and_seq_and_writes_the_keylet() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_escrow_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::ESCROW_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $escrow_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.escrow_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// A keylet getter reading three regions — two accounts and a currency: all three +/// reach the host as a triple, and the keylet lands where the guest asked. +#[test] +fn trustline_id_reads_two_accounts_and_a_currency() { + let account1 = vec![0u8; 20]; + let account2 = vec![0u8; 20]; + let currency = vec![0u8; 20]; + let host = FakeHost::new().answering_trust_line_keylet( + account1.clone(), + account2.clone(), + currency.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::TRUSTLINE_ID, ONE_PAGE], + "(call $trustline_id + (i32.const 0) (i32.const 20) + (i32.const 20) (i32.const 20) + (i32.const 40) (i32.const 20) + (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!( + *host.trust_line_keylets_asked.borrow(), + vec![(account1, account2, currency)] + ); +} + +/// The issuer-and-sequence keylet shape (like escrow), with its own answer set. +#[test] +fn mpt_issuance_id_reads_the_issuer_and_seq() { + let issuer = vec![0u8; 20]; + let host = FakeHost::new().answering_mpt_issuance_keylet( + issuer.clone(), + 5, + support::Answer::filler(32), + ); + + let wat = module( + &[import::MPT_ISSUANCE_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $mpt_issuance_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.mpt_issuance_keylets_asked.borrow(), vec![(issuer, 5)]); +} + +/// A keylet from a 24-byte MPT id and a 20-byte holder: both reach the host as a +/// pair, and the keylet lands where the guest asked. +#[test] +fn mptoken_id_reads_the_mptid_and_holder() { + let mptid = vec![0u8; 24]; + let holder = vec![0u8; 20]; + let host = FakeHost::new().answering_mptoken_keylet( + mptid.clone(), + holder.clone(), + support::Answer::filler(32), + ); + + let wat = module( + &[import::MPTOKEN_ID, ONE_PAGE], + "(call $mptoken_id + (i32.const 0) (i32.const 24) + (i32.const 24) (i32.const 20) + (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.mptoken_keylets_asked.borrow(), vec![(mptid, holder)]); +} + +/// Another account-and-sequence keylet, with its own answer set. +#[test] +fn nft_offer_id_reads_the_account_and_seq() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_nft_offer_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::NFT_OFFER_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $nft_offer_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.nft_offer_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// A third account-and-sequence keylet, distinct from the NFT-offer set, to pin the +/// pattern rather than a single instance of it. +#[test] +fn offer_id_reads_the_account_and_seq() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_offer_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::OFFER_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $offer_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.offer_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// The account-and-scalar keylet, keyed on a document id rather than a sequence; its +/// own answer set, to keep it distinct from the other account-and-scalar getters. +#[test] +fn oracle_id_reads_the_account_and_doc_id() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_oracle_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::ORACLE_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $oracle_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.oracle_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// A keylet that reads two account regions and a scalar: both accounts and the +/// sequence reach the host, keyed together, and the answered bytes land where asked. +#[test] +fn paychan_id_reads_both_accounts_and_the_seq() { + let account = vec![0u8; 20]; + let destination = vec![0u8; 20]; + let host = FakeHost::new().answering_paychannel_keylet( + account.clone(), + destination.clone(), + 5, + support::Answer::filler(32), + ); + + let wat = module( + &[import::PAYCHAN_ID, ONE_PAGE], + "(i32.store (i32.const 24) (i32.const 5)) + (call $paychan_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 20) (i32.const 24) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!( + *host.paychannel_keylets_asked.borrow(), + vec![(account, destination, 5)] + ); +} + +/// Another account-and-sequence keylet, with its own answer set, for a permissioned +/// domain. +#[test] +fn permissioned_domain_id_reads_the_account_and_seq() { + let account = vec![0u8; 20]; + let host = FakeHost::new().answering_permissioned_domain_keylet( + account.clone(), + 5, + support::Answer::filler(32), + ); + + let wat = module( + &[import::PERMISSIONED_DOMAIN_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $permissioned_domain_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.domain_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// An account-only keylet: the account reaches the host and the answered bytes land +/// where the guest asked, with no scalar in the shape. +#[test] +fn signers_id_reads_the_account() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_signer_list_keylet(account.clone(), support::Answer::filler(32)); + + let wat = module( + &[import::SIGNERS_ID, ONE_PAGE], + "(call $signers_id (i32.const 0) (i32.const 20) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.signer_list_keylets_asked.borrow(), vec![account]); +} + +/// Another account-and-sequence keylet, with its own answer set, for a ticket. +#[test] +fn ticket_id_reads_the_account_and_seq() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_ticket_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::TICKET_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $ticket_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.ticket_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// The last account-and-sequence keylet, with its own answer set, for a vault. +#[test] +fn vault_id_reads_the_account_and_seq() { + let account = vec![0u8; 20]; + let host = + FakeHost::new().answering_vault_keylet(account.clone(), 5, support::Answer::filler(32)); + + let wat = module( + &[import::VAULT_ID, ONE_PAGE], + "(i32.store (i32.const 20) (i32.const 5)) + (call $vault_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 4) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 32, "the 32-byte keylet length"); + assert_eq!(*host.vault_keylets_asked.borrow(), vec![(account, 5)]); +} + +/// A call that reads an input region and returns a scalar rather than writing bytes: +/// the data blob reaches the host, and the byte count it reports comes back as the +/// call's status. +#[test] +fn set_data_passes_the_data_through_and_returns_the_count() { + let host = FakeHost::new().answering_update_data(Ok(8)); + + let wat = module( + &[import::SET_DATA, ONE_PAGE], + "(call $set_data (i32.const 64) (i32.const 8))", + ); + assert_eq!(status(&wat, &host), 8, "the byte count the host reported"); + assert_eq!( + *host.update_data_asked.borrow(), + [vec![0u8; 8]], + "the 8-byte region reached the host" + ); +} + +/// A getter that reads two input regions — an account and an nft id — and writes the +/// answer to a third: both inputs reach the host, keyed together, and the bytes it +/// answers land where the guest asked. +#[test] +fn nft_uri_reads_the_account_and_id_and_writes_the_uri() { + let account = vec![0u8; 20]; + let nft_id = vec![0u8; 32]; + let host = FakeHost::new().answering_get_nft( + account.clone(), + nft_id.clone(), + support::Answer::bytes([0xab, 0xcd, 0xef]), + ); + + let wat = module( + &[import::NFT_URI, ONE_PAGE], + "(call $nft_uri (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 32) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 3, "the uri length"); + assert_eq!(*host.nfts_asked.borrow(), vec![(account, nft_id)]); +} + +/// A single-input byte getter: the nft id reaches the host and the issuer bytes it +/// answers land where the guest asked. +#[test] +fn nft_issuer_reads_the_id_and_writes_the_issuer() { + let nft_id = vec![0u8; 32]; + let host = FakeHost::new().answering_nft_issuer(nft_id.clone(), support::Answer::filler(20)); + + let wat = module( + &[import::NFT_ISSUER, ONE_PAGE], + "(call $nft_issuer (i32.const 0) (i32.const 32) (i32.const 64) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 20, "the issuer length"); + assert_eq!(*host.nft_issuers_asked.borrow(), vec![nft_id]); +} + +/// A u32-valued getter whose four bytes the host writes to the output region: the id +/// reaches the host, and the little-endian bytes land where the guest asked. +#[test] +fn nft_taxon_reads_the_id_and_writes_four_bytes() { + let nft_id = vec![0u8; 32]; + let host = + FakeHost::new().answering_nft_taxon(nft_id.clone(), support::Answer::bytes([7, 0, 0, 0])); + + let wat = module( + &[import::NFT_TAXON, ONE_PAGE], + "(drop (call $nft_taxon (i32.const 0) (i32.const 32) (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 7, "the taxon the host wrote"); + assert_eq!(*host.nft_taxons_asked.borrow(), vec![nft_id]); +} + +/// A single-input scalar getter: the nft id reaches the host and the flags it reports +/// come back as the call's status, no output region involved. +#[test] +fn nft_flags_reads_the_id_and_returns_the_flags() { + let nft_id = vec![0u8; 32]; + let host = FakeHost::new().answering_nft_flags(Ok(11)); + + let wat = module( + &[import::NFT_FLAGS, ONE_PAGE], + "(call $nft_flags (i32.const 0) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 11, "the flags the host reported"); + assert_eq!(*host.nft_flags_asked.borrow(), vec![nft_id]); +} + +/// A second scalar getter, to pin the pattern: the transfer fee comes back as the +/// status. +#[test] +fn nft_xfer_fee_reads_the_id_and_returns_the_fee() { + let nft_id = vec![0u8; 32]; + let host = FakeHost::new().answering_nft_transfer_fee(Ok(314)); + + let wat = module( + &[import::NFT_XFER_FEE, ONE_PAGE], + "(call $nft_xfer_fee (i32.const 0) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 314, "the fee the host reported"); + assert_eq!(*host.nft_fee_asked.borrow(), vec![nft_id]); +} + +/// The last NFT getter, a u32 sequence written to the output region. +#[test] +fn nft_serial_reads_the_id_and_writes_four_bytes() { + let nft_id = vec![0u8; 32]; + let host = FakeHost::new() + .answering_nft_sequence(nft_id.clone(), support::Answer::bytes([42, 0, 0, 0])); + + let wat = module( + &[import::NFT_SERIAL, ONE_PAGE], + "(drop (call $nft_serial (i32.const 0) (i32.const 32) (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 42, "the sequence the host wrote"); + assert_eq!(*host.nft_sequences_asked.borrow(), vec![nft_id]); +} + +/// A float built from an i64 scalar and no input region: the value and mode reach the +/// host, and the float bytes it answers land where the guest asked. `float_from_int` +/// carries a genuine `i64` parameter, so this pins that the wide scalar survives. +#[test] +fn float_from_int_passes_the_value_and_writes_the_float() { + let host = FakeHost::new().answering_float(support::Answer::filler(8)); + + let wat = module( + &[import::FLOAT_FROM_INT, ONE_PAGE], + "(call $float_from_int (i64.const 42) (i32.const 64) (i32.const 8) (i32.const 3))", + ); + assert_eq!(status(&wat, &host), 8, "the float length"); + assert_eq!(*host.float_from_int_asked.borrow(), vec![(42, 3)]); +} + +/// A float built from an 8-byte input region: the integer bytes and mode reach the +/// host, and the float bytes it answers land where the guest asked. +#[test] +fn float_from_uint_reads_the_input_and_writes_the_float() { + let host = FakeHost::new().answering_float(support::Answer::filler(8)); + + let wat = module( + &[import::FLOAT_FROM_UINT, ONE_PAGE], + "(call $float_from_uint (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 1))", + ); + assert_eq!(status(&wat, &host), 8, "the float length"); + assert_eq!( + *host.float_from_uint_asked.borrow(), + vec![(vec![0u8; 8], 1)] + ); +} + +/// The one call that writes two output regions: the mantissa lands in the first, the +/// exponent in the second, and the status is their combined length. +#[test] +fn float_to_mant_exp_writes_both_regions() { + let host = + FakeHost::new().answering_float_mant_exp(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![9, 10, 11, 12]); + + // Mantissa to offset 64, exponent to offset 80; read the first byte of each back. + let wat = module( + &[import::FLOAT_TO_MANT_EXP, ONE_PAGE], + "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 80) (i32.const 4))", + ); + assert_eq!(status(&wat, &host), 12, "the mantissa and exponent lengths"); + assert_eq!(*host.float_to_mant_exp_asked.borrow(), vec![vec![0u8; 8]]); + + let wat = module( + &[import::FLOAT_TO_MANT_EXP, ONE_PAGE], + "(drop (call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 80) (i32.const 4))) + (i32.load8_u (i32.const 80))", + ); + assert_eq!(status(&wat, &host), 9, "the exponent's first byte"); +} + +/// The widths that call writes are the ABI's, so a host reporting any other total has +/// contradicted it: the regions are wide enough and the guest asked for nothing wrong, +/// yet the mantissa is short of its eight bytes, so the rest of what would be copied is +/// whatever the buffer already held. The run stops instead. +#[test] +fn float_to_mant_exp_with_a_wrong_total_stops_the_run() { + let host = FakeHost::new().answering_float_mant_exp(vec![1, 2, 3, 4], vec![9, 10, 11, 12]); + + let wat = module( + &[import::FLOAT_TO_MANT_EXP, ONE_PAGE], + "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 80) (i32.const 4))", + ); + assert!( + matches!(failure(&wat, &host).error, RunError::Internal), + "a total that is not the two widths must stop the run" + ); +} + +/// The two regions are one answer, so a call that cannot place all of it places none +/// of it: an exponent region too small refuses the call with the mantissa's own region +/// wide enough and untouched. +#[test] +fn float_to_mant_exp_with_a_short_exponent_region_writes_neither() { + let host = + FakeHost::new().answering_float_mant_exp(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![9, 10, 11, 12]); + + // Eight bytes for the mantissa at offset 64, but two for the exponent at 80. + let call = "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 80) (i32.const 2))"; + + let wat = module(&[import::FLOAT_TO_MANT_EXP, ONE_PAGE], call); + assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall)); + + let wat = module( + &[import::FLOAT_TO_MANT_EXP, ONE_PAGE], + &format!( + "(drop {call}) + (i32.or (i32.load8_u (i32.const 64)) (i32.load8_u (i32.const 80)))" + ), + ); + assert_eq!(status(&wat, &host), 0, "neither region should be written"); +} + +/// A comparison that reads two float regions and returns a scalar verdict, no output +/// region involved. +#[test] +fn float_cmp_reads_both_and_returns_the_verdict() { + let host = FakeHost::new().answering_float_compare(Ok(-1)); + + let wat = module( + &[import::FLOAT_CMP, ONE_PAGE], + "(call $float_cmp (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))", + ); + assert_eq!(status(&wat, &host), -1, "the comparison verdict"); + assert_eq!( + *host.float_compare_asked.borrow(), + vec![(vec![0u8; 8], vec![0u8; 8])] + ); +} + +/// A binary operator that reads two float regions and a mode, and writes the result: +/// both operands and the mode reach the host, tagged by operator. +#[test] +fn float_add_reads_both_operands_and_the_mode() { + let host = FakeHost::new().answering_float(support::Answer::filler(8)); + + let wat = module( + &[import::FLOAT_ADD, ONE_PAGE], + "(call $float_add (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 64) (i32.const 8) (i32.const 2))", + ); + assert_eq!(status(&wat, &host), 8, "the result length"); + assert_eq!( + *host.float_binary_ops_asked.borrow(), + vec![("add", vec![0u8; 8], vec![0u8; 8], 2)] + ); +} + +/// A unary operator that reads one float region, an integer, and a mode: all three +/// reach the host, tagged by operator. +#[test] +fn float_root_reads_the_float_the_degree_and_the_mode() { + let host = FakeHost::new().answering_float(support::Answer::filler(8)); + + let wat = module( + &[import::FLOAT_ROOT, ONE_PAGE], + "(call $float_root (i32.const 0) (i32.const 8) (i32.const 3) (i32.const 64) (i32.const 8) (i32.const 1))", + ); + assert_eq!(status(&wat, &host), 8, "the result length"); + assert_eq!( + *host.float_unary_ops_asked.borrow(), + vec![("root", vec![0u8; 8], 3, 1)] + ); +} + +/// A leading scalar parameter reaches the host as declared. +#[test] +fn home_le_field_passes_the_field_selector_through() { + let host = FakeHost::new().answering_field(17, support::Answer::bytes([0xab, 0xcd])); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 17) (i32.const 0) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 2); + assert_eq!(*host.fields_asked.borrow(), vec![17]); +} + +/// A host error reaches the guest as its negative wire code, and the output +/// region is left as the guest had it. +#[test] +fn a_host_error_becomes_its_wire_code() { + let host = FakeHost::new(); + const UNTOUCHED: i32 = 7; + + // Field 99 is unanswered, so the host returns `FieldNotFound`. The guest + // stamps its buffer first, then checks the byte survived the failed call. + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!( + "(i32.store8 (i32.const 0) (i32.const {UNTOUCHED})) + (drop (call $home_le_field (i32.const 99) (i32.const 0) (i32.const 64))) + (i32.load8_u (i32.const 0))" + ), + ); + assert_eq!(status(&wat, &host), UNTOUCHED, "nothing was written"); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 99) (i32.const 0) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), code(HostError::FieldNotFound)); +} + +/// `sha512_half` reads one region and writes another in the same call. +#[test] +fn sha512_half_carries_bytes_in_and_out() { + const MARKER: u8 = 99; + + let host = FakeHost::new().answering_digest(support::Answer::bytes([MARKER; HASH_LEN])); + + let wat = module( + &[ + import::SHA512_HALF, + ONE_PAGE, + r#"(data (i32.const 0) "hello wasm")"#, + ], + &format!( + "(drop (call $sha512_half (i32.const 0) (i32.const 10) + (i32.const 128) (i32.const {HASH_LEN}))) + (i32.load8_u (i32.const 128))" + ), + ); + assert_eq!( + status(&wat, &host), + i32::from(MARKER), + "the first digest byte" + ); + assert_eq!( + *host.digested.borrow(), + vec![b"hello wasm".to_vec()], + "the input the host saw" + ); +} + +/// An empty input region is a legal read, not an error. +#[test] +fn sha512_half_accepts_an_empty_input() { + let host = FakeHost::new(); + + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + "(call $sha512_half (i32.const 0) (i32.const 0) (i32.const 128) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 32); + assert_eq!(*host.digested.borrow(), vec![Vec::::new()]); +} + +/// `trace` reads two regions and a type, and hands the guest back nothing — the +/// module returns a constant of its own, which is what a completed run looks like. +#[test] +fn trace_passes_its_message_type_and_data_through() { + let host = FakeHost::new(); + + let wat = module( + &[ + import::TRACE, + ONE_PAGE, + r#"(data (i32.const 0) "note")"#, + r#"(data (i32.const 16) "\01\02\03")"#, + ], + &traced( + TraceDataType::AsHex, + "(i32.const 0) (i32.const 4)", + "(i32.const 16) (i32.const 3)", + ), + ); + assert_eq!(status(&wat, &host), COMPLETED); + assert_eq!( + host.traces(), + vec![Trace { + msg: "note".to_owned(), + data_type: TraceDataType::AsHex, + data: vec![1, 2, 3], + }] + ); +} + +/// The type is the guest's to choose and the host's to act on, so every code the +/// ABI names has to arrive as the type it names. +#[test] +fn every_data_type_reaches_the_host_as_declared() { + for &data_type in TraceDataType::ALL { + let host = FakeHost::new(); + let wat = module( + &[import::TRACE, ONE_PAGE], + &traced(data_type, EMPTY_REGION, EMPTY_REGION), + ); + assert_eq!(status(&wat, &host), COMPLETED, "{data_type:?}"); + assert_eq!( + host.traces().first().map(|t| t.data_type), + Some(data_type), + "{data_type:?}" + ); + } +} + +/// A code no type carries is the guest's mistake, and there is no channel to tell it +/// so: the call is dropped and the run carries on. +#[test] +fn a_code_that_names_no_data_type_drops_the_call() { + for code in [0, -1, 8] { + let host = FakeHost::new(); + let wat = module( + &[import::TRACE, ONE_PAGE], + &format!( + "(call $trace (i32.const 0) (i32.const 0) (i32.const {code}) (i32.const 0) (i32.const 0)) + (i32.const {COMPLETED})" + ), + ); + assert_eq!(status(&wat, &host), COMPLETED, "code {code}"); + assert!( + host.traces().is_empty(), + "code {code}: the host is not called" + ); + } +} + +/// A `&str` parameter is a byte region the engine validates: the host is handed +/// a `&str`, so bytes that are not UTF-8 cannot be passed on. +#[test] +fn a_message_that_is_not_utf8_is_refused() { + let host = FakeHost::new(); + + let wat = module( + &[import::TRACE, ONE_PAGE, r#"(data (i32.const 0) "\ff\fe")"#], + &traced( + TraceDataType::AsText, + "(i32.const 0) (i32.const 2)", + EMPTY_REGION, + ), + ); + assert_eq!(status(&wat, &host), COMPLETED); + assert!(host.traces().is_empty(), "the host must not be called"); +} + +/// The error a host with no result to report may still return: a soft one is the +/// engine's to drop, since there is nowhere to put it and the contract asked +/// nothing. +#[test] +fn a_soft_error_from_a_call_with_no_result_is_dropped() { + let host = FakeHost::new().failing_trace(HostError::InvalidParams); + + let wat = module( + &[import::TRACE, ONE_PAGE], + &traced(TraceDataType::AsText, EMPTY_REGION, EMPTY_REGION), + ); + assert_eq!(status(&wat, &host), COMPLETED); + assert_eq!(host.traces().len(), 1, "the host was called and failed"); +} + +/// A host-fatal error is not an answer to the call, so having no answer to give +/// changes nothing: the run stops. +#[test] +fn a_fatal_error_from_a_call_with_no_result_still_stops_the_run() { + let host = FakeHost::new().failing_trace(HostError::InternalFatal); + + let wat = module( + &[import::TRACE, ONE_PAGE], + &traced(TraceDataType::AsText, EMPTY_REGION, EMPTY_REGION), + ); + assert!( + matches!(failure(&wat, &host).error, RunError::Internal), + "a fatal host error must stop the run" + ); +} + +/// Several host calls in one run each see their own arguments: the two fields answer +/// with distinct marker bytes and `finish` returns their sum, so a value landing in +/// the wrong place gives a different total. +#[test] +fn calls_do_not_bleed_into_each_other() { + const FIRST: u8 = 11; + const SECOND: u8 = 22; + + let host = FakeHost::new() + .answering_field(1, support::Answer::bytes([FIRST])) + .answering_field(2, support::Answer::bytes([SECOND, SECOND])); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(drop (call $home_le_field (i32.const 1) (i32.const 0) (i32.const 64))) + (drop (call $home_le_field (i32.const 2) (i32.const 64) (i32.const 64))) + (i32.add (i32.load8_u (i32.const 0)) (i32.load8_u (i32.const 64)))", + ); + assert_eq!(status(&wat, &host), i32::from(FIRST) + i32::from(SECOND)); + assert_eq!(*host.fields_asked.borrow(), vec![1, 2]); +} + +/// The run's outcome carries the entry point's return value, and that value is +/// the guest's own — the engine does not interpret it. +#[test] +fn the_outcome_carries_whatever_the_guest_returned() { + let host = FakeHost::new(); + + for value in [0, 1, -1, i32::MAX, i32::MIN] { + let wat = module(&[ONE_PAGE], &format!("(i32.const {value})")); + let outcome = run(&wat, &host).expect("the module should run"); + assert_eq!(outcome.result, value); + } +} diff --git a/crates/xrpl-wasm-vm/tests/memory_policy.rs b/crates/xrpl-wasm-vm/tests/memory_policy.rs new file mode 100644 index 0000000000..d8bc344ea4 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/memory_policy.rs @@ -0,0 +1,621 @@ +//! The bounds, field-cap and buffer-fit rules `abi.rs` enforces on every region +//! crossing the boundary. This is the policy the guest observes, so each rule is +//! pinned to the code it answers with. + +mod support; + +use support::{ + Answer, COMPLETED, EMPTY_REGION, FakeHost, ONE_PAGE, code, failure, import, module, status, + traced, +}; +use xrpl_host_functions::{HASH_LEN, HostError, TraceDataType}; +use xrpl_wasm_vm::{MAX_FIELD_BYTES, RunError}; + +/// One page, so anything at or past 65536 is out of bounds. +const PAGE: i64 = 64 * 1024; + +/// The per-field size cap, as a wasm operand. +const CAP: i64 = MAX_FIELD_BYTES as i64; +/// One byte over the cap: the smallest value the engine must refuse. +const OVER_CAP: i64 = CAP + 1; + +// --------------------------------------------------------------------------- +// Output regions (`write_into`) +// --------------------------------------------------------------------------- + +/// The whole output region must be in bounds, not merely its start — the engine +/// checks `[dst, dst + cap)` before the host is allowed to write. +#[test] +fn an_output_region_running_past_memory_is_refused() { + let host = FakeHost::new(); + + for (dst, cap) in [(PAGE, 4), (PAGE - 3, 4), (PAGE + 1024, 4), (0, PAGE + 1)] { + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + &format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"), + ); + assert_eq!( + status(&wat, &host), + code(HostError::PointerOutOfBounds), + "dst {dst} cap {cap}" + ); + } +} + +/// A region ending exactly at the last byte of memory is in bounds. +#[test] +fn an_output_region_ending_at_the_last_byte_is_allowed() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + &format!("(call $ldgr_index (i32.const {}) (i32.const 4))", PAGE - 4), + ); + assert_eq!(status(&wat, &host), 4); +} + +/// The wire carries `i32`, so a guest can present a negative pointer or length. +#[test] +fn a_negative_output_pointer_or_length_is_refused() { + let host = FakeHost::new(); + + for (dst, cap) in [(-1, 4), (0, -1), (-1, -1), (i32::MIN, 4)] { + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + &format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"), + ); + assert_eq!( + status(&wat, &host), + code(HostError::InvalidParams), + "dst {dst} cap {cap}" + ); + } +} + +/// The host reports a value's true length whether or not it fitted; a value that +/// did not fit is the guest's error, not the host's. +#[test] +fn a_value_larger_than_the_buffer_is_refused() { + let host = FakeHost::new().answering_field(1, Answer::filler(64)); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))", + ); + assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall)); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 64, "exactly enough room is enough"); +} + +/// A zero-length output region is in bounds and simply cannot hold anything. +#[test] +fn a_zero_length_output_region_is_in_bounds_but_too_small() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, ONE_PAGE], + "(call $ldgr_index (i32.const 0) (i32.const 0))", + ); + assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall)); +} + +/// A host that reports more than the per-field cap is refused even when the +/// guest offered room for it: the cap is the engine's rule, not the buffer's. +#[test] +fn a_value_past_the_field_cap_is_refused() { + let host = FakeHost::new() + .answering_field(1, Answer::claiming(OVER_CAP as usize)) + .answering_field(2, Answer::claiming(MAX_FIELD_BYTES)); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4096))", + ); + assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge)); + + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 2) (i32.const 0) (i32.const 4096))", + ); + assert_eq!(status(&wat, &host), CAP as i32, "the cap itself is allowed"); +} + +/// A refused over-cap value leaves nothing behind. `write_into` hands the host at +/// most [`MAX_FIELD_BYTES`] of the guest's buffer however much room the guest +/// declared, so a value past the cap does not fit the region it is offered and no +/// prefix of it can reach guest memory either. +/// +/// The host answers with a real over-cap value: [`Answer::claiming`] writes +/// nothing whatever the engine does, so it could not tell the two apart. The +/// second module folds the *whole* declared buffer rather than one byte, so the +/// claim is about the region and not about its first byte. +#[test] +fn an_over_cap_value_is_refused_without_reaching_guest_memory() { + /// The buffer the guest declares: well over the cap, so the clamp bites. + const BUFFER: usize = 4096; + + let over_cap = vec![0xff; MAX_FIELD_BYTES + 1]; + let host = FakeHost::new().answering_field(1, Answer::bytes(over_cap)); + let call = format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {BUFFER}))"); + + // The status the guest sees, from a module that returns it directly. + let refusing = module(&[import::HOME_LE_FIELD, ONE_PAGE], &call); + assert_eq!( + status(&refusing, &host), + code(HostError::DataFieldTooLarge), + "the value is refused" + ); + + // Every byte of the buffer, or-ed together: guest memory starts zero-filled, + // so any byte the host wrote shows up here. + let reading = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + &format!( + "(local $i i32) + (local $seen i32) + (drop {call}) + (loop $l + (local.set $seen (i32.or (local.get $seen) (i32.load8_u (local.get $i)))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br_if $l (i32.lt_u (local.get $i) (i32.const {BUFFER})))) + (local.get $seen)" + ), + ); + assert_eq!( + status(&reading, &host), + 0, + "and not one of its bytes is in the guest's buffer" + ); +} + +/// The field cap is checked before the buffer-fit rule, so a value that breaks both +/// is reported as over-cap. The guest branches on the code, and the two rules +/// answer different questions, so the order is worth pinning. +#[test] +fn the_field_cap_precedes_the_buffer_fit_check() { + let host = FakeHost::new().answering_field(1, Answer::claiming(MAX_FIELD_BYTES + 1)); + + // A 63-byte buffer: the value is both over the cap and far too big to fit. + let wat = module( + &[import::HOME_LE_FIELD, ONE_PAGE], + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))", + ); + assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge)); +} + +// --------------------------------------------------------------------------- +// Input regions (`Region::read`, via `sha512_half`) +// +// `sha512_half`'s first pair is an input region like any other, and it is the +// input the guest gets a status back from: `trace`, the other reader, answers +// nothing at all. So the codes are pinned here and the silence below. +// --------------------------------------------------------------------------- + +/// An input region is bounds-checked the same way an output region is. Every case +/// here stays within the field cap, which on an input is checked first. +#[test] +fn an_input_region_running_past_memory_is_refused() { + let host = FakeHost::new(); + + for (ptr, len) in [(PAGE, 1), (PAGE - 3, 4), (PAGE - 1, CAP)] { + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const {ptr}) (i32.const {len}) + (i32.const 0) (i32.const {HASH_LEN}))" + ), + ); + assert_eq!( + status(&wat, &host), + code(HostError::PointerOutOfBounds), + "ptr {ptr} len {len}" + ); + assert!(host.digested.borrow().is_empty(), "the host is not called"); + } +} + +#[test] +fn a_negative_input_pointer_or_length_is_refused() { + let host = FakeHost::new(); + + for (ptr, len) in [(-1, 1), (0, -1), (i32::MIN, 1)] { + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const {ptr}) (i32.const {len}) + (i32.const 0) (i32.const {HASH_LEN}))" + ), + ); + assert_eq!( + status(&wat, &host), + code(HostError::InvalidParams), + "ptr {ptr} len {len}" + ); + } +} + +/// The field cap bounds what the guest may hand *in*, too. +#[test] +fn an_input_past_the_field_cap_is_refused() { + let host = FakeHost::new(); + let digest = |len: i64| { + module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const 0) (i32.const {len}) + (i32.const 2048) (i32.const {HASH_LEN}))" + ), + ) + }; + + assert_eq!( + status(&digest(OVER_CAP), &host), + code(HostError::DataFieldTooLarge) + ); + assert!(host.digested.borrow().is_empty()); + + assert_eq!( + status(&digest(CAP), &host), + HASH_LEN as i32, + "the cap itself is allowed" + ); +} + +/// The two directions check in opposite orders: an input's length is known before +/// the read, so the cap comes first, while an output's region has to be resolved +/// before the host can produce a value, so bounds come first there. +#[test] +fn the_field_cap_precedes_the_bounds_check_on_an_input() { + let host = FakeHost::new(); + + let reading = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const 0) (i32.const {}) + (i32.const 0) (i32.const {HASH_LEN}))", + PAGE + 1 + ), + ); + assert_eq!(status(&reading, &host), code(HostError::DataFieldTooLarge)); + + let writing = module( + &[import::LDGR_INDEX, ONE_PAGE], + &format!("(call $ldgr_index (i32.const 0) (i32.const {}))", PAGE + 1), + ); + assert_eq!(status(&writing, &host), code(HostError::PointerOutOfBounds)); +} + +// --------------------------------------------------------------------------- +// The reader with no result (`read_borrowed`, via `trace`) +// --------------------------------------------------------------------------- + +/// `trace` reads two regions and either one being bad refuses the call. The same +/// rule as above, and the guest is told nothing: the refusal is the host not being +/// called, and the run carries on to the constant that follows. +#[test] +fn both_of_traces_regions_are_checked_silently() { + let host = FakeHost::new(); + let regions = [ + ( + format!("(i32.const {PAGE}) (i32.const 1)"), + EMPTY_REGION.to_owned(), + ), + ( + EMPTY_REGION.to_owned(), + format!("(i32.const {PAGE}) (i32.const 1)"), + ), + ( + EMPTY_REGION.to_owned(), + format!("(i32.const 0) (i32.const {OVER_CAP})"), + ), + ( + "(i32.const -1) (i32.const 1)".to_owned(), + EMPTY_REGION.to_owned(), + ), + ]; + + for (msg, data) in regions { + let wat = module( + &[import::TRACE, ONE_PAGE], + &traced(TraceDataType::AsHex, &msg, &data), + ); + assert_eq!(status(&wat, &host), COMPLETED, "msg {msg} data {data}"); + assert!( + host.traces().is_empty(), + "msg {msg} data {data}: the host must not be called" + ); + } +} + +// --------------------------------------------------------------------------- +// Both at once (`write_buffered`, via `sha512_half`) +// --------------------------------------------------------------------------- + +/// A call with an input and an output region decides everything about the input +/// before anything about the output, so a bad input is reported however the output +/// region is wrong — out of bounds, or a pointer that is not one at all. +/// +/// The whole output region, params included, is judged after the host has answered. +/// Hoisting any part of that above the call would put the output's verdict first for +/// these cases, and there is no half of it that can be hoisted on a principle the +/// other half shares. +#[test] +fn a_read_write_checks_its_input_before_its_output() { + let host = FakeHost::new(); + let digest = |src: i64, src_len: i64, dst: i64| { + module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const {src}) (i32.const {src_len}) + (i32.const {dst}) (i32.const {HASH_LEN}))" + ), + ) + }; + + let over_cap = digest(0, OVER_CAP, 0); + assert_eq!(status(&over_cap, &host), code(HostError::DataFieldTooLarge)); + + let out_of_bounds = digest(PAGE, 4, 0); + assert_eq!( + status(&out_of_bounds, &host), + code(HostError::PointerOutOfBounds) + ); + + // A bad input against each way the output can be wrong: the input's verdict is + // the one reported, and the host is never asked for a value nobody can take. + for dst in [PAGE, -1] { + let both_bad = digest(0, OVER_CAP, dst); + assert_eq!( + status(&both_bad, &host), + code(HostError::DataFieldTooLarge), + "dst {dst}" + ); + } + assert!(host.digested.borrow().is_empty(), "the host is not reached"); +} + +/// The output half of a read-write call obeys the same rules as a plain write. +#[test] +fn a_read_write_output_obeys_the_write_rules() { + let host = FakeHost::new().answering_digest(Answer::filler(32)); + + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + "(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 0) (i32.const 31))", + ); + assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall)); + + let wat = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!( + "(call $sha512_half (i32.const 0) (i32.const 4) (i32.const {PAGE}) (i32.const 32))" + ), + ); + assert_eq!(status(&wat, &host), code(HostError::PointerOutOfBounds)); +} + +/// A refused value reaches guest memory in no part, however much of it the host +/// wrote. The host answers with 32 bytes it did write and a length it did not, so +/// the refusal happens with the value sitting in the run's output buffer — and the +/// guest's buffer has to come back untouched. +/// +/// Stronger than the contract asks for: a guest must not read its buffer on a +/// negative status. It holds because the buffer is copied to the guest only after +/// the length, the bounds, the fit and the budget have all passed, so there is no +/// window in which a refused value is in guest memory. +#[test] +fn a_refused_value_leaves_nothing_in_guest_memory() { + const MARKER: u8 = 77; + + // The two refusals a value can meet after the host has produced it: longer + // than the field cap, and longer than the buffer the guest offered. + let refusals = [ + (MAX_FIELD_BYTES + 1, HASH_LEN, HostError::DataFieldTooLarge), + (HASH_LEN, HASH_LEN - 1, HostError::BufferTooSmall), + ]; + + for (claimed, cap, expected) in refusals { + let host = + FakeHost::new().answering_digest(Answer::writing_but_claiming([MARKER; 32], claimed)); + let call = format!( + "(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 64) (i32.const {cap}))" + ); + + let refused = module(&[import::SHA512_HALF, ONE_PAGE], &call); + assert_eq!( + status(&refused, &host), + code(expected), + "claiming {claimed}" + ); + + // The same call, reporting what is at the output region afterwards. + let inspect = module( + &[import::SHA512_HALF, ONE_PAGE], + &format!("(drop {call}) (i32.load8_u (i32.const 64))"), + ); + assert_eq!( + status(&inspect, &host), + 0, + "claiming {claimed}: the refused value must not have been written" + ); + } +} + +/// An input region may overlap the output region: the host is served the input as +/// it stands and its answer lands afterwards, so the two cannot interfere. The +/// marker is any byte distinct from the input's first (`a`), so `finish` returning +/// it proves the write landed. +#[test] +fn an_input_may_overlap_the_output() { + const MARKER: u8 = 99; + + let host = FakeHost::new().answering_digest(Answer::bytes([MARKER; HASH_LEN])); + + let wat = module( + &[ + import::SHA512_HALF, + ONE_PAGE, + r#"(data (i32.const 0) "abcd")"#, + ], + &format!( + "(drop (call $sha512_half (i32.const 0) (i32.const 4) + (i32.const 0) (i32.const {HASH_LEN}))) + (i32.load8_u (i32.const 0))" + ), + ); + assert_eq!( + status(&wat, &host), + i32::from(MARKER), + "the output overwrote the input" + ); + assert_eq!( + *host.digested.borrow(), + vec![b"abcd".to_vec()], + "the host saw the input as it was" + ); +} + +// --------------------------------------------------------------------------- +// The memory export itself +// --------------------------------------------------------------------------- + +/// A host call with no memory to work in ends the run instead of answering the +/// guest: there is no buffer for a status to describe, and nothing the guest could +/// do about the answer — which is what puts this beside out-of-gas on the fatal +/// channel. What the guest burned getting there is still charged. +fn assert_no_memory(wat: &str, host: &FakeHost) { + let failure = failure(wat, host); + assert!( + matches!(failure.error, RunError::NoMemory), + "expected the run to end for want of a memory export, got: {failure}" + ); + assert!(failure.fuel_used > 0, "{failure}"); +} + +/// Every region is relative to the guest's exported memory, so a module without +/// one cannot make a host call at all. +#[test] +fn a_module_that_exports_no_memory_cannot_call_the_host() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, "(memory 1)"], + "(call $ldgr_index (i32.const 0) (i32.const 4))", + ); + assert_no_memory(&wat, &host); +} + +/// Having no memory is answered before anything about a call's arguments, so a +/// module without one ends the run even when its arguments would have earned a +/// guest-visible code of their own (here an input over the field cap). +/// +/// The order is deliberate: no memory is a fact about the instance, not about this +/// call, and a region cannot be validated against a memory that is not there. It +/// costs the guest nothing — every call such a module makes ends the run anyway. +#[test] +fn no_memory_is_answered_before_a_calls_arguments_are() { + let host = FakeHost::new(); + + let wat = module( + &[import::SHA512_HALF, "(memory 1)"], + &format!( + "(call $sha512_half (i32.const 0) (i32.const {OVER_CAP}) + (i32.const 0) (i32.const {HASH_LEN}))" + ), + ); + assert_no_memory(&wat, &host); +} + +/// The memory's export *name* is not part of the contract: the engine takes the +/// module's memory whatever it is called. Nothing in the wasm spec attaches meaning +/// to `"memory"` — it is a toolchain convention, so the kind decides. +#[test] +fn a_memory_exported_under_any_name_is_the_guests_memory() { + let host = FakeHost::new(); + + for name in ["mem", "linear", "the memory"] { + let wat = module( + &[ + import::LDGR_INDEX, + &format!(r#"(memory (export "{name}") 1)"#), + ], + "(drop (call $ldgr_index (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!( + status(&wat, &host), + 7, + "the host wrote into the memory exported as '{name}'" + ); + } +} + +/// One memory exported under several names is one memory. The engine resolves the +/// first export of kind memory, and with at most one memory per module every such +/// export is that memory, so the order the exports are walked in cannot change the +/// answer. +#[test] +fn one_memory_exported_under_several_names_is_still_that_memory() { + let host = FakeHost::new(); + + let wat = module( + &[ + import::LDGR_INDEX, + r#"(memory (export "memory") (export "mem") (export "linear") 1)"#, + ], + "(drop (call $ldgr_index (i32.const 64) (i32.const 4))) + (i32.load (i32.const 64))", + ); + assert_eq!(status(&wat, &host), 7); +} + +/// The export has to *be* a memory: a global named `memory` is not one, and it +/// neither serves as the guest's memory nor hides the memory the module really +/// exports. The kind decides, so the conventional name carries no weight on +/// either side. +#[test] +fn an_export_named_memory_that_is_not_a_memory_is_not_the_guests_memory() { + let host = FakeHost::new(); + + let call = "(call $ldgr_index (i32.const 0) (i32.const 4))"; + + let wrong_kind = module( + &[ + import::LDGR_INDEX, + "(memory 1)", + r#"(global (export "memory") i32 (i32.const 0))"#, + ], + call, + ); + assert_no_memory(&wrong_kind, &host); + + let shadowed = module( + &[ + import::LDGR_INDEX, + r#"(memory (export "mem") 1)"#, + r#"(global (export "memory") i32 (i32.const 0))"#, + ], + call, + ); + assert_eq!( + status(&shadowed, &host), + 4, + "the real memory is found past the global that took its name" + ); +} + +/// Bounds follow the memory the module actually declared, not a fixed page. +#[test] +fn bounds_follow_the_declared_memory_size() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, r#"(memory (export "memory") 2)"#], + &format!("(call $ldgr_index (i32.const {PAGE}) (i32.const 4))"), + ); + assert_eq!(status(&wat, &host), 4, "the second page is in bounds"); +} diff --git a/crates/xrpl-wasm-vm/tests/preflight.rs b/crates/xrpl-wasm-vm/tests/preflight.rs new file mode 100644 index 0000000000..4746237916 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/preflight.rs @@ -0,0 +1,592 @@ +//! What screening refuses, and that it refuses nothing a run would have served. +//! +//! `check` reaches its verdict from the compiled module alone, so these tests take +//! no host — except the ones that put the same module through `run` to compare the +//! two. + +mod support; + +use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module}; +use xrpl_host_functions::HostFunctionSpec; +use xrpl_wasm_vm::{CheckError, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError}; + +/// Assert which stage screening refused a module at, because the caller maps the +/// stages separately. The error comes back out for the tests that also read its +/// message. +macro_rules! assert_stage { + ($refusal:expr, $stage:pat) => {{ + let refusal = $refusal; + assert!( + matches!(refusal, $stage), + concat!("expected a ", stringify!($stage), " refusal, got: {}"), + refusal + ); + refusal + }}; +} + +/// Screens `wat`, which must assemble. +fn check(wat: &str) -> Result<(), CheckError> { + xrpl_wasm_vm::check(&assemble(wat), ENTRY) +} + +fn refusal(wat: &str) -> CheckError { + check(wat).expect_err(&format!("expected this module to be refused:\n{wat}")) +} + +fn passes(wat: &str) { + if let Err(refusal) = check(wat) { + panic!("expected this module to pass, but: {refusal}\n{wat}"); + } +} + +// --------------------------------------------------------------------------- +// Compiling +// --------------------------------------------------------------------------- + +/// A contract that imports a host function, exports its memory and exports the +/// entry point is what screening is looking for. +#[test] +fn a_runnable_contract_passes() { + passes(&module( + &[import::LDGR_INDEX, ONE_PAGE], + "(call $ldgr_index (i32.const 0) (i32.const 4))", + )); +} + +/// Bytes that are not a wasm module at all. +#[test] +fn garbage_does_not_pass() { + for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] { + let refusal = xrpl_wasm_vm::check(bytes, ENTRY).expect_err("garbage must not pass"); + assert_stage!(refusal, CheckError::Compile(_)); + } +} + +/// Screening takes wasm binaries, and text is not one — the same rule the VM +/// applies, from the same `wasmi` built without its `wat` feature. Turning that +/// feature on would make this transaction blob valid at both ends. +#[test] +fn a_text_format_module_does_not_pass() { + let text = module(&[ONE_PAGE], "(i32.const 0)"); + + let refusal = + xrpl_wasm_vm::check(text.as_bytes(), ENTRY).expect_err("text must not pass as a module"); + assert_stage!(refusal, CheckError::Compile(_)); + + // The same module, assembled first, passes: the text is sound and only the + // format was refused. + passes(&text); +} + +/// A feature the engine disables is refused here too, because both stages compile +/// against the one engine. `vm_limits.rs` walks every disabled feature; this pins +/// that screening sees the same configuration. +#[test] +fn a_disabled_feature_does_not_pass() { + let refusal = refusal(&module( + &[ONE_PAGE], + "(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)", + )); + let refusal = assert_stage!(refusal, CheckError::Compile(_)).to_string(); + assert!(refusal.contains("floating-point"), "{refusal}"); +} + +// --------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------- + +/// Every host function the ABI declares, spelled as a guest imports it. The count +/// is asserted against the ABI so a function added to it cannot be left out here. +const ALL_IMPORTS: [&str; 61] = [ + import::LDGR_INDEX, + import::PARENT_LDGR_TIME, + import::PARENT_LDGR_HASH, + import::BASE_FEE, + import::AMENDMENT_ENABLED, + import::CACHE_LE, + import::TX_FIELD, + import::HOME_LE_FIELD, + import::LE_FIELD, + import::TX_INNER, + import::HOME_LE_INNER, + import::LE_INNER, + import::TX_ARR_LEN, + import::HOME_LE_ARR_LEN, + import::LE_ARR_LEN, + import::TX_INNER_ARR_LEN, + import::HOME_LE_INNER_ARR_LEN, + import::LE_INNER_ARR_LEN, + import::CHECK_SIG, + import::ACCOUNTROOT_ID, + import::AMM_ID, + import::CHECK_ID, + import::CREDENTIAL_ID, + import::DELEGATE_ID, + import::DEPOSIT_PREAUTH_ID, + import::DID_ID, + import::ESCROW_ID, + import::TRUSTLINE_ID, + import::MPT_ISSUANCE_ID, + import::MPTOKEN_ID, + import::NFT_OFFER_ID, + import::OFFER_ID, + import::ORACLE_ID, + import::PAYCHAN_ID, + import::PERMISSIONED_DOMAIN_ID, + import::SIGNERS_ID, + import::TICKET_ID, + import::VAULT_ID, + import::SHA512_HALF, + import::TRACE, + import::SET_DATA, + import::NFT_URI, + import::NFT_ISSUER, + import::NFT_TAXON, + import::NFT_FLAGS, + import::NFT_XFER_FEE, + import::NFT_SERIAL, + import::FLOAT_FROM_INT, + import::FLOAT_FROM_UINT, + import::FLOAT_FROM_STAMOUNT, + import::FLOAT_FROM_STNUMBER, + import::FLOAT_TO_INT, + import::FLOAT_TO_MANT_EXP, + import::FLOAT_FROM_MANT_EXP, + import::FLOAT_CMP, + import::FLOAT_ADD, + import::FLOAT_SUB, + import::FLOAT_MULT, + import::FLOAT_DIV, + import::FLOAT_ROOT, + import::FLOAT_POW, +]; + +#[test] +fn every_declared_host_function_may_be_imported() { + assert_eq!( + ALL_IMPORTS.len(), + HostFunctionSpec::ALL.len(), + "the ABI gained a host function with no import declaration in this test" + ); + + let mut parts = ALL_IMPORTS.to_vec(); + parts.push(ONE_PAGE); + passes(&module(&parts, "(i32.const 0)")); +} + +/// A module may import fewer host functions than are registered, but not more. +#[test] +fn an_unknown_host_function_does_not_pass() { + let refusal = refusal(&module( + &[ + r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#, + ONE_PAGE, + ], + "(call $f (i32.const 0))", + )); + let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string(); + assert!( + refusal.contains("no host function 'no_such_function'"), + "{refusal}" + ); +} + +/// Host functions live under one module name — `host_lib` — and an import naming +/// another is refused even when the function name is real. `env` is in the list +/// because that is what plain clang emits. +#[test] +fn an_import_from_another_module_does_not_pass() { + for module_name in ["host", "env", ""] { + let refusal = refusal(&module( + &[ + &format!( + r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"# + ), + ONE_PAGE, + ], + "(call $f (i32.const 0) (i32.const 4))", + )); + let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string(); + assert!(refusal.contains("is not from 'host_lib'"), "{refusal}"); + } +} + +/// A host function's name imported as something other than a function. The engine +/// defines it as a function and nothing else, so this does not link either. +#[test] +fn a_host_function_imported_as_a_global_does_not_pass() { + let refusal = refusal(&module( + &[ + r#"(import "host_lib" "ldgr_index" (global $g i32))"#, + ONE_PAGE, + ], + "(global.get $g)", + )); + let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string(); + assert!( + refusal.contains("'host_lib::ldgr_index' is not a function"), + "{refusal}" + ); +} + +/// A module faulty at two stages is refused by the earlier one — it imports what no +/// engine serves *and* exports no entry point. The imports are what the rest of the +/// module depends on, so that is the message worth having. +#[test] +fn the_earlier_stage_is_the_one_reported() { + let refusal = refusal( + r#"(module + (import "host_lib" "no_such_function" (func $f (result i32))) + (memory (export "memory") 1) + (func (export "not_the_entry_point") (result i32) (call $f)))"#, + ); + + assert_stage!(refusal, CheckError::Import(_)); +} + +/// The signature is the one part of an import screening does not compare, so a +/// module that will not link can still pass. Recorded here because it is the gap +/// this stage leaves, not because it is wanted. +#[test] +fn an_import_with_the_wrong_signature_still_passes() { + let wat = module( + &[ + r#"(import "host_lib" "ldgr_index" (func $f (param i64 i64) (result i32)))"#, + ONE_PAGE, + ], + "(i32.const 0)", + ); + passes(&wat); + + let host = FakeHost::new(); + let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) + .expect_err("a mistyped import must not link"); + assert!( + matches!(failure.error, RunError::Instantiate(_)), + "{failure}" + ); +} + +// --------------------------------------------------------------------------- +// The entry point +// --------------------------------------------------------------------------- + +#[test] +fn a_missing_entry_point_does_not_pass() { + let refusal = refusal( + r#"(module (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0)))"#, + ); + let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string(); + assert_eq!(refusal, "no entry point 'finish'"); +} + +/// The entry point is looked up by the name the caller asks for, as a run looks it +/// up: screening a contract for one entry point says nothing about another. +#[test] +fn the_entry_point_is_the_name_the_caller_gives() { + let wasm = assemble( + r#"(module (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0)))"#, + ); + + assert!(xrpl_wasm_vm::check(&wasm, "other").is_ok()); + assert!(xrpl_wasm_vm::check(&wasm, ENTRY).is_err()); +} + +/// Both halves of the entry point's type are screened: a module returning the +/// wrong thing, or taking anything at all, would fail the run's typed lookup. +#[test] +fn an_entry_point_of_the_wrong_type_does_not_pass() { + for (signature, body) in [ + ("(result i64)", "(i64.const 0)"), + ("(param i32) (result i32)", "(i32.const 0)"), + ("", "(nop)"), + ] { + let refusal = refusal(&format!( + r#"(module (memory (export "memory") 1) + (func (export "finish") {signature} {body}))"# + )); + let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string(); + assert_eq!( + refusal, "entry point 'finish' has the wrong signature, expected '() -> i32'", + "{signature}" + ); + } +} + +/// An export of the entry point's name that is not a function at all is a third +/// case, and named as such: nothing is missing and no signature is wrong. +#[test] +fn an_entry_point_that_is_not_a_function_does_not_pass() { + let refusal = refusal( + r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#, + ); + let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string(); + assert_eq!(refusal, "export 'finish' is not a function"); +} + +// --------------------------------------------------------------------------- +// Agreement with a run +// --------------------------------------------------------------------------- + +/// A module with no linear memory to export passes. A contract that makes no host +/// call needs none, and one that does is refused at the call and charged — a +/// runtime fault, not a malformed module. +#[test] +fn a_module_exporting_no_memory_passes() { + let wat = r#"(module (func (export "finish") (result i32) (i32.const 0)))"#; + passes(wat); + + let host = FakeHost::new(); + assert_eq!( + xrpl_wasm_vm::run(&assemble(wat), PLENTY_OF_GAS, &host, ENTRY) + .expect("a module that calls no host function needs no memory") + .result, + 0 + ); +} + +/// Modules spanning what screening decides, each also put through a run. +fn modules() -> Vec<(&'static str, String)> { + vec![ + ( + "a runnable contract", + module(&[import::LDGR_INDEX, ONE_PAGE], "(i32.const 0)"), + ), + ( + "a contract that traps", + module(&[ONE_PAGE], "(unreachable)"), + ), + ( + "a disabled feature", + module(&[ONE_PAGE], "(i32.extend8_s (i32.const 1))"), + ), + ( + "an unknown host function", + module( + &[ + r#"(import "host_lib" "nope" (func $f (result i32)))"#, + ONE_PAGE, + ], + "(call $f)", + ), + ), + ( + "an import from another module", + module( + &[ + r#"(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))"#, + ONE_PAGE, + ], + "(i32.const 0)", + ), + ), + ( + "a host function imported as a global", + module( + &[r#"(import "host_lib" "trace" (global $g i32))"#, ONE_PAGE], + "(global.get $g)", + ), + ), + ( + "no entry point", + r#"(module (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0)))"# + .to_string(), + ), + ( + "an entry point of the wrong type", + r#"(module (memory (export "memory") 1) + (func (export "finish") (result i64) (i64.const 0)))"# + .to_string(), + ), + ] +} + +/// Screening refuses a module exactly when a run would refuse it at one of the +/// three stages screening covers — nothing it rejects would have run, and nothing +/// it passes stops before the entry point is called. The exceptions are the ones +/// [`what_static_screening_cannot_see`] lists. +#[test] +fn screening_and_a_run_agree() { + let host = FakeHost::new(); + + for (label, wat) in modules() { + let wasm = assemble(&wat); + let refused_early = match xrpl_wasm_vm::run(&wasm, PLENTY_OF_GAS, &host, ENTRY) { + Err(failure) => matches!( + failure.error, + RunError::Compile(_) | RunError::Instantiate(_) | RunError::EntryPoint(_) + ), + Ok(_) => false, + }; + + assert_eq!( + xrpl_wasm_vm::check(&wasm, ENTRY).is_err(), + refused_early, + "{label}" + ); + } +} + +/// A module asking for more memory than the engine grants is refused, so the +/// contract that could never run does not reach the ledger. The cap itself passes. +#[test] +fn an_exported_memory_past_the_cap_does_not_pass() { + let wat = module( + &[&format!( + r#"(memory (export "memory") {})"#, + MAX_MEMORY_PAGES + 1 + )], + "(i32.const 0)", + ); + let refusal = assert_stage!(refusal(&wat), CheckError::Memory(_)).to_string(); + assert!(refusal.contains("past the 128-page cap"), "{refusal}"); + + passes(&module( + &[&format!(r#"(memory (export "memory") {MAX_MEMORY_PAGES})"#)], + "(i32.const 0)", + )); +} + +/// A declared *maximum* past the cap is legal and simply unreachable, so screening +/// must not turn it away: `vm_limits` runs this very module to completion. +#[test] +fn a_declared_maximum_past_the_cap_still_passes() { + passes(&module( + &[&format!( + r#"(memory (export "memory") 1 {})"#, + MAX_MEMORY_PAGES + 1 + )], + "(i32.const 0)", + )); +} + +/// A module asking for more table than the engine grants is refused for the same +/// reason a memory is. The cap itself passes. +#[test] +fn an_exported_table_past_the_cap_does_not_pass() { + let wat = module( + &[&format!( + r#"(table (export "t") {} funcref)"#, + MAX_TABLE_ELEMENTS + 1 + )], + "(i32.const 0)", + ); + let refusal = assert_stage!(refusal(&wat), CheckError::Table(_)).to_string(); + assert!(refusal.contains("past the 1024-element cap"), "{refusal}"); + + passes(&module( + &[&format!( + r#"(table (export "t") {MAX_TABLE_ELEMENTS} funcref)"# + )], + "(i32.const 0)", + )); +} + +/// Both caps are applied in one pass over the exports, so neither may end the walk +/// early: a passing memory must not hide a failing table declared after it, and a +/// passing table must not hide a failing memory. +#[test] +fn one_pass_screens_both_resources() { + let after_a_passing_memory = refusal(&module( + &[ + ONE_PAGE, + &format!(r#"(table (export "t") {} funcref)"#, MAX_TABLE_ELEMENTS + 1), + ], + "(i32.const 0)", + )); + assert_stage!(after_a_passing_memory, CheckError::Table(_)); + + let after_a_passing_table = refusal(&module( + &[ + r#"(table (export "t") 1 funcref)"#, + &format!(r#"(memory (export "memory") {})"#, MAX_MEMORY_PAGES + 1), + ], + "(i32.const 0)", + )); + assert_stage!(after_a_passing_table, CheckError::Memory(_)); +} + +/// As with memory, a declared *maximum* past the cap is unreachable rather than +/// wrong: `vm_limits` runs this very module to completion. +#[test] +fn a_declared_table_maximum_past_the_cap_still_passes() { + passes(&module( + &[&format!( + r#"(table (export "t") 1 {} funcref)"#, + MAX_TABLE_ELEMENTS + 1 + )], + "(i32.const 0)", + )); +} + +/// The gap, listed rather than described. A memory or a table a module keeps to +/// itself is not in its exports, so these are the modules that pass screening and +/// then fail to *instantiate* — which is why a run's refusal at that stage cannot be +/// read as the node's fault. +/// +/// The two entries are not equally remote. A contract needs an exported memory to +/// make any host call, so the memory row can do nothing but compute and the SDK does +/// not produce one. A table, though, is *normally* unexported — Rust exports +/// `__indirect_function_table` only under `--export-table` — so the table row is the +/// shape a hostile module actually takes, and the store's limiter is the only thing +/// standing in front of it. +#[test] +fn what_static_screening_cannot_see() { + let host = FakeHost::new(); + + for (label, declaration) in [ + ("memory", format!("(memory {})", MAX_MEMORY_PAGES + 1)), + ( + "table", + format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1), + ), + ] { + let wat = format!( + r#"(module {declaration} + (func (export "finish") (result i32) (i32.const 0)))"# + ); + + passes(&wat); + + let failure = match xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) { + Err(failure) => failure, + Ok(outcome) => panic!( + "the store's limiter must refuse the {label}, but the module returned {}", + outcome.result + ), + }; + assert!( + matches!(failure.error, RunError::Instantiate(_)), + "{label}: {failure}" + ); + } +} + +/// A start section is guest code, so screening cannot see whether it traps — and does +/// not have to. A trap is the guest's fault wherever it happens, so the run charges the +/// contract for what it burned instead of reporting a module the node should have +/// screened. +#[test] +fn a_start_section_screening_cannot_see_is_charged_as_a_trap() { + let host = FakeHost::new(); + let wat = format!( + r#"(module {ONE_PAGE} + (func $init (unreachable)) + (start $init) + (func (export "finish") (result i32) (i32.const 0)))"# + ); + + passes(&wat); + + let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) + .expect_err("a start section that traps must not complete the run"); + assert!(matches!(failure.error, RunError::Trap(_)), "{failure}"); + assert!( + failure.fuel_used > 0, + "charged for what it burned: {failure}" + ); +} diff --git a/crates/xrpl-wasm-vm/tests/support/mod.rs b/crates/xrpl-wasm-vm/tests/support/mod.rs new file mode 100644 index 0000000000..2d0ea923e6 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/support/mod.rs @@ -0,0 +1,1585 @@ +//! Shared scaffolding for the integration tests: a host whose every answer the +//! test sets, and the pieces of a wasm module to run against it. +//! +//! `abi.rs`'s guest-memory marshaling is reachable only from a live host call, so +//! each test assembles the smallest module that exercises one rule and reads the +//! verdict out of `finish`'s return value. + +#![allow(dead_code)] // Each test binary uses a different part of this module. + +use std::cell::RefCell; +use std::collections::HashMap; + +use xrpl_host_functions::{HostError, HostFunctions, HostResult, TraceDataType}; +use xrpl_wasm_vm::{RunFailure, RunOutcome}; + +/// The entry point every test module exports. +pub const ENTRY: &str = "finish"; + +/// Gas for a test that is not about gas: enough that nothing runs out. +pub const PLENTY_OF_GAS: u64 = 100_000_000; + +// --------------------------------------------------------------------------- +// The fake host +// --------------------------------------------------------------------------- + +/// What the host does when asked for a value. +#[derive(Clone, Debug)] +pub enum Answer { + /// Writes `bytes` into the output region if they fit, and reports `len` as + /// the true length either way. `len` is separate from `bytes.len()` so a + /// test can reach the over-cap and buffer-fit rules without a value that + /// large. + Value { bytes: Vec, len: usize }, + /// Fills the output region with `mark` and reports its length: a host whose + /// value is as large as the room it is given. What it writes and what it + /// reports are then both the width of the window the engine opened, which is + /// how a test observes that width rather than inferring it from a refusal. + AsMuchAsOffered { mark: u8 }, + /// Fails without touching the output region. + Fail(HostError), +} + +impl Answer { + /// Writes `bytes` and reports their true length. + pub fn bytes(bytes: impl Into>) -> Answer { + let bytes = bytes.into(); + Answer::Value { + len: bytes.len(), + bytes, + } + } + + /// Writes nothing and claims a value of `len` bytes. It under-writes relative + /// to a real host, which writes whenever the value fits `out`, so a test about + /// what lands in guest memory wants [`Answer::bytes`] instead. + pub fn claiming(len: usize) -> Answer { + Answer::Value { + bytes: Vec::new(), + len, + } + } + + /// Writes `bytes` and reports `len` regardless — a host whose value is longer + /// than what it put in the buffer, which the engine has to refuse without + /// letting those bytes reach the guest. + pub fn writing_but_claiming(bytes: impl Into>, len: usize) -> Answer { + Answer::Value { + bytes: bytes.into(), + len, + } + } + + /// `len` bytes counting up from 0, written and reported. + pub fn filler(len: usize) -> Answer { + Answer::bytes((0..len).map(|i| i as u8).collect::>()) + } + + /// A `mark` in every byte it is offered, reporting that many. See + /// [`Answer::AsMuchAsOffered`]. + pub fn as_much_as_offered(mark: u8) -> Answer { + Answer::AsMuchAsOffered { mark } + } + + fn fill(&self, out: &mut [u8]) -> HostResult { + match self { + Answer::Value { bytes, len } => { + if bytes.len() <= out.len() { + out[..bytes.len()].copy_from_slice(bytes); + } + Ok(*len) + } + Answer::AsMuchAsOffered { mark } => { + out.fill(*mark); + Ok(out.len()) + } + Answer::Fail(error) => Err(*error), + } + } +} + +/// One `trace` call, as the host received it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Trace { + pub msg: String, + pub data_type: TraceDataType, + pub data: Vec, +} + +/// The `(message, signature, pubkey)` `check_signature` takes. +pub type SigCheck = (Vec, Vec, Vec); + +/// The `(subject, issuer, type)` `credential_keylet` takes. +pub type CredentialKey = (Vec, Vec, Vec); + +/// The `(account1, account2, currency)` `trust_line_keylet` takes. +pub type TrustLineKey = (Vec, Vec, Vec); + +/// The `(account, destination, seq)` `paychannel_keylet` takes. +pub type PaychannelKey = (Vec, Vec, i32); + +/// One call to a float operator over two floats — `float_add`, `float_subtract`, +/// `float_multiply`, `float_divide` — as `(operator, x, y, mode)`. +pub type FloatBinaryCall = (&'static str, Vec, Vec, i32); + +/// One call to a float operator over a float and an integer — `float_root`, +/// `float_power` — as `(operator, x, n, mode)`. +pub type FloatUnaryCall = (&'static str, Vec, i32, i32); + +/// A `HostFunctions` implementation that answers from what the test put in it and +/// records what it was asked. The ABI's receiver is `&self`, so the recording goes +/// behind `RefCell`, as a real mutating host's would. +pub struct FakeHost { + /// What `get_ledger_sqn` answers. + pub ledger_sqn: Answer, + /// What `get_parent_ledger_time` answers. + pub parent_ledger_time: Answer, + /// What `get_parent_ledger_hash` answers. + pub parent_ledger_hash: Answer, + /// What `get_base_fee` answers. + pub base_fee: Answer, + /// What `is_amendment_enabled` answers, whatever amendment it is given. + pub amendment_enabled: HostResult, + /// Every amendment `is_amendment_enabled` was asked about. + pub amendments_asked: RefCell>>, + /// What `cache_ledger_obj` answers: the slot it "used". + pub cache_slot: HostResult, + /// Every (object id, requested slot) `cache_ledger_obj` was asked to cache. + pub cached: RefCell, i32)>>, + /// What `get_tx_field` answers, by field selector. An unlisted selector answers + /// `FieldNotFound`. + pub tx_fields: HashMap, + /// Every field selector `get_tx_field` was asked for. + pub tx_fields_asked: RefCell>, + /// What `get_current_ledger_obj_field` answers, by field selector. An + /// unlisted selector answers `FieldNotFound`. + pub fields: HashMap, + /// What `get_ledger_obj_field` answers, by (cache slot, field selector). An + /// unlisted key answers `FieldNotFound`. + pub le_fields: HashMap<(i32, i32), Answer>, + /// Every (cache slot, field selector) `get_ledger_obj_field` was asked for. + pub le_fields_asked: RefCell>, + /// What `get_tx_nested_field` answers, by locator bytes. An unlisted locator + /// answers `FieldNotFound`. + pub tx_nested: HashMap, Answer>, + /// Every locator `get_tx_nested_field` was asked for. + pub tx_nested_asked: RefCell>>, + /// What `get_current_ledger_obj_nested_field` answers, by locator bytes. An + /// unlisted locator answers `FieldNotFound`. + pub home_le_nested: HashMap, Answer>, + /// Every locator `get_current_ledger_obj_nested_field` was asked for. + pub home_le_nested_asked: RefCell>>, + /// What `get_ledger_obj_nested_field` answers, by (cache slot, locator bytes). An + /// unlisted key answers `FieldNotFound`. + pub le_nested: HashMap<(i32, Vec), Answer>, + /// Every (cache slot, locator) `get_ledger_obj_nested_field` was asked for. + pub le_nested_asked: RefCell)>>, + /// What `get_tx_array_len` answers, by field selector. An unlisted selector + /// answers `NoArray`. + pub tx_arr_lens: HashMap, + /// Every field selector `get_tx_array_len` was asked for. + pub tx_arr_lens_asked: RefCell>, + /// What `get_current_ledger_obj_array_len` answers, by field selector. An + /// unlisted selector answers `NoArray`. + pub home_le_arr_lens: HashMap, + /// Every field selector `get_current_ledger_obj_array_len` was asked for. + pub home_le_arr_lens_asked: RefCell>, + /// What `get_ledger_obj_array_len` answers, by (cache slot, field selector). An + /// unlisted key answers `NoArray`. + pub le_arr_lens: HashMap<(i32, i32), i32>, + /// Every (cache slot, field selector) `get_ledger_obj_array_len` was asked for. + pub le_arr_lens_asked: RefCell>, + /// What `get_tx_nested_array_len` answers, by locator bytes. An unlisted locator + /// answers `NoArray`. + pub tx_nested_arr_lens: HashMap, i32>, + /// Every locator `get_tx_nested_array_len` was asked for. + pub tx_nested_arr_lens_asked: RefCell>>, + /// What `get_current_ledger_obj_nested_array_len` answers, by locator bytes. An + /// unlisted locator answers `NoArray`. + pub home_le_nested_arr_lens: HashMap, i32>, + /// Every locator `get_current_ledger_obj_nested_array_len` was asked for. + pub home_le_nested_arr_lens_asked: RefCell>>, + /// What `get_ledger_obj_nested_array_len` answers, by (cache slot, locator bytes). + /// An unlisted key answers `NoArray`. + pub le_nested_arr_lens: HashMap<(i32, Vec), i32>, + /// Every (cache slot, locator) `get_ledger_obj_nested_array_len` was asked for. + pub le_nested_arr_lens_asked: RefCell)>>, + /// What `check_signature` answers, whatever it is given. + pub sig_valid: HostResult, + /// Every (message, signature, pubkey) `check_signature` was asked to verify. + pub sigs_checked: RefCell>, + /// What `account_keylet` answers, by account bytes. An unlisted account answers + /// `InvalidAccount`. + pub account_keylets: HashMap, Answer>, + /// Every account `account_keylet` was asked for. + pub account_keylets_asked: RefCell>>, + /// What `amm_keylet` answers, by (asset1, asset2) bytes. An unlisted pair answers + /// `InvalidParams`. + pub amm_keylets: HashMap<(Vec, Vec), Answer>, + /// Every (asset1, asset2) pair `amm_keylet` was asked for. + pub amm_keylets_asked: RefCell, Vec)>>, + /// What `check_keylet` answers, by (account bytes, seq). An unlisted key answers + /// `InvalidAccount`. + pub check_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `check_keylet` was asked for. + pub check_keylets_asked: RefCell, i32)>>, + /// What `credential_keylet` answers, by (subject, issuer, type) bytes. An unlisted + /// key answers `InvalidAccount`. + pub credential_keylets: HashMap, + /// Every (subject, issuer, type) `credential_keylet` was asked for. + pub credential_keylets_asked: RefCell>, + /// What `delegate_keylet` answers, by (account, authorize) bytes. An unlisted key + /// answers `InvalidAccount`. + pub delegate_keylets: HashMap<(Vec, Vec), Answer>, + /// Every (account, authorize) `delegate_keylet` was asked for. + pub delegate_keylets_asked: RefCell, Vec)>>, + /// What `deposit_preauth_keylet` answers, by (account, authorize) bytes. An + /// unlisted key answers `InvalidAccount`. + pub deposit_preauth_keylets: HashMap<(Vec, Vec), Answer>, + /// Every (account, authorize) `deposit_preauth_keylet` was asked for. + pub deposit_preauth_keylets_asked: RefCell, Vec)>>, + /// What `did_keylet` answers, by account bytes. An unlisted account answers + /// `InvalidAccount`. + pub did_keylets: HashMap, Answer>, + /// Every account `did_keylet` was asked for. + pub did_keylets_asked: RefCell>>, + /// What `escrow_keylet` answers, by (account bytes, seq). An unlisted key answers + /// `InvalidAccount`. + pub escrow_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `escrow_keylet` was asked for. + pub escrow_keylets_asked: RefCell, i32)>>, + /// What `trust_line_keylet` answers, by (account1, account2, currency) bytes. An + /// unlisted key answers `InvalidAccount`. + pub trust_line_keylets: HashMap, + /// Every (account1, account2, currency) `trust_line_keylet` was asked for. + pub trust_line_keylets_asked: RefCell>, + /// What `mptoken_issuance_keylet` answers, by (issuer bytes, seq). An unlisted key + /// answers `InvalidAccount`. + pub mpt_issuance_keylets: HashMap<(Vec, i32), Answer>, + /// Every (issuer, seq) `mptoken_issuance_keylet` was asked for. + pub mpt_issuance_keylets_asked: RefCell, i32)>>, + /// What `mptoken_keylet` answers, by (mptid, holder) bytes. An unlisted key answers + /// `InvalidParams`. + pub mptoken_keylets: HashMap<(Vec, Vec), Answer>, + /// Every (mptid, holder) `mptoken_keylet` was asked for. + pub mptoken_keylets_asked: RefCell, Vec)>>, + /// What `nftoken_offer_keylet` answers, by (account bytes, seq). An unlisted key + /// answers `InvalidAccount`. + pub nft_offer_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `nftoken_offer_keylet` was asked for. + pub nft_offer_keylets_asked: RefCell, i32)>>, + /// What `offer_keylet` answers, by (account bytes, seq). An unlisted key + /// answers `InvalidAccount`. + pub offer_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `offer_keylet` was asked for. + pub offer_keylets_asked: RefCell, i32)>>, + /// What `oracle_keylet` answers, by (account bytes, doc id). An unlisted key + /// answers `InvalidAccount`. + pub oracle_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, doc id) `oracle_keylet` was asked for. + pub oracle_keylets_asked: RefCell, i32)>>, + /// What `paychannel_keylet` answers, by (account, destination, seq). An unlisted + /// key answers `InvalidAccount`. + pub paychannel_keylets: HashMap, + /// Every (account, destination, seq) `paychannel_keylet` was asked for. + pub paychannel_keylets_asked: RefCell>, + /// What `permissioned_domain_keylet` answers, by (account bytes, seq). An unlisted + /// key answers `InvalidAccount`. + pub domain_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `permissioned_domain_keylet` was asked for. + pub domain_keylets_asked: RefCell, i32)>>, + /// What `signer_list_keylet` answers, by account bytes. An unlisted account + /// answers `InvalidAccount`. + pub signer_list_keylets: HashMap, Answer>, + /// Every account `signer_list_keylet` was asked for. + pub signer_list_keylets_asked: RefCell>>, + /// What `ticket_keylet` answers, by (account bytes, seq). An unlisted key answers + /// `InvalidAccount`. + pub ticket_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `ticket_keylet` was asked for. + pub ticket_keylets_asked: RefCell, i32)>>, + /// What `vault_keylet` answers, by (account bytes, seq). An unlisted key answers + /// `InvalidAccount`. + pub vault_keylets: HashMap<(Vec, i32), Answer>, + /// Every (account, seq) `vault_keylet` was asked for. + pub vault_keylets_asked: RefCell, i32)>>, + /// What `sha512_half` answers, whatever it is given. + pub digest: Answer, + /// Every field selector `get_current_ledger_obj_field` was asked for. + pub fields_asked: RefCell>, + /// Every input `sha512_half` was given. + pub digested: RefCell>>, + /// Every `trace` call, in order. + pub traces: RefCell>, + /// What `trace` fails with, after recording the call. `trace` has no result, + /// so this is how a test reaches what the engine does with an error it cannot + /// report. + pub trace_failure: Option, + /// What `update_data` answers, whatever data it is given. + pub update_data_answer: HostResult, + /// Every data blob `update_data` was given. + pub update_data_asked: RefCell>>, + /// What `get_nft` answers, by (account, nft id) bytes. An unlisted key answers + /// `InvalidParams`. + pub nfts: HashMap<(Vec, Vec), Answer>, + /// Every (account, nft id) `get_nft` was asked for. + pub nfts_asked: RefCell, Vec)>>, + /// What `get_nft_issuer` answers, by nft id. An unlisted id answers `InvalidParams`. + pub nft_issuers: HashMap, Answer>, + /// Every nft id `get_nft_issuer` was asked for. + pub nft_issuers_asked: RefCell>>, + /// What `get_nft_taxon` answers, by nft id. An unlisted id answers `InvalidParams`. + pub nft_taxons: HashMap, Answer>, + /// Every nft id `get_nft_taxon` was asked for. + pub nft_taxons_asked: RefCell>>, + /// What `get_nft_flags` answers, whatever nft id it is given. + pub nft_flags_answer: HostResult, + /// Every nft id `get_nft_flags` was asked for. + pub nft_flags_asked: RefCell>>, + /// What `get_nft_transfer_fee` answers, whatever nft id it is given. + pub nft_fee_answer: HostResult, + /// Every nft id `get_nft_transfer_fee` was asked for. + pub nft_fee_asked: RefCell>>, + /// What `get_nft_sequence` answers, by nft id. An unlisted id answers + /// `InvalidParams`. + pub nft_sequences: HashMap, Answer>, + /// Every nft id `get_nft_sequence` was asked for. + pub nft_sequences_asked: RefCell>>, + + /// What every float-producing call writes. + pub float_answer: Answer, + /// Every `(x, mode)` `float_from_int` was asked for. + pub float_from_int_asked: RefCell>, + /// Every `(x, mode)` `float_from_uint` was asked for. + pub float_from_uint_asked: RefCell, i32)>>, + /// Every `(amount, mode)` `float_from_stamount` was asked for. + pub float_from_stamount_asked: RefCell, i32)>>, + /// Every `(number, mode)` `float_from_stnumber` was asked for. + pub float_from_stnumber_asked: RefCell, i32)>>, + /// Every `(x, mode)` `float_to_int` was asked for. + pub float_to_int_asked: RefCell, i32)>>, + /// The mantissa and exponent bytes `float_to_mant_exp` writes to its two regions. + pub float_mant_exp_answer: (Vec, Vec), + /// Every float `float_to_mant_exp` was asked for. + pub float_to_mant_exp_asked: RefCell>>, + /// Every `(mantissa, exponent, mode)` `float_from_mant_exp` was asked for. + pub float_from_mant_exp_asked: RefCell>, + /// What `float_compare` answers, whatever floats it is given. + pub float_compare_answer: HostResult, + /// Every `(x, y)` `float_compare` was asked for. + pub float_compare_asked: RefCell, Vec)>>, + /// Every `(x, y, mode)` the four binary float operators were asked for, tagged by + /// operator name. + pub float_binary_ops_asked: RefCell>, + /// Every `(x, n, mode)` `float_root` and `float_power` were asked for, tagged by + /// operator name. + pub float_unary_ops_asked: RefCell>, +} + +impl Default for FakeHost { + fn default() -> FakeHost { + FakeHost { + // 4 little-endian bytes, as the declaration's doc comment specifies. + ledger_sqn: Answer::bytes(7u32.to_le_bytes()), + // A distinct value from the sequence number, so a test cannot pass by + // reading one where it meant the other. + parent_ledger_time: Answer::bytes(9u32.to_le_bytes()), + // 32 bytes counting up from 0, the length of a real ledger hash. + parent_ledger_hash: Answer::filler(32), + // A distinct value again, so no getter can pass by reading another's answer. + base_fee: Answer::bytes(10u32.to_le_bytes()), + // Enabled by default; the id-or-name dispatch is the host's job, not the ABI's. + amendment_enabled: Ok(1), + amendments_asked: RefCell::new(Vec::new()), + // Slot 1 by default; slot assignment is the host's job, not the ABI's. + cache_slot: Ok(1), + cached: RefCell::new(Vec::new()), + tx_fields: HashMap::new(), + tx_fields_asked: RefCell::new(Vec::new()), + fields: HashMap::new(), + le_fields: HashMap::new(), + le_fields_asked: RefCell::new(Vec::new()), + tx_nested: HashMap::new(), + tx_nested_asked: RefCell::new(Vec::new()), + home_le_nested: HashMap::new(), + home_le_nested_asked: RefCell::new(Vec::new()), + le_nested: HashMap::new(), + le_nested_asked: RefCell::new(Vec::new()), + tx_arr_lens: HashMap::new(), + tx_arr_lens_asked: RefCell::new(Vec::new()), + home_le_arr_lens: HashMap::new(), + home_le_arr_lens_asked: RefCell::new(Vec::new()), + le_arr_lens: HashMap::new(), + le_arr_lens_asked: RefCell::new(Vec::new()), + tx_nested_arr_lens: HashMap::new(), + tx_nested_arr_lens_asked: RefCell::new(Vec::new()), + home_le_nested_arr_lens: HashMap::new(), + home_le_nested_arr_lens_asked: RefCell::new(Vec::new()), + le_nested_arr_lens: HashMap::new(), + le_nested_arr_lens_asked: RefCell::new(Vec::new()), + // Valid by default; the verification itself is the host's job, not the ABI's. + sig_valid: Ok(1), + sigs_checked: RefCell::new(Vec::new()), + account_keylets: HashMap::new(), + account_keylets_asked: RefCell::new(Vec::new()), + amm_keylets: HashMap::new(), + amm_keylets_asked: RefCell::new(Vec::new()), + check_keylets: HashMap::new(), + check_keylets_asked: RefCell::new(Vec::new()), + credential_keylets: HashMap::new(), + credential_keylets_asked: RefCell::new(Vec::new()), + delegate_keylets: HashMap::new(), + delegate_keylets_asked: RefCell::new(Vec::new()), + deposit_preauth_keylets: HashMap::new(), + deposit_preauth_keylets_asked: RefCell::new(Vec::new()), + did_keylets: HashMap::new(), + did_keylets_asked: RefCell::new(Vec::new()), + escrow_keylets: HashMap::new(), + escrow_keylets_asked: RefCell::new(Vec::new()), + trust_line_keylets: HashMap::new(), + trust_line_keylets_asked: RefCell::new(Vec::new()), + mpt_issuance_keylets: HashMap::new(), + mpt_issuance_keylets_asked: RefCell::new(Vec::new()), + mptoken_keylets: HashMap::new(), + mptoken_keylets_asked: RefCell::new(Vec::new()), + nft_offer_keylets: HashMap::new(), + nft_offer_keylets_asked: RefCell::new(Vec::new()), + offer_keylets: HashMap::new(), + offer_keylets_asked: RefCell::new(Vec::new()), + oracle_keylets: HashMap::new(), + oracle_keylets_asked: RefCell::new(Vec::new()), + paychannel_keylets: HashMap::new(), + paychannel_keylets_asked: RefCell::new(Vec::new()), + domain_keylets: HashMap::new(), + domain_keylets_asked: RefCell::new(Vec::new()), + signer_list_keylets: HashMap::new(), + signer_list_keylets_asked: RefCell::new(Vec::new()), + ticket_keylets: HashMap::new(), + ticket_keylets_asked: RefCell::new(Vec::new()), + vault_keylets: HashMap::new(), + vault_keylets_asked: RefCell::new(Vec::new()), + digest: Answer::filler(32), + fields_asked: RefCell::new(Vec::new()), + digested: RefCell::new(Vec::new()), + traces: RefCell::new(Vec::new()), + trace_failure: None, + update_data_answer: Ok(0), + update_data_asked: RefCell::new(Vec::new()), + nfts: HashMap::new(), + nfts_asked: RefCell::new(Vec::new()), + nft_issuers: HashMap::new(), + nft_issuers_asked: RefCell::new(Vec::new()), + nft_taxons: HashMap::new(), + nft_taxons_asked: RefCell::new(Vec::new()), + nft_flags_answer: Ok(0), + nft_flags_asked: RefCell::new(Vec::new()), + nft_fee_answer: Ok(0), + nft_fee_asked: RefCell::new(Vec::new()), + nft_sequences: HashMap::new(), + nft_sequences_asked: RefCell::new(Vec::new()), + float_answer: Answer::filler(8), + float_from_int_asked: RefCell::new(Vec::new()), + float_from_uint_asked: RefCell::new(Vec::new()), + float_from_stamount_asked: RefCell::new(Vec::new()), + float_from_stnumber_asked: RefCell::new(Vec::new()), + float_to_int_asked: RefCell::new(Vec::new()), + float_mant_exp_answer: (vec![0u8; 8], vec![0u8; 4]), + float_to_mant_exp_asked: RefCell::new(Vec::new()), + float_from_mant_exp_asked: RefCell::new(Vec::new()), + float_compare_answer: Ok(0), + float_compare_asked: RefCell::new(Vec::new()), + float_binary_ops_asked: RefCell::new(Vec::new()), + float_unary_ops_asked: RefCell::new(Vec::new()), + } + } +} + +impl FakeHost { + pub fn new() -> FakeHost { + FakeHost::default() + } + + pub fn answering_sqn(mut self, answer: Answer) -> FakeHost { + self.ledger_sqn = answer; + self + } + + pub fn answering_parent_ledger_time(mut self, answer: Answer) -> FakeHost { + self.parent_ledger_time = answer; + self + } + + pub fn answering_parent_ledger_hash(mut self, answer: Answer) -> FakeHost { + self.parent_ledger_hash = answer; + self + } + + pub fn answering_base_fee(mut self, answer: Answer) -> FakeHost { + self.base_fee = answer; + self + } + + pub fn answering_amendment_enabled(mut self, answer: HostResult) -> FakeHost { + self.amendment_enabled = answer; + self + } + + pub fn answering_cache_slot(mut self, answer: HostResult) -> FakeHost { + self.cache_slot = answer; + self + } + + pub fn answering_tx_field(mut self, field: i32, answer: Answer) -> FakeHost { + self.tx_fields.insert(field, answer); + self + } + + pub fn answering_field(mut self, field: i32, answer: Answer) -> FakeHost { + self.fields.insert(field, answer); + self + } + + pub fn answering_le_field(mut self, cache_idx: i32, field: i32, answer: Answer) -> FakeHost { + self.le_fields.insert((cache_idx, field), answer); + self + } + + pub fn answering_tx_nested(mut self, locator: Vec, answer: Answer) -> FakeHost { + self.tx_nested.insert(locator, answer); + self + } + + pub fn answering_home_le_nested(mut self, locator: Vec, answer: Answer) -> FakeHost { + self.home_le_nested.insert(locator, answer); + self + } + + pub fn answering_le_nested( + mut self, + cache_idx: i32, + locator: Vec, + answer: Answer, + ) -> FakeHost { + self.le_nested.insert((cache_idx, locator), answer); + self + } + + pub fn answering_tx_arr_len(mut self, field: i32, len: i32) -> FakeHost { + self.tx_arr_lens.insert(field, len); + self + } + + pub fn answering_home_le_arr_len(mut self, field: i32, len: i32) -> FakeHost { + self.home_le_arr_lens.insert(field, len); + self + } + + pub fn answering_le_arr_len(mut self, cache_idx: i32, field: i32, len: i32) -> FakeHost { + self.le_arr_lens.insert((cache_idx, field), len); + self + } + + pub fn answering_tx_nested_arr_len(mut self, locator: Vec, len: i32) -> FakeHost { + self.tx_nested_arr_lens.insert(locator, len); + self + } + + pub fn answering_home_le_nested_arr_len(mut self, locator: Vec, len: i32) -> FakeHost { + self.home_le_nested_arr_lens.insert(locator, len); + self + } + + pub fn answering_le_nested_arr_len( + mut self, + cache_idx: i32, + locator: Vec, + len: i32, + ) -> FakeHost { + self.le_nested_arr_lens.insert((cache_idx, locator), len); + self + } + + pub fn answering_check_sig(mut self, answer: HostResult) -> FakeHost { + self.sig_valid = answer; + self + } + + pub fn answering_account_keylet(mut self, account: Vec, answer: Answer) -> FakeHost { + self.account_keylets.insert(account, answer); + self + } + + pub fn answering_amm_keylet( + mut self, + asset1: Vec, + asset2: Vec, + answer: Answer, + ) -> FakeHost { + self.amm_keylets.insert((asset1, asset2), answer); + self + } + + pub fn answering_check_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.check_keylets.insert((account, seq), answer); + self + } + + pub fn answering_credential_keylet( + mut self, + subject: Vec, + issuer: Vec, + credential_type: Vec, + answer: Answer, + ) -> FakeHost { + self.credential_keylets + .insert((subject, issuer, credential_type), answer); + self + } + + pub fn answering_delegate_keylet( + mut self, + account: Vec, + authorize: Vec, + answer: Answer, + ) -> FakeHost { + self.delegate_keylets.insert((account, authorize), answer); + self + } + + pub fn answering_deposit_preauth_keylet( + mut self, + account: Vec, + authorize: Vec, + answer: Answer, + ) -> FakeHost { + self.deposit_preauth_keylets + .insert((account, authorize), answer); + self + } + + pub fn answering_did_keylet(mut self, account: Vec, answer: Answer) -> FakeHost { + self.did_keylets.insert(account, answer); + self + } + + pub fn answering_escrow_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.escrow_keylets.insert((account, seq), answer); + self + } + + pub fn answering_trust_line_keylet( + mut self, + account1: Vec, + account2: Vec, + currency: Vec, + answer: Answer, + ) -> FakeHost { + self.trust_line_keylets + .insert((account1, account2, currency), answer); + self + } + + pub fn answering_mpt_issuance_keylet( + mut self, + issuer: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.mpt_issuance_keylets.insert((issuer, seq), answer); + self + } + + pub fn answering_mptoken_keylet( + mut self, + mptid: Vec, + holder: Vec, + answer: Answer, + ) -> FakeHost { + self.mptoken_keylets.insert((mptid, holder), answer); + self + } + + pub fn answering_nft_offer_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.nft_offer_keylets.insert((account, seq), answer); + self + } + + pub fn answering_offer_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.offer_keylets.insert((account, seq), answer); + self + } + + pub fn answering_oracle_keylet( + mut self, + account: Vec, + doc_id: i32, + answer: Answer, + ) -> FakeHost { + self.oracle_keylets.insert((account, doc_id), answer); + self + } + + pub fn answering_paychannel_keylet( + mut self, + account: Vec, + destination: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.paychannel_keylets + .insert((account, destination, seq), answer); + self + } + + pub fn answering_permissioned_domain_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.domain_keylets.insert((account, seq), answer); + self + } + + pub fn answering_signer_list_keylet(mut self, account: Vec, answer: Answer) -> FakeHost { + self.signer_list_keylets.insert(account, answer); + self + } + + pub fn answering_ticket_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.ticket_keylets.insert((account, seq), answer); + self + } + + pub fn answering_vault_keylet( + mut self, + account: Vec, + seq: i32, + answer: Answer, + ) -> FakeHost { + self.vault_keylets.insert((account, seq), answer); + self + } + + pub fn answering_digest(mut self, answer: Answer) -> FakeHost { + self.digest = answer; + self + } + + pub fn answering_update_data(mut self, answer: HostResult) -> FakeHost { + self.update_data_answer = answer; + self + } + + pub fn answering_get_nft( + mut self, + account: Vec, + nft_id: Vec, + answer: Answer, + ) -> FakeHost { + self.nfts.insert((account, nft_id), answer); + self + } + + pub fn answering_nft_issuer(mut self, nft_id: Vec, answer: Answer) -> FakeHost { + self.nft_issuers.insert(nft_id, answer); + self + } + + pub fn answering_nft_taxon(mut self, nft_id: Vec, answer: Answer) -> FakeHost { + self.nft_taxons.insert(nft_id, answer); + self + } + + pub fn answering_nft_flags(mut self, answer: HostResult) -> FakeHost { + self.nft_flags_answer = answer; + self + } + + pub fn answering_nft_transfer_fee(mut self, answer: HostResult) -> FakeHost { + self.nft_fee_answer = answer; + self + } + + pub fn answering_nft_sequence(mut self, nft_id: Vec, answer: Answer) -> FakeHost { + self.nft_sequences.insert(nft_id, answer); + self + } + + pub fn answering_float(mut self, answer: Answer) -> FakeHost { + self.float_answer = answer; + self + } + + pub fn answering_float_mant_exp(mut self, mantissa: Vec, exponent: Vec) -> FakeHost { + self.float_mant_exp_answer = (mantissa, exponent); + self + } + + pub fn answering_float_compare(mut self, answer: HostResult) -> FakeHost { + self.float_compare_answer = answer; + self + } + + pub fn failing_trace(mut self, error: HostError) -> FakeHost { + self.trace_failure = Some(error); + self + } + + pub fn traces(&self) -> Vec { + self.traces.borrow().clone() + } +} + +impl HostFunctions for FakeHost { + fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult { + self.ledger_sqn.fill(out) + } + + fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult { + self.parent_ledger_time.fill(out) + } + + fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult { + self.parent_ledger_hash.fill(out) + } + + fn get_base_fee(&self, out: &mut [u8]) -> HostResult { + self.base_fee.fill(out) + } + + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult { + self.amendments_asked.borrow_mut().push(amendment.to_vec()); + self.amendment_enabled + } + + fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult { + self.cached.borrow_mut().push((obj_id.to_vec(), cache_idx)); + self.cache_slot + } + + fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult { + self.tx_fields_asked.borrow_mut().push(field); + match self.tx_fields.get(&field) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult { + self.fields_asked.borrow_mut().push(field); + match self.fields.get(&field) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_ledger_obj_field( + &self, + cache_idx: i32, + field: i32, + out: &mut [u8], + ) -> HostResult { + self.le_fields_asked.borrow_mut().push((cache_idx, field)); + match self.le_fields.get(&(cache_idx, field)) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult { + self.tx_nested_asked.borrow_mut().push(locator.to_vec()); + match self.tx_nested.get(locator) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_current_ledger_obj_nested_field( + &self, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + self.home_le_nested_asked + .borrow_mut() + .push(locator.to_vec()); + match self.home_le_nested.get(locator) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_ledger_obj_nested_field( + &self, + cache_idx: i32, + locator: &[u8], + out: &mut [u8], + ) -> HostResult { + self.le_nested_asked + .borrow_mut() + .push((cache_idx, locator.to_vec())); + match self.le_nested.get(&(cache_idx, locator.to_vec())) { + Some(answer) => answer.fill(out), + None => Err(HostError::FieldNotFound), + } + } + + fn get_tx_array_len(&self, field: i32) -> HostResult { + self.tx_arr_lens_asked.borrow_mut().push(field); + match self.tx_arr_lens.get(&field) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult { + self.home_le_arr_lens_asked.borrow_mut().push(field); + match self.home_le_arr_lens.get(&field) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult { + self.le_arr_lens_asked.borrow_mut().push((cache_idx, field)); + match self.le_arr_lens.get(&(cache_idx, field)) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult { + self.tx_nested_arr_lens_asked + .borrow_mut() + .push(locator.to_vec()); + match self.tx_nested_arr_lens.get(locator) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult { + self.home_le_nested_arr_lens_asked + .borrow_mut() + .push(locator.to_vec()); + match self.home_le_nested_arr_lens.get(locator) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult { + self.le_nested_arr_lens_asked + .borrow_mut() + .push((cache_idx, locator.to_vec())); + match self.le_nested_arr_lens.get(&(cache_idx, locator.to_vec())) { + Some(&len) => Ok(len), + None => Err(HostError::NoArray), + } + } + + fn check_signature(&self, message: &[u8], signature: &[u8], pubkey: &[u8]) -> HostResult { + self.sigs_checked.borrow_mut().push(( + message.to_vec(), + signature.to_vec(), + pubkey.to_vec(), + )); + self.sig_valid + } + + fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + self.account_keylets_asked + .borrow_mut() + .push(account.to_vec()); + match self.account_keylets.get(account) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult { + self.amm_keylets_asked + .borrow_mut() + .push((asset1.to_vec(), asset2.to_vec())); + match self.amm_keylets.get(&(asset1.to_vec(), asset2.to_vec())) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + self.check_keylets_asked + .borrow_mut() + .push((account.to_vec(), seq)); + match self.check_keylets.get(&(account.to_vec(), seq)) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn credential_keylet( + &self, + subject: &[u8], + issuer: &[u8], + credential_type: &[u8], + out: &mut [u8], + ) -> HostResult { + let key = (subject.to_vec(), issuer.to_vec(), credential_type.to_vec()); + self.credential_keylets_asked.borrow_mut().push(key.clone()); + match self.credential_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn delegate_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + let key = (account.to_vec(), authorize.to_vec()); + self.delegate_keylets_asked.borrow_mut().push(key.clone()); + match self.delegate_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn deposit_preauth_keylet( + &self, + account: &[u8], + authorize: &[u8], + out: &mut [u8], + ) -> HostResult { + let key = (account.to_vec(), authorize.to_vec()); + self.deposit_preauth_keylets_asked + .borrow_mut() + .push(key.clone()); + match self.deposit_preauth_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + self.did_keylets_asked.borrow_mut().push(account.to_vec()); + match self.did_keylets.get(account) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), seq); + self.escrow_keylets_asked.borrow_mut().push(key.clone()); + match self.escrow_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn trust_line_keylet( + &self, + account1: &[u8], + account2: &[u8], + currency: &[u8], + out: &mut [u8], + ) -> HostResult { + let key = (account1.to_vec(), account2.to_vec(), currency.to_vec()); + self.trust_line_keylets_asked.borrow_mut().push(key.clone()); + match self.trust_line_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn mptoken_issuance_keylet( + &self, + issuer: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + let key = (issuer.to_vec(), seq); + self.mpt_issuance_keylets_asked + .borrow_mut() + .push(key.clone()); + match self.mpt_issuance_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult { + let key = (mptid.to_vec(), holder.to_vec()); + self.mptoken_keylets_asked.borrow_mut().push(key.clone()); + match self.mptoken_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn nftoken_offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), seq); + self.nft_offer_keylets_asked.borrow_mut().push(key.clone()); + match self.nft_offer_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), seq); + self.offer_keylets_asked.borrow_mut().push(key.clone()); + match self.offer_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), doc_id); + self.oracle_keylets_asked.borrow_mut().push(key.clone()); + match self.oracle_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn paychannel_keylet( + &self, + account: &[u8], + destination: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + let key = (account.to_vec(), destination.to_vec(), seq); + self.paychannel_keylets_asked.borrow_mut().push(key.clone()); + match self.paychannel_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn permissioned_domain_keylet( + &self, + account: &[u8], + seq: i32, + out: &mut [u8], + ) -> HostResult { + let key = (account.to_vec(), seq); + self.domain_keylets_asked.borrow_mut().push(key.clone()); + match self.domain_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult { + self.signer_list_keylets_asked + .borrow_mut() + .push(account.to_vec()); + match self.signer_list_keylets.get(account) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), seq); + self.ticket_keylets_asked.borrow_mut().push(key.clone()); + match self.ticket_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), seq); + self.vault_keylets_asked.borrow_mut().push(key.clone()); + match self.vault_keylets.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidAccount), + } + } + + fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult { + self.digested.borrow_mut().push(data.to_vec()); + self.digest.fill(out) + } + + /// Records before failing, so a test can tell a host that was called and then + /// failed from one that was never reached. + fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()> { + self.traces.borrow_mut().push(Trace { + msg: msg.to_owned(), + data_type, + data: data.to_vec(), + }); + match self.trace_failure { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn update_data(&self, data: &[u8]) -> HostResult { + self.update_data_asked.borrow_mut().push(data.to_vec()); + self.update_data_answer + } + + fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult { + let key = (account.to_vec(), nft_id.to_vec()); + self.nfts_asked.borrow_mut().push(key.clone()); + match self.nfts.get(&key) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + self.nft_issuers_asked.borrow_mut().push(nft_id.to_vec()); + match self.nft_issuers.get(nft_id) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + self.nft_taxons_asked.borrow_mut().push(nft_id.to_vec()); + match self.nft_taxons.get(nft_id) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult { + self.nft_flags_asked.borrow_mut().push(nft_id.to_vec()); + self.nft_flags_answer + } + + fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult { + self.nft_fee_asked.borrow_mut().push(nft_id.to_vec()); + self.nft_fee_answer + } + + fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult { + self.nft_sequences_asked.borrow_mut().push(nft_id.to_vec()); + match self.nft_sequences.get(nft_id) { + Some(answer) => answer.fill(out), + None => Err(HostError::InvalidParams), + } + } + + fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult { + self.float_from_int_asked.borrow_mut().push((x, mode)); + self.float_answer.fill(out) + } + + fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_from_uint_asked + .borrow_mut() + .push((x.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_from_stamount_asked + .borrow_mut() + .push((amount.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_from_stnumber_asked + .borrow_mut() + .push((number.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_to_int_asked + .borrow_mut() + .push((x.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_to_mant_exp( + &self, + x: &[u8], + mantissa_out: &mut [u8], + exponent_out: &mut [u8], + ) -> HostResult { + self.float_to_mant_exp_asked.borrow_mut().push(x.to_vec()); + let (mantissa, exponent) = &self.float_mant_exp_answer; + mantissa_out[..mantissa.len()].copy_from_slice(mantissa); + exponent_out[..exponent.len()].copy_from_slice(exponent); + Ok(mantissa.len() + exponent.len()) + } + + fn float_from_mant_exp( + &self, + mantissa: i64, + exponent: i32, + mode: i32, + out: &mut [u8], + ) -> HostResult { + self.float_from_mant_exp_asked + .borrow_mut() + .push((mantissa, exponent, mode)); + self.float_answer.fill(out) + } + + fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult { + self.float_compare_asked + .borrow_mut() + .push((x.to_vec(), y.to_vec())); + self.float_compare_answer + } + + fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_binary_ops_asked + .borrow_mut() + .push(("add", x.to_vec(), y.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_binary_ops_asked + .borrow_mut() + .push(("sub", x.to_vec(), y.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_binary_ops_asked + .borrow_mut() + .push(("mult", x.to_vec(), y.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult { + self.float_binary_ops_asked + .borrow_mut() + .push(("div", x.to_vec(), y.to_vec(), mode)); + self.float_answer.fill(out) + } + + fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult { + self.float_unary_ops_asked + .borrow_mut() + .push(("root", x.to_vec(), n, mode)); + self.float_answer.fill(out) + } + + fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult { + self.float_unary_ops_asked + .borrow_mut() + .push(("pow", x.to_vec(), n, mode)); + self.float_answer.fill(out) + } +} + +// --------------------------------------------------------------------------- +// Module pieces +// --------------------------------------------------------------------------- + +/// One `(import …)` declaration per host function, spelled with the module name +/// and signature it is registered under and binding the `$name` call sites use. A +/// wrong module name or signature fails instantiation. +pub mod import { + pub const LDGR_INDEX: &str = + r#"(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))"#; + pub const PARENT_LDGR_TIME: &str = r#"(import "host_lib" "parent_ldgr_time" (func $parent_ldgr_time (param i32 i32) (result i32)))"#; + pub const PARENT_LDGR_HASH: &str = r#"(import "host_lib" "parent_ldgr_hash" (func $parent_ldgr_hash (param i32 i32) (result i32)))"#; + pub const BASE_FEE: &str = + r#"(import "host_lib" "base_fee" (func $base_fee (param i32 i32) (result i32)))"#; + pub const AMENDMENT_ENABLED: &str = r#"(import "host_lib" "amendment_enabled" (func $amendment_enabled (param i32 i32) (result i32)))"#; + pub const CACHE_LE: &str = + r#"(import "host_lib" "cache_le" (func $cache_le (param i32 i32 i32) (result i32)))"#; + pub const TX_FIELD: &str = + r#"(import "host_lib" "tx_field" (func $tx_field (param i32 i32 i32) (result i32)))"#; + pub const HOME_LE_FIELD: &str = r#"(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))"#; + pub const LE_FIELD: &str = + r#"(import "host_lib" "le_field" (func $le_field (param i32 i32 i32 i32) (result i32)))"#; + pub const TX_INNER: &str = + r#"(import "host_lib" "tx_inner" (func $tx_inner (param i32 i32 i32 i32) (result i32)))"#; + pub const HOME_LE_INNER: &str = r#"(import "host_lib" "home_le_inner" (func $home_le_inner (param i32 i32 i32 i32) (result i32)))"#; + pub const LE_INNER: &str = r#"(import "host_lib" "le_inner" (func $le_inner (param i32 i32 i32 i32 i32) (result i32)))"#; + pub const TX_ARR_LEN: &str = + r#"(import "host_lib" "tx_arr_len" (func $tx_arr_len (param i32) (result i32)))"#; + pub const HOME_LE_ARR_LEN: &str = + r#"(import "host_lib" "home_le_arr_len" (func $home_le_arr_len (param i32) (result i32)))"#; + pub const LE_ARR_LEN: &str = + r#"(import "host_lib" "le_arr_len" (func $le_arr_len (param i32 i32) (result i32)))"#; + pub const TX_INNER_ARR_LEN: &str = r#"(import "host_lib" "tx_inner_arr_len" (func $tx_inner_arr_len (param i32 i32) (result i32)))"#; + pub const HOME_LE_INNER_ARR_LEN: &str = r#"(import "host_lib" "home_le_inner_arr_len" (func $home_le_inner_arr_len (param i32 i32) (result i32)))"#; + pub const LE_INNER_ARR_LEN: &str = r#"(import "host_lib" "le_inner_arr_len" (func $le_inner_arr_len (param i32 i32 i32) (result i32)))"#; + pub const CHECK_SIG: &str = r#"(import "host_lib" "check_sig" (func $check_sig (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const ACCOUNTROOT_ID: &str = r#"(import "host_lib" "accountroot_id" (func $accountroot_id (param i32 i32 i32 i32) (result i32)))"#; + pub const AMM_ID: &str = r#"(import "host_lib" "amm_id" (func $amm_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const CHECK_ID: &str = r#"(import "host_lib" "check_id" (func $check_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const CREDENTIAL_ID: &str = r#"(import "host_lib" "credential_id" (func $credential_id (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const DELEGATE_ID: &str = r#"(import "host_lib" "delegate_id" (func $delegate_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const DEPOSIT_PREAUTH_ID: &str = r#"(import "host_lib" "deposit_preauth_id" (func $deposit_preauth_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const DID_ID: &str = + r#"(import "host_lib" "did_id" (func $did_id (param i32 i32 i32 i32) (result i32)))"#; + pub const ESCROW_ID: &str = r#"(import "host_lib" "escrow_id" (func $escrow_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const TRUSTLINE_ID: &str = r#"(import "host_lib" "trustline_id" (func $trustline_id (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const MPT_ISSUANCE_ID: &str = r#"(import "host_lib" "mpt_issuance_id" (func $mpt_issuance_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const MPTOKEN_ID: &str = r#"(import "host_lib" "mptoken_id" (func $mptoken_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const NFT_OFFER_ID: &str = r#"(import "host_lib" "nft_offer_id" (func $nft_offer_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const OFFER_ID: &str = r#"(import "host_lib" "offer_id" (func $offer_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const ORACLE_ID: &str = r#"(import "host_lib" "oracle_id" (func $oracle_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const PAYCHAN_ID: &str = r#"(import "host_lib" "paychan_id" (func $paychan_id (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const PERMISSIONED_DOMAIN_ID: &str = r#"(import "host_lib" "permissioned_domain_id" (func $permissioned_domain_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const SIGNERS_ID: &str = r#"(import "host_lib" "signers_id" (func $signers_id (param i32 i32 i32 i32) (result i32)))"#; + pub const TICKET_ID: &str = r#"(import "host_lib" "ticket_id" (func $ticket_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const VAULT_ID: &str = r#"(import "host_lib" "vault_id" (func $vault_id (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const SHA512_HALF: &str = r#"(import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32)))"#; + /// No result, unlike every other import here: `trace` answers the guest nothing. + pub const TRACE: &str = + r#"(import "host_lib" "trace" (func $trace (param i32 i32 i32 i32 i32)))"#; + pub const SET_DATA: &str = + r#"(import "host_lib" "set_data" (func $set_data (param i32 i32) (result i32)))"#; + pub const NFT_URI: &str = r#"(import "host_lib" "nft_uri" (func $nft_uri (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const NFT_ISSUER: &str = r#"(import "host_lib" "nft_issuer" (func $nft_issuer (param i32 i32 i32 i32) (result i32)))"#; + pub const NFT_TAXON: &str = + r#"(import "host_lib" "nft_taxon" (func $nft_taxon (param i32 i32 i32 i32) (result i32)))"#; + pub const NFT_FLAGS: &str = + r#"(import "host_lib" "nft_flags" (func $nft_flags (param i32 i32) (result i32)))"#; + pub const NFT_XFER_FEE: &str = + r#"(import "host_lib" "nft_xfer_fee" (func $nft_xfer_fee (param i32 i32) (result i32)))"#; + pub const NFT_SERIAL: &str = r#"(import "host_lib" "nft_serial" (func $nft_serial (param i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_FROM_INT: &str = r#"(import "host_lib" "float_from_int" (func $float_from_int (param i64 i32 i32 i32) (result i32)))"#; + pub const FLOAT_FROM_UINT: &str = r#"(import "host_lib" "float_from_uint" (func $float_from_uint (param i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_FROM_STAMOUNT: &str = r#"(import "host_lib" "float_from_stamount" (func $float_from_stamount (param i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_FROM_STNUMBER: &str = r#"(import "host_lib" "float_from_stnumber" (func $float_from_stnumber (param i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_TO_INT: &str = r#"(import "host_lib" "float_to_int" (func $float_to_int (param i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_TO_MANT_EXP: &str = r#"(import "host_lib" "float_to_mant_exp" (func $float_to_mant_exp (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_FROM_MANT_EXP: &str = r#"(import "host_lib" "float_from_mant_exp" (func $float_from_mant_exp (param i64 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_CMP: &str = + r#"(import "host_lib" "float_cmp" (func $float_cmp (param i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_ADD: &str = r#"(import "host_lib" "float_add" (func $float_add (param i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_SUB: &str = r#"(import "host_lib" "float_sub" (func $float_sub (param i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_MULT: &str = r#"(import "host_lib" "float_mult" (func $float_mult (param i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_DIV: &str = r#"(import "host_lib" "float_div" (func $float_div (param i32 i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_ROOT: &str = r#"(import "host_lib" "float_root" (func $float_root (param i32 i32 i32 i32 i32 i32) (result i32)))"#; + pub const FLOAT_POW: &str = r#"(import "host_lib" "float_pow" (func $float_pow (param i32 i32 i32 i32 i32 i32) (result i32)))"#; +} + +/// One page of linear memory, exported under the name the engine looks for. +pub const ONE_PAGE: &str = r#"(memory (export "memory") 1)"#; + +/// What a module returns after a `trace`: the call leaves nothing on the stack, so a +/// test asserting on the run rather than on an answer asserts this. +pub const COMPLETED: i32 = 1; + +/// A `(ptr, len)` pair naming no bytes, for the half of a `trace` a test is not +/// about. +pub const EMPTY_REGION: &str = "(i32.const 0) (i32.const 0)"; + +/// A `trace` of `data` as `data_type`. `msg` and `data` are each a `(ptr, len)` +/// pair. +pub fn trace_call(data_type: TraceDataType, msg: &str, data: &str) -> String { + format!( + "(call $trace {msg} (i32.const {code}) {data})", + code = data_type.code() + ) +} + +/// [`trace_call`] as a whole module body: the call, then the constant that stands +/// in for the status it does not return. +pub fn traced(data_type: TraceDataType, msg: &str, data: &str) -> String { + format!( + "{call}\n (i32.const {COMPLETED})", + call = trace_call(data_type, msg, data) + ) +} + +/// A module of `parts`, wrapping `body` in an exported `finish` returning `i32`. +pub fn module(parts: &[&str], body: &str) -> String { + format!( + "(module {parts}\n (func (export \"{ENTRY}\") (result i32)\n {body}))", + parts = parts.join("\n ") + ) +} + +// --------------------------------------------------------------------------- +// Running +// +// The tests write their modules as text and assemble them here: the VM takes +// binaries only, so the crate builds `wasmi` without its `wat` feature. +// --------------------------------------------------------------------------- + +/// Assembles a text-format module into the binary the VM takes. +/// +/// Panics rather than returning an error: text that will not assemble is a +/// mistake in the test, not a case under test. +pub fn assemble(wat: &str) -> Vec { + wat::parse_str(wat) + .unwrap_or_else(|e| panic!("this test's module does not assemble: {e}\n{wat}")) +} + +/// Runs `wat`'s `finish` against `host` with gas to spare. +pub fn run(wat: &str, host: &FakeHost) -> Result { + run_with_gas(wat, PLENTY_OF_GAS, host) +} + +/// Runs `wat`'s `finish` against `host` with exactly `gas` to spend. +pub fn run_with_gas(wat: &str, gas: u64, host: &FakeHost) -> Result { + xrpl_wasm_vm::run(&assemble(wat), gas, host, ENTRY) +} + +/// Runs the export named `entry` rather than `finish`. +pub fn run_entry(wat: &str, host: &FakeHost, entry: &str) -> Result { + xrpl_wasm_vm::run(&assemble(wat), PLENTY_OF_GAS, host, entry) +} + +/// The value `finish` returned, for a run expected to complete: the host call's +/// status, so a byte count on success or a negative [`HostError`] code. +pub fn status(wat: &str, host: &FakeHost) -> i32 { + run(wat, host) + .unwrap_or_else(|e| panic!("expected the module to run, but: {e}\n{wat}")) + .result +} + +/// The wire code a `HostError` reaches the guest as, for readable assertions. +pub fn code(error: HostError) -> i32 { + error.code() +} + +/// The failure from a run that was expected not to complete. +pub fn failure(wat: &str, host: &FakeHost) -> RunFailure { + match run(wat, host) { + Err(failure) => failure, + Ok(outcome) => panic!( + "expected a failure, but the module returned {}", + outcome.result + ), + } +} diff --git a/crates/xrpl-wasm-vm/tests/vm_limits.rs b/crates/xrpl-wasm-vm/tests/vm_limits.rs new file mode 100644 index 0000000000..065204efef --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/vm_limits.rs @@ -0,0 +1,642 @@ +//! What the engine refuses outright: modules it will not compile, will not +//! instantiate, or cannot find an entry point in — plus the memory and table caps. +//! +//! These are the sandbox's outer wall. Everything here fails the run rather than +//! returning a code to the guest, so each test reads the failure's message. + +mod support; + +use support::{ + FakeHost, ONE_PAGE, PLENTY_OF_GAS, failure, import, module, run, run_entry, run_with_gas, +}; +use xrpl_wasm_vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError}; + +/// Assert which stage a run failed at, because the caller maps the stages to +/// different outcomes. A stage is one `RunError` variant, so the expectation is a +/// pattern; the failure comes back out for the tests that also read its message. +macro_rules! assert_stage { + ($failure:expr, $stage:pat) => {{ + let failure = $failure; + assert!( + matches!(failure.error, $stage), + concat!("expected a ", stringify!($stage), " failure, got: {}"), + failure + ); + failure + }}; +} + +// --------------------------------------------------------------------------- +// Linear memory +// --------------------------------------------------------------------------- + +/// A module declaring more than the cap fails to instantiate — the limit applies +/// to the initial memory, not only to growth. +#[test] +fn an_initial_memory_past_the_cap_is_refused() { + let host = FakeHost::new(); + + let wat = module( + &[&format!( + r#"(memory (export "memory") {})"#, + MAX_MEMORY_PAGES + 1 + )], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// The cap itself is allowed. +#[test] +fn an_initial_memory_at_the_cap_is_allowed() { + let host = FakeHost::new(); + + let wat = module( + &[&format!(r#"(memory (export "memory") {MAX_MEMORY_PAGES})"#)], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + +/// Growth up to the cap succeeds; growth past it traps rather than answering -1 as +/// `memory.grow` otherwise would, because the engine's limiter sets +/// `trap_on_grow_failure(true)`. +#[test] +fn growth_stops_at_the_cap() { + let host = FakeHost::new(); + + let wat = module( + &[ONE_PAGE], + &format!("(memory.grow (i32.const {}))", MAX_MEMORY_PAGES - 1), + ); + assert_eq!( + run(&wat, &host).expect("should run").result, + 1, + "growing to exactly the cap answers the previous size" + ); + + let wat = module( + &[ONE_PAGE], + &format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"), + ); + assert_stage!(failure(&wat, &host), RunError::Trap(_)); +} + +/// A module may declare a maximum above the cap: the cap is enforced on the initial +/// memory and on growth, not on the memory type's declared bound. +#[test] +fn a_declared_maximum_past_the_cap_is_allowed_but_unreachable() { + let host = FakeHost::new(); + let memory = format!(r#"(memory (export "memory") 1 {})"#, MAX_MEMORY_PAGES + 1); + + let wat = module(&[&memory], "(i32.const 0)"); + assert_eq!(run(&wat, &host).expect("should run").result, 0); + + let wat = module( + &[&memory], + &format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"), + ); + assert_stage!(failure(&wat, &host), RunError::Trap(_)); +} + +// --------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------- + +/// A table's whole cost is paid at instantiation: wasmi writes all 8 bytes of every +/// element before the guest's first instruction, so a module declaring more than the +/// cap must be refused there rather than charged for it. +#[test] +fn an_initial_table_past_the_cap_is_refused() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1)], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// The cap itself is allowed. +#[test] +fn an_initial_table_at_the_cap_is_allowed() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {MAX_TABLE_ELEMENTS} funcref)")], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + +/// The cap binds a table the module keeps to itself, which is the case that matters: +/// a contract has no reason to export its table, so screening never sees the one a +/// hostile module declares. +#[test] +fn the_table_cap_binds_an_unexported_table() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {} funcref)", u32::from(u16::MAX) * 100)], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// A declared *maximum* past the cap is legal and simply unreachable, mirroring what +/// linear memory allows. Nothing can reach it: `table.grow` is a reference-types +/// instruction and the engine turns that feature off, so a table's declared minimum +/// is also its final size. +#[test] +fn a_declared_table_maximum_past_the_cap_is_allowed_but_unreachable() { + let host = FakeHost::new(); + + let wat = module( + &[&format!( + "(table 1 {} funcref)", + u64::try_from(MAX_TABLE_ELEMENTS).expect("fits") + 1 + )], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + +// --------------------------------------------------------------------------- +// Engine configuration +// --------------------------------------------------------------------------- + +/// One row per feature `build_wasm_engine` turns off: the smallest module that uses +/// it, and the fragment of wasmi's refusal that names the feature. A row declaring +/// its own memory omits [`ONE_PAGE`], or it is refused for having two memories +/// instead. +fn disabled_features() -> Vec<(&'static str, Vec<&'static str>, &'static str, &'static str)> { + vec![ + ( + "wasm_multi_value", + vec![ + ONE_PAGE, + "(func $two (result i32 i32) (i32.const 1) (i32.const 2))", + ], + "(call $two) (drop) (drop) (i32.const 0)", + "multi-value", + ), + ( + "wasm_sign_extension", + vec![ONE_PAGE], + "(i32.extend8_s (i32.const 1))", + "sign extension", + ), + ( + "wasm_bulk_memory", + vec![ONE_PAGE], + "(memory.fill (i32.const 0) (i32.const 0) (i32.const 1)) (i32.const 0)", + "bulk memory", + ), + ( + "wasm_reference_types", + vec![ONE_PAGE, "(table 1 externref)"], + "(i32.const 0)", + "reference types", + ), + // The proposal covers mutable globals crossing the module boundary; an + // internal one is core wasm and stays allowed — see the test below. + ( + "wasm_mutable_global", + vec![ONE_PAGE, r#"(global (export "g") (mut i32) (i32.const 0))"#], + "(i32.const 0)", + "mutable global", + ), + ( + "wasm_tail_call", + vec![ONE_PAGE, "(func $f (result i32) (i32.const 0))"], + "(return_call $f)", + "tail call", + ), + // Arithmetic in a constant initialiser. wasmi names the operator rather + // than the proposal here. + ( + "wasm_extended_const", + vec![ + ONE_PAGE, + "(global $g i32 (i32.add (i32.const 1) (i32.const 2)))", + ], + "(global.get $g)", + "non-constant operator", + ), + ( + "wasm_multi_memory", + vec![ONE_PAGE, "(memory 1)"], + "(i32.const 0)", + "multiple memories", + ), + ( + "wasm_memory64", + vec![r#"(memory (export "memory") i64 1)"#], + "(i32.const 0)", + "memory64", + ), + ( + "wasm_custom_page_sizes", + vec![r#"(memory (export "memory") 1 (pagesize 1))"#], + "(i32.const 0)", + "custom page sizes", + ), + ( + "wasm_wide_arithmetic", + vec![ONE_PAGE], + "(drop (i64.add128 (i64.const 1) (i64.const 2) (i64.const 3) (i64.const 4))) + (i32.const 0)", + "wide arithmetic", + ), + // Determinism across nodes is the reason floats are off. + ( + "floats", + vec![ONE_PAGE], + "(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)", + "floating-point", + ), + ] +} + +/// Every feature the engine disables is refused, and refused for that reason. +/// +/// `wasm_custom_page_sizes` and `wasm_wide_arithmetic` are off by default in wasmi +/// 1.1 (`engine/config.rs:72,74`), so their rows guard against wasmi changing that +/// default rather than against this engine's own config. +#[test] +fn every_disabled_feature_is_refused_by_name() { + let host = FakeHost::new(); + + for (knob, parts, body, expected) in disabled_features() { + let wat = module(&parts, body); + let failure = assert_stage!(failure(&wat, &host), RunError::Compile(_)).to_string(); + + assert!( + failure.contains(expected), + "{knob}: expected a refusal mentioning {expected:?}, got: {failure}" + ); + } +} + +/// The three knobs [`every_disabled_feature_is_refused_by_name`] cannot cover. The +/// engine is a process-wide `LazyLock`, so a test observes the one configuration +/// `build_wasm_engine` makes: a knob masked by another, or with no caller-visible +/// effect, has no distinguishing module. +#[test] +fn the_knobs_without_a_module_of_their_own() { + let host = FakeHost::new(); + + // `wasm_saturating_float_to_int(false)`: every saturating conversion takes a + // float operand, so `floats(false)` refuses it first, as the message shows. + let wat = module(&[ONE_PAGE], "(i32.trunc_sat_f32_s (f32.const 1))"); + let refusal = failure(&wat, &host).to_string(); + assert!(refusal.contains("floating-point"), "{refusal}"); + assert!(!refusal.contains("saturating"), "{refusal}"); + + // `ignore_custom_sections(true)`: governs whether wasmi retains custom + // sections, not accept/reject, so this pins only that one is harmless. + let wat = module( + &[ONE_PAGE, r#"(@custom "note" "ignored")"#], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); + + // `consume_fuel(true)`: with it off, `Store::set_fuel` fails and `run` returns + // before instantiating, so every test in the suite fails. + let wat = module(&[ONE_PAGE], "(i32.const 0)"); + assert!(run(&wat, &host).expect("should run").fuel_used > 0); +} + +/// A mutable global the module keeps to itself is core wasm, so the disabled +/// proposal does not reach it: a guest can still have mutable state. +#[test] +fn an_internal_mutable_global_is_still_allowed() { + let host = FakeHost::new(); + + let wat = module( + &[ONE_PAGE, "(global $g (mut i32) (i32.const 0))"], + "(global.set $g (i32.const 7)) (global.get $g)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 7); +} + +/// Bytes that are not a wasm module at all. +#[test] +fn garbage_does_not_compile() { + let host = FakeHost::new(); + + for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] { + let failure = xrpl_wasm_vm::run(bytes, PLENTY_OF_GAS, &host, support::ENTRY) + .expect_err("garbage must not compile"); + assert_stage!(failure, RunError::Compile(_)); + } +} + +/// The VM takes wasm binaries, and text is not one. wasmi's `wat` feature is on by +/// default and would have `Module::new` assemble text too, so the crate builds +/// wasmi without it; turning it back on would make this transaction blob valid. +#[test] +fn the_vm_refuses_a_text_format_module() { + let host = FakeHost::new(); + let text = module(&[ONE_PAGE], "(i32.const 0)"); + + let failure = xrpl_wasm_vm::run(text.as_bytes(), PLENTY_OF_GAS, &host, support::ENTRY) + .expect_err("text must not compile as a module"); + assert_stage!(failure, RunError::Compile(_)); + + // The same module, assembled first, runs: the text is sound and only the + // format was refused. + assert_eq!(run(&text, &host).expect("should run").result, 0); +} + +// --------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------- + +/// A module may import fewer host functions than are registered, but not more: +/// an import the linker does not define fails instantiation. +#[test] +fn an_unknown_import_fails_instantiation() { + let host = FakeHost::new(); + + let wat = module( + &[ + r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#, + ONE_PAGE, + ], + "(call $f (i32.const 0))", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// Host functions are registered under one module name — `host_lib`, the name the +/// guest SDK and this repo's fixtures import from — and a guest naming a different +/// one does not link. `env` is in the list because that is what plain clang emits. +#[test] +fn the_import_module_name_must_match() { + let host = FakeHost::new(); + + for module_name in ["host", "env", ""] { + let wat = module( + &[ + &format!( + r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"# + ), + ONE_PAGE, + ], + "(call $f (i32.const 0) (i32.const 4))", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); + } +} + +/// An import spelled with the wrong signature does not link even under the right +/// name, which is what makes the registered signatures load-bearing. +#[test] +fn an_import_with_the_wrong_signature_fails_instantiation() { + let host = FakeHost::new(); + + for signature in [ + "(param i32) (result i32)", // too few parameters + "(param i32 i32 i32) (result i32)", // too many + "(param i64 i64) (result i32)", // wrong parameter types + "(param i32 i32) (result i64)", // wrong result type + "(param i32 i32)", // no result + ] { + let wat = module( + &[ + &format!(r#"(import "host_lib" "ldgr_index" (func $f {signature}))"#), + ONE_PAGE, + ], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); + } +} + +/// A module that imports a host function it never calls still has to link. +#[test] +fn an_unused_import_is_still_linked() { + let host = FakeHost::new(); + + let wat = module( + &[import::LDGR_INDEX, import::TRACE, ONE_PAGE], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + +// --------------------------------------------------------------------------- +// The start section +// --------------------------------------------------------------------------- + +/// A start section runs guest code during instantiation, before the entry point +/// is even looked up, and `set_fuel` and the memory limiter are both installed by +/// then — so it is metered like any other guest code, and a run it stops is +/// charged for what it burned. +/// +/// Reported as a **trap**, not as a module that would not instantiate: a trap is the +/// guest's fault wherever it happens, and the stage a run stopped at is not what the +/// caller maps. Filing it under the stage would put a contract's own defect among the +/// faults a caller treats as the node's, and charge nothing for the instructions the +/// contract burned reaching it. +#[test] +fn a_trapping_start_section_is_a_guest_trap_and_is_charged() { + let host = FakeHost::new(); + + let wat = format!( + r#"(module {ONE_PAGE} + (func $init (unreachable)) + (start $init) + (func (export "finish") (result i32) (i32.const 0)))"# + ); + let failure = assert_stage!( + run_with_gas(&wat, PLENTY_OF_GAS, &host) + .expect_err("a start section that traps must not complete the run"), + RunError::Trap(_) + ); + assert!( + failure.fuel_used > 0, + "the start section's instructions are metered: {failure}" + ); +} + +/// What `RunError::Instantiate` is left to mean: a module the linker or the store +/// would not accept, rather than one whose guest code failed. Its two shapes, so the +/// variant is not left standing for nothing. +#[test] +fn instantiation_failure_is_a_module_the_engine_will_not_accept() { + let host = FakeHost::new(); + + // The linker defines no such import. + let wat = module( + &[ + r#"(import "host_lib" "no_such_function" (func $f (result i32)))"#, + ONE_PAGE, + ], + "(call $f)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); + + // The store's limiter will not grant the memory, and does not trap to say so. + let wat = module( + &[&format!("(memory {})", MAX_MEMORY_PAGES + 1)], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// A start section that runs out of gas is reported as out of gas, not as a module +/// that would not instantiate. The stage a run stopped at is not what the caller +/// maps — the reason is — and gas exhaustion is one outcome wherever the guest +/// reaches it. +#[test] +fn a_start_section_that_exhausts_gas_is_out_of_gas_not_an_instantiation_failure() { + const GAS: u64 = 10_000; + + let host = FakeHost::new(); + let wat = format!( + r#"(module {ONE_PAGE} + (func $init (loop $l (br $l))) + (start $init) + (func (export "finish") (result i32) (i32.const 0)))"# + ); + + let failure = assert_stage!( + run_with_gas(&wat, GAS, &host).expect_err("an endless start section must not instantiate"), + RunError::OutOfGas + ); + assert_eq!( + failure.fuel_used, GAS, + "a runaway start section burns the whole limit" + ); +} + +/// A start section cannot make a host call that needs guest memory, even in a +/// module that exports one: the memory is resolved from the *instance's* exports, +/// and instantiation is what produces the instance, so a call made while it is +/// still running has no memory to work in and ends the run. +/// +/// Not a choice: `Module::instantiate` is `pub(crate)` in wasmi, so instantiation +/// cannot be split from the start section to resolve the memory in between. +#[test] +fn a_start_section_cannot_make_a_host_call() { + let host = FakeHost::new(); + + let wat = format!( + r#"(module {ldgr_index} {ONE_PAGE} + (func $init (drop (call $ldgr_index (i32.const 0) (i32.const 4)))) + (start $init) + (func (export "finish") (result i32) (i32.const 0)))"#, + ldgr_index = import::LDGR_INDEX + ); + + let failure = assert_stage!( + run_with_gas(&wat, PLENTY_OF_GAS, &host) + .expect_err("a host call from a start section must not be served"), + RunError::NoMemory + ); + assert!( + failure.fuel_used > 0, + "the start section is metered up to the refused call: {failure}" + ); +} + +// --------------------------------------------------------------------------- +// The entry point +// --------------------------------------------------------------------------- + +#[test] +fn a_missing_entry_point_fails() { + let host = FakeHost::new(); + + let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 0)))"#; + let failure = assert_stage!( + run_with_gas(wat, PLENTY_OF_GAS, &host) + .expect_err("a module without the entry point must not run"), + RunError::EntryPoint(_) + ); + assert!( + failure.to_string().contains("no entry point 'finish'"), + "{failure}" + ); +} + +/// The entry point is looked up by the name the caller asks for. +#[test] +fn the_entry_point_is_the_name_the_caller_gives() { + let host = FakeHost::new(); + + let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 9)))"#; + let outcome = run_entry(wat, &host, "other").expect("should run"); + assert_eq!(outcome.result, 9); +} + +/// The entry point must take nothing and return an `i32`. A module that exports the +/// name with another signature is told so, rather than being told the export is +/// missing: wasmi answers both cases with one error, and "no entry point" would send +/// a contract author looking for a function they already have. +#[test] +fn an_entry_point_of_the_wrong_type_fails() { + let host = FakeHost::new(); + + for signature in ["(result i64)", "(param i32) (result i32)", ""] { + let body = if signature.contains("result i64") { + "(i64.const 0)" + } else if signature.is_empty() { + "(nop)" + } else { + "(i32.const 0)" + }; + let wat = format!( + r#"(module (memory (export "memory") 1) (func (export "finish") {signature} {body}))"# + ); + let failure = assert_stage!( + run_with_gas(&wat, PLENTY_OF_GAS, &host) + .expect_err("a wrongly-typed entry point must not run"), + RunError::EntryPoint(_) + ) + .to_string(); + assert!( + failure.contains("entry point 'finish' has the wrong signature"), + "{signature}: {failure}" + ); + assert!( + !failure.contains("no entry point"), + "a present export must not be reported as absent — {signature}: {failure}" + ); + } +} + +/// An export of the entry point's name that is not a function at all is a third +/// case, and named as such: nothing is missing and no signature is wrong. +#[test] +fn an_entry_point_that_is_not_a_function_fails() { + let host = FakeHost::new(); + + let wat = + r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#; + let failure = assert_stage!( + run_with_gas(wat, PLENTY_OF_GAS, &host).expect_err("a non-function export must not run"), + RunError::EntryPoint(_) + ) + .to_string(); + assert!( + failure.contains("export 'finish' is not a function"), + "{failure}" + ); +} + +/// A guest that traps fails the run rather than returning a value. +#[test] +fn a_trapping_guest_fails_the_run() { + let host = FakeHost::new(); + + let wat = module(&[ONE_PAGE], "(unreachable)"); + assert_stage!(failure(&wat, &host), RunError::Trap(_)); + + // An out-of-bounds guest access is a trap too, caught by the engine rather + // than anything the host is asked about. + let wat = module(&[ONE_PAGE], "(i32.load (i32.const 100000))"); + assert_stage!(failure(&wat, &host), RunError::Trap(_)); +} diff --git a/docs/build/environment.md b/docs/build/environment.md index 51580b12a5..f5853321db 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -1,5 +1,5 @@ Our [build instructions][BUILD.md] assume you have a C++ development -environment complete with Git, Python, Conan, CMake, and a C++ compiler. +environment complete with Git, Python, Conan, CMake, Rust, and a C++ compiler. This document explains how to set one up. [BUILD.md]: ../../BUILD.md @@ -36,19 +36,17 @@ compiler building. Treat support for anything outside the table as best-effort. Besides a compiler, building `xrpld` requires: -| Tool | Minimum version | -| ------------------------------------------- | --------------- | -| [Git](https://git-scm.com/downloads) | any recent | -| [Python](https://www.python.org/downloads/) | 3.11 | -| [Conan](https://conan.io/downloads.html) | 2.17 | -| [CMake](https://cmake.org/download/) | 3.16 | +| Tool | Minimum version | +| ------------------------------------------- | ------------------------ | +| [Git](https://git-scm.com/downloads) | any recent | +| [Python](https://www.python.org/downloads/) | 3.11 | +| [Conan](https://conan.io/downloads.html) | 2.17 | +| [CMake](https://cmake.org/download/) | 3.16 | +| [Rust](https://rustup.rs) | 1.95 (see [Rust](#rust)) | On Linux and macOS, the [Nix development shell](./nix.md) provides all of them (see below). On Windows they have to be installed manually. -Building with `-Drust=ON` additionally requires a Rust toolchain, see -[Rust](#rust). A default build does not, so it is not in the table above. - Once they are in place, verify that everything is installed and runnable with: ```bash @@ -122,18 +120,14 @@ manually: "x64 Native Tools Command Prompt". CI configures CMake with the `Visual Studio 18 2026` generator. - [Git for Windows](https://git-scm.com/download/win) -- Python, Conan, and CMake, at the versions listed in +- Python, Conan, CMake, and Rust, at the versions listed in [Required tools](#required-tools). -- a [Rust toolchain](https://rustup.rs) — only needed to build with - `-Drust=ON`, see [Rust](#rust) ## Rust The repository contains a Rust workspace in [`crates/`](../../crates), whose -crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not** -part of a default build: the CMake `rust` option is OFF by default, and with it -off no Rust toolchain is needed. It is only required when configuring with -`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options). +crates are exposed to C++ through [cxx](https://cxx.rs) bindings and compiled by +the CMake build, so a Rust toolchain is required. The toolchain (`cargo`, `rustc`) is pinned to the channel in [`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If diff --git a/docs/build/nix.md b/docs/build/nix.md index 0b701b39f3..9a49416657 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -128,9 +128,8 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. -Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell -provides the Rust toolchain pinned in -[`rust-toolchain.toml`](../../rust-toolchain.toml) (see +The Rust toolchain the build needs is included too: every shell provides the +channel pinned in [`rust-toolchain.toml`](../../rust-toolchain.toml) (see [Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and `cargo-nextest` plugins. diff --git a/include/xrpl/tx/wasm/HostContext.h b/include/xrpl/tx/wasm/HostContext.h new file mode 100644 index 0000000000..3ce625181c --- /dev/null +++ b/include/xrpl/tx/wasm/HostContext.h @@ -0,0 +1,426 @@ +#pragma once + +#include + +#include + +namespace xrpl { +// `xrpl::HostFunctions` is forward-declared rather than included: this header is +// `include!()`d by the cxxbridge-generated translation unit, whose target gets only the +// project's `include/` directory - not the Boost paths that HostFunc.h -> Slice.h -> +// strHex.h transitively need. A reference member and declarations alone do not require a +// complete type; HostContext.cpp, compiled into libxrpl, includes the real header. +class HostFunctions; + +// Defined by the cxx bridge, which emits it into `xrpl_wasm_vm_ffi_cxxbridge/lib.h` from the +// declaration in `crates/xrpl-wasm-vm-ffi` - so the data types and their wire values are +// written once, in Rust, rather than kept in step with a copy here. +// +// Forward-declared for the reason `HostFunctions` above is: that generated header includes +// this one, so naming its definition here would be circular. A scoped enum with a fixed +// underlying type needs no definition to appear in a signature; `HostContext.cpp` includes +// the generated header for the `switch`. +enum class TraceDataType : std::int32_t; + +// The host handed to the Rust wasm engine: one method per entry in the wasm host ABI, +// each forwarding to `xrpl::HostFunctions` - the single source of truth for ledger +// access - and lowering its typed `std::expected` result onto the ABI's wire form. +// +// Every method is `noexcept`, and every body catches everything: a C++ exception +// unwinding into the Rust frames that called it would be undefined behaviour, so a caught +// one leaves here as `HostFunctionError::InternalFatal`, which the engine reads as a fatal +// error and reports as `tecINTERNAL`. +// +// Not an owner: it borrows the `HostFunctions` it is built over for the length of one run. +class HostContext +{ + // Non-const so a host function that mutates (`cacheLedgerObj`, `updateData`) can be + // reached from the `const` methods below: constness of the reference is not + // constness of the referent. + HostFunctions& hostFunctions_; + +public: + HostContext(HostFunctions& hostFunctions); + + // A byte-producing call is handed `out` - a slice aliasing either guest linear + // memory or the engine's output buffer - writes the value only if the whole of it + // fits, and returns the value's *true* length, which may exceed `out`. That is how a + // guest learns the size to ask for, and it is why these methods never need to know + // the guest's capacity: the engine owns the buffer-fit, field-cap and transfer-budget + // rules and derives all three from the length returned here. + // + // A negative return is a `HostFunctionError` code. + [[nodiscard]] std::int32_t + getLedgerSqn(rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getParentLedgerTime(rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getParentLedgerHash(rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getBaseFee(rust::Slice out) const noexcept; + + // The amendment is either a 32-byte id or a name; a 32-byte input is tried as an + // id first and falls back to a name lookup. Answers 1 or 0, or a negative + // `HostFunctionError` code. + [[nodiscard]] std::int32_t + isAmendmentEnabled(rust::Slice amendment) const noexcept; + + // The object id must be a 32-byte uint256, else `InvalidParams`. `cacheIdx` selects + // the slot (0 = pick a free one). Answers the slot used, or a negative + // `HostFunctionError` code. + [[nodiscard]] std::int32_t + cacheLedgerObj(rust::Slice objId, std::int32_t cacheIdx) const noexcept; + + [[nodiscard]] std::int32_t + getTxField(std::int32_t field, rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getCurrentLedgerObjField(std::int32_t field, rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getLedgerObjField(std::int32_t cacheIdx, std::int32_t field, rust::Slice out) + const noexcept; + + // The locator is a path of little-endian i32 steps, so its byte length must be a + // non-zero multiple of 4, else `LocatorMalformed`. + [[nodiscard]] std::int32_t + getTxNestedField(rust::Slice locator, rust::Slice out) + const noexcept; + + [[nodiscard]] std::int32_t + getCurrentLedgerObjNestedField( + rust::Slice locator, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + getLedgerObjNestedField( + std::int32_t cacheIdx, + rust::Slice locator, + rust::Slice out) const noexcept; + + // Answers the array's element count directly, or a negative `HostFunctionError` + // code (`NoArray` if the field is not an array). + [[nodiscard]] std::int32_t + getTxArrayLen(std::int32_t field) const noexcept; + + [[nodiscard]] std::int32_t + getCurrentLedgerObjArrayLen(std::int32_t field) const noexcept; + + [[nodiscard]] std::int32_t + getLedgerObjArrayLen(std::int32_t cacheIdx, std::int32_t field) const noexcept; + + [[nodiscard]] std::int32_t + getTxNestedArrayLen(rust::Slice locator) const noexcept; + + [[nodiscard]] std::int32_t + getCurrentLedgerObjNestedArrayLen(rust::Slice locator) const noexcept; + + [[nodiscard]] std::int32_t + getLedgerObjNestedArrayLen(std::int32_t cacheIdx, rust::Slice locator) + const noexcept; + + // Answers 1/0 for a valid/invalid signature, or a negative `HostFunctionError`. + [[nodiscard]] std::int32_t + checkSignature( + rust::Slice message, + rust::Slice signature, + rust::Slice pubkey) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + accountKeylet(rust::Slice account, rust::Slice out) + const noexcept; + + // Each asset is decoded by length (24 = MPT, 20 = XRP, 40 = issue), else + // `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + ammKeylet( + rust::Slice asset1, + rust::Slice asset2, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + checkKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // Subject and issuer must each be 20 bytes, else `InvalidParams`. Writes the + // 32-byte keylet. + [[nodiscard]] std::int32_t + credentialKeylet( + rust::Slice subject, + rust::Slice issuer, + rust::Slice credentialType, + rust::Slice out) const noexcept; + + // Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + delegateKeylet( + rust::Slice account, + rust::Slice authorize, + rust::Slice out) const noexcept; + + // Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + depositPreauthKeylet( + rust::Slice account, + rust::Slice authorize, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + didKeylet(rust::Slice account, rust::Slice out) + const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + escrowKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // Both accounts and the currency must each be 20 bytes, else `InvalidParams`. + // Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + trustLineKeylet( + rust::Slice account1, + rust::Slice account2, + rust::Slice currency, + rust::Slice out) const noexcept; + + // The issuer id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + mptokenIssuanceKeylet( + rust::Slice issuer, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The MPT id must be 24 bytes and the holder 20, else `InvalidParams`. Writes the + // 32-byte keylet. + [[nodiscard]] std::int32_t + mptokenKeylet( + rust::Slice mptid, + rust::Slice holder, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + nftokenOfferKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + offerKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `docId` carries the + // guest's u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + oracleKeylet( + rust::Slice account, + std::int32_t docId, + rust::Slice out) const noexcept; + + // Both account ids must be 20 bytes, else `InvalidParams`. `seq` carries the + // guest's u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + paychannelKeylet( + rust::Slice account, + rust::Slice destination, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + permissionedDomainKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + signerListKeylet(rust::Slice account, rust::Slice out) + const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + ticketKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + // The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's + // u32 as its i32 bit pattern. Writes the 32-byte keylet. + [[nodiscard]] std::int32_t + vaultKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + sha512Half(rust::Slice data, rust::Slice out) const noexcept; + + // Renders `data` as `dataType` says, and hands the text to `HostFunctions::trace`, which + // is what puts it in this node's log. + // + // The one call that answers nothing: the guest's wasm function has no result, and this + // node's own log is the only thing a trace touches, so a buffer that does not hold what + // it claims is logged here and dropped rather than reported to a contract. + void + trace(rust::Str msg, rust::Slice data, TraceDataType dataType) + const noexcept; + + // Stores `data` as the current object's data field and returns the number of bytes + // stored, or a negative `HostFunctionError` code. + [[nodiscard]] std::int32_t + updateData(rust::Slice data) const noexcept; + + // The account id must be 20 bytes and the nft id 32 bytes, else `InvalidParams`. + // Writes the token's URI bytes. + [[nodiscard]] std::int32_t + getNFT( + rust::Slice account, + rust::Slice nftId, + rust::Slice out) const noexcept; + + // The nft id must be 32 bytes, else `InvalidParams`. Writes the 20-byte issuer + // account encoded in the id. + [[nodiscard]] std::int32_t + getNFTIssuer(rust::Slice nftId, rust::Slice out) + const noexcept; + + // The nft id must be 32 bytes, else `InvalidParams`. Writes the taxon as its four + // little-endian bytes. + [[nodiscard]] std::int32_t + getNFTTaxon(rust::Slice nftId, rust::Slice out) + const noexcept; + + // The nft id must be 32 bytes, else `InvalidParams`. Returns the flags, or a + // negative `HostFunctionError` code. + [[nodiscard]] std::int32_t + getNFTFlags(rust::Slice nftId) const noexcept; + + // The nft id must be 32 bytes, else `InvalidParams`. Returns the transfer fee, or a + // negative `HostFunctionError` code. + [[nodiscard]] std::int32_t + getNFTTransferFee(rust::Slice nftId) const noexcept; + + // The nft id must be 32 bytes, else `InvalidParams`. Writes the sequence number as + // its four little-endian bytes. + [[nodiscard]] std::int32_t + getNFTSequence(rust::Slice nftId, rust::Slice out) + const noexcept; + + // Float / number arithmetic. A float is an XRPL `Number` in serialized form; + // `mode` is a rounding mode. Each writes the result float bytes unless noted. + + [[nodiscard]] std::int32_t + floatFromInt(std::int64_t x, std::int32_t mode, rust::Slice out) const noexcept; + + // The integer region must be eight bytes, else `InvalidParams`. + [[nodiscard]] std::int32_t + floatFromUint( + rust::Slice x, + std::int32_t mode, + rust::Slice out) const noexcept; + + // `amount` must be a serialized `STAmount`, else `InvalidParams`. + [[nodiscard]] std::int32_t + floatFromSTAmount( + rust::Slice amount, + std::int32_t mode, + rust::Slice out) const noexcept; + + // `number` must be a serialized `STNumber`, else `InvalidParams`. + [[nodiscard]] std::int32_t + floatFromSTNumber( + rust::Slice number, + std::int32_t mode, + rust::Slice out) const noexcept; + + // Rounds the float to an integer, written as its eight little-endian bytes. + [[nodiscard]] std::int32_t + floatToInt(rust::Slice x, std::int32_t mode, rust::Slice out) + const noexcept; + + // Writes the mantissa (eight little-endian bytes) and the exponent (four little- + // endian bytes) to two output regions; returns their total size. + [[nodiscard]] std::int32_t + floatToMantExp( + rust::Slice x, + rust::Slice mantissaOut, + rust::Slice exponentOut) const noexcept; + + [[nodiscard]] std::int32_t + floatFromMantExp( + std::int64_t mantissa, + std::int32_t exponent, + std::int32_t mode, + rust::Slice out) const noexcept; + + // Returns a negative, zero, or positive scalar as `x` is less than, equal to, or + // greater than `y`, or a negative `HostFunctionError` code on failure. + [[nodiscard]] std::int32_t + floatCompare(rust::Slice x, rust::Slice y) + const noexcept; + + [[nodiscard]] std::int32_t + floatAdd( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + floatSubtract( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + floatMultiply( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + floatDivide( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + floatRoot( + rust::Slice x, + std::int32_t n, + std::int32_t mode, + rust::Slice out) const noexcept; + + [[nodiscard]] std::int32_t + floatPower( + rust::Slice x, + std::int32_t n, + std::int32_t mode, + rust::Slice out) const noexcept; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/wasm/HostFunc.h b/include/xrpl/tx/wasm/HostFunc.h index 96953fbf90..ae0adcd9be 100644 --- a/include/xrpl/tx/wasm/HostFunc.h +++ b/include/xrpl/tx/wasm/HostFunc.h @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -12,9 +11,6 @@ #include #include -#include -#include -#include #include #include @@ -73,7 +69,6 @@ floatPowerImpl(Slice const& x, int32_t n, int32_t mode); class HostFunctions { protected: - RTOptRef rt_; beast::Journal j_; public: @@ -81,26 +76,6 @@ public: { } - void - setRT(WasmRuntimeWrapper& rt) - { - rt_ = rt; - } - - void - resetRT() - { - rt_ = std::nullopt; - } - - [[nodiscard]] WasmRuntimeWrapper& - getRT() const - { - if (!rt_) - Throw("Wasm runtime not set"); - return rt_->get(); - } - [[nodiscard]] beast::Journal getJournal() const { @@ -495,6 +470,4 @@ public: // LCOV_EXCL_STOP }; -using HFRef = std::reference_wrapper; - } // namespace xrpl diff --git a/include/xrpl/tx/wasm/HostFuncWrapper.h b/include/xrpl/tx/wasm/HostFuncWrapper.h deleted file mode 100644 index 4884c750f1..0000000000 --- a/include/xrpl/tx/wasm/HostFuncWrapper.h +++ /dev/null @@ -1,244 +0,0 @@ -#pragma once - -#include - -#include - -#include - -namespace xrpl { - -#define WASM_CB_PARAMS_LIST void *env, wasm_val_vec_t const *params, wasm_val_vec_t *results -#define WASM_SECONDARY_CB_PARAMS_LIST \ - HostFunctions &hf, wasm_val_vec_t const *params, wasm_val_vec_t *results - -wasm_trap_t* HostFuncMain_wrap(WASM_CB_PARAMS_LIST); - -using getLedgerSqn_proto = int32_t(uint8_t*, int32_t); -wasm_trap_t* getLedgerSqn_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getParentLedgerTime_proto = int32_t(uint8_t*, int32_t); -wasm_trap_t* getParentLedgerTime_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getParentLedgerHash_proto = int32_t(uint8_t*, int32_t); -wasm_trap_t* getParentLedgerHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getBaseFee_proto = int32_t(uint8_t*, int32_t); -wasm_trap_t* getBaseFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using isAmendmentEnabled_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* isAmendmentEnabled_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using cacheLedgerObj_proto = int32_t(uint8_t const*, int32_t, int32_t); -wasm_trap_t* cacheLedgerObj_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getTxField_proto = int32_t(int32_t, uint8_t*, int32_t); -wasm_trap_t* getTxField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getCurrentLedgerObjField_proto = int32_t(int32_t, uint8_t*, int32_t); -wasm_trap_t* getCurrentLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getLedgerObjField_proto = int32_t(int32_t, int32_t, uint8_t*, int32_t); -wasm_trap_t* getLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getTxNestedField_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getTxNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getCurrentLedgerObjNestedField_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getCurrentLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getLedgerObjNestedField_proto = int32_t(int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getTxArrayLen_proto = int32_t(int32_t); -wasm_trap_t* getTxArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getCurrentLedgerObjArrayLen_proto = int32_t(int32_t); -wasm_trap_t* getCurrentLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getLedgerObjArrayLen_proto = int32_t(int32_t, int32_t); -wasm_trap_t* getLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getTxNestedArrayLen_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* getTxNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getCurrentLedgerObjNestedArrayLen_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* getCurrentLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getLedgerObjNestedArrayLen_proto = int32_t(int32_t, uint8_t const*, int32_t); -wasm_trap_t* getLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using updateData_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* updateData_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using checkSignature_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t const*, int32_t); -wasm_trap_t* checkSignature_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using computeSha512HalfHash_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* computeSha512HalfHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using accountKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* accountKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using ammKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* ammKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using checkKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* checkKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using credentialKeylet_proto = int32_t( - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t*, - int32_t); -wasm_trap_t* credentialKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using delegateKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* delegateKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using depositPreauthKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* depositPreauthKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using didKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* didKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using escrowKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* escrowKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using trustLineKeylet_proto = int32_t( - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t*, - int32_t); -wasm_trap_t* trustLineKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using mptokenIssuanceKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* mptokenIssuanceKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using mptokenKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* mptokenKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using nftokenOfferKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* nftokenOfferKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using offerKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* offerKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using oracleKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* oracleKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using paychannelKeylet_proto = int32_t( - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t const*, - int32_t, - uint8_t*, - int32_t); -wasm_trap_t* paychannelKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using permissionedDomainKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* permissionedDomainKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using signerListKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* signerListKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using ticketKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* ticketKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using vaultKeylet_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* vaultKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFT_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getNFT_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFTIssuer_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getNFTIssuer_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFTTaxon_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getNFTTaxon_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFTFlags_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* getNFTFlags_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFTTransferFee_proto = int32_t(uint8_t const*, int32_t); -wasm_trap_t* getNFTTransferFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using getNFTSequence_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t); -wasm_trap_t* getNFTSequence_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -// trace(msg_ptr, msg_len, data_type, data_ptr, data_len); data_type is a -// TraceDataType. -using trace_proto = void(uint8_t const*, int32_t, int32_t, uint8_t const*, int32_t); -wasm_trap_t* trace_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatFromInt_proto = int32_t(int64_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatFromInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatFromUint_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatFromUint_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatFromSTAmount_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatFromSTAmount_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatFromSTNumber_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatFromSTNumber_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatToInt_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatToInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatToMantExp_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, uint8_t*, int32_t); -wasm_trap_t* floatToMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatFromMantExp_proto = int32_t(int64_t, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatFromMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatCompare_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t); -wasm_trap_t* floatCompare_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatAdd_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatAdd_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatSubtract_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatSubtract_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatMultiply_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatMultiply_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatDivide_proto = - int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatDivide_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatRoot_proto = int32_t(uint8_t const*, int32_t, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatRoot_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -using floatPower_proto = int32_t(uint8_t const*, int32_t, int32_t, uint8_t*, int32_t, int32_t); -wasm_trap_t* floatPower_wrap(WASM_SECONDARY_CB_PARAMS_LIST); - -} // namespace xrpl diff --git a/include/xrpl/tx/wasm/README.md b/include/xrpl/tx/wasm/README.md index 04958b663a..6b94abc873 100644 --- a/include/xrpl/tx/wasm/README.md +++ b/include/xrpl/tx/wasm/README.md @@ -1,189 +1,41 @@ # WASM Module for Programmable Escrows -This module provides WebAssembly (WASM) execution capabilities for programmable -escrows on the XRP Ledger. When an escrow is finished, the WASM code runs to -determine whether the escrow conditions are met, enabling custom programmable -logic for escrow release conditions. - -For the full specification, see +WebAssembly execution for programmable escrows. When an escrow is finished, its contract +runs to decide whether the release conditions are met. Specification: [XLS-0102: WASM VM](https://xls.xrpl.org/xls/XLS-0102-wasm-vm.html). -## Architecture +The engine itself is Rust (`crates/xrpl-wasm-vm`, over wasmi), reached through a cxx +bridge. -The module follows a layered architecture: +## What is in this directory -``` -┌─────────────────────────────────────────────────────────────┐ -│ WasmEngine (WasmVM.h) │ -│ runEscrowWasm(), preflightEscrowWasm() │ -│ Host function registration │ -├─────────────────────────────────────────────────────────────┤ -│ WasmiEngine (WasmiVM.h) │ -│ Low-level wasmi interpreter integration │ -├─────────────────────────────────────────────────────────────┤ -│ HostFuncWrapper │ HostFuncImpl │ -│ C-style WASM bridges │ C++ implementations │ -├─────────────────────────────────────────────────────────────┤ -│ HostFunc (Interface) │ -│ Abstract base class for host functions │ -└─────────────────────────────────────────────────────────────┘ -``` +- **`WasmVM.h`** — the entry points xrpld calls: `runEscrowWasm` (execute a contract, + returning a result and its gas cost, or a `WasmTER`) and `preflightEscrowWasm` (screen a + module with no host and no execution). Both own their TER maps. +- **`HostFunc.h`** — the `HostFunctions` interface: one virtual per host function, each + defaulting to `Unimplemented`, returning `std::expected`. +- **`HostFuncImpl.h`** — `WasmHostFunctionsImpl`, the implementation over an + `ApplyContext&`. Bodies are split across `HostFuncImpl*.cpp` by category. +- **`HostContext.h`** — the bridge's C++ half: an ABI-shaped, `noexcept` view of + `HostFunctions` that the engine calls back into. Nothing may unwind into Rust, so every + method catches everything — through `guarded()`, except `trace`, which answers the guest + nothing and so has its own catch that only logs. +- **`WasmCommon.h`** — the shared vocabulary: `HostFunctionError` (the codes a contract + sees), `Bytes`, `FieldLocator`, `WasmTER`, `adjustWasmEndianess`, which is where the + boundary's byte order is decided, and `guarded()`, the catch that turns a throwing host + body into a code the engine can read. -### Key Components +## Host functions -- **`WasmVM.h` / `detail/WasmVM.cpp`** - High-level facade providing: - - `WasmEngine` singleton that wraps the underlying WASM interpreter - - `runEscrowWasm()` - Execute WASM code for escrow finish - - `preflightEscrowWasm()` - Validate WASM code during preflight - - `createWasmImport()` - Register all host functions +Grouped by what they reach: ledger information; transaction and ledger-object field access; +keylet construction; cryptography; float arithmetic; NFT queries; tracing. -- **`WasmiVM.h` / `detail/WasmiVM.cpp`** - Low-level integration with the - [wasmi](https://github.com/wasmi-labs/wasmi) WebAssembly interpreter: - - `WasmiEngine` - Manages WASM modules, instances, and execution - - Memory management and gas metering - - Function invocation and result handling +The wire names and per-call gas costs are declared in `crates/xrpl-host-functions` — +one `host_functions!` block that generates the ABI trait and the spec table. That +declaration is the single source of truth; `HostFunc.h` is the C++ side of it. -- **`HostFunc.h`** - Abstract `HostFunctions` base class defining the interface - for all callable host functions. Each method returns - `std::expected`. +## Entry point -- **`HostFuncImpl.h` / `detail/HostFuncImpl*.cpp`** - Concrete - `WasmHostFunctionsImpl` class that implements host functions with access to - `ApplyContext` for ledger state queries. Implementation split across files: - - `HostFuncImpl.cpp` - Core utilities (updateData, checkSignature, etc.) - - `HostFuncImplFloat.cpp` - Float/number arithmetic operations - - `HostFuncImplGetter.cpp` - Field access (transaction, ledger objects) - - `HostFuncImplKeylet.cpp` - Keylet construction functions - - `HostFuncImplLedgerHeader.cpp` - Ledger header info access - - `HostFuncImplNFT.cpp` - NFT-related queries - - `HostFuncImplTrace.cpp` - Debugging/tracing functions - -- **`HostFuncWrapper.h` / `detail/HostFuncWrapper.cpp`** - C-style wrapper - functions that bridge WASM calls to C++ `HostFunctions` methods. Each host - function has: - - A `_proto` type alias defining the function signature - - A `_wrap` function that extracts parameters and calls the implementation - -- **`ParamsHelper.h`** - Utilities for WASM parameter handling: - - `WASM_IMPORT_FUNC` / `WASM_IMPORT_FUNC2` macros for registration - - `wasmParams()` helper for building parameter vectors - - Type conversion between WASM and C++ types - -## Host Functions - -Host functions allow WASM code to interact with the XRP Ledger. They are -organized into categories: - -- **Ledger Information** - Access ledger sequence, timestamps, hashes, fees -- **Transaction & Ledger Object Access** - Read fields from the transaction - and ledger objects (including the current escrow object) -- **Keylet Construction** - Build keylets to look up various ledger object types -- **Cryptography** - Signature verification and hashing -- **Float Arithmetic** - Mathematical operations for amount calculations -- **NFT Operations** - Query NFT properties -- **Tracing/Debugging** - Log messages for debugging - -For the complete list of available host functions, their WASM names, and gas -costs, see the [XLS-0102 specification](https://xls.xrpl.org/xls/XLS-0102-wasm-vm.html) -or `detail/WasmVM.cpp` where they are registered via `WASM_IMPORT_FUNC2` macros. -For method signatures, see `HostFunc.h`. - -## Gas Model - -Each host function has an associated gas cost. The gas cost is specified when -registering the function in `detail/WasmVM.cpp`: - -```cpp -WASM_IMPORT_FUNC2(i, getLedgerSqn, "get_ledger_sqn", hfs, 60); -// ^^ gas cost -``` - -WASM execution is metered, and if the gas limit is exceeded, execution fails. - -## Entry Point - -The WASM module must export a function with the name defined by -`escrowFunctionName` (currently `"escrow_finish"`). This function: - -- Takes no parameters (or parameters passed via host function calls) -- Returns an `int32_t`: - - `1` (or positive): Escrow conditions are met, allow finish - - `0` (or negative): Escrow conditions are not met, reject finish - -## Adding a New Host Function - -To add a new host function, follow these steps: - -### 1. Add to HostFunc.h (Base Class) - -Add a virtual method declaration with a default implementation that returns an -error: - -```cpp -virtual std::expected -myNewFunction(ParamType1 param1, ParamType2 param2) -{ - return std::unexpected(HostFunctionError::INTERNAL); -} -``` - -### 2. Add to HostFuncImpl.h (Declaration) - -Add the method override declaration in `WasmHostFunctionsImpl`: - -```cpp -std::expected -myNewFunction(ParamType1 param1, ParamType2 param2) override; -``` - -### 3. Implement in detail/HostFuncImpl\*.cpp - -Add the implementation in the appropriate file: - -```cpp -std::expected -WasmHostFunctionsImpl::myNewFunction(ParamType1 param1, ParamType2 param2) -{ - // Implementation using ctx (ApplyContext) for ledger access - return result; -} -``` - -### 4. Add Wrapper to HostFuncWrapper.h - -Add the prototype and wrapper declaration: - -```cpp -using myNewFunction_proto = int32_t(uint8_t const*, int32_t, ...); -wasm_trap_t* -myNewFunction_wrap(void* env, wasm_val_vec_t const* params, wasm_val_vec_t* results); -``` - -### 5. Implement Wrapper in detail/HostFuncWrapper.cpp - -Implement the C-style wrapper that bridges WASM to C++: - -```cpp -wasm_trap_t* -myNewFunction_wrap(void* env, wasm_val_vec_t const* params, wasm_val_vec_t* results) -{ - // Extract parameters from params - // Call hfs->myNewFunction(...) - // Set results and return -} -``` - -### 6. Register in WasmVM.cpp - -Add the function registration in `setCommonHostFunctions()` or -`createWasmImport()`: - -```cpp -WASM_IMPORT_FUNC2(i, myNewFunction, "my_new_function", hfs, 100); -// ^^ WASM name ^^ gas cost -``` - -> [!IMPORTANT] -> New host functions MUST be amendment-gated in `WasmVM.cpp`. -> Wrap the registration in an amendment check to ensure the function is only -> available after the corresponding amendment is enabled on the network. +A module must export `escrow_finish` (`escrowFunctionName`) taking no parameters and +returning `int32_t`: positive means the conditions are met, zero or negative rejects the +finish. Everything the contract needs it asks for through a host call. diff --git a/include/xrpl/tx/wasm/WasmCommon.h b/include/xrpl/tx/wasm/WasmCommon.h index f73ca7c2d2..421dd84b29 100644 --- a/include/xrpl/tx/wasm/WasmCommon.h +++ b/include/xrpl/tx/wasm/WasmCommon.h @@ -1,16 +1,19 @@ #pragma once +#include #include #include +#include #include #include #include #include -#include +#include +#include #include +#include #include -#include #include #include #include @@ -21,30 +24,6 @@ using Bytes = std::vector; using Hash = xrpl::uint256; using FloatPair = std::pair; -// Error signals that cross the wasm boundary as trap messages (the C API has no -// trap code). WasmiEngine::call maps them to TER: hfErrInternal -> tecINTERNAL, -// hfErrOutOfGas / wasmi's OutOfFuel -> tecOUT_OF_GAS, anything else -> -// tecFAILED_PROCESSING. -// -// Matched as substrings, not by equality: the C API returns the Rust Debug form -// of the error, e.g. `Error { kind: Message("HfInternal") }` or -// `Error { kind: TrapCode(OutOfFuel) }`. -std::string_view inline constexpr hfErrInternal = "HfInternal"; -std::string_view inline constexpr hfErrOutOfGas = "HfOutOfGas"; -std::string_view inline constexpr wasmiTrapOutOfFuel = "OutOfFuel"; - -// Guest ABI, mirrored in the wasm stdlib: append only, never renumber. Starts at -// 1 so a zeroed data_type is rejected rather than treated as Int64. -enum class TraceDataType : std::int32_t { - Int64 = 1, - Uint64, - Xfloat, - Account, - Amount, - AsHex, // raw bytes, hex-encoded by the host before printing - AsText, // bytes printed verbatim as text -}; - enum class HostFunctionError : int32_t { Unimplemented = -1, FieldNotFound = -2, @@ -66,19 +45,15 @@ enum class HostFunctionError : int32_t { IndexOutOfBounds = -18, FloatInputMalformed = -19, FloatComputationError = -20, -}; -enum class WasmTypes { WtI32, WtI64 }; - -struct Wmem -{ - std::uint8_t* p = nullptr; - std::size_t s = 0; - - Wmem() = default; - Wmem(void* ptr, std::size_t size) : p(reinterpret_cast(ptr)), s(size) - { - } + // The call was not served at all, so the engine stops the run and the transaction is + // tecINTERNAL rather than the contract being handed a code to interpret. `guarded` + // answers it for a host body that throws. + // + // The only entry outside the -1 ..= -20 range a contract reads: it needs no number + // there, and INT32_MIN cannot collide with a code appended above. Negative so that a + // reader treating it as an ordinary failure is still right. + InternalFatal = std::numeric_limits::min(), }; template @@ -148,71 +123,6 @@ public: } }; -class WasmRuntimeWrapper -{ -public: - virtual ~WasmRuntimeWrapper() = default; - - virtual Wmem - getMem() = 0; - - virtual std::int64_t - getGas() = 0; - - virtual std::int64_t - setGas(std::int64_t gas) = 0; - - virtual std::int64_t - getTransferLimit() = 0; - - virtual std::int64_t - setTransferLimit(std::int64_t transferLimit) = 0; -}; -using RTOptRef = std::optional>; - -struct WasmParam -{ - // We are not supporting float/double - - WasmTypes type = WasmTypes::WtI32; - union - { - std::int32_t i32; - std::int64_t i64 = 0; - } of; -}; - -template -inline void -wasmParamsHlp(std::vector& v, std::int32_t p, Types&&... args) -{ - v.push_back({.type = WasmTypes::WtI32, .of = {.i32 = p}}); - wasmParamsHlp(v, std::forward(args)...); -} - -template -inline void -wasmParamsHlp(std::vector& v, std::int64_t p, Types&&... args) -{ - v.push_back({.type = WasmTypes::WtI64, .of = {.i64 = p}}); - wasmParamsHlp(v, std::forward(args)...); -} - -inline void -wasmParamsHlp(std::vector& v) -{ -} - -template -inline std::vector -wasmParams(Types&&... args) -{ - std::vector v; - v.reserve(sizeof...(args)); - wasmParamsHlp(v, std::forward(args)...); - return v; -} - template constexpr T adjustWasmEndianessHlp(T x) @@ -250,4 +160,28 @@ hfErrorToInt(HostFunctionError e) return static_cast(e); } +template +std::invoke_result_t +guarded( + beast::Journal journal, + std::invoke_result_t onThrow, + Body&& body, + std::source_location const location = std::source_location::current()) noexcept +{ + try + { + return body(); + } + catch (std::exception const& e) + { + JLOG(journal.error()) << "wasm: " << location.function_name() << " threw: " << e.what(); + } + catch (...) + { + JLOG(journal.error()) << "wasm: " << location.function_name() << " threw"; + } + + return onThrow; +} + } // namespace xrpl diff --git a/include/xrpl/tx/wasm/WasmImportsHelper.h b/include/xrpl/tx/wasm/WasmImportsHelper.h deleted file mode 100644 index 0c31e969c1..0000000000 --- a/include/xrpl/tx/wasm/WasmImportsHelper.h +++ /dev/null @@ -1,126 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include - -namespace bft = boost::function_types; - -namespace xrpl { - -using wasmSecondaryCbFuncType = - wasm_trap_t*(HostFunctions&, wasm_val_vec_t const*, wasm_val_vec_t*); - -struct WasmImportFunc -{ - std::string_view name; - std::optional result; - std::vector params; - - wasmSecondaryCbFuncType* wrap = nullptr; - uint32_t gas = 0; -}; - -using WasmUserData = std::pair; -// string - import function name -using ImportVec = std::unordered_map; - -template -void -WasmImpArgs(WasmImportFunc& e) -{ - if constexpr (N < C) - { - using at = boost::mpl::at_c::type; - if constexpr (std::is_pointer_v || std::is_same_v) - { - e.params.push_back(WasmTypes::WtI32); - } - else if constexpr (std::is_same_v) - { - e.params.push_back(WasmTypes::WtI64); - } - else - { - static_assert(std::is_pointer_v, "Unsupported argument type"); - } - - return WasmImpArgs(e); - } -} - -template -inline constexpr bool wasmDependentFalse = false; - -template -void -WasmImpRet(WasmImportFunc& e) -{ - if constexpr (std::is_pointer_v || std::is_same_v) - { - e.result = WasmTypes::WtI32; - } - else if constexpr (std::is_same_v) - { - e.result = WasmTypes::WtI64; - } - else if constexpr (std::is_void_v) - { - e.result.reset(); - } - else - { - static_assert(wasmDependentFalse, "Unsupported return type"); - } -} - -template -void -WasmImpFuncHelper(WasmImportFunc& e) -{ - using rt = bft::result_type::type; - using pt = bft::parameter_types::type; - // typename boost::mpl::at_c::type - - WasmImpRet(e); - WasmImpArgs<0, bft::function_arity::value, pt>(e); - // WasmImpWrap(e, std::forward(f)); -} - -// imp_name - string literal, must have static lifetime -template -void -WasmImpFunc( - ImportVec& v, - std::string_view impName, - wasmSecondaryCbFuncType* fWrap, - HostFunctions& hf, - uint32_t gas = 0) -{ - WasmImportFunc e; - e.name = impName; - e.wrap = fWrap; - e.gas = gas; - WasmImpFuncHelper(e); - v.emplace(impName, std::make_pair(HFRef(hf), std::move(e))); -} - -#define WASM_IMPORT_FUNC(v, f, ...) WasmImpFunc(v, #f, &f##_wrap, ##__VA_ARGS__) - -// n - string literal name, must have static lifetime -#define WASM_IMPORT_FUNC2(v, f, n, ...) WasmImpFunc(v, n, &f##_wrap, ##__VA_ARGS__) - -} // namespace xrpl diff --git a/include/xrpl/tx/wasm/WasmVM.h b/include/xrpl/tx/wasm/WasmVM.h index e20488de00..99161b93af 100644 --- a/include/xrpl/tx/wasm/WasmVM.h +++ b/include/xrpl/tx/wasm/WasmVM.h @@ -4,94 +4,47 @@ #include #include #include -#include #include #include -#include -#include #include -#include namespace xrpl { -std::string_view inline constexpr wEnv = "env"; -std::string_view inline constexpr wHostLib = "host_lib"; -std::string_view inline constexpr wMem = "memory"; -std::string_view inline constexpr wStore = "store"; -std::string_view inline constexpr wLoad = "load"; -std::string_view inline constexpr wSize = "size"; -std::string_view inline constexpr wAlloc = "allocate"; -std::string_view inline constexpr wDealloc = "deallocate"; -std::string_view inline constexpr wProcExit = "proc_exit"; - +// The export a programmable escrow's contract is run through. std::string_view inline constexpr escrowFunctionName = "escrow_finish"; -uint32_t inline constexpr maxPages = 128; // 8MB = 64KB*128 - -class WasmiEngine; - -class WasmEngine -{ - std::unique_ptr const impl_; - - WasmEngine(); - -public: - WasmEngine(WasmEngine const&) = delete; - WasmEngine(WasmEngine&&) = delete; - WasmEngine& - operator=(WasmEngine const&) = delete; - WasmEngine& - operator=(WasmEngine&&) = delete; - - static WasmEngine& - instance(); - - std::expected, WasmTER> - run(Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName = {}, - std::vector const& params = {}, - ImportVec const& imports = {}, - beast::Journal j = beast::Journal{beast::Journal::getNullSink()}); - - NotTEC - check( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params = {}, - ImportVec const& imports = {}, - beast::Journal j = beast::Journal{beast::Journal::getNullSink()}); - - // Host functions helper functionality - void* - newTrap(std::string const& txt = std::string()); - - [[nodiscard]] beast::Journal - getJournal() const; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -ImportVec -createWasmImport(HostFunctions& hfs); - +// Run `wasmCode`'s `funcName` export with `gasLimit` gas, servicing its host calls +// through `hfs`. +// +// On success the result is what the contract returned - positive means the escrow may +// finish - together with the gas it consumed. On failure it is the TER to apply and, +// when the number means anything, the gas to write to transaction metadata: a contract +// that traps or exhausts its budget is charged for what it burned, while a `tecINTERNAL` +// reports no cost because the fault is the node's rather than the transaction's. std::expected runEscrowWasm( Bytes const& wasmCode, HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName = escrowFunctionName, - std::vector const& params = {}); + std::int64_t gasLimit, + std::string_view funcName = escrowFunctionName) noexcept; +// Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's +// first instruction. Compiles the module and reads its imports and exports; runs +// nothing. +// +// Takes no `HostFunctions`, because the verdict comes from the compiled module alone. +// That is what makes this callable from a transactor's `preflight`, which has no view +// to build a host over. +// +// `temBAD_WASM` for every fault in the module - the transaction carries something this +// engine cannot run, so it is refused before it can reach the ledger. +// `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the +// module, and a defect here is not evidence that the transaction is malformed. NotTEC preflightEscrowWasm( Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName = escrowFunctionName, - std::vector const& params = {}); + beast::Journal j, + std::string_view funcName = escrowFunctionName) noexcept; } // namespace xrpl diff --git a/include/xrpl/tx/wasm/WasmiVM.h b/include/xrpl/tx/wasm/WasmiVM.h deleted file mode 100644 index 5a72cd35f6..0000000000 --- a/include/xrpl/tx/wasm/WasmiVM.h +++ /dev/null @@ -1,462 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl { - -template -class WasmVec -{ - using TD = std::remove_pointer_t; - T vec_; - -public: - WasmVec(size_t s = 0) : vec_ WASM_EMPTY_VEC - { - if (s > 0) - Create(&vec_, s); // zeroes memory - } - - ~WasmVec() - { - clear(); - } - - WasmVec(WasmVec const&) = delete; - WasmVec& - operator=(WasmVec const&) = delete; - - WasmVec(WasmVec&& other) noexcept : vec_ WASM_EMPTY_VEC - { - *this = std::move(other); - } - - WasmVec& - operator=(WasmVec&& other) noexcept - { - if (this != &other) - { - clear(); - vec_ = other.vec_; - other.vec_ = WASM_EMPTY_VEC; - } - return *this; - } - - void - clear() - { - Destroy(&vec_); // call destructor for every elements too - vec_ = WASM_EMPTY_VEC; - } - - T - release() - { - T result = vec_; - vec_ = WASM_EMPTY_VEC; - return result; - } - - T* - get() - { - return &vec_; - } - - [[nodiscard]] T const* - get() const - { - return &vec_; - } - - TD& - operator[](size_t i) - { - if (i >= vec_.size) - Throw("Out of bound"); - return vec_.data[i]; - } - - TD const& - operator[](size_t i) const - { - if (i >= vec_.size) - Throw("Out of bound"); - return vec_.data[i]; - } - - [[nodiscard]] size_t - size() const - { - return vec_.size; - } - - [[nodiscard]] bool - empty() const - { - return vec_.size == 0u; - } -}; - -using WasmValtypeVec = - WasmVec; -using WasmValVec = WasmVec; -using WasmExternVec = - WasmVec; -using WasmExporttypeVec = WasmVec< - wasm_exporttype_vec_t, - &wasm_exporttype_vec_new_uninitialized, - &wasm_exporttype_vec_delete>; -using WasmImporttypeVec = WasmVec< - wasm_importtype_vec_t, - &wasm_importtype_vec_new_uninitialized, - &wasm_importtype_vec_delete>; - -struct WasmiResult -{ - WasmValVec r; - // Set iff the call trapped. Holds the TER the trap was classified into - // (tecINTERNAL / tecOUT_OF_GAS / tecFAILED_PROCESSING); see - // WasmiEngine::call. std::nullopt means the call returned normally. - std::optional ter; - - WasmiResult(unsigned n = 0) : r(n) - { - } - - WasmiResult() = delete; - ~WasmiResult() = default; - WasmiResult(WasmiResult&& o) = default; - WasmiResult& - operator=(WasmiResult&& o) = default; -}; - -using ModulePtr = std::unique_ptr; -using InstancePtr = std::unique_ptr; -using EnginePtr = std::unique_ptr; -using StorePtr = std::unique_ptr; - -using FuncInfo = std::pair; - -class InstanceWrapper -{ - wasm_store_t* store_ = nullptr; - WasmExternVec exports_; - mutable int memIdx_ = -1; - InstancePtr instance_; - beast::Journal j_ = beast::Journal(beast::Journal::getNullSink()); - std::int64_t transferLimit_ = kWasmTransferLimit; - -private: - static InstancePtr - init( - StorePtr& s, - ModulePtr& m, - WasmExternVec& expt, - WasmExternVec const& imports, - beast::Journal j); - -public: - InstanceWrapper() : instance_(nullptr, &wasm_instance_delete) {}; - - InstanceWrapper(InstanceWrapper const&) = delete; - - InstanceWrapper(InstanceWrapper&& o) : instance_(nullptr, &wasm_instance_delete) - { - *this = std::move(o); // LCOV_EXCL_LINE - } - - InstanceWrapper(StorePtr& s, ModulePtr& m, WasmExternVec const& imports, beast::Journal j) - : store_(s.get()), instance_(init(s, m, exports_, imports, j)), j_(j) - { - } - - InstanceWrapper& - operator=(InstanceWrapper&& o); - - InstanceWrapper& - operator=(InstanceWrapper const&) = delete; - - operator bool() const - { - return static_cast(instance_); - } - - FuncInfo - getFunc(std::string_view funcName, WasmExporttypeVec const& exportTypes) const; - - Wmem - getMem() const; - - std::int64_t - getGas() const; - - std::int64_t - setGas(std::int64_t) const; - - std::int64_t - getTransferLimit() const; - - std::int64_t - setTransferLimit(std::int64_t); -}; - -class ModuleWrapper -{ - ModulePtr module_; - InstanceWrapper instanceWrap_; - WasmExporttypeVec exportTypes_; - beast::Journal j_ = beast::Journal(beast::Journal::getNullSink()); - -public: - // LCOV_EXCL_START - ModuleWrapper() : module_(nullptr, &wasm_module_delete) - { - } - - ModuleWrapper(ModuleWrapper&& o) : module_(nullptr, &wasm_module_delete) - { - *this = std::move(o); - } - // LCOV_EXCL_STOP - - ModuleWrapper& - operator=(ModuleWrapper&& o); - ModuleWrapper( - StorePtr& s, - Bytes const& wasmBin, - bool instantiate, - ImportVec const& imports, - beast::Journal j); - ~ModuleWrapper() = default; - - operator bool() const - { - return instanceWrap_; - } - - FuncInfo - getFunc(std::string_view funcName) const - { - return instanceWrap_.getFunc(funcName, exportTypes_); - } - - wasm_functype_t const* - getFuncType(std::string_view funcName) const; - - Wmem - getMem() const - { - return instanceWrap_.getMem(); - } - - InstanceWrapper& - getInstance(int i = 0) - { - return instanceWrap_; - } - - InstanceWrapper const& - getInstance(int i = 0) const - { - return instanceWrap_; - } - - int - addInstance(StorePtr& s, WasmExternVec const& imports) - { - instanceWrap_ = {s, module_, imports, j_}; - return 0; - } - - std::int64_t - getGas() const - { - return instanceWrap_ ? instanceWrap_.getGas() : -1; - } - -private: - static ModulePtr - init(StorePtr& s, Bytes const& wasmBin, beast::Journal j); - - WasmExternVec - buildImports(StorePtr& s, ImportVec const& imports) const; -}; - -class WasmiEngine -{ - EnginePtr engine_; - StorePtr store_; - std::unique_ptr moduleWrap_; - beast::Journal j_ = beast::Journal(beast::Journal::getNullSink()); - - std::mutex m_; // 1 instance mutex - -public: - WasmiEngine() : engine_(init()), store_(nullptr, &wasm_store_delete) - { - } - - ~WasmiEngine() = default; - - static EnginePtr - init(); - - std::expected, WasmTER> - run(Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gas, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j); - - NotTEC - check( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j); - - [[nodiscard]] std::int64_t - getGas() const - { - return moduleWrap_ ? moduleWrap_->getGas() : -1; // LCOV_EXCL_LINE - } - - // Host functions helper functionality - wasm_trap_t* - newTrap(std::string const& msg); - - // LCOV_EXCL_START - [[nodiscard]] beast::Journal - getJournal() const - { - return j_; - } - // LCOV_EXCL_STOP - -private: - [[nodiscard]] InstanceWrapper& - getRT(int m = 0, int i = 0) const - { - if (!moduleWrap_) - Throw("no module"); - return moduleWrap_->getInstance(i); - } - - [[nodiscard]] Wmem - getMem() const - { - return moduleWrap_ ? moduleWrap_->getMem() : Wmem(); - } - - std::expected, WasmTER> - runHlp( - Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gas, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j); - - NotTEC - checkHlp( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j); - - int - addModule(Bytes const& wasmCode, bool instantiate, ImportVec const& imports, int64_t gas); - void - clearModules(); - - // int addInstance(); - - int32_t - runFunc(std::string_view const funcName, int32_t p); - - int32_t - makeModule(Bytes const& wasmCode, WasmExternVec const& imports = {}); - - [[nodiscard]] FuncInfo - getFunc(std::string_view funcName) const - { - return moduleWrap_->getFunc(funcName); - } - - static std::vector - convertParams(std::vector const& params); - - static int - compareParamTypes(wasm_valtype_vec_t const* ftp, std::vector const& p); - - static void - addParam(std::vector& in, int32_t p); - static void - addParam(std::vector& in, int64_t p); - - template - inline WasmiResult - call(std::string_view func, Types&&... args); - - template - inline WasmiResult - call(FuncInfo const& f, Types&&... args); - - template - inline WasmiResult - call(FuncInfo const& f, std::vector& in); - - template - inline WasmiResult - call(FuncInfo const& f, std::vector& in, std::int32_t p, Types&&... args); - - template - inline WasmiResult - call(FuncInfo const& f, std::vector& in, std::int64_t p, Types&&... args); - - template - inline WasmiResult - call( - FuncInfo const& f, - std::vector& in, - uint8_t const* d, - int32_t sz, - Types&&... args); - - template - inline WasmiResult - call(FuncInfo const& f, std::vector& in, Bytes const& p, Types&&... args); -}; - -} // namespace xrpl diff --git a/src/libxrpl/tx/wasm/HostContext.cpp b/src/libxrpl/tx/wasm/HostContext.cpp new file mode 100644 index 0000000000..a67860604a --- /dev/null +++ b/src/libxrpl/tx/wasm/HostContext.cpp @@ -0,0 +1,1249 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +// For `TraceDataType`: declared in the cxx bridge, defined in the header it generates. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +namespace { + +// What a host call answers when it could not be served at all: every method below hands it +// to `guarded` as the answer for a body that throws. +constexpr std::int32_t kHostInternal = hfErrorToInt(HostFunctionError::InternalFatal); + +// Copy `value` into `out` only if the whole of it fits, and answer its true length either +// way. A value too large for the guest's buffer must reach it in no part: a prefix would +// be a wrong answer where a length is a usable one. +std::int32_t +answer(rust::Slice out, std::uint8_t const* value, std::size_t size) +{ + XRPL_ASSERT( + value != nullptr || size == 0, "xrpl::answer : nullptr value should have zero size"); + if (value != nullptr && size <= out.size()) + { + std::memcpy(out.data(), value, size); + } + size = std::min(size, static_cast(std::numeric_limits::max())); + return static_cast(size); +} + +// A scalar the ABI carries as bytes, in the wire's byte order. +// +// `adjustWasmEndianess` is the one place that order is decided for the whole wasm boundary, +// and it is `constexpr` with the swap under `if constexpr (std::endian::native == +// std::endian::big)` - so this costs nothing on a little-endian host and is correct on a +// big-endian one, which a hand-written shift sequence per call site would have to get right +// each time. +template +std::int32_t +answerScalar(rust::Slice out, T value) +{ + auto const wire = adjustWasmEndianess(value); + return answer(out, reinterpret_cast(&wire), sizeof(wire)); +} + +// Decode an asset from its wire bytes, whose length selects the kind: an MPT id, a +// bare currency (which must be XRP), or a currency followed by an issuer (which must +// not be XRP). Any other length is malformed. +std::expected +parseAsset(rust::Slice bytes) +{ + if (bytes.size() == MPTID::size()) + { + return Asset{MPTID::fromVoid(bytes.data())}; + } + + if (bytes.size() == Currency::size()) + { + auto const issue = Issue{Currency::fromVoid(bytes.data()), xrpAccount()}; + if (!issue.native()) + { + return std::unexpected(HostFunctionError::InvalidParams); + } + return Asset{issue}; + } + + if (bytes.size() == Currency::size() + AccountID::size()) + { + auto const issue = Issue{ + Currency::fromVoid(bytes.data()), AccountID::fromVoid(bytes.data() + Currency::size())}; + if (issue.native()) + { + return std::unexpected(HostFunctionError::InvalidParams); + } + return Asset{issue}; + } + + return std::unexpected(HostFunctionError::InvalidParams); +} + +// Decode a `uint64` from its eight wire bytes, in the wire's byte order. The region +// must be exactly eight bytes, else `InvalidParams`. +std::expected +parseUint64(rust::Slice bytes) +{ + if (bytes.size() != sizeof(std::uint64_t)) + { + return std::unexpected(HostFunctionError::InvalidParams); + } + + auto x = std::uint64_t{}; + std::memcpy(&x, bytes.data(), sizeof(x)); + return adjustWasmEndianess(x); +} + +// Deserialize an `ST` object from its wire bytes; `InvalidParams` if the bytes are not +// a well-formed one, which `SerialIter` reports by throwing. +template +std::expected +parseST(rust::Slice bytes) +{ + try + { + auto sit = SerialIter{Slice{bytes.data(), bytes.size()}}; + return T{sit, sfGeneric}; + } + catch (std::exception const&) + { + return std::unexpected(HostFunctionError::InvalidParams); + } +} + +template +std::int32_t +invokeWithLocator( + rust::Slice locator, + rust::Slice out, + Functor&& functor) +{ + if (locator.empty() || (locator.size() & 3) != 0) + { + return hfErrorToInt(HostFunctionError::LocatorMalformed); + } + + std::uint32_t const steps = locator.size() / sizeof(std::int32_t); + auto locBuf = std::vector(steps); + std::memcpy(locBuf.data(), locator.data(), locator.size()); + auto const fl = FieldLocator{std::move(locBuf)}; + + auto const value = functor(fl); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return answer(out, value->data(), value->size()); +} + +template +std::int32_t +invokeWithLocator(rust::Slice locator, Functor&& functor) +{ + if (locator.empty() || (locator.size() & 3) != 0) + { + return hfErrorToInt(HostFunctionError::LocatorMalformed); + } + + std::uint32_t const steps = locator.size() / sizeof(std::int32_t); + auto locBuf = std::vector(steps); + std::memcpy(locBuf.data(), locator.data(), locator.size()); + auto const fl = FieldLocator{std::move(locBuf)}; + + auto const value = functor(fl); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return *value; +} + +template +std::int32_t +invokeWithField(std::int32_t field, rust::Slice out, Functor&& functor) +{ + auto const& knownSFields = SField::getKnownCodeToField(); + auto const it = knownSFields.find(field); + if (it == std::end(knownSFields)) + { + return hfErrorToInt(HostFunctionError::InvalidField); + } + + auto const value = functor(*it->second); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return answer(out, value->data(), value->size()); +} + +template +std::int32_t +invokeWithField(std::int32_t field, Functor&& functor) +{ + auto const& knownSFields = SField::getKnownCodeToField(); + auto const it = knownSFields.find(field); + if (it == std::end(knownSFields)) + { + return hfErrorToInt(HostFunctionError::InvalidField); + } + + auto const len = functor(*it->second); + if (!len) + { + return hfErrorToInt(len.error()); + } + + return *len; +} + +template +std::int32_t +invokeWithAccount( + rust::Slice account, + rust::Slice out, + Functor&& functor) +{ + if (account.size() != AccountID::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + + auto const value = functor(AccountID::fromVoid(account.data())); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return answer(out, value->data(), value->size()); +} + +template +std::int32_t +invokeWithAccounts( + rust::Slice account1, + rust::Slice account2, + rust::Slice out, + Functor&& functor) +{ + if (account1.size() != AccountID::size() || account2.size() != AccountID::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + + auto const value = + functor(AccountID::fromVoid(account1.data()), AccountID::fromVoid(account2.data())); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return answer(out, value->data(), value->size()); +} + +template +std::int32_t +invokeNFT(rust::Slice nftId, rust::Slice out, Functor&& functor) +{ + if (nftId.size() != uint256::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + + auto const value = functor(uint256::fromVoid(nftId.data())); + if (!value) + { + return hfErrorToInt(value.error()); + } + + if constexpr (Scalar) + { + return answerScalar(out, *value); + } + else + { + return answer(out, value->data(), value->size()); + } +} + +template +std::int32_t +invokeNFT(rust::Slice nftId, Functor&& functor) +{ + if (nftId.size() != uint256::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + + auto const value = functor(uint256::fromVoid(nftId.data())); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return *value; +} + +template +std::int32_t +invoke(rust::Slice out, Functor&& functor) +{ + auto const value = functor(); + if (!value) + { + return hfErrorToInt(value.error()); + } + + if constexpr (Scalar) + { + return answerScalar(out, *value); + } + else + { + return answer(out, value->data(), value->size()); + } +} + +template +std::int32_t +invoke(Functor&& functor) +{ + auto const value = functor(); + if (!value) + { + return hfErrorToInt(value.error()); + } + + return *value; +} + +// A traced integer, which the guest sends as bytes rather than as a wasm scalar so that one +// import serves every type. `std::nullopt` if the buffer is not the width the type needs. +// +// `memcpy` regardless of alignment, and no `reinterpret_cast` fast path: a trace must cost +// the same whatever address the guest chose for its buffer. +template +std::optional +traceInt(Slice const& data) +{ + static_assert(std::is_integral_v); + if (data.size() != sizeof(T)) + return std::nullopt; + + T x; + std::memcpy(&x, data.data(), sizeof(T)); + return adjustWasmEndianess(x); +} + +// The guest's bytes as the text a log line carries, or `std::nullopt` when they do not hold +// the type they claim. +// +// The engine refuses a code that names no type before it crosses, so `type` is always one of +// the variants; the trailing `return` is what the `switch` owes a scoped enum, not a case +// this can meet. +// +// May throw: `STAmount`'s deserializer rejects malformed input that way. +std::optional +traceFormat(TraceDataType type, Slice const& data) +{ + switch (type) + { + case TraceDataType::Int64: + if (auto const x = traceInt(data)) + return std::to_string(*x); + return std::nullopt; + + case TraceDataType::Uint64: + if (auto const x = traceInt(data)) + return std::to_string(*x); + return std::nullopt; + + case TraceDataType::Xfloat: + return wasm_float::floatToString(data); + + case TraceDataType::Account: + if (data.size() != AccountID::size()) + return std::nullopt; + return toBase58(AccountID::fromVoid(data.data())); + + case TraceDataType::Amount: { + SerialIter iter(data); + STAmount const amount(iter, sfGeneric); + return amount.getFullText(); + } + + case TraceDataType::AsHex: + return strHex(data); + + case TraceDataType::AsText: + // An empty Slice has a null data(), which std::string may not be handed. + if (data.empty()) + return std::string(); + return std::string(reinterpret_cast(data.data()), data.size()); + } + + return std::nullopt; +} + +} // namespace + +HostContext::HostContext(HostFunctions& hostFunctions) : hostFunctions_{hostFunctions} +{ +} + +std::int32_t +HostContext::getLedgerSqn(rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { return hostFunctions_.getLedgerSqn(); }); + }); +} + +std::int32_t +HostContext::getParentLedgerTime(rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { return hostFunctions_.getParentLedgerTime(); }); + }); +} + +std::int32_t +HostContext::getParentLedgerHash(rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { return hostFunctions_.getParentLedgerHash(); }); + }); +} + +std::int32_t +HostContext::getBaseFee(rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { return hostFunctions_.getBaseFee(); }); + }); +} + +std::int32_t +HostContext::isAmendmentEnabled(rust::Slice amendment) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + // A 32-byte input may be an amendment id; try that first and fall through to + // a name lookup if it is not an enabled amendment - the 32 bytes could spell + // a name instead. + if (amendment.size() == uint256::size()) + { + auto const enabled = + hostFunctions_.isAmendmentEnabled(uint256::fromVoid(amendment.data())); + if (enabled && *enabled == 1) + { + return *enabled; + } + } + + static constexpr auto kMaxAmendmentSize = 64UZ; + if (amendment.size() > kMaxAmendmentSize) + { + return hfErrorToInt(HostFunctionError::DataFieldTooLarge); + } + + auto const name = + std::string_view{reinterpret_cast(amendment.data()), amendment.size()}; + return invoke([&] { return hostFunctions_.isAmendmentEnabled(name); }); + }); +} + +std::int32_t +HostContext::cacheLedgerObj(rust::Slice objId, std::int32_t cacheIdx) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + if (objId.size() != uint256::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + return invoke([&] { + return hostFunctions_.cacheLedgerObj(uint256::fromVoid(objId.data()), cacheIdx); + }); + }); +} + +std::int32_t +HostContext::getTxField(std::int32_t field, rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, out, [&](auto const& innerField) { + return hostFunctions_.getTxField(innerField); + }); + }); +} + +std::int32_t +HostContext::getCurrentLedgerObjField(std::int32_t field, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, out, [&](auto const& innerField) { + return hostFunctions_.getCurrentLedgerObjField(innerField); + }); + }); +} + +std::int32_t +HostContext::getLedgerObjField( + std::int32_t cacheIdx, + std::int32_t field, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, out, [&](auto const& innerField) { + return hostFunctions_.getLedgerObjField(cacheIdx, innerField); + }); + }); +} + +std::int32_t +HostContext::getTxNestedField( + rust::Slice locator, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, out, [&](FieldLocator const& fl) { + return hostFunctions_.getTxNestedField(fl); + }); + }); +} + +std::int32_t +HostContext::getCurrentLedgerObjNestedField( + rust::Slice locator, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, out, [&](FieldLocator const& fl) { + return hostFunctions_.getCurrentLedgerObjNestedField(fl); + }); + }); +} + +std::int32_t +HostContext::getLedgerObjNestedField( + std::int32_t cacheIdx, + rust::Slice locator, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, out, [&](FieldLocator const& fl) { + return hostFunctions_.getLedgerObjNestedField(cacheIdx, fl); + }); + }); +} + +std::int32_t +HostContext::getTxArrayLen(std::int32_t field) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, [&](auto const& innerField) { + return hostFunctions_.getTxArrayLen(innerField); + }); + }); +} + +std::int32_t +HostContext::getCurrentLedgerObjArrayLen(std::int32_t field) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, [&](auto const& innerField) { + return hostFunctions_.getCurrentLedgerObjArrayLen(innerField); + }); + }); +} + +std::int32_t +HostContext::getLedgerObjArrayLen(std::int32_t cacheIdx, std::int32_t field) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithField(field, [&](auto const& innerField) { + return hostFunctions_.getLedgerObjArrayLen(cacheIdx, innerField); + }); + }); +} + +std::int32_t +HostContext::getTxNestedArrayLen(rust::Slice locator) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, [&](FieldLocator const& fl) { + return hostFunctions_.getTxNestedArrayLen(fl); + }); + }); +} + +std::int32_t +HostContext::getCurrentLedgerObjNestedArrayLen( + rust::Slice locator) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, [&](FieldLocator const& fl) { + return hostFunctions_.getCurrentLedgerObjNestedArrayLen(fl); + }); + }); +} + +std::int32_t +HostContext::getLedgerObjNestedArrayLen( + std::int32_t cacheIdx, + rust::Slice locator) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithLocator(locator, [&](FieldLocator const& fl) { + return hostFunctions_.getLedgerObjNestedArrayLen(cacheIdx, fl); + }); + }); +} + +std::int32_t +HostContext::checkSignature( + rust::Slice message, + rust::Slice signature, + rust::Slice pubkey) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke([&] { + return hostFunctions_.checkSignature( + Slice{message.data(), message.size()}, + Slice{signature.data(), signature.size()}, + Slice{pubkey.data(), pubkey.size()}); + }); + }); +} + +std::int32_t +HostContext::accountKeylet(rust::Slice account, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.accountKeylet(accountId); + }); + }); +} + +std::int32_t +HostContext::ammKeylet( + rust::Slice asset1, + rust::Slice asset2, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const a1 = parseAsset(asset1); + if (!a1) + { + return hfErrorToInt(a1.error()); + } + + auto const a2 = parseAsset(asset2); + if (!a2) + { + return hfErrorToInt(a2.error()); + } + return invoke(out, [&] { return hostFunctions_.ammKeylet(*a1, *a2); }); + }); +} + +std::int32_t +HostContext::checkKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.checkKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::credentialKeylet( + rust::Slice subject, + rust::Slice issuer, + rust::Slice credentialType, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccounts( + subject, issuer, out, [&](auto const& account1, auto const& account2) { + return hostFunctions_.credentialKeylet( + account1, account2, Slice{credentialType.data(), credentialType.size()}); + }); + }); +} + +std::int32_t +HostContext::delegateKeylet( + rust::Slice account, + rust::Slice authorize, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccounts( + account, authorize, out, [&](auto const& account1, auto const& account2) { + return hostFunctions_.delegateKeylet(account1, account2); + }); + }); +} + +std::int32_t +HostContext::depositPreauthKeylet( + rust::Slice account, + rust::Slice authorize, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccounts( + account, authorize, out, [&](auto const& account1, auto const& account2) { + return hostFunctions_.depositPreauthKeylet(account1, account2); + }); + }); +} + +std::int32_t +HostContext::didKeylet(rust::Slice account, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.didKeylet(accountId); + }); + }); +} + +std::int32_t +HostContext::escrowKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.escrowKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::trustLineKeylet( + rust::Slice account1, + rust::Slice account2, + rust::Slice currency, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + if (currency.size() != Currency::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + + return invokeWithAccounts( + account1, account2, out, [&](auto const& innerAccount1, auto const& innerAccount2) { + return hostFunctions_.trustLineKeylet( + innerAccount1, innerAccount2, Currency::fromVoid(currency.data())); + }); + }); +} + +std::int32_t +HostContext::mptokenIssuanceKeylet( + rust::Slice issuer, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(issuer, out, [&](auto const& accountId) { + return hostFunctions_.mptokenIssuanceKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::mptokenKeylet( + rust::Slice mptid, + rust::Slice holder, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + if (mptid.size() != MPTID::size() || holder.size() != AccountID::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + return invoke(out, [&] { + return hostFunctions_.mptokenKeylet( + MPTID::fromVoid(mptid.data()), AccountID::fromVoid(holder.data())); + }); + }); +} + +std::int32_t +HostContext::nftokenOfferKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.nftokenOfferKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::offerKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.offerKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::oracleKeylet( + rust::Slice account, + std::int32_t docId, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.oracleKeylet(accountId, static_cast(docId)); + }); + }); +} + +std::int32_t +HostContext::paychannelKeylet( + rust::Slice account, + rust::Slice destination, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccounts( + account, destination, out, [&](auto const& account1, auto const& account2) { + return hostFunctions_.paychannelKeylet( + account1, account2, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::permissionedDomainKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.permissionedDomainKeylet( + accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::signerListKeylet( + rust::Slice account, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.signerListKeylet(accountId); + }); + }); +} + +std::int32_t +HostContext::ticketKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.ticketKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::vaultKeylet( + rust::Slice account, + std::int32_t seq, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeWithAccount(account, out, [&](auto const& accountId) { + return hostFunctions_.vaultKeylet(accountId, static_cast(seq)); + }); + }); +} + +std::int32_t +HostContext::sha512Half(rust::Slice data, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { + return hostFunctions_.computeSha512HalfHash(Slice{data.data(), data.size()}); + }); + }); +} + +void +HostContext::trace(rust::Str msg, rust::Slice data, TraceDataType dataType) + const noexcept +{ + auto const journal = hostFunctions_.getJournal(); + + // Not `guarded`: a buffer that does not hold what it claims is an ordinary contract + // mistake, so it belongs in the log the contract is writing to rather than in the error + // log as an internal failure - and it must not become one, since there is nothing to + // report it to. + try + { + if (msg.size() + data.size() > kMaxWasmDataLength) + { + JLOG(journal.trace()) << "WasmTrace: message and data too long"; + return; + } + + // Rendered whatever the log level: the level decides what is written, never whether + // the host is called, so a run costs the same on every node. + auto const text = traceFormat(dataType, Slice{data.data(), data.size()}); + if (!text) + { + JLOG(journal.trace()) << "WasmTrace: data does not hold the type it names"; + return; + } + + hostFunctions_.trace(std::string_view{msg.data(), msg.size()}, *text); + } + catch (std::exception const& e) + { + JLOG(journal.trace()) << "WasmTrace: threw: " << e.what(); + } + catch (...) + { + JLOG(journal.trace()) << "WasmTrace: threw"; + } +} + +std::int32_t +HostContext::updateData(rust::Slice data) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke([&] { return hostFunctions_.updateData(Slice{data.data(), data.size()}); }); + }); +} + +std::int32_t +HostContext::getNFT( + rust::Slice account, + rust::Slice nftId, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + if (account.size() != AccountID::size()) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } + return invokeNFT(nftId, out, [&](auto const& nft) { + return hostFunctions_.getNFT(AccountID::fromVoid(account.data()), nft); + }); + }); +} + +std::int32_t +HostContext::getNFTIssuer(rust::Slice nftId, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeNFT( + nftId, out, [&](auto const& nft) { return hostFunctions_.getNFTIssuer(nft); }); + }); +} + +std::int32_t +HostContext::getNFTTaxon(rust::Slice nftId, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeNFT( + nftId, out, [&](auto const& nft) { return hostFunctions_.getNFTTaxon(nft); }); + }); +} + +std::int32_t +HostContext::getNFTFlags(rust::Slice nftId) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeNFT(nftId, [&](auto const& nft) { return hostFunctions_.getNFTFlags(nft); }); + }); +} + +std::int32_t +HostContext::getNFTTransferFee(rust::Slice nftId) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeNFT( + nftId, [&](auto const& nft) { return hostFunctions_.getNFTTransferFee(nft); }); + }); +} + +std::int32_t +HostContext::getNFTSequence(rust::Slice nftId, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invokeNFT( + nftId, out, [&](auto const& nft) { return hostFunctions_.getNFTSequence(nft); }); + }); +} + +std::int32_t +HostContext::floatFromInt(std::int64_t x, std::int32_t mode, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { return hostFunctions_.floatFromInt(x, mode); }); + }); +} + +std::int32_t +HostContext::floatFromUint( + rust::Slice x, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const parsed = parseUint64(x); + if (!parsed) + { + return hfErrorToInt(parsed.error()); + } + return invoke(out, [&] { return hostFunctions_.floatFromUint(*parsed, mode); }); + }); +} + +std::int32_t +HostContext::floatFromSTAmount( + rust::Slice amount, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const parsed = parseST(amount); + if (!parsed) + { + return hfErrorToInt(parsed.error()); + } + return invoke(out, [&] { return hostFunctions_.floatFromSTAmount(*parsed, mode); }); + }); +} + +std::int32_t +HostContext::floatFromSTNumber( + rust::Slice number, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const parsed = parseST(number); + if (!parsed) + { + return hfErrorToInt(parsed.error()); + } + return invoke(out, [&] { return hostFunctions_.floatFromSTNumber(*parsed, mode); }); + }); +} + +std::int32_t +HostContext::floatToInt( + rust::Slice x, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke( + out, [&] { return hostFunctions_.floatToInt(Slice{x.data(), x.size()}, mode); }); + }); +} + +std::int32_t +HostContext::floatToMantExp( + rust::Slice x, + rust::Slice mantissaOut, + rust::Slice exponentOut) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const value = hostFunctions_.floatToMantExp(Slice{x.data(), x.size()}); + if (!value) + { + return hfErrorToInt(value.error()); + } + + // The engine copies each region only if the whole value fits, so writing the + // true lengths here and summing them matches its accounting. + auto const r1 = answerScalar(mantissaOut, value->first); + auto const r2 = answerScalar(exponentOut, value->second); + return r1 + r2; + }); +} + +std::int32_t +HostContext::floatFromMantExp( + std::int64_t mantissa, + std::int32_t exponent, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke( + out, [&] { return hostFunctions_.floatFromMantExp(mantissa, exponent, mode); }); + }); +} + +std::int32_t +HostContext::floatCompare(rust::Slice x, rust::Slice y) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke([&] { + return hostFunctions_.floatCompare( + Slice{x.data(), x.size()}, Slice{y.data(), y.size()}); + }); + }); +} + +std::int32_t +HostContext::floatAdd( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { + return hostFunctions_.floatAdd( + Slice{x.data(), x.size()}, Slice{y.data(), y.size()}, mode); + }); + }); +} + +std::int32_t +HostContext::floatSubtract( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { + return hostFunctions_.floatSubtract( + Slice{x.data(), x.size()}, Slice{y.data(), y.size()}, mode); + }); + }); +} + +std::int32_t +HostContext::floatMultiply( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { + return hostFunctions_.floatMultiply( + Slice{x.data(), x.size()}, Slice{y.data(), y.size()}, mode); + }); + }); +} + +std::int32_t +HostContext::floatDivide( + rust::Slice x, + rust::Slice y, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke(out, [&] { + return hostFunctions_.floatDivide( + Slice{x.data(), x.size()}, Slice{y.data(), y.size()}, mode); + }); + }); +} + +std::int32_t +HostContext::floatRoot( + rust::Slice x, + std::int32_t n, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke( + out, [&] { return hostFunctions_.floatRoot(Slice{x.data(), x.size()}, n, mode); }); + }); +} + +std::int32_t +HostContext::floatPower( + rust::Slice x, + std::int32_t n, + std::int32_t mode, + rust::Slice out) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + return invoke( + out, [&] { return hostFunctions_.floatPower(Slice{x.data(), x.size()}, n, mode); }); + }); +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp b/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp index 4ae0c72426..27b0370171 100644 --- a/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp +++ b/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp @@ -15,7 +15,6 @@ #include #include #include -#include #include #include @@ -124,9 +123,10 @@ getAnyFieldData(FieldValue const& variantObj) if (uint256 const* const* u = std::get_if(&variantObj)) return Bytes((*u)->begin(), (*u)->end()); - // Unreachable: the variant only holds the two alternatives above. If not, - // it's an xrpld bug -> tecINTERNAL (thrown, caught by HostFuncMain_wrap). - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE + // Unreachable: the variant only holds the two alternatives above. If not, it is an + // xrpld bug, and `guarded` turns the throw into `InternalFatal`, which stops the run -> + // tecINTERNAL. + Throw("field value variant holds neither alternative"); // LCOV_EXCL_LINE } static inline bool diff --git a/src/libxrpl/tx/wasm/HostFuncWrapper.cpp b/src/libxrpl/tx/wasm/HostFuncWrapper.cpp deleted file mode 100644 index a6cd5fc1e3..0000000000 --- a/src/libxrpl/tx/wasm/HostFuncWrapper.cpp +++ /dev/null @@ -1,1903 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl { - -using SFieldCRef = std::reference_wrapper; - -constexpr int64_t unalignedGas = 50; - -// Charge `delta` gas; returns the remaining gas. Out-of-gas throws hfErrOutOfGas -// (-> tecOUT_OF_GAS); a failed setGas is an xrpld bug, throws hfErrInternal -// (-> tecINTERNAL). HostFuncMain_wrap turns both into traps. -static inline std::int64_t -checkGas(WasmRuntimeWrapper& rt, int64_t delta) -{ - int64_t const gas = rt.getGas(); - if (delta == 0) - return gas; - - int64_t const x = gas >= delta ? gas - delta : 0; - - if (rt.setGas(x) < 0) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - if (gas < delta) - Throw(std::string(hfErrOutOfGas)); - - return x; -} - -// Transfer limit is a separate soft budget: exceeding it is a normal guest-facing -// return code, not a trap. Only a failed setTransferLimit (an xrpld bug) throws. -static inline std::expected -checkTransfer(WasmRuntimeWrapper& rt, int64_t delta) -{ - auto const transLimit = rt.getTransferLimit(); - int64_t const x = transLimit >= delta ? transLimit - delta : 0; - - if (rt.setTransferLimit(x) < 0) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - if (transLimit < delta) - return std::unexpected(HostFunctionError::OutOfTransferLimit); - - return x; -} - -// On any failure here a C++ exception is thrown; HostFuncMain_wrap's catch-all -// turns it into tecINTERNAL. These conditions are all xrpld-side invariants. -static std::tuple -mainCheck(void* env, wasm_val_vec_t const* params, wasm_val_vec_t* results) -{ - if (env == nullptr) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - if (params == nullptr) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - if (results == nullptr) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - WasmUserData const* udata = reinterpret_cast(env); - HostFunctions& hf = udata->first; - WasmRuntimeWrapper& rt = hf.getRT(); - WasmImportFunc const& impFunc = udata->second; - - // Charge the per-call gas. Throws (and terminates) if out of gas. - checkGas(rt, impFunc.gas); - - return std::tie(hf, impFunc); -} - -//---------------------------------------------------------------------------------------------------------------------- - -static int32_t -setData( - WasmRuntimeWrapper& runtime, - int32_t dst, - int32_t dstSize, - uint8_t const* src, - int32_t srcSize) -{ - if (srcSize == 0) - return 0; // LCOV_EXCL_LINE - - if (dst < 0 || dstSize < 0 || (src == nullptr) || srcSize < 0) - return hfErrorToInt(HostFunctionError::InvalidParams); - - if (srcSize > kMaxWasmDataLength) - return hfErrorToInt(HostFunctionError::DataFieldTooLarge); - - auto const memory = runtime.getMem(); - - // LCOV_EXCL_START - if (memory.s == 0u) - return hfErrorToInt(HostFunctionError::NoMemExported); - // LCOV_EXCL_STOP - if (std::cmp_greater((int64_t)dst + dstSize, memory.s)) - return hfErrorToInt(HostFunctionError::PointerOutOfBounds); - if (srcSize > dstSize) - return hfErrorToInt(HostFunctionError::BufferTooSmall); - - if (auto t = checkTransfer(runtime, srcSize); !t) - return hfErrorToInt(t.error()); - - memcpy(memory.p + dst, src, srcSize); - - return srcSize; -} - -static std::expected -getDataSlice(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - int64_t const ptr = params->data[i].of.i32; - int64_t const size = params->data[i + 1].of.i32; - i += 2; - if (ptr < 0 || size < 0) - return std::unexpected(HostFunctionError::InvalidParams); - - if (size == 0) - return Slice(); - - if (size > kMaxWasmDataLength) - return std::unexpected(HostFunctionError::DataFieldTooLarge); - - auto const memory = runtime.getMem(); - // LCOV_EXCL_START - if (memory.s == 0u) - return std::unexpected(HostFunctionError::NoMemExported); - // LCOV_EXCL_STOP - - if (std::cmp_greater(ptr + size, memory.s)) - return std::unexpected(HostFunctionError::PointerOutOfBounds); - - Slice const data(memory.p + ptr, size); - return data; -} - -static std::expected -getDataInt32(WasmRuntimeWrapper const&, wasm_val_vec_t const* params, int32_t& i) -{ - auto const result = params->data[i].of.i32; - i++; - return result; -} - -static std::expected -getDataInt64(WasmRuntimeWrapper const&, wasm_val_vec_t const* params, int32_t& i) -{ - auto const result = params->data[i].of.i64; - i++; - return result; -} - -template -static std::expected -getDataUnsigned(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - static_assert(std::is_unsigned_v); - auto const r = getDataSlice(runtime, params, i); - if (!r) - return std::unexpected(r.error()); - if (r->size() != sizeof(T)) - return std::unexpected(HostFunctionError::InvalidParams); - - T x; - auto const p = reinterpret_cast(r->data()); - if (p & (alignof(T) - 1)) // unaligned - { - memcpy(&x, r->data(), sizeof(T)); - } - else - { - x = *reinterpret_cast(r->data()); - } - x = adjustWasmEndianess(x); - - return x; -} - -static std::expected -getDataUInt32(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - return getDataUnsigned(runtime, params, i); -} - -static std::expected -getDataUInt64(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - return getDataUnsigned(runtime, params, i); -} - -static std::expected -getDataSField(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const& m = SField::getKnownCodeToField(); - auto const it = m.find(params->data[i].of.i32); - i++; - if (it == m.end()) - return std::unexpected(HostFunctionError::InvalidField); - - return *it->second; -} - -static std::expected -getDataUInt256(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - - if (slice->size() != uint256::size()) - return std::unexpected(HostFunctionError::InvalidParams); - - if (auto t = checkTransfer(runtime, uint256::size()); !t) - return std::unexpected(t.error()); - - return uint256::fromVoid(slice->data()); -} - -static std::expected -getDataAccountID(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - - if (slice->size() != AccountID::size()) - return std::unexpected(HostFunctionError::InvalidParams); - - if (auto t = checkTransfer(runtime, AccountID::size()); !t) - return std::unexpected(t.error()); - - return AccountID::fromVoid(slice->data()); -} - -static std::expected -getDataCurrency(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - - if (slice->size() != Currency::size()) - return std::unexpected(HostFunctionError::InvalidParams); - - if (auto t = checkTransfer(runtime, Currency::size()); !t) - return std::unexpected(t.error()); - - return Currency::fromVoid(slice->data()); -} - -static std::expected -getDataAsset(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - - if (slice->size() == MPTID::size()) - { - if (auto t = checkTransfer(runtime, slice->size()); !t) - return std::unexpected(t.error()); - - auto const mptid = MPTID::fromVoid(slice->data()); - return Asset{mptid}; - } - - if (slice->size() == Currency::size()) - { - if (auto t = checkTransfer(runtime, slice->size()); !t) - return std::unexpected(t.error()); - - auto const currency = Currency::fromVoid(slice->data()); - auto const issue = Issue{currency, xrpAccount()}; - if (!issue.native()) - return std::unexpected(HostFunctionError::InvalidParams); - - return Asset{issue}; - } - - if (slice->size() == (Currency::size() + AccountID::size())) - { - if (auto t = checkTransfer(runtime, slice->size()); !t) - return std::unexpected(t.error()); - - auto const issue = Issue( - Currency::fromVoid(slice->data()), - AccountID::fromVoid(slice->data() + Currency::size())); - - if (issue.native()) - return std::unexpected(HostFunctionError::InvalidParams); - - return Asset{issue}; - } - - return std::unexpected(HostFunctionError::InvalidParams); -} - -static std::expected -getDataString(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - - return std::string_view(reinterpret_cast(slice->data()), slice->size()); -} - -static std::expected -getDataLocator(WasmRuntimeWrapper& runtime, wasm_val_vec_t const* params, int32_t& i) -{ - static_assert(kMaxWasmDataLength % sizeof(int32_t) == 0); - - auto const slice = getDataSlice(runtime, params, i); - if (!slice) - return std::unexpected(slice.error()); - if (slice->empty() || ((slice->size() & 3) != 0u)) // must be multiple of 4 - return std::unexpected(HostFunctionError::LocatorMalformed); - - uint32_t const locSize = slice->size() / sizeof(int32_t); - auto const p = reinterpret_cast(slice->data()); - - if ((p & (alignof(int32_t) - 1)) != 0u) - { // unaligned - - // Use gas and transfer limit for copying. checkGas throws (and - // terminates execution) if out of gas; checkTransfer keeps returning a - // guest-facing code when the transfer limit is exceeded. - checkGas(runtime, unalignedGas); - if (auto t = checkTransfer(runtime, slice->size()); !t) - return std::unexpected(t.error()); - - std::vector locBuf(locSize); - memcpy(&locBuf[0], slice->data(), slice->size()); - FieldLocator locator(std::move(locBuf)); - - return locator; - } - - auto const* locPtr = reinterpret_cast(slice->data()); - return FieldLocator(locPtr, locSize); -} - -static inline std::nullptr_t -hfResult(wasm_val_vec_t* results, int32_t value) -{ - results->data[0] = WASM_I32_VAL(value); - // results->size = 1; - return nullptr; -} - -static inline std::nullptr_t -hfResult(wasm_val_vec_t* results, HostFunctionError value) -{ - results->data[0] = WASM_I32_VAL(hfErrorToInt(value)); - // results->size = 1; - return nullptr; -} - -template -static std::nullptr_t -returnResult( - WasmRuntimeWrapper& runtime, - wasm_val_vec_t const* params, - wasm_val_vec_t* results, - std::expected const& res, - int32_t index) -{ - if (!res) - return hfResult(results, res.error()); - - if constexpr (std::is_same_v) - { - if (index < 0 || index + 1 >= params->size) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - auto const dataResult = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - res->data(), - res->size()); - return hfResult(results, dataResult); - } - else if constexpr (std::is_same_v) - { - if (index < 0 || index + 1 >= params->size) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - auto const dataResult = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - res->data(), - res->size()); - return hfResult(results, dataResult); - } - else if constexpr (std::is_same_v) - { - return hfResult(results, res.value()); - } - else if constexpr (std::is_same_v) - { - if (index < 0 || index + 1 >= params->size) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - auto const resultValue = adjustWasmEndianess(res.value()); - auto const dataResult = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - reinterpret_cast(&resultValue), - static_cast(sizeof(resultValue))); - return hfResult(results, dataResult); - } - else if constexpr (std::is_same_v) - { - if (index < 0 || index + 1 >= params->size) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - auto const resultValue = adjustWasmEndianess(res.value()); - auto const dataResult = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - reinterpret_cast(&resultValue), - static_cast(sizeof(resultValue))); - return hfResult(results, dataResult); - } - else if constexpr (std::is_same_v) - { - if (index < 0 || index + 3 >= params->size) - Throw(std::string(hfErrInternal)); // LCOV_EXCL_LINE - - auto const mantissa = adjustWasmEndianess(res->first); - auto const r1 = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - reinterpret_cast(&mantissa), - static_cast(sizeof(mantissa))); - if (r1 < 0) - return hfResult(results, r1); - - index += 2; - auto const exponent = adjustWasmEndianess(res->second); - auto const r2 = setData( - runtime, - params->data[index].of.i32, - params->data[index + 1].of.i32, - reinterpret_cast(&exponent), - static_cast(sizeof(exponent))); - if (r2 < 0) - return hfResult(results, r2); - - return hfResult(results, r1 + r2); // 12 bytes - } - else - { - static_assert([] { return false; }(), "Unhandled return type in returnResult"); - } -} - -//---------------------------------------------------------------------------------------------------------------------- - -wasm_trap_t* -HostFuncMain_wrap(WASM_CB_PARAMS_LIST) -{ - [[maybe_unused]] std::string_view hfName; - - try - { - auto [hf, impFunc] = mainCheck(env, params, results); - hfName = impFunc.name; - auto* fWrap = reinterpret_cast(impFunc.wrap); - return fWrap(hf, params, results); - } - catch (std::exception const& e) - { -#ifdef DEBUG_OUTPUT - std::cerr << "Hostfunction " << hfName << " exception: " << e.what() << std::endl; -#endif - // Normalize to the two boundary signals: explicit out-of-gas, else any - // exception (including stray ones from helpers) is an internal fault. - bool const oog = std::string_view(e.what()) == hfErrOutOfGas; - wasm_trap_t* trap = reinterpret_cast( // NOLINT - WasmEngine::instance().newTrap(std::string(oog ? hfErrOutOfGas : hfErrInternal))); - return trap; - } - catch (...) - { -#ifdef DEBUG_OUTPUT - std::cerr << "Hostfunction " << hfName << " unknown exception." << std::endl; -#endif - wasm_trap_t* trap = reinterpret_cast( // NOLINT - WasmEngine::instance().newTrap(std::string(hfErrInternal))); // LCOV_EXCL_LINE - return trap; - } - - return nullptr; // LCOV_EXCL_LINE -} - -//---------------------------------------------------------------------------------------------------------------------- -wasm_trap_t* -getLedgerSqn_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t const index = 0; - auto& runtime = hf.getRT(); - - return returnResult(runtime, params, results, hf.getLedgerSqn(), index); -} - -wasm_trap_t* -getParentLedgerTime_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t const index = 0; - auto& runtime = hf.getRT(); - - return returnResult(runtime, params, results, hf.getParentLedgerTime(), index); -} - -wasm_trap_t* -getParentLedgerHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t const index = 0; - auto& runtime = hf.getRT(); - - return returnResult(runtime, params, results, hf.getParentLedgerHash(), index); -} - -wasm_trap_t* -getBaseFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t const index = 0; - auto& runtime = hf.getRT(); - - return returnResult(runtime, params, results, hf.getBaseFee(), index); -} - -wasm_trap_t* -isAmendmentEnabled_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const slice = getDataSlice(runtime, params, index); - if (!slice) - return hfResult(results, slice.error()); - - if (slice->size() == uint256::size()) - { - if (auto const ret = hf.isAmendmentEnabled(uint256::fromVoid(slice->data())); - ret && *ret == 1) - return returnResult(runtime, params, results, ret, index); - // Fall through to string lookup — the 32 bytes may be an amendment name - } - - if (slice->size() > 64) - return hfResult(results, HostFunctionError::DataFieldTooLarge); - - auto const str = std::string_view(reinterpret_cast(slice->data()), slice->size()); - return returnResult(runtime, params, results, hf.isAmendmentEnabled(str), index); -} - -wasm_trap_t* -cacheLedgerObj_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const id = getDataUInt256(runtime, params, index); - if (!id) - return hfResult(results, id.error()); - - auto const cache = getDataInt32(runtime, params, index); - if (!cache) - return hfResult(results, cache.error()); // LCOV_EXCL_LINE - - return returnResult(runtime, params, results, hf.cacheLedgerObj(*id, *cache), index); -} - -wasm_trap_t* -getTxField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getTxField(*fname), index); -} - -wasm_trap_t* -getCurrentLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getCurrentLedgerObjField(*fname), index); -} - -wasm_trap_t* -getLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const cache = getDataInt32(runtime, params, index); - if (!cache) - return hfResult(results, cache.error()); // LCOV_EXCL_LINE - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getLedgerObjField(*cache, *fname), index); -} - -wasm_trap_t* -getTxNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult(runtime, params, results, hf.getTxNestedField(*locator), index); -} - -wasm_trap_t* -getCurrentLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult( - runtime, params, results, hf.getCurrentLedgerObjNestedField(*locator), index); -} - -wasm_trap_t* -getLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const cache = getDataInt32(runtime, params, index); - if (!cache) - return hfResult(results, cache.error()); // LCOV_EXCL_LINE - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult( - runtime, params, results, hf.getLedgerObjNestedField(*cache, *locator), index); -} - -wasm_trap_t* -getTxArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getTxArrayLen(*fname), index); -} - -wasm_trap_t* -getCurrentLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getCurrentLedgerObjArrayLen(*fname), index); -} - -wasm_trap_t* -getLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const cache = getDataInt32(runtime, params, index); - if (!cache) - return hfResult(results, cache.error()); // LCOV_EXCL_LINE - - auto const fname = getDataSField(runtime, params, index); - if (!fname) - return hfResult(results, fname.error()); - - return returnResult(runtime, params, results, hf.getLedgerObjArrayLen(*cache, *fname), index); -} - -wasm_trap_t* -getTxNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult(runtime, params, results, hf.getTxNestedArrayLen(*locator), index); -} - -wasm_trap_t* -getCurrentLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult( - runtime, params, results, hf.getCurrentLedgerObjNestedArrayLen(*locator), index); -} -wasm_trap_t* -getLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const cache = getDataInt32(runtime, params, index); - if (!cache) - return hfResult(results, cache.error()); // LCOV_EXCL_LINE - - auto const locator = getDataLocator(runtime, params, index); - if (!locator) - return hfResult(results, locator.error()); - - return returnResult( - runtime, params, results, hf.getLedgerObjNestedArrayLen(*cache, *locator), index); -} - -wasm_trap_t* -updateData_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const bytes = getDataSlice(runtime, params, index); - if (!bytes) - return hfResult(results, bytes.error()); - - return returnResult(runtime, params, results, hf.updateData(*bytes), index); -} - -wasm_trap_t* -checkSignature_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const message = getDataSlice(runtime, params, index); - if (!message) - return hfResult(results, message.error()); - - auto const signature = getDataSlice(runtime, params, index); - if (!signature) - return hfResult(results, signature.error()); - - auto const pubkey = getDataSlice(runtime, params, index); - if (!pubkey) - return hfResult(results, pubkey.error()); - - return returnResult( - runtime, params, results, hf.checkSignature(*message, *signature, *pubkey), index); -} - -wasm_trap_t* -computeSha512HalfHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const bytes = getDataSlice(runtime, params, index); - if (!bytes) - return hfResult(results, bytes.error()); - - return returnResult(runtime, params, results, hf.computeSha512HalfHash(*bytes), index); -} - -wasm_trap_t* -accountKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - return returnResult(runtime, params, results, hf.accountKeylet(*acc), index); -} - -wasm_trap_t* -ammKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const issue1 = getDataAsset(runtime, params, index); - if (!issue1) - return hfResult(results, issue1.error()); - - auto const issue2 = getDataAsset(runtime, params, index); - if (!issue2) - return hfResult(results, issue2.error()); - - return returnResult( - runtime, params, results, hf.ammKeylet(issue1.value(), issue2.value()), index); -} - -wasm_trap_t* -checkKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult(runtime, params, results, hf.checkKeylet(acc.value(), *seq), index); -} - -wasm_trap_t* -credentialKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const subj = getDataAccountID(runtime, params, index); - if (!subj) - return hfResult(results, subj.error()); - - auto const iss = getDataAccountID(runtime, params, index); - if (!iss) - return hfResult(results, iss.error()); - - auto const credType = getDataSlice(runtime, params, index); - if (!credType) - return hfResult(results, credType.error()); - - return returnResult( - runtime, params, results, hf.credentialKeylet(*subj, *iss, *credType), index); -} - -wasm_trap_t* -delegateKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const authorize = getDataAccountID(runtime, params, index); - if (!authorize) - return hfResult(results, authorize.error()); - - return returnResult( - runtime, params, results, hf.delegateKeylet(acc.value(), authorize.value()), index); -} - -wasm_trap_t* -depositPreauthKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const authorize = getDataAccountID(runtime, params, index); - if (!authorize) - return hfResult(results, authorize.error()); - - return returnResult( - runtime, params, results, hf.depositPreauthKeylet(acc.value(), authorize.value()), index); -} - -wasm_trap_t* -didKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - return returnResult(runtime, params, results, hf.didKeylet(acc.value()), index); -} - -wasm_trap_t* -escrowKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult(runtime, params, results, hf.escrowKeylet(*acc, *seq), index); -} - -wasm_trap_t* -trustLineKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc1 = getDataAccountID(runtime, params, index); - if (!acc1) - return hfResult(results, acc1.error()); - - auto const acc2 = getDataAccountID(runtime, params, index); - if (!acc2) - return hfResult(results, acc2.error()); - - auto const currency = getDataCurrency(runtime, params, index); - if (!currency) - return hfResult(results, currency.error()); - - return returnResult( - runtime, - params, - results, - hf.trustLineKeylet(acc1.value(), acc2.value(), currency.value()), - index); -} - -wasm_trap_t* -mptokenIssuanceKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult( - runtime, params, results, hf.mptokenIssuanceKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -mptokenKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const slice = getDataSlice(runtime, params, index); - if (!slice) - return hfResult(results, slice.error()); - - if (slice->size() != MPTID::size()) - return hfResult(results, HostFunctionError::InvalidParams); - auto const mptid = MPTID::fromVoid(slice->data()); - - auto const holder = getDataAccountID(runtime, params, index); - if (!holder) - return hfResult(results, holder.error()); - - return returnResult(runtime, params, results, hf.mptokenKeylet(mptid, holder.value()), index); -} - -wasm_trap_t* -nftokenOfferKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult( - runtime, params, results, hf.nftokenOfferKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -offerKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult(runtime, params, results, hf.offerKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -oracleKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const documentId = getDataUInt32(runtime, params, index); - if (!documentId) - return hfResult(results, documentId.error()); - - return returnResult(runtime, params, results, hf.oracleKeylet(*acc, *documentId), index); -} - -wasm_trap_t* -paychannelKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const dest = getDataAccountID(runtime, params, index); - if (!dest) - return hfResult(results, dest.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult( - runtime, - params, - results, - hf.paychannelKeylet(acc.value(), dest.value(), seq.value()), - index); -} - -wasm_trap_t* -permissionedDomainKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult( - runtime, params, results, hf.permissionedDomainKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -signerListKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - return returnResult(runtime, params, results, hf.signerListKeylet(acc.value()), index); -} - -wasm_trap_t* -ticketKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult(runtime, params, results, hf.ticketKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -vaultKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const seq = getDataUInt32(runtime, params, index); - if (!seq) - return hfResult(results, seq.error()); - - return returnResult(runtime, params, results, hf.vaultKeylet(acc.value(), seq.value()), index); -} - -wasm_trap_t* -getNFT_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const acc = getDataAccountID(runtime, params, index); - if (!acc) - return hfResult(results, acc.error()); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFT(*acc, *nftId), index); -} - -wasm_trap_t* -getNFTIssuer_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFTIssuer(*nftId), index); -} - -wasm_trap_t* -getNFTTaxon_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFTTaxon(*nftId), index); -} - -wasm_trap_t* -getNFTFlags_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFTFlags(*nftId), index); -} - -wasm_trap_t* -getNFTTransferFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFTTransferFee(*nftId), index); -} - -wasm_trap_t* -getNFTSequence_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const nftId = getDataUInt256(runtime, params, index); - if (!nftId) - return hfResult(results, nftId.error()); - - return returnResult(runtime, params, results, hf.getNFTSequence(*nftId), index); -} - -// log() ignores the journal under DEBUG_OUTPUT, so the gate must not either. -static inline bool -traceActive([[maybe_unused]] HostFunctions const& hf) -{ -#ifdef DEBUG_OUTPUT - return true; -#else - return hf.getJournal().active(beast::Severity::Trace); -#endif -} - -// Not getDataUnsigned: that branches on pointer alignment, and trace must cost -// the same regardless of how the guest laid out its buffer. -template -static std::optional -traceInt(Slice const& data) -{ - static_assert(std::is_integral_v); - if (data.size() != sizeof(T)) - return std::nullopt; - - T x; - memcpy(&x, data.data(), sizeof(T)); - return adjustWasmEndianess(x); -} - -// std::nullopt means the buffer does not match the type. May throw. -static std::optional -traceFormat(TraceDataType type, Slice const& data) -{ - switch (type) - { - case TraceDataType::Int64: - if (auto const x = traceInt(data)) - return std::to_string(*x); - return std::nullopt; - - case TraceDataType::Uint64: - if (auto const x = traceInt(data)) - return std::to_string(*x); - return std::nullopt; - - case TraceDataType::Xfloat: - return wasm_float::floatToString(data); - - case TraceDataType::Account: - // Not getDataAccountID: it charges the transfer limit. - if (data.size() != AccountID::size()) - return std::nullopt; - return toBase58(AccountID::fromVoid(data.data())); - - case TraceDataType::Amount: { - auto serialIter = SerialIter(data); - STAmount const amount(serialIter, sfGeneric); // may throw - return amount.getFullText(); - } - - case TraceDataType::AsHex: { - std::string hex; - hex.reserve(data.size() * 2); - boost::algorithm::hex(data.begin(), data.end(), std::back_inserter(hex)); - return hex; - } - - case TraceDataType::AsText: - // An empty Slice has a null data(), which std::string may not take. - if (data.empty()) - return std::string(); - return std::string(reinterpret_cast(data.data()), data.size()); - } - - return std::nullopt; // unknown data_type -} - -// trace's only effect is this node's local log, so nothing observable may depend -// on the log level: gas is charged in mainCheck before this runs, no transfer -// limit is charged, and errors are logged rather than trapped. -wasm_trap_t* -trace_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - if (!traceActive(hf)) - return nullptr; - - try - { - int32_t index = 0; - auto& runtime = hf.getRT(); - - auto const msg = getDataString(runtime, params, index); - if (!msg) - { - hf.getJournal().trace() << "WasmTrace: invalid message"; - return nullptr; - } - - auto const type = getDataInt32(runtime, params, index); - // LCOV_EXCL_START - if (!type) - { - hf.getJournal().trace() << "WasmTrace: invalid data type"; - return nullptr; - } - // LCOV_EXCL_STOP - - auto const data = getDataSlice(runtime, params, index); - if (!data) - { - hf.getJournal().trace() << "WasmTrace: invalid data"; - return nullptr; - } - - if (msg->size() + data->size() > kMaxWasmDataLength) - { - hf.getJournal().trace() << "WasmTrace: message and data too long"; - return nullptr; - } - - auto const text = traceFormat(static_cast(*type), *data); - if (!text) - { - hf.getJournal().trace() << "WasmTrace: data does not match the data type"; - return nullptr; - } - - hf.trace(*msg, *text); - } - catch (std::exception const& e) - { - hf.getJournal().trace() << "WasmTrace: error: " << e.what(); - } - // LCOV_EXCL_START - catch (...) - { - hf.getJournal().trace() << "WasmTrace: unknown error"; - } - // LCOV_EXCL_STOP - return nullptr; -} - -wasm_trap_t* -floatFromInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataInt64(runtime, params, i); - if (!x) - return hfResult(results, x.error()); // LCOV_EXCL_LINE - - i = 3; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 1; - return returnResult(runtime, params, results, hf.floatFromInt(*x, *rounding), i); -} - -wasm_trap_t* -floatFromUint_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataUInt64(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - i = 4; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 2; - return returnResult(runtime, params, results, hf.floatFromUint(*x, *rounding), i); -} - -wasm_trap_t* -floatFromSTAmount_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto serialIter = SerialIter(*x); - std::optional amount; - try - { - amount = STAmount(serialIter, sfGeneric); - } - catch (std::exception const&) - { - amount = std::nullopt; - } - if (!amount) - return hfResult(results, HostFunctionError::InvalidParams); - - i = 4; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 2; - return returnResult(runtime, params, results, hf.floatFromSTAmount(*amount, *rounding), i); -} - -wasm_trap_t* -floatFromSTNumber_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto serialIter = SerialIter(*x); - std::optional num; - try - { - num = STNumber(serialIter, sfGeneric); - } - catch (std::exception const&) - { - num = std::nullopt; - } - if (!num) - return hfResult(results, HostFunctionError::InvalidParams); - - i = 4; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 2; - return returnResult(runtime, params, results, hf.floatFromSTNumber(*num, *rounding), i); -} - -wasm_trap_t* -floatToInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - i = 4; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 2; - return returnResult(runtime, params, results, hf.floatToInt(*x, *rounding), i); -} - -wasm_trap_t* -floatToMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - i = 2; - return returnResult(runtime, params, results, hf.floatToMantExp(*x), i); -} - -wasm_trap_t* -floatFromMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const mant = getDataInt64(runtime, params, i); - if (!mant) - return hfResult(results, mant.error()); // LCOV_EXCL_LINE - - auto const exp = getDataInt32(runtime, params, i); - if (!exp) - return hfResult(results, exp.error()); // LCOV_EXCL_LINE - - i = 4; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 2; - return returnResult(runtime, params, results, hf.floatFromMantExp(*mant, *exp, *rounding), i); -} - -wasm_trap_t* -floatCompare_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const y = getDataSlice(runtime, params, i); - if (!y) - return hfResult(results, y.error()); - - return returnResult(runtime, params, results, hf.floatCompare(*x, *y), i); -} - -wasm_trap_t* -floatAdd_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const y = getDataSlice(runtime, params, i); - if (!y) - return hfResult(results, y.error()); - - i = 6; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 4; - return returnResult(runtime, params, results, hf.floatAdd(*x, *y, *rounding), i); -} - -wasm_trap_t* -floatSubtract_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const y = getDataSlice(runtime, params, i); - if (!y) - return hfResult(results, y.error()); - - i = 6; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 4; - return returnResult(runtime, params, results, hf.floatSubtract(*x, *y, *rounding), i); -} - -wasm_trap_t* -floatMultiply_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const y = getDataSlice(runtime, params, i); - if (!y) - return hfResult(results, y.error()); - - i = 6; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 4; - return returnResult(runtime, params, results, hf.floatMultiply(*x, *y, *rounding), i); -} - -wasm_trap_t* -floatDivide_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const y = getDataSlice(runtime, params, i); - if (!y) - return hfResult(results, y.error()); - - i = 6; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 4; - return returnResult(runtime, params, results, hf.floatDivide(*x, *y, *rounding), i); -} - -wasm_trap_t* -floatRoot_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const n = getDataInt32(runtime, params, i); - if (!n) - return hfResult(results, n.error()); // LCOV_EXCL_LINE - - i = 5; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 3; - return returnResult(runtime, params, results, hf.floatRoot(*x, *n, *rounding), i); -} - -wasm_trap_t* -floatPower_wrap(WASM_SECONDARY_CB_PARAMS_LIST) -{ - int32_t i = 0; - auto& runtime = hf.getRT(); - - auto const x = getDataSlice(runtime, params, i); - if (!x) - return hfResult(results, x.error()); - - auto const n = getDataInt32(runtime, params, i); - if (!n) - return hfResult(results, n.error()); // LCOV_EXCL_LINE - - i = 5; - auto const rounding = getDataInt32(runtime, params, i); - if (!rounding) - return hfResult(results, rounding.error()); // LCOV_EXCL_LINE - - i = 3; - return returnResult(runtime, params, results, hf.floatPower(*x, *n, *rounding), i); -} - -// LCOV_EXCL_START -namespace test { - -class MockWasmRuntimeWrapper : public WasmRuntimeWrapper -{ - Wmem mem_; - - std::int64_t gas_ = 1'000'000; - std::int64_t transferLimit_ = kWasmTransferLimit; - -public: - MockWasmRuntimeWrapper(Wmem memory) : mem_(memory) - { - } - - // Mock methods to simulate the behavior of WasmRuntimeWrapper - [[nodiscard]] Wmem - getMem() override - { - return mem_; - } - - std::int64_t - getGas() override - { - return gas_; - } - - std::int64_t - setGas(std::int64_t gas) override - { - gas_ = gas; - return gas_; - } - - std::int64_t - getTransferLimit() override - { - return transferLimit_; - } - - std::int64_t - setTransferLimit(std::int64_t x) override - { - transferLimit_ = x; - return transferLimit_; - } -}; - -bool -testGetDataIncrement() -{ - wasm_val_t values[4]; - - std::array buffer = {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'}; - MockWasmRuntimeWrapper runtime(Wmem(buffer.data(), buffer.size())); - - { - // test int32_t - wasm_val_vec_t const params = {.size = 1, .data = &values[0]}; - - values[0] = WASM_I32_VAL(42); - - int32_t index = 0; - auto const result = getDataInt32(runtime, ¶ms, index); - if (!result || result.value() != 42 || index != 1) - return false; - } - - { - // test int64_t - wasm_val_vec_t const params = {.size = 1, .data = &values[0]}; - - values[0] = WASM_I64_VAL(1234); - - int32_t index = 0; - auto const result = getDataInt64(runtime, ¶ms, index); - if (!result || result.value() != 1234 || index != 1) - return false; - } - - { - // test SFieldCRef - wasm_val_vec_t const params = {.size = 1, .data = &values[0]}; - - values[0] = WASM_I32_VAL(sfAccount.getCode()); - - int32_t index = 0; - auto const result = getDataSField(runtime, ¶ms, index); - if (!result || result.value().get() != sfAccount || index != 1) - return false; - } - - { - // test Slice - wasm_val_vec_t const params = {.size = 2, .data = &values[0]}; - - values[0] = WASM_I32_VAL(0); - values[1] = WASM_I32_VAL(3); - - int32_t index = 0; - auto const result = getDataSlice(runtime, ¶ms, index); - if (!result || result.value() != Slice(buffer.data(), 3) || index != 2) - return false; - } - - { - // test string - wasm_val_vec_t const params = {.size = 2, .data = &values[0]}; - - values[0] = WASM_I32_VAL(0); - values[1] = WASM_I32_VAL(5); - - int32_t index = 0; - auto const result = getDataString(runtime, ¶ms, index); - if (!result || - result.value() != std::string_view(reinterpret_cast(buffer.data()), 5) || - index != 2) - return false; - } - - { - // test account - AccountID const id( - calcAccountID(generateKeyPair(KeyType::Secp256k1, generateSeed("alice")).first)); - - wasm_val_vec_t const params = {.size = 2, .data = &values[0]}; - - values[0] = WASM_I32_VAL(0); - values[1] = WASM_I32_VAL(AccountID::size()); - memcpy(&buffer[0], id.data(), AccountID::size()); - - int32_t index = 0; - auto const result = getDataAccountID(runtime, ¶ms, index); - if (!result || result.value() != id || index != 2) - return false; - } - - { - // test uint256 - - Hash h1 = sha512Half(Slice(buffer.data(), 8)); - wasm_val_vec_t const params = {.size = 2, .data = &values[0]}; - - values[0] = WASM_I32_VAL(0); - values[1] = WASM_I32_VAL(Hash::size()); - memcpy(&buffer[0], h1.data(), Hash::size()); - - int32_t index = 0; - auto const result = getDataUInt256(runtime, ¶ms, index); - if (!result || result.value() != h1 || index != 2) - return false; - } - - { - // test Currency - - Currency const c = xrpCurrency(); - wasm_val_vec_t const params = {.size = 2, .data = &values[0]}; - - values[0] = WASM_I32_VAL(0); - values[1] = WASM_I32_VAL(Currency::size()); - memcpy(&buffer[0], c.data(), Currency::size()); - - int32_t index = 0; - auto const result = getDataCurrency(runtime, ¶ms, index); - if (!result || result.value() != c || index != 2) - return false; - } - - return true; -} - -} // namespace test -// LCOV_EXCL_STOP - -} // namespace xrpl diff --git a/src/libxrpl/tx/wasm/WasmVM.cpp b/src/libxrpl/tx/wasm/WasmVM.cpp index ed87f7c4ac..7f05eea138 100644 --- a/src/libxrpl/tx/wasm/WasmVM.cpp +++ b/src/libxrpl/tx/wasm/WasmVM.cpp @@ -1,215 +1,186 @@ #include +#include #include +#include #include -#include // IWYU pragma: keep +#include +#include #include -#include + +#include +#include #include #include -#include -#include -#ifdef _DEBUG -// #define DEBUG_OUTPUT 1 -#endif - -#include -#include - -#include +#include +#include +#include namespace xrpl { -// WARNING: Per XLS-0102, the host functions registered here form a stable -// ABI. Their name, semantics, parameters, and return types must NEVER be -// changed, as there may always be a program that uses it. New host functions -// may be added and existing gas costs may be adjusted, but every such change -// must be gated by an amendment. -// See XLS-0102 §6.5 (Future-Proofing): -// https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0102-wasm-vm#65-future-proofing -static void -setCommonHostFunctions(HostFunctions& hfs, ImportVec& i) + +namespace { + +using RunStatus = rs::wasm_vm::RunStatus; +using CheckStatus = rs::wasm_vm::CheckStatus; + +// The engine's outcome as the caller's: a value with its gas cost, or a TER with the gas cost +// to record beside it. +// +// A `tecINTERNAL` reports no cost. It says the fault is the node's, and charging a +// transaction for a node's defect would write that defect into the ledger. +// +// Exhaustive over the status enum, with no `default`: the enum is generated from the +// engine's `RunError`, so an outcome added there fails this switch under -Wswitch -Werror +// rather than quietly picking up a neighbour's TER. The return past the switch is for the +// compilers that will not call an exhaustive switch exhaustive; it sits after the switch, +// not in a `default`, so the coverage check above still holds. +std::expected +outcome(rs::wasm_vm::RunResult const& run) { - // clang-format off - WASM_IMPORT_FUNC2(i, getLedgerSqn, "ldgr_index", hfs, 60); - WASM_IMPORT_FUNC2(i, getParentLedgerTime, "parent_ldgr_time", hfs, 60); - WASM_IMPORT_FUNC2(i, getParentLedgerHash, "parent_ldgr_hash", hfs, 60); - WASM_IMPORT_FUNC2(i, getBaseFee, "base_fee", hfs, 60); - WASM_IMPORT_FUNC2(i, isAmendmentEnabled, "amendment_enabled", hfs, 100); + auto const cost = static_cast(run.gas_used); - WASM_IMPORT_FUNC2(i, cacheLedgerObj, "cache_le", hfs, 5'000); - WASM_IMPORT_FUNC2(i, getTxField, "tx_field", hfs, 70); - WASM_IMPORT_FUNC2(i, getCurrentLedgerObjField, "home_le_field", hfs, 70); - WASM_IMPORT_FUNC2(i, getLedgerObjField, "le_field", hfs, 70); - WASM_IMPORT_FUNC2(i, getTxNestedField, "tx_inner", hfs, 110); - WASM_IMPORT_FUNC2(i, getCurrentLedgerObjNestedField, "home_le_inner", hfs, 110); - WASM_IMPORT_FUNC2(i, getLedgerObjNestedField, "le_inner", hfs, 110); - WASM_IMPORT_FUNC2(i, getTxArrayLen, "tx_arr_len", hfs, 40); - WASM_IMPORT_FUNC2(i, getCurrentLedgerObjArrayLen, "home_le_arr_len", hfs, 40); - WASM_IMPORT_FUNC2(i, getLedgerObjArrayLen, "le_arr_len", hfs, 40); - WASM_IMPORT_FUNC2(i, getTxNestedArrayLen, "tx_inner_arr_len", hfs, 70); - WASM_IMPORT_FUNC2(i, getCurrentLedgerObjNestedArrayLen, "home_le_inner_arr_len", hfs, 70); - WASM_IMPORT_FUNC2(i, getLedgerObjNestedArrayLen, "le_inner_arr_len", hfs, 70); + switch (run.status) + { + case RunStatus::Ok: + return EscrowResult{.result = run.result, .cost = cost}; - WASM_IMPORT_FUNC2(i, checkSignature, "check_sig", hfs, 300); - WASM_IMPORT_FUNC2(i, computeSha512HalfHash, "sha512_half", hfs, 2000); + // The cost is the whole limit: XLS-0102 halts the guest the instant the meter runs + // out, and the run is charged for all of it. + case RunStatus::OutOfGas: + return std::unexpected{WasmTER{.ter = tecOUT_OF_GAS, .cost = cost}}; - WASM_IMPORT_FUNC2(i, accountKeylet, "accountroot_id", hfs, 350); - WASM_IMPORT_FUNC2(i, ammKeylet, "amm_id", hfs, 450); - WASM_IMPORT_FUNC2(i, checkKeylet, "check_id", hfs, 350); - WASM_IMPORT_FUNC2(i, credentialKeylet, "credential_id", hfs, 350); - WASM_IMPORT_FUNC2(i, delegateKeylet, "delegate_id", hfs, 350); - WASM_IMPORT_FUNC2(i, depositPreauthKeylet, "deposit_preauth_id", hfs, 350); - WASM_IMPORT_FUNC2(i, didKeylet, "did_id", hfs, 350); - WASM_IMPORT_FUNC2(i, escrowKeylet, "escrow_id", hfs, 350); - WASM_IMPORT_FUNC2(i, trustLineKeylet, "trustline_id", hfs, 400); - WASM_IMPORT_FUNC2(i, mptokenIssuanceKeylet, "mpt_issuance_id", hfs, 350); - WASM_IMPORT_FUNC2(i, mptokenKeylet, "mptoken_id", hfs, 500); - WASM_IMPORT_FUNC2(i, nftokenOfferKeylet, "nft_offer_id", hfs, 350); - WASM_IMPORT_FUNC2(i, offerKeylet, "offer_id", hfs, 350); - WASM_IMPORT_FUNC2(i, oracleKeylet, "oracle_id", hfs, 350); - WASM_IMPORT_FUNC2(i, paychannelKeylet, "paychan_id", hfs, 350); - WASM_IMPORT_FUNC2(i, permissionedDomainKeylet, "permissioned_domain_id", hfs, 350); - WASM_IMPORT_FUNC2(i, signerListKeylet, "signers_id", hfs, 350); - WASM_IMPORT_FUNC2(i, ticketKeylet, "ticket_id", hfs, 350); - WASM_IMPORT_FUNC2(i, vaultKeylet, "vault_id", hfs, 350); + // The contract's own fault - it trapped, or it never exported the linear memory + // its host calls need - so it is charged for what it burned reaching that point. + case RunStatus::Trap: + case RunStatus::NoMemory: + // A module that will not instantiate is the contract's fault too. Screening + // cannot see every way this happens - a linear memory the module keeps to itself + // is absent from its exports - so a module can pass preflight and still be + // refused here. It is a deterministic property of the code either way, and one + // this node's own conduct had no part in. + case RunStatus::Instantiate: + return std::unexpected{WasmTER{.ter = tecFAILED_PROCESSING, .cost = cost}}; - WASM_IMPORT_FUNC2(i, getNFT, "nft_uri", hfs, 5'000); - WASM_IMPORT_FUNC2(i, getNFTIssuer, "nft_issuer", hfs, 70); - WASM_IMPORT_FUNC2(i, getNFTTaxon, "nft_taxon", hfs, 60); - WASM_IMPORT_FUNC2(i, getNFTFlags, "nft_flags", hfs, 60); - WASM_IMPORT_FUNC2(i, getNFTTransferFee, "nft_xfer_fee", hfs, 60); - WASM_IMPORT_FUNC2(i, getNFTSequence, "nft_serial", hfs, 60); - - WASM_IMPORT_FUNC (i, trace, hfs, 30); - - WASM_IMPORT_FUNC2(i, floatFromInt, "float_from_int", hfs, 100); - WASM_IMPORT_FUNC2(i, floatFromUint, "float_from_uint", hfs, 130); - WASM_IMPORT_FUNC2(i, floatFromSTAmount, "float_from_stamount", hfs, 150); - WASM_IMPORT_FUNC2(i, floatFromSTNumber, "float_from_stnumber", hfs, 150); - WASM_IMPORT_FUNC2(i, floatToInt, "float_to_int", hfs, 130); - WASM_IMPORT_FUNC2(i, floatToMantExp, "float_to_mant_exp", hfs, 130); - WASM_IMPORT_FUNC2(i, floatFromMantExp, "float_from_mant_exp", hfs, 100); - WASM_IMPORT_FUNC2(i, floatCompare, "float_cmp", hfs, 80); - WASM_IMPORT_FUNC2(i, floatAdd, "float_add", hfs, 160); - WASM_IMPORT_FUNC2(i, floatSubtract, "float_sub", hfs, 160); - WASM_IMPORT_FUNC2(i, floatMultiply, "float_mult", hfs, 300); - WASM_IMPORT_FUNC2(i, floatDivide, "float_div", hfs, 300); - WASM_IMPORT_FUNC2(i, floatRoot, "float_root", hfs, 5'500); - WASM_IMPORT_FUNC2(i, floatPower, "float_pow", hfs, 5'500); - // clang-format on + // A module that will not compile, or does not expose the entry point, should have + // been refused at preflight with `temBAD_WASM`: screening decides both from the + // same bytes and the same engine, so agreeing here is not a matter of degree. + // Reaching apply means the screening did not happen, which is a node-side fault + // rather than the transaction's. + case RunStatus::Compile: + case RunStatus::EntryPoint: + // The host could not serve a call, or it threw and `HostContext` caught it. + case RunStatus::Internal: + // The engine panicked: a defect in the engine, reported rather than fatal to the + // node. + case RunStatus::Panic: + return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; + } + UNREACHABLE("xrpl::outcome : unknown RunStatus"); + return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; } -ImportVec -createWasmImport(HostFunctions& hfs) +// A screening verdict as a TER. +// +// `temBAD_WASM` says the transaction carries something this engine cannot run: a +// malformed transaction, refused before it can reach the ledger. A panic inside the +// engine is different in kind - nothing was learned about the module - so the answer is +// node-local rather than a claim about the transaction. +// +// Exhaustive over the status enum, with no `default`, for the same reason `outcome` is. +NotTEC +verdict(CheckStatus status) { - ImportVec i; + switch (status) + { + case CheckStatus::Ok: + return tesSUCCESS; - setCommonHostFunctions(hfs, i); - WASM_IMPORT_FUNC2(i, updateData, "set_data", hfs, 1000); + // The module will not compile, imports what no engine of this ABI serves, does + // not export the entry point as `() -> i32`, or asks for more linear memory or + // table than it may have. + case CheckStatus::Compile: + case CheckStatus::Import: + case CheckStatus::EntryPoint: + case CheckStatus::Memory: + case CheckStatus::Table: + return temBAD_WASM; - return i; + // The engine panicked: a defect in the engine, reported rather than fatal to + // the node, and not the transaction's fault. + case CheckStatus::Panic: + return telFAILED_PROCESSING; + } + UNREACHABLE("xrpl::verdict : unknown CheckStatus"); + return telFAILED_PROCESSING; } +} // namespace + std::expected runEscrowWasm( Bytes const& wasmCode, HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName, - std::vector const& params) + std::int64_t gasLimit, + std::string_view funcName) noexcept { - // create VM and set cost limit - auto& vm = WasmEngine::instance(); - // vm.initMaxPages(MAX_PAGES); + XRPL_ASSERT( + gasLimit > 0, + "::xrpl::runEscrowWasm : gas limit is positive (should be checked in preflight)"); + // A run needs a budget to spend. Refused here rather than in the engine because what a + // non-positive limit means is a transaction-validity rule; the engine's own budget is + // therefore an unsigned quantity with no invalid value to represent. + if (gasLimit <= 0) + return std::unexpected{WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt}}; - auto const ret = - vm.run(wasmCode, hfs, gasLimit, funcName, params, createWasmImport(hfs), hfs.getJournal()); + auto const nodeSideFault = std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; - if (!ret) - { -#ifdef DEBUG_OUTPUT - std::cout << ", error: " << ret.error().ter << std::endl; -#endif - // Carries the TER (tecOUT_OF_GAS / tecFAILED_PROCESSING / tecINTERNAL / - // temBAD_AMOUNT) and, when meaningful, the gas consumed. The caller is - // responsible for writing that gas to tx metadata. - return std::unexpected(ret.error()); - } + return guarded(hfs.getJournal(), nodeSideFault, [&]() -> std::expected { + // The host caches the current ledger object, the slot table and the + // contract's data for the length of one run, so a reused one would answer a + // later contract out of an earlier contract's state. + XRPL_ASSERT( + hfs.checkSelf(), "::xrpl::runEscrowWasm : host functions not clean before the run"); + if (!hfs.checkSelf()) + { + throw std::runtime_error("host functions not clean before the run"); + } -#ifdef DEBUG_OUTPUT - std::cout << ", ret: " << ret->result << ", gas spent: " << ret->cost << std::endl; -#endif - return EscrowResult{.result = ret->result, .cost = ret->cost}; + HostContext const ctx{hfs}; + auto const run = rs::wasm_vm::run_escrow( + ctx, + rust::Slice{wasmCode.data(), wasmCode.size()}, + static_cast(gasLimit), + rust::Str{funcName.data(), funcName.size()}); + + auto const result = outcome(run); + if (!result) + { + JLOG(hfs.getJournal().warn()) + << "wasm: " << std::string_view{run.detail.data(), run.detail.size()} + << ", ter: " << transToken(result.error().ter); + } + return result; + }); } NotTEC -preflightEscrowWasm( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params) +preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view funcName) noexcept { - // create VM and set cost limit - auto& vm = WasmEngine::instance(); - // vm.initMaxPages(MAX_PAGES); + return guarded(j, NotTEC{telFAILED_PROCESSING}, [&]() { + auto const checked = rs::wasm_vm::check_escrow( + rust::Slice{wasmCode.data(), wasmCode.size()}, + rust::Str{funcName.data(), funcName.size()}); - auto const ret = - vm.check(wasmCode, hfs, funcName, params, createWasmImport(hfs), hfs.getJournal()); - - return ret; + auto const ter = verdict(checked.status); + if (!isTesSuccess(ter)) + { + JLOG(j.warn()) << "wasm: " + << std::string_view{checked.detail.data(), checked.detail.size()} + << ", ter: " << transToken(ter); + } + return ter; + }); } -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -WasmEngine::WasmEngine() : impl_(std::make_unique()) -{ -} - -WasmEngine& -WasmEngine::instance() -{ - static WasmEngine e; - return e; -} - -std::expected, WasmTER> -WasmEngine::run( - Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - return impl_->run(wasmCode, hfs, gasLimit, funcName, params, imports, j); -} - -NotTEC -WasmEngine::check( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - return impl_->check(wasmCode, hfs, funcName, params, imports, j); -} - -void* -WasmEngine::newTrap(std::string const& msg) -{ - return impl_->newTrap(msg); -} - -// LCOV_EXCL_START -beast::Journal -WasmEngine::getJournal() const -{ - return impl_->getJournal(); -} -// LCOV_EXCL_STOP - } // namespace xrpl diff --git a/src/libxrpl/tx/wasm/WasmiVM.cpp b/src/libxrpl/tx/wasm/WasmiVM.cpp deleted file mode 100644 index cfe54fccc2..0000000000 --- a/src/libxrpl/tx/wasm/WasmiVM.cpp +++ /dev/null @@ -1,958 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _DEBUG -// #define DEBUG_OUTPUT 1 -#endif -// #define SHOW_CALL_TIME 1 - -namespace xrpl { - -wasm_trap_t* -HostFuncMain_wrap(void* env, wasm_val_vec_t const* params, wasm_val_vec_t* results); - -namespace { - -void -printWasmError(std::string_view msg, wasm_trap_t* trap, beast::Journal jlog) -{ -#ifdef DEBUG_OUTPUT - auto& j = std::cerr; -#else - auto j = jlog.warn(); - if (jlog.active(beast::Severity::Warning)) -#endif - { - wasm_byte_vec_t errorMessage WASM_EMPTY_VEC; - - if (trap != nullptr) - wasm_trap_message(trap, &errorMessage); - - if (errorMessage.size != 0u) - { - j << "WASMI Error: " << msg << ", " - << std::string_view(errorMessage.data, errorMessage.size - 1); - } - else - { - j << "WASMI Error: " << msg; - } - - if (errorMessage.size != 0u) - wasm_byte_vec_delete(&errorMessage); - } - - if (trap != nullptr) - wasm_trap_delete(trap); - -#ifdef DEBUG_OUTPUT - j << std::endl; -#endif -} -// LCOV_EXCL_STOP - -// Extract a trap's message into a std::string (the only signal the C API gives -// for classification; see the trap-signal constants in WasmCommon.h). Does not -// take ownership of `trap`. -std::string -trapMessage(wasm_trap_t* trap) -{ - if (trap == nullptr) - return {}; // LCOV_EXCL_LINE - wasm_byte_vec_t msg WASM_EMPTY_VEC; - wasm_trap_message(trap, &msg); - std::string out; - if (msg.size != 0u) - { - // wasm_trap_message NUL-terminates, so drop the trailing NUL. - out.assign(msg.data, msg.size - 1); - wasm_byte_vec_delete(&msg); - } - return out; -} - -} // namespace - -class WasmiRuntimeWrapper : public WasmRuntimeWrapper -{ - InstanceWrapper& iw_; - -public: - WasmiRuntimeWrapper(InstanceWrapper& iw) : iw_(iw) - { - } - - Wmem - getMem() override - { - return iw_.getMem(); - } - - std::int64_t - getGas() override - { - return iw_.getGas(); - } - - std::int64_t - setGas(std::int64_t gas) override - { - return iw_.setGas(gas); - } - - std::int64_t - getTransferLimit() override - { - return iw_.getTransferLimit(); - } - - std::int64_t - setTransferLimit(std::int64_t x) override - { - return iw_.setTransferLimit(x); - } -}; - -InstancePtr -InstanceWrapper::init( - StorePtr& s, - ModulePtr& m, - WasmExternVec& expt, - WasmExternVec const& imports, - beast::Journal j) -{ - wasm_trap_t* trap = nullptr; - InstancePtr mi = InstancePtr( - wasm_instance_new(s.get(), m.get(), imports.get(), &trap), &wasm_instance_delete); - - if (!mi || (trap != nullptr)) - { - printWasmError("can't create instance", trap, j); - Throw("can't create instance"); - } - wasm_instance_exports(mi.get(), expt.get()); - return mi; -} - -InstanceWrapper& -InstanceWrapper::operator=(InstanceWrapper&& o) -{ - if (this == &o) - return *this; // LCOV_EXCL_LINE - - store_ = o.store_; - o.store_ = nullptr; - exports_ = std::move(o.exports_); - memIdx_ = o.memIdx_; - o.memIdx_ = -1; - instance_ = std::move(o.instance_); - - j_ = o.j_; - - return *this; -} - -FuncInfo -InstanceWrapper::getFunc(std::string_view funcName, WasmExporttypeVec const& exportTypes) const -{ - wasm_func_t const* f = nullptr; - wasm_functype_t const* ft = nullptr; - - if (!instance_) - Throw("no instance"); // LCOV_EXCL_LINE - - if (exportTypes.empty()) - Throw("no export"); // LCOV_EXCL_LINE - if (exportTypes.size() != exports_.size()) - Throw("invalid export"); // LCOV_EXCL_LINE - - for (unsigned i = 0; i < exportTypes.size(); ++i) - { - auto const* expType(exportTypes[i]); - - wasm_name_t const* name = wasm_exporttype_name(expType); - wasm_externtype_t const* exnType = wasm_exporttype_type(expType); - if (wasm_externtype_kind(exnType) == WASM_EXTERN_FUNC) - { - if (funcName != std::string_view(name->data, name->size)) - continue; - - auto const* exn(exports_[i]); - if (wasm_extern_kind(exn) != WASM_EXTERN_FUNC) - Throw("invalid export"); // LCOV_EXCL_LINE - - ft = wasm_externtype_as_functype_const(exnType); - f = wasm_extern_as_func_const(exn); - break; - } - } - - if ((f == nullptr) || (ft == nullptr)) - Throw("can't find function <" + std::string(funcName) + ">"); - - return {f, ft}; -} - -Wmem -InstanceWrapper::getMem() const -{ - if (memIdx_ >= 0) - { - auto* e(exports_[memIdx_]); - wasm_memory_t* mem = wasm_extern_as_memory(e); - return Wmem(wasm_memory_data(mem), wasm_memory_data_size(mem)); - } - - wasm_memory_t* mem = nullptr; - for (int i = 0; i < exports_.size(); ++i) - { - auto* e(exports_[i]); - if (wasm_extern_kind(e) == WASM_EXTERN_MEMORY) - { - memIdx_ = i; - mem = wasm_extern_as_memory(e); - break; - } - } - - if (mem == nullptr) - return {}; // LCOV_EXCL_LINE - - return Wmem(wasm_memory_data(mem), wasm_memory_data_size(mem)); -} - -std::int64_t -InstanceWrapper::getGas() const -{ - if (store_ == nullptr) - return -1; // LCOV_EXCL_LINE - std::uint64_t gas = 0; - wasm_store_get_fuel(store_, &gas); - return static_cast(gas); -} - -std::int64_t -InstanceWrapper::setGas(std::int64_t gas) const -{ - if (store_ == nullptr) - return -1; // LCOV_EXCL_LINE - - if (gas < 0) - gas = std::numeric_limits::max(); - wasmi_error_t* err = wasm_store_set_fuel(store_, static_cast(gas)); - if (err != nullptr) - { - // LCOV_EXCL_START - printWasmError("Can't set instance gas", nullptr, j_); - wasmi_error_delete(err); - return -1; - // LCOV_EXCL_STOP - } - - return gas; -} - -std::int64_t -InstanceWrapper::getTransferLimit() const -{ - if (store_ == nullptr) - return -1; // LCOV_EXCL_LINE - - return transferLimit_; -} - -std::int64_t -InstanceWrapper::setTransferLimit(std::int64_t x) -{ - if (store_ == nullptr) - return -1; // LCOV_EXCL_LINE - if (x < 0) - { - transferLimit_ = std::numeric_limits::max(); - } - else - { - transferLimit_ = x; - } - - return transferLimit_; -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -ModulePtr -ModuleWrapper::init(StorePtr& s, Bytes const& wasmBin, beast::Journal j) -{ - wasm_byte_vec_t const code{ - .size = wasmBin.size(), - // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) - .data = const_cast(reinterpret_cast(wasmBin.data()))}; - ModulePtr m = ModulePtr(wasm_module_new(s.get(), &code), &wasm_module_delete); - if (!m) - throw std::runtime_error("can't create module"); - - return m; -} - -ModuleWrapper::ModuleWrapper( - StorePtr& s, - Bytes const& wasmBin, - bool instantiate, - ImportVec const& imports, - beast::Journal j) - : module_(init(s, wasmBin, j)), j_(j) -{ - wasm_module_exports(module_.get(), exportTypes_.get()); - auto wimports = buildImports(s, imports); - if (instantiate) - { - addInstance(s, wimports); - } -} - -// LCOV_EXCL_START -ModuleWrapper& -ModuleWrapper::operator=(ModuleWrapper&& o) -{ - if (this == &o) - return *this; - - module_ = std::move(o.module_); - instanceWrap_ = std::move(o.instanceWrap_); - exportTypes_ = std::move(o.exportTypes_); - j_ = o.j_; - - return *this; -} - -// LCOV_EXCL_STOP - -static WasmValtypeVec -makeImpParams(WasmImportFunc const& imp) -{ - auto const paramSize = imp.params.size(); - if (paramSize == 0u) - return {}; - - WasmValtypeVec v(paramSize); - - for (unsigned i = 0; i < paramSize; ++i) - { - auto const vt = imp.params[i]; - switch (vt) - { - case WasmTypes::WtI32: - v[i] = wasm_valtype_new_i32(); - break; - case WasmTypes::WtI64: - v[i] = wasm_valtype_new_i64(); - break; - // LCOV_EXCL_START - default: - throw std::runtime_error("invalid import type"); - // LCOV_EXCL_STOP - } - } - return v; -} - -static WasmValtypeVec -makeImpReturn(WasmImportFunc const& imp) -{ - if (!imp.result) - return {}; // LCOV_EXCL_LINE - - WasmValtypeVec v(1); - switch (*imp.result) - { - case WasmTypes::WtI32: - v[0] = wasm_valtype_new_i32(); - break; - // LCOV_EXCL_START - case WasmTypes::WtI64: - v[0] = wasm_valtype_new_i64(); - break; - default: - throw std::runtime_error("invalid return type"); - // LCOV_EXCL_STOP - } - return v; -} - -WasmExternVec -ModuleWrapper::buildImports(StorePtr& s, ImportVec const& imports) const -{ - WasmImporttypeVec importTypes; - wasm_module_imports(module_.get(), importTypes.get()); - - if (importTypes.empty()) - return {}; - if (imports.empty()) - Throw("Empty imports"); - - WasmExternVec wimports(importTypes.size()); - - unsigned impCnt = 0; - for (unsigned i = 0; i < importTypes.size(); ++i) - { - wasm_importtype_t const* importType = importTypes[i]; - - // wasm_name_t const* mn = wasm_importtype_module(importtype); - // auto modName = std::string_view(mn->data, mn->num_elems); - wasm_name_t const* fn = wasm_importtype_name(importType); - auto fieldName = std::string_view(fn->data, fn->size); - - wasm_externkind_t const itype = wasm_externtype_kind(wasm_importtype_type(importType)); - if (itype != WASM_EXTERN_FUNC) - { - Throw( - "Invalid import type " + std::to_string(itype)); // LCOV_EXCL_LINE - } - - // for multi-module support - // if ((W_ENV != modName) && (W_HOST_LIB != modName)) - // continue; - - auto const it = imports.find(fieldName); - if (it == imports.end()) - { - printWasmError("Import not found: " + std::string(fieldName), nullptr, j_); - continue; // print all missed import - } - - WasmUserData const& obj = it->second; - WasmImportFunc const& imp = obj.second; - - WasmValtypeVec params(makeImpParams(imp)); - WasmValtypeVec results(makeImpReturn(imp)); - - std::unique_ptr const ftype( - wasm_functype_new(params.get(), results.get()), &wasm_functype_delete); - - params.release(); - results.release(); - - wasm_func_t* func = - wasm_func_new_with_env(s.get(), ftype.get(), HostFuncMain_wrap, (void*)&obj, nullptr); - if (func == nullptr) - { - Throw( - "can't create import function " + std::string(imp.name)); // LCOV_EXCL_LINE - } - - wimports[i] = wasm_func_as_extern(func); - ++impCnt; - } - - if (impCnt != importTypes.size()) - { - printWasmError( - std::string("Imports not finished: ") + std::to_string(impCnt) + "/" + - std::to_string(importTypes.size()), - nullptr, - j_); - Throw("Missing imports"); - } - - return wimports; -} - -wasm_functype_t const* -ModuleWrapper::getFuncType(std::string_view funcName) const -{ - for (size_t i = 0; i < exportTypes_.size(); i++) - { - auto const* expType(exportTypes_[i]); - wasm_name_t const* name = wasm_exporttype_name(expType); - wasm_externtype_t const* exnType = wasm_exporttype_type(expType); - if (wasm_externtype_kind(exnType) == WASM_EXTERN_FUNC && - funcName == std::string_view(name->data, name->size)) - { - return wasm_externtype_as_functype_const(exnType); - } - } - - throw std::runtime_error("can't find function <" + std::string(funcName) + ">"); -} - -// int -// my_module_t::delInstance(int i) -// { -// if (i >= mod_inst.size()) -// return -1; -// if (!mod_inst[i]) -// mod_inst[i] = my_mod_inst_t(); -// return i; -// } - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -// void -// WasmiEngine::clearModules() -// { -// modules.clear(); -// store.reset(); // to free the memory before creating new store -// store = {wasm_store_new(engine.get()), &wasm_store_delete}; -// } - -std::unique_ptr -WasmiEngine::init() -{ - wasm_config_t* config = wasm_config_new(); - if (config == nullptr) - { - return std::unique_ptr{ - nullptr, &wasm_engine_delete}; // LCOV_EXCL_LINE - } - wasmi_config_consume_fuel_set(config, true); - wasmi_config_ignore_custom_sections_set(config, true); - wasmi_config_wasm_mutable_globals_set(config, false); - wasmi_config_wasm_multi_value_set(config, false); - wasmi_config_wasm_sign_extension_set(config, false); - wasmi_config_wasm_saturating_float_to_int_set(config, false); - wasmi_config_wasm_bulk_memory_set(config, false); - wasmi_config_wasm_reference_types_set(config, false); - wasmi_config_wasm_tail_call_set(config, false); - wasmi_config_wasm_extended_const_set(config, false); - wasmi_config_floats_set(config, false); - wasmi_config_wasm_multi_memory_set(config, false); - wasmi_config_wasm_custom_page_sizes_set(config, false); - wasmi_config_wasm_memory64_set(config, false); - wasmi_config_wasm_wide_arithmetic_set(config, false); - - return std::unique_ptr( - wasm_engine_new_with_config(config), &wasm_engine_delete); -} - -int -WasmiEngine::addModule( - Bytes const& wasmCode, - bool instantiate, - ImportVec const& imports, - int64_t gas) -{ - moduleWrap_.reset(); - store_.reset(); // to free the memory before creating new store - store_ = {wasm_store_new_with_memory_max_pages(engine_.get(), maxPages), &wasm_store_delete}; - - if (gas < 0) - gas = std::numeric_limits::max(); - wasmi_error_t* err = wasm_store_set_fuel(store_.get(), static_cast(gas)); - if (err != nullptr) - { - // LCOV_EXCL_START - printWasmError("Error setting gas", nullptr, j_); - wasmi_error_delete(err); - throw std::runtime_error("can't set gas"); - // LCOV_EXCL_STOP - } - - moduleWrap_ = std::make_unique(store_, wasmCode, instantiate, imports, j_); - - if (!moduleWrap_) - throw std::runtime_error("can't create module wrapper"); // LCOV_EXCL_LINE - - return moduleWrap_ ? 0 : -1; -} - -// int -// WasmiEngine::addInstance() -// { -// return module->addInstance(store.get()); -// } - -std::vector -WasmiEngine::convertParams(std::vector const& params) -{ - std::vector v; - v.reserve(params.size()); - for (auto const& p : params) - { - switch (p.type) - { - case WasmTypes::WtI32: - v.push_back(WASM_I32_VAL(p.of.i32)); - break; - // LCOV_EXCL_START - case WasmTypes::WtI64: - v.push_back(WASM_I64_VAL(p.of.i64)); - break; - default: - throw std::runtime_error( - "unknown parameter type: " + std::to_string(static_cast(p.type))); - break; - // LCOV_EXCL_STOP - } - } - - return v; -} - -int -WasmiEngine::compareParamTypes(wasm_valtype_vec_t const* ftp, std::vector const& p) -{ - if (ftp->size != p.size()) - return std::min(ftp->size, p.size()); - - for (unsigned i = 0; i < ftp->size; ++i) - { - auto const t1 = wasm_valtype_kind(ftp->data[i]); - auto const t2 = p[i].kind; - if (t1 != t2) - return i; - } - - return -1; -} - -// LCOV_EXCL_START -void -WasmiEngine::addParam(std::vector& in, int32_t p) -{ - in.emplace_back(); - auto& el(in.back()); - memset(&el, 0, sizeof(el)); - el = WASM_I32_VAL(p); // WASM_I32; -} - -// LCOV_EXCL_STOP - -void -WasmiEngine::addParam(std::vector& in, int64_t p) -{ - in.emplace_back(); - auto& el(in.back()); - el = WASM_I64_VAL(p); -} - -template -WasmiResult -WasmiEngine::call(std::string_view func, Types&&... args) -{ - // Lookup our export function - auto f = getFunc(func); - return call(f, std::forward(args)...); -} - -template -WasmiResult -WasmiEngine::call(FuncInfo const& f, Types&&... args) -{ - std::vector in; - return call(f, in, std::forward(args)...); -} - -#ifdef SHOW_CALL_TIME -static inline uint64_t -usecs() -{ - uint64_t x = std::chrono::duration_cast( - std::chrono::high_resolution_clock::now().time_since_epoch()) - .count(); - return x; -} -#endif - -template -WasmiResult -WasmiEngine::call(FuncInfo const& f, std::vector& in) -{ - WasmiResult ret(NR); - wasm_val_vec_t const inv = in.empty() ? wasm_val_vec_t WASM_EMPTY_VEC - : wasm_val_vec_t{.size = in.size(), .data = in.data()}; - -#ifdef SHOW_CALL_TIME - auto const start = usecs(); -#endif - - wasm_trap_t* trap = wasm_func_call(f.first, &inv, ret.r.get()); - -#ifdef SHOW_CALL_TIME - auto const finish = usecs(); - auto const delta_ms = (finish - start) / 1000; - std::cout << "wasm_func_call: " << delta_ms << "ms" << std::endl; -#endif - - if (trap) - { - // Classify the trap into a TER by matching tokens as substrings of the - // message (see the trap-signal constants in WasmCommon.h for why). - std::string const msg = trapMessage(trap); - auto const has = [&msg](std::string_view token) { return msg.contains(token); }; - if (has(hfErrInternal)) - { - ret.ter = tecINTERNAL; - } - else if (has(hfErrOutOfGas) || has(wasmiTrapOutOfFuel)) - { - ret.ter = tecOUT_OF_GAS; - } - else - { - ret.ter = tecFAILED_PROCESSING; - } - printWasmError("failure to call func", trap, j_); - } - - return ret; -} - -template -WasmiResult -WasmiEngine::call(FuncInfo const& f, std::vector& in, std::int32_t p, Types&&... args) -{ - addParam(in, p); - return call(f, in, std::forward(args)...); -} - -template -WasmiResult -WasmiEngine::call(FuncInfo const& f, std::vector& in, std::int64_t p, Types&&... args) -{ - addParam(in, p); - return call(f, in, std::forward(args)...); -} - -template -WasmiResult -WasmiEngine::call(FuncInfo const& f, std::vector& in, Bytes const& p, Types&&... args) -{ - return call(f, in, p.data(), p.size(), std::forward(args)...); -} - -static inline void -checkImports(ImportVec const& imports, HostFunctions* hfs) -{ - for (auto const& obj : imports) - { - if (hfs != &obj.second.first.get()) - Throw("Imports hf unsync"); - } -} - -std::expected, WasmTER> -WasmiEngine::run( - Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gas, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - if (gas <= 0) - return std::unexpected(WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt}); - - try - { - checkImports(imports, &hfs); - return runHlp(wasmCode, hfs, gas, funcName, params, imports, j); - } - catch (std::exception const& e) - { - printWasmError(std::string("exception: ") + e.what(), nullptr, j); - } - // LCOV_EXCL_START - catch (...) - { - printWasmError(std::string("exception: unknown"), nullptr, j); - } - // LCOV_EXCL_STOP - // An exception escaping the engine is an xrpld-side fault -> tecINTERNAL, - // no gas. Genuine wasm faults don't throw; they surface as traps in runHlp. - return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}); -} - -std::expected, WasmTER> -WasmiEngine::runHlp( - Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gas, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - // currently only 1 module support, possible parallel UT run - std::scoped_lock const lg(m_); - j_ = j; - - if (wasmCode.empty()) - throw std::runtime_error("empty module"); - if (!hfs.checkSelf()) - throw std::runtime_error("hfs isn't clean"); - - // Create and instantiate the module. - [[maybe_unused]] int const m = addModule(wasmCode, true, imports, gas); - - if (!moduleWrap_ || !moduleWrap_->getInstance()) - throw std::runtime_error("no instance"); // LCOV_EXCL_LINE - - auto clearRT = [](HostFunctions* p) { p->resetRT(); }; - std::unique_ptr const clearGuard(&hfs, clearRT); - WasmiRuntimeWrapper iw(getRT()); - hfs.setRT(iw); - - // Call main - auto const f = getFunc(!funcName.empty() ? funcName : "_start"); - auto const* ftp = wasm_functype_params(f.second); - - // not const because passed directly to VM function (which accept non - // const) - auto p = convertParams(params); - - if (int const comp = compareParamTypes(ftp, p); comp >= 0) - throw std::runtime_error("invalid parameter type #" + std::to_string(comp)); - - auto const res = call<1>(f, p); - - if (gas == -1) - gas = std::numeric_limits::max(); - - if (res.ter.has_value()) - { - // call() already classified the trap (see WasmiEngine::call). - // tecINTERNAL is an xrpld-side bug: report no gas. - if (*res.ter == tecINTERNAL) - return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}); - - // Out-of-gas / wasm faults report gas (caller writes it to metadata). - // Force fuel to 0 on out-of-gas so cost is the full limit (wasmi leaves - // nonzero leftover fuel on its own out-of-fuel trap). - if (*res.ter == tecOUT_OF_GAS) - iw.setGas(0); - - return std::unexpected(WasmTER{.ter = *res.ter, .cost = gas - moduleWrap_->getGas()}); - } - - if (res.r.empty()) - { - Throw( - "<" + std::string(funcName) + "> return nothing"); // LCOV_EXCL_LINE - } - - if (res.r[0].kind != WASM_I32) - { - Throw( - "<" + std::string(funcName) + - "> return type mismatch, ret: " + std::to_string(static_cast(res.r[0].kind))); - } - - WasmResult const ret{.result = res.r[0].of.i32, .cost = gas - moduleWrap_->getGas()}; - - // #ifdef DEBUG_OUTPUT - // auto& j = std::cerr; - // #else - // auto j = j_.debug(); - // #endif - // j << "WASMI Res: " << ret.result << " cost: " << ret.cost << std::endl; - - return ret; -} - -NotTEC -WasmiEngine::check( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - try - { - checkImports(imports, &hfs); - return checkHlp(wasmCode, hfs, funcName, params, imports, j); - } - catch (std::exception const& e) - { - printWasmError(std::string("exception: ") + e.what(), nullptr, j); - } - // LCOV_EXCL_START - catch (...) - { - printWasmError(std::string("exception: unknown"), nullptr, j); - } - // LCOV_EXCL_STOP - - return temBAD_WASM; -} - -NotTEC -WasmiEngine::checkHlp( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) -{ - // currently only 1 module support, possible parallel UT run - std::scoped_lock const lg(m_); - j_ = j; - - // Create and instantiate the module. - if (wasmCode.empty()) - throw std::runtime_error("empty module"); - - int const m = addModule(wasmCode, false, imports, -1); - if ((m < 0) || !moduleWrap_) - throw std::runtime_error("no module"); // LCOV_EXCL_LINE - - // Looking for a func and compare parameter types - auto const f = moduleWrap_->getFuncType(!funcName.empty() ? funcName : "_start"); - auto const* ftp = wasm_functype_params(f); - auto const p = convertParams(params); - - if (int const comp = compareParamTypes(ftp, p); comp >= 0) - throw std::runtime_error("invalid parameter type #" + std::to_string(comp)); - - return tesSUCCESS; -} - -wasm_trap_t* -WasmiEngine::newTrap(std::string const& txt) -{ - static char empty[1] = {0}; - wasm_message_t msg = {.size = 1, .data = empty}; - - if (!txt.empty()) - wasm_name_new(&msg, txt.size() + 1, txt.c_str()); // include 0 - - wasm_trap_t* trap = wasm_trap_new(store_.get(), &msg); // NOLINT - - if (!txt.empty()) - wasm_byte_vec_delete(&msg); - - return trap; -} - -} // namespace xrpl diff --git a/src/test/app/HostFuncImpl_test.cpp b/src/test/app/HostFuncImpl_test.cpp index 58d4eb4419..b4de4a0475 100644 --- a/src/test/app/HostFuncImpl_test.cpp +++ b/src/test/app/HostFuncImpl_test.cpp @@ -1,4 +1,4 @@ - +/* #include #include #include @@ -3930,28 +3930,36 @@ struct HostFuncImpl_test : public beast::unit_test::Suite int const normalExp = 18; - Bytes const floatIntMin = {0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; // -2^63 (rounds to nearest: -(2^63-1)) - Bytes const floatIntZero = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 0 - Bytes const floatIntMax = {0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}; // 2^63-1 - Bytes const floatUIntMax = {0x19, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9A, 0x00, 0x00, 0x00, 0x01}; // 2^64-1 + Bytes const floatIntMin = {0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, +0x00, 0x00}; // -2^63 (rounds to nearest: -(2^63-1)) Bytes const floatIntZero = {0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 0 Bytes const floatIntMax = +{0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}; // 2^63-1 Bytes const +floatUIntMax = {0x19, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9A, 0x00, 0x00, 0x00, 0x01}; // +2^64-1 - Bytes const floatMaxExp = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00}; // 1e(Number::kMaxExponent + normalExp) - Bytes const floatPreMaxExp = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF}; // 1e(Number::kMaxExponent + normalExp - 1) - Bytes const floatMinusMaxExp = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00}; // -1e(Number::kMaxExponent + normalExp) - Bytes const floatMinExp = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 1e(Number::kMinExponent - normalExp) - Bytes const floatMax = {0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x80, 0x00}; // Number::kMaxRep e(Number::kMaxExponent - normalExp) + Bytes const floatMaxExp = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, +0x80, 0x00}; // 1e(Number::kMaxExponent + normalExp) Bytes const floatPreMaxExp = {0x0D, 0xE0, +0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF}; // 1e(Number::kMaxExponent + normalExp +- 1) Bytes const floatMinusMaxExp = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, 0x00, 0x00, 0x00, +0x80, 0x00}; // -1e(Number::kMaxExponent + normalExp) Bytes const floatMinExp = {0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 1e(Number::kMinExponent - +normalExp) Bytes const floatMax = {0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, +0x00, 0x80, 0x00}; // Number::kMaxRep e(Number::kMaxExponent - normalExp) - Bytes const floatMaxIOU = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x63, 0xFF, 0x9C, 0x00, 0x00, 0x00, 0x4E}; // 9999999999999999e(96) - Bytes const floatMinIOU = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x9D}; // 1e(-96 - 3 + normalExp = -81) + Bytes const floatMaxIOU = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x63, 0xFF, 0x9C, 0x00, 0x00, +0x00, 0x4E}; // 9999999999999999e(96) Bytes const floatMinIOU = {0x0D, 0xE0, 0xB6, 0xB3, +0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x9D}; // 1e(-96 - 3 + normalExp = -81) - Bytes const float1 = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // 1 - Bytes const floatMinus1 = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -1 - Bytes const float1More = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x03, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; // 1.000 000 000 000 001 - Bytes const float2 = {0x1B, 0xC1, 0x6D, 0x67, 0x4E, 0xC8, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // 2 - Bytes const float10 = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEF}; // 10 - Bytes const floatPi = {0x2B, 0x99, 0x2D, 0xDF, 0xA2, 0x32, 0x48, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; // 3.141592653589793 - Bytes const floatInvalidZero = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0x00, 0x00, 0x00}; // INVALID - Bytes const floatMinus3 = {0xD6, 0x5D, 0xDB, 0xE5, 0x09, 0xD4, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -3 + Bytes const float1 = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, +0xFF, 0xEE}; // 1 Bytes const floatMinus1 = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, +0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -1 Bytes const float1More = {0x0D, 0xE0, 0xB6, 0xB3, +0xA7, 0x64, 0x03, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; // 1.000 000 000 000 001 Bytes const float2 = +{0x1B, 0xC1, 0x6D, 0x67, 0x4E, 0xC8, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // 2 Bytes const float10 += {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEF}; // 10 Bytes const +floatPi = {0x2B, 0x99, 0x2D, 0xDF, 0xA2, 0x32, 0x48, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; +// 3.141592653589793 Bytes const floatInvalidZero = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x81, 0x00, 0x00, 0x00}; // INVALID Bytes const floatMinus3 = {0xD6, 0x5D, 0xDB, +0xE5, 0x09, 0xD4, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -3 std::string const invalid = "invalid_data"; @@ -6436,3 +6444,4 @@ struct HostFuncImpl_test : public beast::unit_test::Suite BEAST_DEFINE_TESTSUITE(HostFuncImpl, app, xrpl); } // namespace xrpl::test +*/ diff --git a/src/test/app/Wasm_test.cpp b/src/test/app/Wasm_test.cpp index c2a493de42..4f77df98b5 100644 --- a/src/test/app/Wasm_test.cpp +++ b/src/test/app/Wasm_test.cpp @@ -1,3 +1,10 @@ +// Not built. These suites drive a C++ wasm engine interface -- WasmVM over the wasm.h C API, +// HostFuncWrapper, WasmImportsHelper -- that this tree does not provide; the VM lives in the +// Rust crates. Kept as the coverage target for the port. The body is one comment block, and +// the fixtures it reads (wasm_fixtures/fixtures.cpp) are disabled the same way; re-enabling +// the suites means uncommenting both. + +/* #include #ifdef _DEBUG // #define DEBUG_OUTPUT 1 @@ -78,32 +85,32 @@ struct Wasm_test : public beast::unit_test::Suite { testcase("wasm lib test"); // clang-format off - /* The WASM module buffer. */ - Bytes const wasm = {/* WASM header */ + // The WASM module buffer. // + Bytes const wasm = {// WASM header // 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, - /* Type section */ + // Type section // 0x01, 0x07, 0x01, - /* function type {i32, i32} -> {i32} */ + // function type {i32, i32} -> {i32} // 0x60, 0x02, 0x7F, 0x7F, 0x01, 0x7F, - /* Import section */ + // Import section // 0x02, 0x13, 0x01, - /* module name: "extern" */ + // module name: "extern" // 0x06, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6E, - /* extern name: "func-add" */ + // extern name: "func-add" // 0x08, 0x66, 0x75, 0x6E, 0x63, 0x2D, 0x61, 0x64, 0x64, - /* import desc: func 0 */ + // import desc: func 0 // 0x00, 0x00, - /* Function section */ + // Function section // 0x03, 0x02, 0x01, 0x00, - /* Export section */ + // Export section // 0x07, 0x0A, 0x01, - /* export name: "addTwo" */ + // export name: "addTwo" // 0x06, 0x61, 0x64, 0x64, 0x54, 0x77, 0x6F, - /* export desc: func 0 */ + // export desc: func 0 // 0x00, 0x01, - /* Code section */ + // Code section // 0x0A, 0x0A, 0x01, - /* code body */ + // code body // 0x08, 0x00, 0x20, 0x00, 0x20, 0x01, 0x10, 0x00, 0x0B}; // clang-format on auto& vm = WasmEngine::instance(); @@ -467,3 +474,4 @@ struct Wasm_test : public beast::unit_test::Suite BEAST_DEFINE_TESTSUITE(Wasm, app, xrpl); } // namespace xrpl::test +*/ diff --git a/src/test/app/wasm_fixtures/fixtures.cpp b/src/test/app/wasm_fixtures/fixtures.cpp index 363da88f8d..53b0d90be0 100644 --- a/src/test/app/wasm_fixtures/fixtures.cpp +++ b/src/test/app/wasm_fixtures/fixtures.cpp @@ -1,5 +1,10 @@ +// Not built. The only reader of these blobs is the disabled suite in Wasm_test.cpp, so they +// are left out of the build and cost neither a translation unit nor static-init time. +// Regenerate the hex with copyFixtures.py. +// // TODO: consider moving these to separate files (and figure out the build) +/* #include #include @@ -649,3 +654,4 @@ extern std::string const kBadAlignWasmHex = "32393538616631656533303861373930636664623432626432343732302900490f7461726765745f66656174757265" "73042b0f6d757461626c652d676c6f62616c732b087369676e2d6578742b0f7265666572656e63652d74797065732b" "0a6d756c746976616c7565"; +*/ diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 9cbfb8ca10..44f7b4bdc4 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -23,6 +23,9 @@ set_target_properties( target_include_directories(xrpl_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl) +target_link_libraries(xrpl_tests PRIVATE xrpl_wasm_testkit_cxxbridge) +add_dependencies(xrpl_tests xrpl_crates) + # One source subdirectory per module. Network unit tests are currently not # supported on Windows. set(test_modules @@ -43,9 +46,6 @@ set(test_modules if(NOT WIN32) list(APPEND test_modules net) endif() -if(rust) - target_link_libraries(xrpl_tests PRIVATE rs_hello_world_cxxbridge) -endif() foreach(module IN LISTS test_modules) # Append the module's sources (${module}/*.cpp and ${module}.cpp, if any). @@ -55,12 +55,6 @@ foreach(module IN LISTS test_modules) "${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp" ) - if(NOT rust) - # Tests of the Rust interop include generated cxxbridge headers, which - # do not exist without the crates, so keep them out of the build tree - # entirely. They are named `Rust.cpp`. - list(FILTER sources EXCLUDE REGEX "/Rust[^/]*\\.cpp$") - endif() target_sources(xrpl_tests PRIVATE ${sources}) # Expose the module's private headers under their canonical include path. diff --git a/src/tests/libxrpl/basics/RustInterop.cpp b/src/tests/libxrpl/basics/RustInterop.cpp deleted file mode 100644 index 8a6ad8a4ed..0000000000 --- a/src/tests/libxrpl/basics/RustInterop.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include -#include - -#include - -TEST(RustInteropTest, hello_world) -{ - EXPECT_EQ(std::string(rs::hello_world::hello_world()), "hello_world"); -} diff --git a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h new file mode 100644 index 0000000000..438053fc06 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace xrpl::test { + +// A mock of the host the wasm engine calls back into. +// +// Only the methods the tests beside it exercise are mocked, and that is deliberate: the +// rest keep `HostFunctions`' own `std::unexpected(Unimplemented)`, so a contract reaching +// for one fails the way production would. Add a `MOCK_METHOD` here when a test needs to +// say what that host function answers. +struct MockHostFunctions : HostFunctions +{ + explicit MockHostFunctions(beast::Journal journal) : HostFunctions(journal) + { + } + + MOCK_METHOD(bool, checkSelf, (), (const, override)); + + MOCK_METHOD( + (std::expected), + getLedgerSqn, + (), + (const, override)); + + MOCK_METHOD( + (std::expected), + getCurrentLedgerObjField, + (SField const& fname), + (const, override)); + + MOCK_METHOD( + (std::expected), + computeSha512HalfHash, + (Slice const& data), + (const, override)); + + // Takes the rendered text, not the guest's buffer: rendering is `HostContext`'s, so what + // a test asserts here is the log line a node would write. + MOCK_METHOD( + void, + trace, + (std::string_view const& msg, std::string_view const& data), + (const, override)); +}; + +// Matches a `Slice` (or anything with `data()`/`size()`) against the bytes of a string, so +// an expectation can say *what* the guest asked the host to work on. +MATCHER_P(BytesAre, expected, "") +{ + return std::string_view{reinterpret_cast(arg.data()), arg.size()} == + std::string_view{expected}; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/Preflight.cpp b/src/tests/libxrpl/tx/wasm/Preflight.cpp new file mode 100644 index 0000000000..24b358b269 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/Preflight.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +namespace { + +// A contract the engine can run: it compiles, imports only a declared host function, and +// exports the entry point as `() -> i32`. +constexpr std::string_view kRunnableWat = R"wat( +(module + (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "escrow_finish") (result i32) + (call $ldgr_index (i32.const 0) (i32.const 4)))) +)wat"; + +} // namespace + +// `preflightEscrowWasm` takes no host, so this fixture holds none - which is the point of +// the signature, and what deriving from `WasmTest` would hide. Only a journal, to read the +// refusal out of. +struct PreflightTest : testing::Test +{ + CaptureSink sink{beast::Severity::Warning}; + + NotTEC + preflight(std::string_view wat, std::string_view funcName = escrowFunctionName) + { + return preflightEscrowWasm(assembleWat(wat), beast::Journal{sink}, funcName); + } + + NotTEC + preflightBytes(Bytes const& wasm, std::string_view funcName = escrowFunctionName) + { + return preflightEscrowWasm(wasm, beast::Journal{sink}, funcName); + } + + [[nodiscard]] std::string + logged() const + { + return sink.messages(); + } +}; + +TEST_F(PreflightTest, RunnableContractPasses) +{ + EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS); + EXPECT_TRUE(logged().empty()) << logged(); +} + +TEST_F(PreflightTest, GarbageIsRefused) +{ + EXPECT_EQ(preflightBytes(Bytes{}), temBAD_WASM); + EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM); +} + +// The engine takes wasm binaries, and text is not one. The suite writes its modules as text +// and assembles them, so this feeds the engine the very text the other tests assemble: a +// transaction's validity must not depend on whether an assembler was linked in. +TEST_F(PreflightTest, TextFormatModuleIsRefused) +{ + Bytes const text{kRunnableWat.begin(), kRunnableWat.end()}; + + EXPECT_EQ(preflightBytes(text), temBAD_WASM); + EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS) << "the same module, assembled first"; +} + +TEST_F(PreflightTest, ImportOfAnUnknownHostFunctionIsRefused) +{ + constexpr std::string_view wat = R"wat( + (module + (import "host_lib" "no_such_function" (func $f (param i32) (result i32))) + (memory (export "memory") 1) + (func (export "escrow_finish") (result i32) (call $f (i32.const 0)))) + )wat"; + + EXPECT_EQ(preflight(wat), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("no host function 'no_such_function'")); +} + +// Host functions are registered under one module name. `env` is what plain clang emits, so a +// contract built without the SDK's import attributes lands here. +TEST_F(PreflightTest, ImportFromAnotherModuleIsRefused) +{ + constexpr std::string_view wat = R"wat( + (module + (import "env" "ldgr_index" (func $f (param i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(wat), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("is not from 'host_lib'")); +} + +// A contract asking for more linear memory than the engine grants can never run, so it is +// refused before it can be escrowed. The cap itself is granted. +TEST_F(PreflightTest, MemoryPastTheCapIsRefused) +{ + constexpr std::string_view tooMuch = R"wat( + (module + (memory (export "memory") 129) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(tooMuch), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("memory: initial memory of 129 pages")); + + constexpr std::string_view atTheCap = R"wat( + (module + (memory (export "memory") 128) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(atTheCap), tesSUCCESS); +} + +// A table is allocated in full at instantiation, before any gas is charged, so an oversized +// one is refused before it can be escrowed. Screening sees only an *exported* table; the +// store's limiter is what refuses the table a contract keeps to itself. +TEST_F(PreflightTest, TablePastTheCapIsRefused) +{ + constexpr std::string_view tooMuch = R"wat( + (module + (memory (export "memory") 1) + (table (export "t") 1025 funcref) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(tooMuch), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("table: initial table of 1025 elements")); + + constexpr std::string_view atTheCap = R"wat( + (module + (memory (export "memory") 1) + (table (export "t") 1024 funcref) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(atTheCap), tesSUCCESS); +} + +TEST_F(PreflightTest, MissingEntryPointIsRefused) +{ + constexpr std::string_view wat = R"wat( + (module + (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(wat), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("no entry point 'escrow_finish'")); +} + +TEST_F(PreflightTest, EntryPointOfTheWrongTypeIsRefused) +{ + constexpr std::string_view wat = R"wat( + (module + (memory (export "memory") 1) + (func (export "escrow_finish") (result i64) (i64.const 0))) + )wat"; + + EXPECT_EQ(preflight(wat), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("has the wrong signature")); +} + +// Screening is for the entry point the caller names, as a run is: a contract screened for one +// export says nothing about another. +TEST_F(PreflightTest, EntryPointIsTheNameTheCallerGives) +{ + constexpr std::string_view wat = R"wat( + (module + (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(wat, "other"), tesSUCCESS); + EXPECT_EQ(preflight(wat), temBAD_WASM); +} + +// Every refusal is logged with the engine's own description and the TER: without it a node +// operator has a `temBAD_WASM` and no way to tell a contract author which of the three +// stages refused the module. +TEST_F(PreflightTest, RefusalNamesTheReasonAndTheTer) +{ + EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM); + + EXPECT_THAT(logged(), testing::HasSubstr("compile: ")); + EXPECT_THAT(logged(), testing::HasSubstr(transToken(temBAD_WASM))); +} + +// A module that passes screening still has to pass the run's own stages, and one that fails +// screening would have failed the run. Same modules through both entry points, so the two do +// not have to be trusted to agree. +TEST_F(PreflightTest, ScreeningAgreesWithARun) +{ + struct Case + { + std::string_view label; + std::string_view wat; + bool passes; + }; + + // clang-format off + constexpr Case cases[]{ + {.label = "a runnable contract", .wat = kRunnableWat, .passes = true}, + {.label = "an unknown host function", + .wat = R"wat((module (import "host_lib" "nope" (func $f (result i32))) + (memory (export "memory") 1) + (func (export "escrow_finish") (result i32) (call $f))))wat", + .passes = false}, + {.label = "no entry point", + .wat = R"wat((module (memory (export "memory") 1) + (func (export "other") (result i32) (i32.const 0))))wat", + .passes = false}, + }; + // clang-format on + + for (auto const& [label, wat, passes] : cases) + { + auto const screened = preflight(wat); + EXPECT_EQ(isTesSuccess(screened), passes) << label; + + // The run's own verdict on the same bytes. A refused module must not reach the + // contract's first instruction; an accepted one must get past the entry-point + // lookup, whatever it then does. + testing::StrictMock host{beast::Journal{sink}}; + EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true)); + EXPECT_CALL(host, getLedgerSqn()).WillRepeatedly(testing::Return(7u)); + + auto const ran = runEscrowWasm(assembleWat(wat), host, 100'000); + EXPECT_EQ(ran.has_value(), passes) << label; + } +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/WasmFixture.h b/src/tests/libxrpl/tx/wasm/WasmFixture.h new file mode 100644 index 0000000000..662752806e --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/WasmFixture.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +// Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test that +// holds it. +// +// A free function because not every wasm test needs a host: `preflightEscrowWasm` takes none, +// so its fixture derives from `testing::Test` rather than from `WasmTest`. +inline Bytes +assembleWat(std::string_view wat) +{ + auto const wasm = rs::wasm_testkit::compile_wat(rust::Str{wat.data(), wat.size()}); + return Bytes{wasm.begin(), wasm.end()}; +} + +// Base for every wasm test that runs a contract: a mocked host whose log is captured, and one +// way into the engine. +// +// Modules are written as WebAssembly text and assembled by `assembleWat`. The assembler is in +// a test-only crate: the engine itself refuses text +// (`the_vm_refuses_a_text_format_module`), because a text assembler on the consensus path +// would make a transaction's validity a build flag. +struct WasmTest : testing::Test +{ + // Enough for every module here to run to completion; a test about budgets passes its own. + static constexpr std::int64_t kAmpleGas = 100'000; + + // Keeps what a run logged. The host's default journal is a null sink, which would let a + // swallowed condition pass a test that only checks the TER. + CaptureSink sink{beast::Severity::Warning}; + + // Strict: a host call no test asked for is a failure, not a warning. These modules import + // exactly what they mean to exercise, so an unplanned call means the engine reached for + // something on its own — which is the kind of surprise a test suite exists to catch. + testing::StrictMock host{beast::Journal{sink}}; + + WasmTest() + { + // `runEscrowWasm` asks every run whether the host is clean, so under a strict mock + // every test would have to say so. Declared once here, and any number of times + // (including none, for the runs refused before the engine is reached). A test that + // cares says otherwise and its own expectation wins. + EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true)); + } + + static Bytes + assemble(std::string_view wat) + { + return assembleWat(wat); + } + + std::expected + run(std::string_view wat, + std::int64_t gas = kAmpleGas, + std::string_view entryPoint = escrowFunctionName) + { + return runEscrowWasm(assemble(wat), host, gas, entryPoint); + } + + std::expected + runBytes( + Bytes const& wasm, + std::int64_t gas = kAmpleGas, + std::string_view entryPoint = escrowFunctionName) + { + return runEscrowWasm(wasm, host, gas, entryPoint); + } + + [[nodiscard]] std::string + logged() const + { + return sink.messages(); + } +}; + +// Base for the per-host-function fixtures. Each derives, supplies the module that exercises +// its own import, and runs it through `callHost()` — so a test says only what the host was +// asked and what came back. +struct HostCallTest : WasmTest +{ + // The module under test. One import, one `escrow_finish` that calls it. + [[nodiscard]] virtual std::string + wat() const = 0; + + std::expected + callHost(std::string_view entryPoint = escrowFunctionName) + { + return run(wat(), kAmpleGas, entryPoint); + } + + // The contract's return value, which for these modules is what the host answered — or + // its negative error code. Fails the test if the run did not complete. + std::int32_t + hostAnswer(std::string_view entryPoint = escrowFunctionName) + { + auto const outcome = callHost(entryPoint); + if (!outcome) + { + ADD_FAILURE() << "the run did not complete: " << transToken(outcome.error().ter) + << "; logged: " << logged(); + return 0; + } + return outcome->result; + } +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/WasmVM.cpp b/src/tests/libxrpl/tx/wasm/WasmVM.cpp new file mode 100644 index 0000000000..80e5eb7366 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/WasmVM.cpp @@ -0,0 +1,339 @@ +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +namespace { + +// One module with an export per way a run can end. Kept together because these are properties +// of the engine rather than of any host function: the only import is there so the +// out-of-gas and no-memory cases have a host call to fail in. +constexpr std::string_view kEngineWat = R"wat( +(module + (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32))) + (memory (export "memory") 1) + + (func (export "escrow_finish") (result i32) (i32.const 5)) + + (func (export "calls_the_host") (result i32) + (call $ldgr_index (i32.const 0) (i32.const 4))) + + (func (export "traps") (result i32) unreachable) + + (func (export "never_returns") (result i32) (loop (br 0)) (i32.const 0)) + + (func (export "wrong_signature") (param i32) (result i32) (local.get 0)) + + (global (export "not_a_function") i32 (i32.const 0))) +)wat"; + +// The same host call with no memory exported, so the engine has nothing to resolve a byte +// region against. +constexpr std::string_view kNoMemoryWat = R"wat( +(module + (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32))) + (func (export "escrow_finish") (result i32) + (call $ldgr_index (i32.const 0) (i32.const 4)))) +)wat"; + +} // namespace + +class WasmVMTest : public WasmTest +{ +}; + +TEST_F(WasmVMTest, ContractReturnValueReachesCaller) +{ + auto const outcome = run(kEngineWat); + + ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter); + EXPECT_EQ(outcome->result, 5); + EXPECT_GT(outcome->cost, 0) << "running any instruction costs gas"; + EXPECT_LT(outcome->cost, kAmpleGas); +} + +TEST_F(WasmVMTest, GuestTrapIsChargedAsContractFault) +{ + auto const outcome = run(kEngineWat, kAmpleGas, "traps"); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING); + ASSERT_TRUE(outcome.error().cost.has_value()); + EXPECT_GT(*outcome.error().cost, 0); // NOLINT(bugprone-unchecked-optional-access) +} + +TEST_F(WasmVMTest, NonTerminatingContractSpendsWholeBudget) +{ + auto const outcome = run(kEngineWat, kAmpleGas, "never_returns"); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS); + ASSERT_TRUE(outcome.error().cost.has_value()); + EXPECT_EQ(*outcome.error().cost, kAmpleGas); // NOLINT(bugprone-unchecked-optional-access) +} + +// A budget too small to reach the first host charge is still out of gas, whatever the engine +// can account for by then. +TEST_F(WasmVMTest, BudgetTooSmallToRunIsOutOfGas) +{ + auto const outcome = run(kEngineWat, 1, "calls_the_host"); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS); + EXPECT_TRUE(outcome.error().cost.has_value()); +} + +// A host call needs a memory to resolve its byte regions against, and the export is not +// optional for a contract that makes one. +TEST_F(WasmVMTest, HostCallWithNoExportedMemoryFails) +{ + auto const outcome = run(kNoMemoryWat); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING); + EXPECT_TRUE(outcome.error().cost.has_value()); +} + +// A module that will not instantiate is the contract's fault and is charged, not the node's. +// Screening does not see every way this happens - a linear memory the module keeps to itself +// is absent from its exports - so such a module can pass preflight and still be refused here. +TEST_F(WasmVMTest, ModuleThatWillNotInstantiateIsChargedToTheContract) +{ + // 129 pages, not exported, so nothing outside the module declares it. + static constexpr std::string_view wat = R"wat( + (module + (memory 129) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflightEscrowWasm(assembleWat(wat), beast::Journal{sink}), tesSUCCESS) + << "screening cannot see an unexported memory"; + + auto const outcome = run(wat); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING); + EXPECT_TRUE(outcome.error().cost.has_value()); +} + +// A start section is guest code, so a trap in one is the contract's fault wherever it +// happens - charged for what it burned, rather than reported as a module the node should +// have screened. +TEST_F(WasmVMTest, TrappingStartSectionIsChargedToTheContract) +{ + static constexpr std::string_view wat = R"wat( + (module + (memory (export "memory") 1) + (func $init (unreachable)) + (start $init) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + auto const outcome = run(wat); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING); + ASSERT_TRUE(outcome.error().cost.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_GT(*outcome.error().cost, 0) << "the start section's instructions are metered"; +} + +// Preflight is meant to refuse these with `temBAD_WASM`; reaching apply means the screening +// did not happen, which is the node's fault and not the transaction's. +TEST_F(WasmVMTest, UnrunnableModuleIsNodeSideFault) +{ + struct Case + { + char const* what; + Bytes code; + std::string_view entryPoint; + }; + std::array const cases = { + Case{ + .what = "not wasm at all", .code = Bytes{0, 1, 2, 3}, .entryPoint = escrowFunctionName}, + Case{.what = "empty", .code = Bytes{}, .entryPoint = escrowFunctionName}, + Case{ + .what = "no such export", .code = assemble(kEngineWat), .entryPoint = "no_such_export"}, + Case{ + .what = "export is not a function", + .code = assemble(kEngineWat), + .entryPoint = "not_a_function"}, + Case{ + .what = "export takes a parameter", + .code = assemble(kEngineWat), + .entryPoint = "wrong_signature"}, + }; + + for (auto const& c : cases) + { + auto const outcome = runBytes(c.code, kAmpleGas, c.entryPoint); + + ASSERT_FALSE(outcome.has_value()) << c.what; + EXPECT_EQ(outcome.error().ter, tecINTERNAL) << c.what; + EXPECT_FALSE(outcome.error().cost.has_value()) << c.what; + } +} + +// wasmi's `wat` feature would make `Module::new` accept text as readily as binary, which would +// put an assembler on the consensus path and make a module's validity a build flag. The +// engine turns that feature off; this is the guest-side proof, using the very text the rest +// of this file assembles. +TEST_F(WasmVMTest, TextFormatModuleIsRejected) +{ + Bytes const text{kEngineWat.begin(), kEngineWat.end()}; + + auto const outcome = runBytes(text); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecINTERNAL); +} + +// A soft host error is the contract's to interpret, so its code has to cross the boundary +// unchanged: the engine must not renumber it, clamp it, or turn it into a failure of its own. +// +// Over the whole of `HostFunctionError` rather than a sample, because `HostFunctionError` and +// the Rust ABI's `HostError` are two hand-maintained lists of the same wire numbers: -1 +// through -20 have to mean the same thing on both sides, and this is the test that notices if +// either side renumbers. +// +// The two exclusions are the codes the Rust engine converts into a fault, which stops the run +// instead of reaching the guest: -1 `Unimplemented` and -14 `NoMemExported`. Both say the call +// was not served at all. +TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged) +{ + static constexpr HostFunctionError kSoftErrors[] = { + HostFunctionError::FieldNotFound, + HostFunctionError::BufferTooSmall, + HostFunctionError::NoArray, + HostFunctionError::NotLeafField, + HostFunctionError::LocatorMalformed, + HostFunctionError::SlotOutRange, + HostFunctionError::SlotsFull, + HostFunctionError::EmptySlot, + HostFunctionError::LedgerObjNotFound, + HostFunctionError::OutOfTransferLimit, + HostFunctionError::DataFieldTooLarge, + HostFunctionError::PointerOutOfBounds, + HostFunctionError::InvalidParams, + HostFunctionError::InvalidAccount, + HostFunctionError::InvalidField, + HostFunctionError::IndexOutOfBounds, + HostFunctionError::FloatInputMalformed, + HostFunctionError::FloatComputationError, + }; + + auto refused = HostFunctionError::FieldNotFound; + EXPECT_CALL(host, getLedgerSqn()) + .WillRepeatedly([&refused]() -> std::expected { + return std::unexpected(refused); + }); + + for (auto const error : kSoftErrors) + { + refused = error; + + auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host"); + + ASSERT_TRUE(outcome.has_value()) << hfErrorToInt(error) << " stopped the run"; + EXPECT_EQ(outcome->result, hfErrorToInt(error)); + } +} + +// The counterpart: a fatal code stops the run rather than reaching the contract, so a host +// that cannot serve a call cannot be second-guessed by the contract. +TEST_F(WasmVMTest, FatalHostErrorStopsRun) +{ + auto refused = HostFunctionError::Unimplemented; + EXPECT_CALL(host, getLedgerSqn()) + .WillRepeatedly([&refused]() -> std::expected { + return std::unexpected(refused); + }); + + for (auto const error : + {HostFunctionError::InternalFatal, + HostFunctionError::Unimplemented, + HostFunctionError::NoMemExported}) + { + refused = error; + + auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host"); + + ASSERT_FALSE(outcome.has_value()) << hfErrorToInt(error) << " reached the contract"; + } +} + +// The point of the bridge's C++ half: an exception must not reach the Rust frames that called +// the host, and must not take the node with it. +TEST_F(WasmVMTest, ThrowingHostFunctionBecomesInternal) +{ + EXPECT_CALL(host, getLedgerSqn()) + .WillOnce([]() -> std::expected { + Throw("the ledger came apart"); + }); + + auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host"); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecINTERNAL); + EXPECT_FALSE(outcome.error().cost.has_value()) << "a node-side fault charges nothing"; + // Caught is not swallowed: the condition has to be recorded, and the line has to name the + // call it came out of. + EXPECT_THAT(logged(), testing::HasSubstr("the ledger came apart")); + EXPECT_THAT(logged(), testing::HasSubstr("getLedgerSqn")); +} + +struct WasmVMDeathTest : WasmVMTest +{ +}; + +// No gas is not a small budget, it is a malformed transaction — refused before the engine is +// asked to run anything. +TEST_F(WasmVMDeathTest, NoGasIsRefusedAsMalformedRatherThanRun) +{ + for (auto const gas : {std::int64_t{0}, std::int64_t{-1}}) + { + EXPECT_DEBUG_DEATH( + { + auto const outcome = run(kEngineWat, gas); + + ASSERT_FALSE(outcome.has_value()) << "gas: " << gas; + EXPECT_EQ(outcome.error().ter, temBAD_AMOUNT) << "gas: " << gas; + EXPECT_FALSE(outcome.error().cost.has_value()) << "gas: " << gas; + }, + "gas limit is positive"); + } +} + +// The host caches the current ledger object, the slot table and the contract's data for the +// length of one run, so a reused one would answer a later contract out of an earlier +// contract's state. +TEST_F(WasmVMDeathTest, DirtyHostIsRefusedBeforeContractRuns) +{ + EXPECT_DEBUG_DEATH( + { + EXPECT_CALL(host, checkSelf()).WillOnce(testing::Return(false)); + auto const outcome = run(kEngineWat); + + ASSERT_FALSE(outcome.has_value()); + EXPECT_EQ(outcome.error().ter, tecINTERNAL); + EXPECT_FALSE(outcome.error().cost.has_value()); + EXPECT_THAT(logged(), testing::HasSubstr("not clean")); + }, + "host functions not clean before the run"); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp new file mode 100644 index 0000000000..143c20fa96 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp @@ -0,0 +1,74 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// home_le_field — a scalar field code in, bytes out. +struct CurrentLedgerObjFieldCall : HostCallTest +{ + // The field code the guest asks for. A real one, so the shim's `SField` lookup has + // something to find. + std::int32_t fieldCode = sfBalance.getCode(); + + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "escrow_finish") (result i32) + (call $home_le_field (i32.const )wat"} + + std::to_string(fieldCode) + R"wat() (i32.const 0) (i32.const 32)))) +)wat"; + } +}; + +// The shim turns the guest's `i32` into the `SField` the C++ interface takes; asserting on +// the argument is what pins that translation rather than assuming it. +TEST_F(CurrentLedgerObjFieldCall, FieldCodeBecomesSFieldHostIsAskedFor) +{ + EXPECT_CALL(host, getCurrentLedgerObjField(testing::Ref(sfBalance))) + .WillOnce(Return(Bytes{1, 2, 3})); + + EXPECT_EQ(hostAnswer(), 3) << "the length the host reported"; +} + +TEST_F(CurrentLedgerObjFieldCall, UnknownFieldCodeIsRefusedWithoutAskingHost) +{ + fieldCode = 0x7fff'0000; // a type nothing is registered under + EXPECT_CALL(host, getCurrentLedgerObjField).Times(0); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidField)); +} + +TEST_F(CurrentLedgerObjFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getCurrentLedgerObjField) + .WillOnce(Return(std::unexpected(HostFunctionError::FieldNotFound))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::FieldNotFound)); +} + +// The field cap bounds the status, not just the bytes: a host reporting a length past +// `kMaxWasmDataLength` is too large whatever the guest's buffer was. +TEST_F(CurrentLedgerObjFieldCall, FieldPastProtocolCapIsTooLarge) +{ + EXPECT_CALL(host, getCurrentLedgerObjField) + .WillOnce(Return(Bytes(kMaxWasmDataLength + 1, 0xab))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::DataFieldTooLarge)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp b/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp new file mode 100644 index 0000000000..d4cec43616 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp @@ -0,0 +1,69 @@ +#include + +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// ldgr_index — no input, one scalar output. +struct LedgerSqnCall : HostCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; Four bytes is what the value needs. Returns what the host wrote, or its error code. + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $ldgr_index (i32.const 0) (i32.const 4))) + (select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0)))) + + ;; Two bytes is not enough for the value. Returns the host's code when memory is still + ;; zero, or 1 if anything was written into it - so a refused write is visibly a refusal + ;; and not a truncation. + (func (export "into_two_bytes") (result i32) + (local $n i32) + (local.set $n (call $ldgr_index (i32.const 0) (i32.const 2))) + (select (local.get $n) (i32.const 1) (i32.eqz (i32.load (i32.const 0)))))) +)wat"}; + } +}; + +TEST_F(LedgerSqnCall, SequenceReachesGuestAsFourLittleEndianBytes) +{ + EXPECT_CALL(host, getLedgerSqn()).WillOnce(Return(0x01020304u)); + + // Read back with `i32.load`, which is little-endian by the wasm spec — so the value + // arriving intact is the byte order being right. + EXPECT_EQ(hostAnswer(), 0x01020304); +} + +TEST_F(LedgerSqnCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getLedgerSqn()) + .WillOnce(Return(std::unexpected(HostFunctionError::LedgerObjNotFound))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::LedgerObjNotFound)); +} + +// The engine decides the fit, not the host: the host is never told the guest's capacity, it +// reports the value's true length and the engine turns a length past the buffer into +// `BufferTooSmall` — with nothing written. +TEST_F(LedgerSqnCall, BufferTooSmallIsRefusedWholeNotTruncated) +{ + EXPECT_CALL(host, getLedgerSqn()).WillOnce(Return(0x01020304u)); + + EXPECT_EQ(hostAnswer("into_two_bytes"), hfErrorToInt(HostFunctionError::BufferTooSmall)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp b/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp new file mode 100644 index 0000000000..3653a6e931 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// sha512_half — bytes in and bytes out, the shape that needs the engine's output buffer. +struct Sha512HalfCall : HostCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 64) "abc") + + ;; Hashes the three bytes at 64 into the 32 at 0, then returns the first four bytes of the + ;; digest so the answer is shown to have arrived, not just been counted. + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32))) + (select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0)))) + + ;; Reports the length the host gave, for the cases where the digest itself is not the point. + (func (export "digest_length") (result i32) + (call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32)))) +)wat"}; + } + + // A digest whose first four bytes are distinctive, so the load below cannot pass by + // accident. + static Hash + digest() + { + Hash value; + value.begin()[0] = 0x0d; + value.begin()[1] = 0x0c; + value.begin()[2] = 0x0b; + value.begin()[3] = 0x0a; + return value; + } +}; + +// Both directions in one call: the guest's bytes reach the host borrowed from its memory, and +// the answer comes back into the same memory through the engine's buffer. +TEST_F(Sha512HalfCall, GuestBytesReachHostAndDigestComesBack) +{ + EXPECT_CALL(host, computeSha512HalfHash(BytesAre("abc"))).WillOnce(Return(digest())); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d) << "the digest's first four bytes, little-endian"; +} + +TEST_F(Sha512HalfCall, DigestIsThirtyTwoBytes) +{ + EXPECT_CALL(host, computeSha512HalfHash).WillOnce(Return(digest())); + + EXPECT_EQ(hostAnswer("digest_length"), 32); +} + +TEST_F(Sha512HalfCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, computeSha512HalfHash) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidParams))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp b/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp new file mode 100644 index 0000000000..e7dd854d22 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp @@ -0,0 +1,220 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +// For `TraceDataType`: declared in the cxx bridge, defined in the header it generates. +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +namespace { + +// Bytes as a WAT data segment's contents. Hex-escaped throughout, so a buffer needs no +// thought about which of its bytes the text format would otherwise read. +std::string +watBytes(Bytes const& bytes) +{ + std::string escaped; + escaped.reserve(bytes.size() * 4); + for (auto const byte : bytes) + escaped += std::format("\\{:02x}", byte); + return escaped; +} + +Bytes +serialized(STAmount const& amount) +{ + Serializer s; + amount.add(s); + return s.getData(); +} + +} // namespace + +// trace — a message, a data type, and a buffer holding what that type says. One import +// covers every rendering, so what a test varies is the type rather than the function. +// +// The buffer arrives as bytes and leaves as text: `HostContext` renders it, and the host is +// handed the finished line. So a test says which renderer the type selected. +struct TraceCall : HostCallTest +{ + static constexpr std::int32_t kDataAt = 64; + + // What the guest passes. `typeCode` rather than a `TraceDataType` so a test can send a + // code that names no type, which is the guest's to get wrong. + std::int32_t typeCode{static_cast(TraceDataType::AsText)}; + Bytes data; + + void + traces(TraceDataType type, Bytes bytes) + { + typeCode = static_cast(type); + data = std::move(bytes); + } + + void + traces(TraceDataType type, std::string_view text) + { + traces(type, Bytes{text.begin(), text.end()}); + } + + [[nodiscard]] std::string + wat() const override + { + // {0} data offset, {1} the data itself, {2} the type under test, {3} its length, + // {4} a type the constant modules can name, {5} the data cap. + return std::format( + R"wat( +(module + (import "host_lib" "trace" (func $trace (param i32 i32 i32 i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "note") + (data (i32.const {0}) "{1}") + + (func (export "escrow_finish") (result i32) + (call $trace (i32.const 0) (i32.const 4) (i32.const {2}) (i32.const {0}) (i32.const {3})) + (i32.const 1)) + + (func (export "unnamed_type") (result i32) + (call $trace (i32.const 0) (i32.const 4) (i32.const 0) (i32.const {0}) (i32.const 0)) + (i32.const 1)) + + (func (export "past_memory") (result i32) + (call $trace (i32.const 0) (i32.const 4) (i32.const {4}) (i32.const 65536) (i32.const 1)) + (i32.const 1)) + + (func (export "too_long") (result i32) + (call $trace (i32.const 0) (i32.const 4) (i32.const {4}) (i32.const {0}) (i32.const {5})) + (i32.const 1))) +)wat", + kDataAt, + watBytes(data), + typeCode, + data.size(), + static_cast(TraceDataType::AsHex), + kMaxWasmDataLength); + } + + // The line the host was handed, for a run that is expected to reach it. + void + expectTraced(std::string_view text) + { + EXPECT_CALL(host, trace(std::string_view("note"), text)); + + EXPECT_EQ(hostAnswer(), 1) << "the contract runs on past its trace"; + } +}; + +// The eight-byte types are the pair worth naming: the same bytes, and the type is the whole +// difference between the two readings. +TEST_F(TraceCall, Int64ReadsTheBufferSigned) +{ + traces(TraceDataType::Int64, Bytes(8, 0xff)); + + expectTraced("-1"); +} + +TEST_F(TraceCall, Uint64ReadsTheSameBufferUnsigned) +{ + traces(TraceDataType::Uint64, Bytes(8, 0xff)); + + expectTraced("18446744073709551615"); +} + +TEST_F(TraceCall, AsTextTakesTheBufferVerbatim) +{ + traces(TraceDataType::AsText, "hello"); + + expectTraced("hello"); +} + +TEST_F(TraceCall, AsHexEncodesTheBuffer) +{ + traces(TraceDataType::AsHex, Bytes{0x07, 0x08, 0xff}); + + expectTraced("0708FF"); +} + +// The zero account, so the expectation is the well-known base58 rather than a rendering of +// whatever the renderer happened to do. +TEST_F(TraceCall, AccountIsBase58) +{ + traces(TraceDataType::Account, Bytes(AccountID::size(), 0)); + + expectTraced("rrrrrrrrrrrrrrrrrrrrrhoLvTp"); +} + +TEST_F(TraceCall, AmountCarriesItsAssetIntoTheText) +{ + traces(TraceDataType::Amount, serialized(STAmount{XRPAmount{1000}})); + + expectTraced("1000/XRP"); +} + +TEST_F(TraceCall, XfloatIsDecodedToItsValue) +{ + auto const encoded = wasm_float::floatFromIntImpl( + 42, static_cast(Number::RoundingMode::ToNearest)); + ASSERT_TRUE(encoded.has_value()); + traces(TraceDataType::Xfloat, *encoded); + + expectTraced("42"); +} + +// The width is part of the type, and a buffer that is not it holds no value to print. The +// contract is not told: a trace answers nothing at all. +TEST_F(TraceCall, ABufferOfTheWrongWidthIsDropped) +{ + traces(TraceDataType::Int64, Bytes(4, 0xff)); + + EXPECT_CALL(host, trace).Times(0); + EXPECT_EQ(hostAnswer(), 1); +} + +// `STAmount`'s deserializer rejects this by throwing, which must not escape into the run. +TEST_F(TraceCall, AMalformedAmountIsDroppedRatherThanThrown) +{ + traces(TraceDataType::Amount, Bytes(3, 0xff)); + + EXPECT_CALL(host, trace).Times(0); + EXPECT_EQ(hostAnswer(), 1); +} + +// Zero is the code a guest sends by omission, which is why no type carries it. +TEST_F(TraceCall, ACodeThatNamesNoTypeIsDropped) +{ + EXPECT_CALL(host, trace).Times(0); + + EXPECT_EQ(hostAnswer("unnamed_type"), 1); +} + +// The memory policy every input region is held to, on the one call that cannot report it. +TEST_F(TraceCall, ARegionPastMemoryIsDropped) +{ + EXPECT_CALL(host, trace).Times(0); + + EXPECT_EQ(hostAnswer("past_memory"), 1); +} + +TEST_F(TraceCall, AMessageAndBufferPastTheDataCapAreDropped) +{ + EXPECT_CALL(host, trace).Times(0); + + EXPECT_EQ(hostAnswer("too_long"), 1); +} + +} // namespace xrpl::test