diff --git a/.codecov.yml b/.codecov.yml index cd52e2604d4..4268758e442 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,10 +1,32 @@ codecov: require_ci_to_pass: true + # The C++ and Rust uploads land minutes apart; without this gate Codecov + # publishes a near-zero total from whichever one arrives first. + notify: + after_n_builds: 2 + wait_for_ci: true comment: behavior: default layout: reach,diff,flags,tree,reach - show_carryforward_flags: false + show_carryforward_flags: true + after_n_builds: 2 + +# C++ and Rust coverage upload from independent workflows under the `cpp` and +# `rust` flags; carryforward keeps one language's total when only the other reran. +flag_management: + default_rules: + carryforward: true + individual_flags: + - name: cpp + carryforward: true + paths: + - include/ + - src/ + - name: rust + carryforward: true + paths: + - crates/ coverage: range: "70..85" diff --git a/.cspell.config.yaml b/.cspell.config.yaml index b014b08eb2d..95b457272c0 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,7 +7,6 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy - - src/test/app/wasm_fixtures/*.c language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true @@ -105,6 +104,7 @@ words: - deleteme - demultiplexer - deserializaton + - desugars - desync - desynced - determ @@ -130,6 +130,7 @@ words: - gcov - gcovr - ghead + - gmock - Gnutella - godexsoft - gpgcheck @@ -139,7 +140,9 @@ words: - hwaddress - hwrap - ifndef + - impls - inequation + - initialiser - insuf - insuff - invasively @@ -247,6 +250,7 @@ words: - pyparsing - qalloc - qbsprofile + - qself - queuable - Raphson - rcflags @@ -311,6 +315,7 @@ words: - summands - superpeer - superpeers + - Swatinem - takergets - takerpays - ters @@ -339,6 +344,7 @@ words: - unflatten - unfund - unimpair + - unmetered - unroutable - unscalable - unserviced @@ -359,6 +365,7 @@ words: - vfalco - vinnie - wasmi + - Werror - wextra - wptr - writeme diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fcac44c44c5..1ece5793286 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,3 +19,20 @@ updates: github-actions: patterns: - "*" + + - package-ecosystem: cargo + directory: /crates + schedule: + interval: weekly + day: monday + time: "04:00" + timezone: Etc/GMT + commit-message: + prefix: "ci: [DEPENDABOT] " + target-branch: develop + open-pull-requests-limit: 10 + # Bundle all Rust dependency bumps into a single PR per run to reduce noise. + groups: + rust-dependencies: + patterns: + - "*" diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml new file mode 100644 index 00000000000..2205018214b --- /dev/null +++ b/.github/workflows/cargo-audit.yml @@ -0,0 +1,84 @@ +# This workflow audits the Rust dependencies in crates/ for known security +# advisories using cargo-audit. It runs on a weekly schedule, whenever the +# dependency graph changes (Cargo.lock / Cargo.toml), and on demand. On a +# scheduled run, a failure opens a tracking issue (matching the clang-tidy +# workflow's behavior); on push/PR it simply fails the check. +name: Cargo audit + +on: + schedule: + # 06:32 UTC every Monday. + - cron: "32 6 * * 1" + push: + branches: + - "develop" + - "release*" + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + pull_request: + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-debian:sha-2e25435 + permissions: + contents: read + # Needed to open an issue on scheduled failures. + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Run cargo audit + id: audit + continue-on-error: true + run: | + set -o pipefail + cargo audit | tee /tmp/cargo-audit.txt + + - name: Prepare issue body + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + run: | + { + echo "## \`cargo audit\` found advisories" + echo + echo '```' + cat /tmp/cargo-audit.txt + echo '```' + echo + echo "---" + echo "*This issue was automatically created by the cargo-audit workflow.*" + } >/tmp/cargo-audit-issue.md + + - name: Create issue + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + uses: XRPLF/actions/create-issue@2b8bc36af85b88bca0dd7bfac2e2dc05f94ad712 + with: + title: "cargo audit found vulnerabilities" + body_file: /tmp/cargo-audit-issue.md + labels: "Bug,Security" + + - name: Fail if advisories were found + if: ${{ steps.audit.outcome != 'success' }} + run: | + echo "cargo audit found advisories!" + exit 1 diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 0a4e4b1f49c..b3ac3a28065 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -85,6 +85,7 @@ jobs: .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml .github/workflows/reusable-package.yml + .github/workflows/reusable-rust.yml .github/workflows/reusable-strategy-matrix.yml .github/workflows/reusable-test.yml .github/workflows/reusable-upload-recipe.yml @@ -95,6 +96,7 @@ jobs: cfg/** cmake/** conan/** + crates/** external/** include/** src/** @@ -168,6 +170,13 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + needs: should-run + if: ${{ needs.should-run.outputs.go == 'true' }} + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + package: needs: [should-run, build-test] # Packaging consumes the debian/rhel release binaries, which are only built @@ -211,6 +220,7 @@ jobs: - check-rename - clang-tidy - build-test + - rust - package - upload-recipe - notify-clio diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 73f918d5287..7a1a359cdcf 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -23,6 +23,7 @@ on: - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" - ".github/workflows/reusable-package.yml" + - ".github/workflows/reusable-rust.yml" - ".github/workflows/reusable-strategy-matrix.yml" - ".github/workflows/reusable-test.yml" - ".github/workflows/reusable-upload-recipe.yml" @@ -33,6 +34,7 @@ on: - "cfg/**" - "cmake/**" - "conan/**" + - "crates/**" - "external/**" - "include/**" - "src/**" @@ -96,6 +98,11 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + upload-recipe: needs: build-test # Only run when pushing to the develop branch. diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index d8550efc4ce..ecdbbbbe86c 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -276,7 +276,7 @@ jobs: working-directory: ${{ env.BUILD_DIR }} run: | ldd ./xrpld - if [ "$(ldd ./xrpld | grep -E '(libstdc\+\+|libgcc)' | wc -l)" -eq 0 ]; then + if [ "$(ldd ./xrpld | grep -E '(libstdc\+\+)' | wc -l)" -eq 0 ]; then echo 'The binary is statically linked.' else echo 'The binary is dynamically linked.' @@ -289,6 +289,14 @@ jobs: run: | ./xrpld --version | grep libvoidstar + - name: Run Rust tests + if: ${{ !inputs.build_only }} + working-directory: crates + # `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' || '' }} + - name: Run the separate tests if: ${{ !inputs.build_only }} working-directory: ${{ runner.os == 'Windows' && format('{0}/{1}', env.BUILD_DIR, inputs.build_type) || env.BUILD_DIR }} @@ -394,6 +402,7 @@ jobs: disable_telem: true fail_ci_if_error: true files: ${{ env.BUILD_DIR }}/coverage.xml + flags: cpp plugins: noop token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index f1fdc0569ad..983972e8c15 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -88,6 +88,12 @@ jobs: run: | ninja -j ${{ steps.nproc.outputs.nproc }} xrpl.libpb + # clang-tidy needs cxxbridge headers generated from Rust crates + - name: Build xrpl_crates + working-directory: ${{ env.BUILD_DIR }} + run: | + ninja -j ${{ steps.nproc.outputs.nproc }} xrpl_crates + - name: Run clang tidy id: run_clang_tidy continue-on-error: true diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml new file mode 100644 index 00000000000..33726b2425a --- /dev/null +++ b/.github/workflows/reusable-rust.yml @@ -0,0 +1,86 @@ +# Clippy, coverage and documentation for the Rust crates in crates/. Each runs +# as an independent job on a GitHub-hosted runner, but inside the same container +# image used to build the crates in the C++/Corrosion path, so the toolchain +# (and therefore the lints, coverage instrumentation and the cargo cache) matches +# what production builds use. +# +# Rust unit tests are deliberately NOT run here. They run as part of the C++ +# build (reusable-build-test-config.yml), which already compiles the crates on a +# self-hosted runner, so there is no need to provision a toolchain again. +name: Rust + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + description: "The Codecov token to use for uploading coverage reports." + required: false + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + clippy: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: crates + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + coverage: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: crates + + - name: Generate coverage report + run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info + + - name: Upload coverage report + if: ${{ github.repository == 'XRPLF/rippled' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + disable_telem: true + fail_ci_if_error: true + files: crates/lcov.info + flags: rust + plugins: noop + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + doc: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: crates + + - name: Build documentation + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --workspace --no-deps --all-features --locked diff --git a/.gitignore b/.gitignore index 13b59a7e2ce..c5af8eb7b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ target/ # clangd cache /.cache + +# Rust build directory +crates/target diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d339cb29ed5..e5e69759fd9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,15 @@ repos: types_or: [c++, c, proto] exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/ + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --manifest-path crates/Cargo.toml --all + language: system + types: [rust] + pass_filenames: false # rustfmt formats the whole workspace + - repo: https://github.com/BlankSpruce/gersemi-pre-commit rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7 hooks: diff --git a/CMakeLists.txt b/CMakeLists.txt index 4765a5a7081..e7cb13e96af 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( @@ -159,6 +158,7 @@ if(coverage) include(XrplCov) endif() +add_subdirectory(crates) include(XrplCore) include(XrplProtocolAutogen) include(XrplInstall) diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 7c1eb5688a1..feea6f2a542 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -67,7 +67,6 @@ target_link_libraries( Xrpl::opts Xrpl::syslibs secp256k1::secp256k1 - wasmi::wasmi xrpl.libpb xxHash::xxhash $<$:antithesis-sdk-cpp> @@ -206,7 +205,17 @@ target_link_libraries( ) add_module(xrpl tx) -target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger) +# The wasm engine is a Rust crate reached over cxx: the bridge target supplies the +# generated `lib.h` and `rust/cxx.h` that `tx/wasm` compiles against, and the Rust +# static library everything downstream links. PUBLIC because the include path travels +# with the module's own public headers. +target_link_libraries( + xrpl.libxrpl.tx + PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge +) +# Those headers do not exist at configure time, and the header-verification target +# compiles this module's headers on their own, so both need the crates built first. +add_dependencies(xrpl.libxrpl.tx xrpl_crates) add_module(xrpl consensus) target_link_libraries( diff --git a/conan.lock b/conan.lock index 9bedf3ac646..5b01ffbf763 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 77be8a24c57..2742405b6cd 100644 --- a/conanfile.py +++ b/conanfile.py @@ -35,7 +35,6 @@ class Xrpl(ConanFile): "nudb/2.0.9", "openssl/3.6.3", "soci/4.0.3", - "wasmi/1.0.9", "zlib/1.3.2", ] @@ -224,7 +223,6 @@ def package_info(self): "soci::soci", "secp256k1::secp256k1", "sqlite3::sqlite", - "wasmi::wasmi", "xxhash::xxhash", "zlib::zlib", ] diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml new file mode 100644 index 00000000000..57da03580bf --- /dev/null +++ b/crates/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-args=-static-libgcc"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt new file mode 100644 index 00000000000..5ba2714a4e1 --- /dev/null +++ b/crates/CMakeLists.txt @@ -0,0 +1,59 @@ +set(CORROSION_VERSION 0.6.1) + +find_package(Corrosion ${CORROSION_VERSION} QUIET) +if(NOT Corrosion_FOUND) + include(FetchContent) + FetchContent_Declare( + Corrosion + GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git + GIT_TAG v${CORROSION_VERSION} + ) + FetchContent_MakeAvailable(Corrosion) +endif() + +corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml) + +file( + WRITE "${CMAKE_CURRENT_BINARY_DIR}/.clang-tidy" + "# Auto-generated by crates/CMakeLists.txt. Do not edit.\n" + "# Neutralizes clang-tidy for corrosion/cxxbridge-generated C++.\n" + "# One check kept enabled to avoid clang-tidy's \"no checks enabled\" error.\n" + "Checks: '-*,google-readability-todo'\n" + "WarningsAsErrors: ''\n" + "HeaderFilterRegex: ''\n" + "InheritParentConfig: false\n" +) + +# Umbrella target that aggregates all crate-generated code (cxxbridge headers, +# etc.). Build this before running clang-tidy so generated headers are present. +add_custom_target(xrpl_crates) + +# add_xrpl_crate( CRATE FILES ...) Creates a cxxbridge +# target _cxxbridge and registers it with xrpl_crates. +function(add_xrpl_crate name) + cmake_parse_arguments(ARG "" "CRATE" "FILES" ${ARGN}) + corrosion_add_cxxbridge(${name}_cxxbridge CRATE ${ARG_CRATE} FILES + ${ARG_FILES} + ) + # Generated cxxbridge headers don't exist at configure time; CMake 3.28+ + # validates INTERFACE_SOURCES on consuming targets. Clear it to skip the + # existence check — build-time ordering is enforced by the custom commands. + set_target_properties(${name}_cxxbridge PROPERTIES INTERFACE_SOURCES "") + add_dependencies(xrpl_crates ${name}_cxxbridge) +endfunction() + +add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs) + +# Test-only, and deliberately not part of xrpl_wasm_vm_ffi: it carries the `wat` assembler, +# which the engine's `wasmi default-features = false` exists to keep out of the consensus +# path. Linked from src/tests/libxrpl only, so the shipped node cannot contain it. +add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs) + +# The wasm bridge `include!`s a project header, so its generated translation unit needs +# the project's include root. Deliberately only that: a header reached from here must +# stay light enough to compile without the Boost paths this target does not get, which +# is why `HostContext.h` forward-declares `xrpl::HostFunctions` instead of including it. +target_include_directories( + xrpl_wasm_vm_ffi_cxxbridge + PRIVATE ${CMAKE_SOURCE_DIR}/include +) diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 00000000000..f118b25e849 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "shlex" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "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 new file mode 100644 index 00000000000..840d9a1149d --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +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"] } + +[workspace.package] +edition = "2024" + +[profile.release] +opt-level = 3 +overflow-checks = true +lto = true +debug = true diff --git a/crates/xrpl-host-functions-macros/Cargo.toml b/crates/xrpl-host-functions-macros/Cargo.toml new file mode 100644 index 00000000000..5b5548bec73 --- /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 expansion names `::xrpl_host_functions::HostFnSpec`, so the doctest needs the +# facade crate. 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 00000000000..82d80eb56c0 --- /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 00000000000..80761f9420b --- /dev/null +++ b/crates/xrpl-host-functions-macros/src/lib.rs @@ -0,0 +1,384 @@ +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 expansion refers to and holds the one declaration +/// block. The expansion names those types by absolute path, so a call site needs +/// `xrpl-host-functions` as a dependency but no imports from it. +/// +/// ``` +/// 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<()>; +/// } +/// +/// // A `HostFunctions` trait, holding the declarations verbatim: +/// struct Host; +/// impl HostFunctions for Host { +/// fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult { +/// out[..4].copy_from_slice(&7u32.to_le_bytes()); +/// Ok(4) +/// } +/// fn trace_num(&self, _msg: &str, _number: i64) -> HostResult<()> { Ok(()) } +/// } +/// +/// // A `HostFunctionSpec` enum carrying the ABI metadata as a `const` table: +/// assert_eq!(HostFunctionSpec::GetLedgerSqn.gas(), 60); +/// assert_eq!(HostFunctionSpec::TraceNum.wasm_name(), "trace_num"); +/// assert_eq!(HostFunctionSpec::ALL.len(), 2); +/// ``` +/// +/// 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. C++'s + /// `setData` is the reference point: it wrote only on a value that fit. + 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 00000000000..813dbc5efc3 --- /dev/null +++ b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs @@ -0,0 +1,871 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + Attribute, Expr, ExprLit, Ident, Lit, LitStr, PathArguments, ReceiverKind, ReturnType, Safety, + Signature, TraitItemFn, Type, TypePath, +}; + +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) = + string_value(&attr).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(()) +} + +fn int_value(attr: &Attribute) -> syn::Result { + match &attr.meta.require_name_value()?.value { + Expr::Lit(ExprLit { + lit: Lit::Int(int), .. + }) => { + // `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() + } + other => Err(syn::Error::new_spanned( + other, + format!("`{}` expects an integer literal", path_name(attr)), + )), + } +} + +fn string_value(attr: &Attribute) -> syn::Result { + match &attr.meta.require_name_value()?.value { + Expr::Lit(ExprLit { + lit: Lit::Str(string), + .. + }) => Ok(string.clone()), + other => Err(syn::Error::new_spanned( + other, + format!("`{}` expects a string literal", path_name(attr)), + )), + } +} + +/// 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 quote::ToTokens; + use syn::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 00000000000..c08bb7d62f5 --- /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 00000000000..80d104301b2 --- /dev/null +++ b/crates/xrpl-host-functions/src/lib.rs @@ -0,0 +1,190 @@ +//! 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`], [`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. + +#![no_std] + +// Not re-exported: the ABI is declared once, here, and this is the only call site. +use xrpl_host_functions_macros::host_functions; + +/// Declares [`HostError`] from one list: the variants, [`HostError::ALL`] and +/// [`HostError::from_code`]'s table all expand from the codes below. +/// +/// 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 below 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 { + ($($variant:ident = $code:literal,)+) => { + /// Error codes a host function may return. + /// + /// The discriminants mirror `HostFunctionError` in + /// `include/xrpl/tx/wasm/WasmCommon.h`, so a negative `i32` crossing the wasm + /// boundary means the same thing to the guest, the Rust host, and the existing + /// C++ code. The full set is kept (not just the ones the PoC uses today) to + /// preserve that shared meaning. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(i32)] + pub enum HostError { + $($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 the guest sees as the function's return code. + #[inline] + pub const fn code(self) -> i32 { + self as i32 + } + + /// Reconstruct a `HostError` from its wire code; unknown/positive values + /// map to `Internal`. + pub const fn from_code(code: i32) -> HostError { + match code { + $($code => HostError::$variant,)+ + _ => HostError::Internal, + } + } + } + }; +} + +host_errors! { + Internal = -1, + FieldNotFound = -2, + BufferTooSmall = -3, + NoArray = -4, + NotLeafField = -5, + LocatorMalformed = -6, + SlotOutRange = -7, + SlotsFull = -8, + EmptySlot = -9, + LedgerObjNotFound = -10, + Decoding = -11, + DataFieldTooLarge = -12, + PointerOutOfBounds = -13, + NoMemExported = -14, + InvalidParams = -15, + InvalidAccount = -16, + InvalidField = -17, + IndexOutOfBounds = -18, + FloatInputMalformed = -19, + FloatComputationError = -20, + NoRuntime = -21, + OutOfGas = -22, + OutOfTransferLimit = -23, +} + +/// 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; + +/// Declares [`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, + } + } + } + }; +} + +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 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 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 xrpld's `trace_proto` and the guest stdlib put 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<()>; +} 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 00000000000..32854bfd72d --- /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 00000000000..c0327f86c75 --- /dev/null +++ b/crates/xrpl-host-functions/tests/generated_abi.rs @@ -0,0 +1,190 @@ +//! Exercises what `host_functions!` generates: the trait is implementable and +//! the spec table agrees with the declarations in `src/lib.rs`. + +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()) + } + + /// 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]) + } + + 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(()) + } +} + +#[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_current_ledger_obj_field(3, &mut out), Ok(1)); + assert_eq!(out[0], 3); + 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.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), + ("home_le_field", 70), + ("sha512_half", 2000), + ("trace", 30), + ] + ); +} + +/// 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 00000000000..7815f5c1a6f --- /dev/null +++ b/crates/xrpl-host-functions/tests/host_errors.rs @@ -0,0 +1,70 @@ +//! 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::Internal, -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::Decoding, -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::NoRuntime, -21), + (HostError::OutOfGas, -22), + (HostError::OutOfTransferLimit, -23), + ] + ); +} + +/// 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 `Internal`: a host that answers something this +/// ABI does not define has failed in a way the caller cannot act on, and success is +/// not an error at all. +#[test] +fn a_code_outside_the_set_is_internal() { + let unassigned = -(HostError::ALL.len() as i32) - 1; + + for code in [unassigned, i32::MIN, 0, 1, i32::MAX] { + assert_eq!(HostError::from_code(code), HostError::Internal, "{code}"); + } +} diff --git a/crates/xrpl-wasm-testkit/Cargo.toml b/crates/xrpl-wasm-testkit/Cargo.toml new file mode 100644 index 00000000000..06c1e7c3663 --- /dev/null +++ b/crates/xrpl-wasm-testkit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "xrpl-wasm-testkit" +version = "0.1.0" +edition.workspace = true + +[lib] +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 00000000000..58d3db1885b --- /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 (review finding A5). 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 00000000000..c301eb707ae --- /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 00000000000..5c9b4e9a8c6 --- /dev/null +++ b/crates/xrpl-wasm-vm-ffi/src/lib.rs @@ -0,0 +1,656 @@ +//! The cxx bridge between the escrow wasm engine and xrpld. +//! +//! Three crossings. C++ calls `run_escrow` once per escrow finish; the engine's host +//! calls come back out through the C++ `HostContext`, which `CxxHost` presents to the +//! engine as an ordinary [`HostFunctions`] implementor. The ABI those calls speak is +//! declared once, in `xrpl-host-functions`, so neither side of this file gets to +//! restate a signature. +//! +//! `check_escrow` is the third, and it crosses in one direction only: screening a +//! module needs no host, so nothing comes back out. +//! +//! **Neither direction 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 reports failure as a negative `HostError` code. 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 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 answers with a code, 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 = "getCurrentLedgerObjField"] + fn get_current_ledger_obj_field(self: &HostContext, field: 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); + } +} + +/// 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, + } +} + +impl HostFunctions for CxxHost<'_> { + fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult { + bytes_written(self.ctx.get_ledger_sqn(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 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 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 answers with a code. 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, + } + } +} + +/// 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)); + } + + /// An exception caught on the C++ side arrives as `-1`, which has to reach the + /// engine as a *fatal* error so the run stops and the transaction is + /// `tecINTERNAL` — not as a code handed to the contract to interpret. + #[test] + fn a_caught_cxx_exception_arrives_as_internal() { + assert_eq!(bytes_written(-1), Err(HostError::Internal)); + } + + // ----------------------------------------------------------------------- + // 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()), + ] + } + + /// 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 00000000000..21a5a6f6088 --- /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 00000000000..74a46727ec4 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/abi.rs @@ -0,0 +1,365 @@ +use crate::region::Region; +use crate::vm::{MAX_FIELD_BYTES, VmState}; +use wasmi::{Caller, Memory}; +use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct FatalHostError(pub(crate) HostError); + +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) + } +} + +/// Whether a [`HostError`] stops the run instead of reaching the guest as a code. +pub(crate) fn is_fatal(error: HostError) -> bool { + matches!( + error, + HostError::OutOfGas | HostError::Internal | HostError::NoMemExported + ) +} + +/// 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<'_>>) -> HostResult, +) -> 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<'_>>) -> HostResult<()>, +) -> 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 soft +/// error in, so it is dropped. The host-fatal ones still stop the run — those are a +/// property of the run, not an answer to the call. +fn dropped(result: HostResult<()>) -> Result<(), wasmi::Error> { + match result { + Err(error) if is_fatal(error) => Err(wasmi::Error::host(FatalHostError(error))), + _ => Ok(()), + } +} + +fn to_wire(result: HostResult) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) if is_fatal(error) => Err(wasmi::Error::host(FatalHostError(error))), + Err(error) => Ok(error.code()), + } +} + +/// Deduct `cost` fuel; `OutOfGas` if it would go negative. +fn charge(caller: &mut Caller<'_, T>, cost: u64) -> Result<(), HostError> { + let remaining = caller.get_fuel().map_err(|_| HostError::Internal)?; + match remaining.checked_sub(cost) { + Some(left) => caller.set_fuel(left).map_err(|_| HostError::Internal), + None => { + let _ = caller.set_fuel(0); + Err(HostError::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<'_>>) -> Result { + caller.data().memory.ok_or(HostError::NoMemExported) +} + +/// [`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, +) -> HostResult<&'a [u8]> { + let mem = memory(caller)?; + input.read(mem.data(caller)) +} + +/// 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 nor the +/// cap, and both checks below are reachable. +pub(crate) fn write_into( + caller: &mut Caller<'_, VmState<'_>>, + out: Region, + fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult, +) -> HostResult { + let range = out.range()?; + let cap = range.len(); + let mem = memory(caller)?; + let host: &dyn HostFunctions = caller.data().host; + // Bounds-checked over the guest's whole declared region, so a buffer running + // past memory is a wrong pointer rather than a truncated prefix being served… + let buf = mem + .data_mut(&mut *caller) + .get_mut(range) + .ok_or(HostError::PointerOutOfBounds)?; + // …of which only the field cap is writable, so no call can exceed it whatever + // the guest declared. + let buf = &mut buf[..cap.min(MAX_FIELD_BYTES)]; + + let n = fill(host, buf)?; + + if n > MAX_FIELD_BYTES { + return Err(HostError::DataFieldTooLarge); + } + if n > cap { + return Err(HostError::BufferTooSmall); + } + 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, +) -> HostResult { + 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); + } + let buf = data.get_mut(range).ok_or(HostError::PointerOutOfBounds)?; + if n > cap { + return Err(HostError::BufferTooSmall); + } + 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) +} + +#[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_current_ledger_obj_field(&self, _field: 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 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: HostResult) -> 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)), -3); + } + + /// The fatal set as the tests *expect* it, not as [`is_fatal`] reports it: + /// deriving it from `is_fatal` would make both tests below vacuous, since a + /// condition wrongly classified as soft would simply be skipped. + const MUST_TRAP: [HostError; 3] = [ + HostError::OutOfGas, + HostError::Internal, + HostError::NoMemExported, + ]; + + /// The trap carries the condition, so `run` can name the outcome without + /// parsing a message. + #[test] + fn a_host_fatal_error_becomes_a_trap_carrying_it() { + for error in MUST_TRAP { + let trap = + to_wire(Err(error)).expect_err("a fatal error must not reach the guest as a code"); + let payload = trap.downcast_ref::().unwrap_or_else(|| { + panic!("{error:?}: expected a FatalHostError payload, got: {trap}") + }); + assert_eq!(*payload, FatalHostError(error)); + } + } + + /// Over `HostError::ALL`, so it is the whole ABI and not a sample: a code added + /// to the ABI arrives already asserted to be guest-visible, and making it fatal + /// 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 only_the_host_fatal_errors_trap() { + for &error in HostError::ALL { + if MUST_TRAP.contains(&error) { + assert!(is_fatal(error), "{error:?} must stop the run"); + } else { + assert!(!is_fatal(error), "{error:?} must reach the guest as a code"); + assert_eq!(wire(Err(error)), error.code()); + } + } + } + + /// The result-less path splits the same set differently: the fatal errors still + /// stop the run, and every other one 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_soft_error_and_traps_on_a_fatal_one() { + assert!(dropped(Ok(())).is_ok()); + + for &error in HostError::ALL { + if MUST_TRAP.contains(&error) { + let trap = dropped(Err(error)).expect_err("a fatal error must stop the run"); + let payload = trap.downcast_ref::().unwrap_or_else(|| { + panic!("{error:?}: expected a FatalHostError payload, got: {trap}") + }); + assert_eq!(*payload, FatalHostError(error)); + } else { + assert!( + dropped(Err(error)).is_ok(), + "{error:?} has no channel to the guest and must be dropped" + ); + } + } + } + + #[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 00000000000..aae00006c81 --- /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, 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 00000000000..026d69b721b --- /dev/null +++ b/crates/xrpl-wasm-vm/src/preflight.rs @@ -0,0 +1,351 @@ +//! 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. +//! +//! One thing it screens that a run can only discover: an exported memory larger +//! than the engine grants. See [`check_memory`] for what stays invisible. + +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, 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), +} + +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}"), + } + } +} + +/// Screen `wasm`: it must compile, import only what the engine serves, export +/// `function_name` as `() -> i32`, and ask for no more memory 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; memory comes last, being a resource 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_memory(&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 not declare more linear memory than the engine grants. +/// +/// Only what it *exports* is visible here. A memory a module keeps to itself is not +/// in its exports, and the store's limiter is what refuses that one — at +/// instantiation, where the run is charged nothing and the caller cannot tell it +/// from any other resource failure. Screening the exported case covers every +/// contract built against the guest SDK, since a contract needs an exported memory +/// to make a host call at all. +fn check_memory(module: &Module) -> Result<(), CheckError> { + for export in module.exports() { + if let ExternType::Memory(ty) = export.ty() { + check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?; + } + } + 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(()) +} + +/// 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 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::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 00000000000..06268396c85 --- /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 00000000000..a3fac3824de --- /dev/null +++ b/crates/xrpl-wasm-vm/src/register.rs @@ -0,0 +1,110 @@ +use crate::abi::{charged, charged_unreported, read_borrowed, write_buffered, write_into}; +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::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::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::Decoding)?; + let data_type = + TraceDataType::from_code(data_type).ok_or(HostError::InvalidParams)?; + let data = read_borrowed(c, Region::new(data_ptr, data_len))?; + host.trace(msg, data, data_type) + }) + }, + ), + }?; + } + Ok(()) +} diff --git a/crates/xrpl-wasm-vm/src/vm.rs b/crates/xrpl-wasm-vm/src/vm.rs new file mode 100644 index 00000000000..28643a819ed --- /dev/null +++ b/crates/xrpl-wasm-vm/src/vm.rs @@ -0,0 +1,399 @@ +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::{HostError, HostFunctions}; + +use crate::abi::FatalHostError; +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; + +/// Total bytes that may cross the host/guest boundary in one [`run`], separate +/// from gas. +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 [`MAX_MEMORY_BYTES`] 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(host_fatal(fatal.0)); + } + (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 host-fatal `HostError` is. +/// +/// Exhaustive rather than closed with a wildcard, so a variant added to the ABI +/// must be placed here before this compiles. That is one direction of the agreement +/// with [`crate::abi::is_fatal`], which picks the channel; the other — an existing +/// variant moved into `is_fatal`'s set, landing in the soft arm and reported as +/// `Internal` — is `tests::every_fatal_error_has_an_outcome_of_its_own`. +/// +/// The soft arm is otherwise unreachable: a guest-visible error is a return code +/// and never becomes a trap for [`guest_halted`] to unwrap. +fn host_fatal(error: HostError) -> RunError { + match error { + HostError::OutOfGas => RunError::OutOfGas, + HostError::Internal => RunError::Internal, + HostError::NoMemExported => RunError::NoMemory, + HostError::FieldNotFound + | HostError::BufferTooSmall + | HostError::NoArray + | HostError::NotLeafField + | HostError::LocatorMalformed + | HostError::SlotOutRange + | HostError::SlotsFull + | HostError::EmptySlot + | HostError::LedgerObjNotFound + | HostError::Decoding + | HostError::DataFieldTooLarge + | HostError::PointerOutOfBounds + | HostError::InvalidParams + | HostError::InvalidAccount + | HostError::InvalidField + | HostError::IndexOutOfBounds + | HostError::FloatInputMalformed + | HostError::FloatComputationError + | HostError::NoRuntime + | HostError::OutOfTransferLimit => RunError::Internal, + } +} + +/// 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) +} + +/// 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 mem_limits = StoreLimitsBuilder::new() + .memory_size(MAX_MEMORY_BYTES) + .trap_on_grow_failure(true) + .build(); + let mut store = Store::new( + engine, + VmState { + host, + mem_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::*; + use crate::abi::is_fatal; + + #[test] + fn the_engine_is_one_engine() { + assert!(Engine::same(wasm_engine(), wasm_engine())); + } + + #[test] + fn every_fatal_error_has_an_outcome_of_its_own() { + for &error in HostError::ALL { + let named = !matches!(host_fatal(error), RunError::Internal) + || matches!(error, HostError::Internal); + assert_eq!( + is_fatal(error), + named, + "{error:?}: abi::is_fatal {}, host_fatal {}", + if is_fatal(error) { + "traps it" + } else { + "passes it to the guest" + }, + if named { + "names its outcome" + } else { + "groups it with the soft errors" + } + ); + } + } + + /// 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_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 00000000000..5b5f4a99655 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/budgets.rs @@ -0,0 +1,467 @@ +//! 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::GetCurrentLedgerObjField => ( + import::HOME_LE_FIELD, + "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4))", + 3, + ), + 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, + ), + }; + 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); +} + +/// 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 00000000000..b194ba7dad4 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/host_calls.rs @@ -0,0 +1,286 @@ +//! 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"); +} + +/// 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 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::Internal); + + 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 00000000000..d8bc344ea47 --- /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 00000000000..8c30390fcfd --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/preflight.rs @@ -0,0 +1,459 @@ +//! 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, 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; 4] = [ + import::LDGR_INDEX, + import::HOME_LE_FIELD, + import::SHA512_HALF, + import::TRACE, +]; + +#[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)", + )); +} + +/// The gap, listed rather than described, and now one entry long. A memory a module +/// keeps to itself is not in its exports, so this is the one module that passes +/// screening and then fails to *instantiate* — which is why a run's refusal at that +/// stage cannot be read as the node's fault. +/// +/// A contract needs an exported memory to make any host call, so a module of this +/// shape can do nothing but compute; the SDK does not produce one. +#[test] +fn what_static_screening_cannot_see() { + let host = FakeHost::new(); + let wat = format!( + r#"(module (memory {}) + (func (export "finish") (result i32) (i32.const 0)))"#, + MAX_MEMORY_PAGES + 1 + ); + + passes(&wat); + + let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) + .expect_err("the store's limiter must refuse the memory"); + assert!( + matches!(failure.error, RunError::Instantiate(_)), + "{failure}" + ); +} + +/// A start section is guest code, so screening cannot see whether it traps — but it +/// no longer has 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 00000000000..32b9dfe83a3 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/support/mod.rs @@ -0,0 +1,302 @@ +//! 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 }, + /// 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::>()) + } + + 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::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, +} + +/// 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_current_ledger_obj_field` answers, by field selector. An + /// unlisted selector answers `FieldNotFound`. + pub fields: HashMap, + /// 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, +} + +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()), + fields: HashMap::new(), + digest: Answer::filler(32), + fields_asked: RefCell::new(Vec::new()), + digested: RefCell::new(Vec::new()), + traces: RefCell::new(Vec::new()), + trace_failure: None, + } + } +} + +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_field(mut self, field: i32, answer: Answer) -> FakeHost { + self.fields.insert(field, answer); + self + } + + pub fn answering_digest(mut self, answer: Answer) -> FakeHost { + self.digest = 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_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 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(()), + } + } +} + +// --------------------------------------------------------------------------- +// 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 HOME_LE_FIELD: &str = r#"(import "host_lib" "home_le_field" (func $home_le_field (param 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)))"#; +} + +/// 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 00000000000..4fa60e80148 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/vm_limits.rs @@ -0,0 +1,580 @@ +//! What the engine refuses outright: modules it will not compile, will not +//! instantiate, or cannot find an entry point in — plus the linear-memory cap. +//! +//! 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, 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(_)); +} + +// --------------------------------------------------------------------------- +// 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 our 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 we +/// build: 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/include/xrpl/tx/wasm/HostContext.h b/include/xrpl/tx/wasm/HostContext.h new file mode 100644 index 00000000000..7ba70e0b6e7 --- /dev/null +++ b/include/xrpl/tx/wasm/HostContext.h @@ -0,0 +1,74 @@ +#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 +// failure leaves here as -1, which the engine reads as a fatal error and reports as +// `tecINTERNAL`. +// +// Not an owner: it borrows `hf` for the length of one run. Declared `struct` because the +// Rust side only ever sees an opaque pointer. +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 + getCurrentLedgerObjField(std::int32_t field, 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; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/wasm/HostFunc.h b/include/xrpl/tx/wasm/HostFunc.h index 96953fbf90c..ae0adcd9be4 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 @@ class HostFunctions { } - 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 @@ class HostFunctions // 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 4884c750f16..00000000000 --- 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 04958b663a6..7be22a6feb2 100644 --- a/include/xrpl/tx/wasm/README.md +++ b/include/xrpl/tx/wasm/README.md @@ -1,189 +1,40 @@ # 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 module follows a layered architecture: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 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 │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Key Components - -- **`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 - -- **`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 - -- **`HostFunc.h`** - Abstract `HostFunctions` base class defining the interface - for all callable host functions. Each method returns - `std::expected`. - -- **`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); -``` +The engine itself is Rust (`crates/xrpl-wasm-vm`, over wasmi), reached through a cxx +bridge. -### 5. Implement Wrapper in detail/HostFuncWrapper.cpp +## What is in this directory -Implement the C-style wrapper that bridges WASM to C++: +- **`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 routes through `guarded()`. +- **`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 one catch every crossing of the + bridge's C++ half goes through. -```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 -} -``` +## Host functions -### 6. Register in WasmVM.cpp +Grouped by what they reach: ledger information; transaction and ledger-object field access; +keylet construction; cryptography; float arithmetic; NFT queries; tracing. -Add the function registration in `setCommonHostFunctions()` or -`createWasmImport()`: +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. -```cpp -WASM_IMPORT_FUNC2(i, myNewFunction, "my_new_function", hfs, 100); -// ^^ WASM name ^^ gas cost -``` +## Entry point -> [!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 f73ca7c2d26..fa651bef105 100644 --- a/include/xrpl/tx/wasm/WasmCommon.h +++ b/include/xrpl/tx/wasm/WasmCommon.h @@ -1,16 +1,18 @@ #pragma once +#include #include #include +#include #include #include #include #include -#include +#include #include +#include #include -#include #include #include #include @@ -21,30 +23,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, @@ -68,19 +46,6 @@ enum class HostFunctionError : int32_t { 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) - { - } -}; - template struct WasmResult { @@ -148,71 +113,6 @@ class FieldLocator } }; -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 +150,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 0c31e969c15..00000000000 --- 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 e20488de00d..99161b93afe 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 5a72cd35f67..00000000000 --- 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 00000000000..1c35789bdfc --- /dev/null +++ b/src/libxrpl/tx/wasm/HostContext.cpp @@ -0,0 +1,223 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +// For `TraceDataType`, which the bridge declares and this header defines. +#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. The engine reads -1 as its fatal +// `Internal`, stops the run and reports `tecINTERNAL`. +// +// `HostFunctionError` spells -1 `Unimplemented`, so the two share a code. They also share +// a meaning worth keeping together - "the host could not serve this call, and the contract +// has no business interpreting why" - and they must share a fate. Named here so a call +// site reads as what it is rather than as "unimplemented". +constexpr std::int32_t kHostInternal = hfErrorToInt(HostFunctionError::Unimplemented); + +// 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) +{ + if (size <= out.size()) + std::memcpy(out.data(), value, size); + 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)); +} + +// 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, [&] { + auto const sqn = hostFunctions_.getLedgerSqn(); + if (!sqn) + return hfErrorToInt(sqn.error()); + + return answerScalar(out, *sqn); + }); +} + +std::int32_t +HostContext::getCurrentLedgerObjField(std::int32_t field, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const& knownSFields = SField::getKnownCodeToField(); + auto const it = knownSFields.find(field); + if (it == knownSFields.end()) + return hfErrorToInt(HostFunctionError::InvalidField); + + auto const value = hostFunctions_.getCurrentLedgerObjField(*it->second); + if (!value) + return hfErrorToInt(value.error()); + + return answer(out, value->data(), value->size()); + }); +} + +std::int32_t +HostContext::sha512Half(rust::Slice data, rust::Slice out) + const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const digest = hostFunctions_.computeSha512HalfHash(Slice{data.data(), data.size()}); + if (!digest) + return hfErrorToInt(digest.error()); + + return answer(out, digest->data(), digest->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"; + } +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp b/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp index 4ae0c724268..e622e62b0b8 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 the engine's fatal `Internal` -> + // 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 a6cd5fc1e3a..00000000000 --- 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 ed87f7c4aca..876a52b373a 100644 --- a/src/libxrpl/tx/wasm/WasmVM.cpp +++ b/src/libxrpl/tx/wasm/WasmVM.cpp @@ -1,215 +1,180 @@ #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 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) -{ - // 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); - - 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); - - WASM_IMPORT_FUNC2(i, checkSignature, "check_sig", hfs, 300); - WASM_IMPORT_FUNC2(i, computeSha512HalfHash, "sha512_half", hfs, 2000); - - 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); - - 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 -} - -ImportVec -createWasmImport(HostFunctions& hfs) -{ - ImportVec i; - - setCommonHostFunctions(hfs, i); - WASM_IMPORT_FUNC2(i, updateData, "set_data", hfs, 1000); - - return 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 cost, or a TER with the 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 -runEscrowWasm( - Bytes const& wasmCode, - HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName, - std::vector const& params) +outcome(rs::wasm_vm::RunResult const& run) { - // create VM and set cost limit - auto& vm = WasmEngine::instance(); - // vm.initMaxPages(MAX_PAGES); + auto const cost = static_cast(run.gas_used); - auto const ret = - vm.run(wasmCode, hfs, gasLimit, funcName, params, createWasmImport(hfs), hfs.getJournal()); - - if (!ret) + switch (run.status) { -#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()); + case RunStatus::Ok: + return EscrowResult{.result = run.result, .cost = cost}; + + // 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}}; + + // 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}}; + + // 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}}; } - -#ifdef DEBUG_OUTPUT - std::cout << ", ret: " << ret->result << ", gas spent: " << ret->cost << std::endl; -#endif - return EscrowResult{.result = ret->result, .cost = ret->cost}; + UNREACHABLE("xrpl::outcome : unknown RunStatus"); + return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; } +// 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 -preflightEscrowWasm( - Bytes const& wasmCode, - HostFunctions& hfs, - std::string_view funcName, - std::vector const& params) -{ - // create VM and set cost limit - auto& vm = WasmEngine::instance(); - // vm.initMaxPages(MAX_PAGES); - - auto const ret = - vm.check(wasmCode, hfs, funcName, params, createWasmImport(hfs), hfs.getJournal()); - - return ret; -} - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -WasmEngine::WasmEngine() : impl_(std::make_unique()) +verdict(CheckStatus status) { + switch (status) + { + case CheckStatus::Ok: + return tesSUCCESS; + + // 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 than + // it may have. + case CheckStatus::Compile: + case CheckStatus::Import: + case CheckStatus::EntryPoint: + case CheckStatus::Memory: + return temBAD_WASM; + + // 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; } -WasmEngine& -WasmEngine::instance() -{ - static WasmEngine e; - return e; -} +} // namespace -std::expected, WasmTER> -WasmEngine::run( +std::expected +runEscrowWasm( Bytes const& wasmCode, HostFunctions& hfs, - int64_t gasLimit, - std::string_view funcName, - std::vector const& params, - ImportVec const& imports, - beast::Journal j) + std::int64_t gasLimit, + std::string_view funcName) noexcept { - return impl_->run(wasmCode, hfs, gasLimit, funcName, params, imports, j); + // 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 nodeSideFault = std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; + + 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. + if (!hfs.checkSelf()) + { + JLOG(hfs.getJournal().error()) << "wasm: host functions not clean before the run"; + return nodeSideFault; + } + + 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 -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 +preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view funcName) noexcept { - return impl_->getJournal(); + 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 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; + }); } -// 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 cfe54fccc2e..00000000000 --- 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 58d4eb4419d..b4de4a04753 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 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 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 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 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 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 c2a493de42e..4f77df98b56 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 363da88f8d9..53b0d90be07 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 5e4cda243aa..e4121bc92c4 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -23,6 +23,11 @@ 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) +# Lets the wasm tests write their modules as WebAssembly text. Test-only by construction: +# the assembler lives in a crate nothing in libxrpl or xrpld links (see crates/CMakeLists). +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 diff --git a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h new file mode 100644 index 00000000000..d75291cc9a9 --- /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 ABI currently declares are mocked, and that is deliberate: the ~60 +// others keep `HostFunctions`' own `std::unexpected(Unimplemented)`, so a contract reaching +// for something the ABI has not declared yet fails the way production would. Add a +// `MOCK_METHOD` here when the matching entry is added to `host_functions!`. +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 00000000000..0391f097115 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/Preflight.cpp @@ -0,0 +1,223 @@ +#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); +} + +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 00000000000..662752806e6 --- /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 00000000000..76c774e3ebc --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/WasmVM.cpp @@ -0,0 +1,325 @@ +#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()); +} + +// No gas is not a small budget, it is a malformed transaction — refused before the engine is +// asked to run anything. +TEST_F(WasmVMTest, NoGasIsRefusedAsMalformedRatherThanRun) +{ + for (auto const gas : {std::int64_t{0}, std::int64_t{-1}}) + { + 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; + } +} + +// 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); +} + +// 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(WasmVMTest, DirtyHostIsRefusedBeforeContractRuns) +{ + 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")); +} + +// 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 the C++ and Rust error +// enums are two hand-maintained lists of the same wire numbers and they have already drifted +// once — C++ spells -11 `OutOfTransferLimit` where the Rust ABI spells it `Decoding`. This is +// the test that notices if either side renumbers. +// +// The two exclusions are the codes the Rust engine treats as host-fatal, which stop the run +// instead of reaching the guest: -1 (its `Internal`, which C++ spells `Unimplemented`) and +// -14 `NoMemExported`. +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::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")); +} + +} // 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 00000000000..143c20fa969 --- /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 00000000000..d4cec43616e --- /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 00000000000..3653a6e9313 --- /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 00000000000..5ed645ae2b4 --- /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`, which the bridge declares and this header defines. +#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 for +// what were five, 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