diff --git a/.cspell.config.yaml b/.cspell.config.yaml
index cc5214fc87..d7f1c5f737 100644
--- a/.cspell.config.yaml
+++ b/.cspell.config.yaml
@@ -107,6 +107,7 @@ words:
- deleteme
- demultiplexer
- deserializaton
+ - desugars
- desync
- desynced
- determ
@@ -133,6 +134,7 @@ words:
- gcov
- gcovr
- ghead
+ - gmock
- Gnutella
- godexsoft
- gpgcheck
@@ -142,7 +144,9 @@ words:
- hwaddress
- hwrap
- ifndef
+ - impls
- inequation
+ - initialiser
- insuf
- insuff
- invasively
@@ -252,6 +256,7 @@ words:
- pyparsing
- qalloc
- qbsprofile
+ - qself
- queuable
- Raphson
- rcflags
@@ -350,6 +355,7 @@ words:
- unflatten
- unfund
- unimpair
+ - unmetered
- unroutable
- unscalable
- unserviced
@@ -370,6 +376,8 @@ words:
- vfalco
- vinnie
- wasmi
+ - wasmparser
+ - Werror
- wextra
- wptr
- writeme
diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py
index 7fef6643ff..35cf538e85 100755
--- a/.github/scripts/strategy-matrix/generate.py
+++ b/.github/scripts/strategy-matrix/generate.py
@@ -12,7 +12,6 @@ _BASE_CMAKE_ARGS = [
"-Dwerr=ON",
"-Dxrpld=ON",
"-Dwextra=ON",
- "-Drust=ON",
]
# Maps sanitizer names (as used in cmake) to short config-name suffixes.
diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml
index 656e6ec85b..ccf994de08 100644
--- a/.github/workflows/reusable-build-test-config.yml
+++ b/.github/workflows/reusable-build-test-config.yml
@@ -373,7 +373,10 @@ jobs:
- name: Run Rust tests
if: ${{ !inputs.build_only }}
working-directory: crates
- run: cargo nextest run --workspace --all-features --locked --no-tests=warn
+ # `xrpl-wasm-vm-ffi` is left out on Windows: its tests link as an executable, and
+ # MSVC - unlike the Unix linkers - will not dead-strip the never-called cxx wrappers
+ # whose C++ shims only the CMake build defines. The other runners cover these tests.
+ run: cargo nextest run --workspace --all-features --locked --no-tests=warn ${{ runner.os == 'Windows' && '--exclude xrpl-wasm-vm-ffi' || '' }}
# Smoke-run every benchmark module with a single repetition to confirm the
# benchmarks still build and execute. This is a correctness check, not a
diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml
index 6847ff9b57..ceda062604 100644
--- a/.github/workflows/reusable-clang-tidy.yml
+++ b/.github/workflows/reusable-clang-tidy.yml
@@ -87,7 +87,6 @@ jobs:
-Dwerr=ON \
-Dxrpld=ON \
-Dverify_headers=ON \
- -Drust=ON \
..
- name: Build clang-tidy prerequisites
diff --git a/BUILD.md b/BUILD.md
index e98d204d0b..f39add176d 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -1,6 +1,6 @@
-| :warning: **WARNING** :warning: |
-| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).
These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
+| :warning: **WARNING** :warning: |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, Rust, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).
These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
## Minimum Requirements
@@ -304,7 +304,6 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details.
| ---------------- | ------------- | ----------------------------------------------------------------------------- |
| `assert` | OFF | Force enabling assertions. |
| `coverage` | OFF | Prepare the coverage report. |
-| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. |
| `tests` | OFF | Build tests. |
| `unity` | OFF | Configure a unity build. |
| `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. |
@@ -319,23 +318,15 @@ builds may be faster for incremental builds, and can be helpful for detecting
### Rust crates
-The Rust crates in `crates/` are only part of the build when `rust` is ON. With
-`-Drust=OFF` (the default) the `crates` directory is not added to the build, no
-cxxbridge bindings are generated, and the C++ tests that exercise the Rust
-interop are not compiled — so no Rust toolchain is needed. CI builds always pass
-`-Drust=ON`.
-
-With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`,
-`rustc`) matching the channel pinned in
-[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and
-generates the cxxbridge bindings. It is provided by the
-[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without
-any extra setup; otherwise install it as described in
-[Rust](./docs/build/environment.md#rust).
+The build compiles the Rust workspace in `crates/` and generates the cxxbridge
+bindings the C++ side includes, so it needs a Rust toolchain (`cargo`, `rustc`)
+at the channel pinned in [`rust-toolchain.toml`](./rust-toolchain.toml). The
+[Nix development shell](./docs/build/nix.md) provides one; otherwise install it
+as described in [Rust](./docs/build/environment.md#rust).
The crates also have their own Rust unit tests. Those are run with `cargo` and
-need only the Rust toolchain, independently of CMake and of the `rust` option
-(CI runs them with `cargo nextest`):
+need only the Rust toolchain, independently of CMake (CI runs them with
+`cargo nextest`):
```bash
cargo test --manifest-path crates/Cargo.toml --workspace
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 54a52cf21d..3e99d0e14c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -114,7 +114,6 @@ find_package(OpenSSL REQUIRED)
find_package(secp256k1 REQUIRED)
find_package(SOCI REQUIRED)
find_package(SQLite3 REQUIRED)
-find_package(wasmi REQUIRED)
find_package(xxHash REQUIRED)
target_link_libraries(
@@ -161,11 +160,8 @@ endif()
add_custom_target(tidy_prerequisites)
-if(rust)
- add_subdirectory(crates)
-endif()
+add_subdirectory(crates)
include(XrplCore)
-
include(XrplProtocolAutogen)
include(XrplInstall)
include(XrplValidatorKeys)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 35309a9824..de2aff5325 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -321,7 +321,7 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho
### Running clang-tidy locally
-Before running clang-tidy, you must generate the files it depends on (protobuf headers, and, when the project is configured with `-Drust=ON`, the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them:
+Before running clang-tidy, you must generate the files it depends on (protobuf headers and the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them:
```bash
cmake --build build --target tidy_prerequisites
diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake
index 4cb251c857..e9e3af4c7c 100644
--- a/cmake/XrplCore.cmake
+++ b/cmake/XrplCore.cmake
@@ -69,7 +69,6 @@ target_link_libraries(
Xrpl::opts
Xrpl::syslibs
secp256k1::secp256k1
- wasmi::wasmi
xrpl.libpb
xxHash::xxhash
$<$:antithesis-sdk-cpp>
@@ -208,7 +207,11 @@ target_link_libraries(
)
add_module(xrpl tx)
-target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger)
+target_link_libraries(
+ xrpl.libxrpl.tx
+ PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge
+)
+add_dependencies(xrpl.libxrpl.tx xrpl_crates)
add_module(xrpl consensus)
target_link_libraries(
diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake
index 58b902baa1..be9bf1fda2 100644
--- a/cmake/XrplSettings.cmake
+++ b/cmake/XrplSettings.cmake
@@ -32,11 +32,6 @@ endif()
option(benchmark "Build benchmarks" ON)
-# When OFF, the crates directory is not added to the build at all: no Rust
-# toolchain is required, no cxxbridge bindings are generated, and the C++ tests
-# that consume those bindings are left out of the build tree.
-option(rust "Build the Rust crates and the C++ code that depends on them" OFF)
-
# Enabled by default so every header is compiled on its own as the main file of
# its own compile_commands.json entry - this is what lets clang-tidy (and clangd
# and IDEs) analyse a header's own includes directly. The per-header objects are
diff --git a/conan.lock b/conan.lock
index 02016b83b7..176f0b27cb 100644
--- a/conan.lock
+++ b/conan.lock
@@ -3,7 +3,6 @@
"requires": [
"zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708",
"xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688",
- "wasmi/1.0.9#1fecdab9b90c96698eb35ea99ca4f5cb%1782307153.343419",
"sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447",
"soci/4.0.3#e726491a03468795453f7c83fc924a96%1782392402.679521",
"snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1782307151.633168",
diff --git a/conanfile.py b/conanfile.py
index d0cb95a0e6..ae677b99aa 100644
--- a/conanfile.py
+++ b/conanfile.py
@@ -36,7 +36,6 @@ class Xrpl(ConanFile):
"nudb/2.0.9",
"openssl/3.6.3",
"soci/4.0.3",
- "wasmi/1.0.9",
"zlib/1.3.2",
]
@@ -153,8 +152,12 @@ class Xrpl(ConanFile):
"CMakeLists.txt",
"cfg/*",
"cmake/*",
+ "crates/*",
+ "crates/.cargo/*",
+ "!crates/target/*",
"external/*",
"include/*",
+ "rust-toolchain.toml",
"src/*",
)
@@ -225,7 +228,6 @@ class Xrpl(ConanFile):
"soci::soci",
"secp256k1::secp256k1",
"sqlite3::sqlite",
- "wasmi::wasmi",
"xxhash::xxhash",
"zlib::zlib",
]
diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt
index 3f83045cdb..f26cfeb4e3 100644
--- a/crates/CMakeLists.txt
+++ b/crates/CMakeLists.txt
@@ -101,4 +101,11 @@ function(add_xrpl_crate name)
add_dependencies(xrpl_crates ${name}_cxxbridge)
endfunction()
-add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs)
+add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs)
+
+add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs)
+
+target_include_directories(
+ xrpl_wasm_vm_ffi_cxxbridge
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 70247f8e19..53f6aad57c 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -8,6 +8,18 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
[[package]]
name = "cc"
version = "1.2.61"
@@ -66,7 +78,7 @@ dependencies = [
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
- "foldhash",
+ "foldhash 0.2.0",
"link-cplusplus",
]
@@ -129,12 +141,27 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.0"
@@ -148,9 +175,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.17.0",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "link-cplusplus"
version = "1.0.12"
@@ -160,6 +199,12 @@ dependencies = [
"cc",
]
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -178,19 +223,18 @@ dependencies = [
"proc-macro2",
]
-[[package]]
-name = "rs-hello_world"
-version = "0.1.0"
-dependencies = [
- "cxx",
-]
-
[[package]]
name = "scratch"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -227,6 +271,22 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
+[[package]]
+name = "string-interner"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
+dependencies = [
+ "hashbrown 0.15.5",
+ "serde",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -276,6 +336,99 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "wasm-encoder"
+version = "0.254.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.254.0",
+]
+
+[[package]]
+name = "wasmi"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c"
+dependencies = [
+ "spin",
+ "wasmi_collections",
+ "wasmi_core",
+ "wasmi_ir",
+ "wasmparser 0.239.0",
+]
+
+[[package]]
+name = "wasmi_collections"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884"
+dependencies = [
+ "string-interner",
+]
+
+[[package]]
+name = "wasmi_core"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "wasmi_ir"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68"
+dependencies = [
+ "wasmi_core",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.239.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0"
+dependencies = [
+ "bitflags",
+ "indexmap",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.254.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27"
+dependencies = [
+ "bitflags",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "wast"
+version = "254.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32"
+dependencies = [
+ "bumpalo",
+ "leb128fmt",
+ "memchr",
+ "unicode-width",
+ "wasm-encoder",
+]
+
+[[package]]
+name = "wat"
+version = "1.254.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c"
+dependencies = [
+ "wast",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -299,3 +452,46 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "xrpl-host-functions"
+version = "0.1.0"
+dependencies = [
+ "xrpl-host-functions-macros",
+]
+
+[[package]]
+name = "xrpl-host-functions-macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+ "xrpl-host-functions",
+]
+
+[[package]]
+name = "xrpl-wasm-testkit"
+version = "0.1.0"
+dependencies = [
+ "cxx",
+ "wat",
+]
+
+[[package]]
+name = "xrpl-wasm-vm"
+version = "0.1.0"
+dependencies = [
+ "wasmi",
+ "wat",
+ "xrpl-host-functions",
+]
+
+[[package]]
+name = "xrpl-wasm-vm-ffi"
+version = "0.1.0"
+dependencies = [
+ "cxx",
+ "xrpl-host-functions",
+ "xrpl-wasm-vm",
+]
diff --git a/crates/Cargo.toml b/crates/Cargo.toml
index 0bb0e9c550..d08f4da233 100644
--- a/crates/Cargo.toml
+++ b/crates/Cargo.toml
@@ -1,9 +1,15 @@
[workspace]
-members = ["hello_world"]
+members = [
+ "xrpl-wasm-vm-ffi",
+ "xrpl-wasm-vm",
+ "xrpl-wasm-testkit",
+ "xrpl-host-functions",
+ "xrpl-host-functions-macros",
+]
resolver = "3"
[workspace.dependencies]
-cxx = { version = "1.0.198", features = ["c++20"] }
+cxx = { version = "1.0.199", features = ["c++20"] }
[workspace.package]
edition = "2024"
diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs
deleted file mode 100644
index b1cb121fa0..0000000000
--- a/crates/hello_world/src/lib.rs
+++ /dev/null
@@ -1,10 +0,0 @@
-#[cxx::bridge(namespace = "rs::hello_world")]
-mod ffi {
- extern "Rust" {
- fn hello_world() -> String;
- }
-}
-
-pub fn hello_world() -> String {
- "hello_world".to_string()
-}
diff --git a/crates/xrpl-host-functions-macros/Cargo.toml b/crates/xrpl-host-functions-macros/Cargo.toml
new file mode 100644
index 0000000000..d24ca268d0
--- /dev/null
+++ b/crates/xrpl-host-functions-macros/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "xrpl-host-functions-macros"
+version = "0.1.0"
+edition.workspace = true
+
+[lib]
+proc-macro = true
+
+[dependencies]
+syn = { version = "3", features = ["full"] }
+quote = "1"
+proc-macro2 = "1"
+
+# The doctest declares host functions returning `HostResult`, which the facade
+# crate hand-writes. Cargo allows this cycle because dev-dependencies are outside
+# the library build graph.
+[dev-dependencies]
+xrpl-host-functions.path = "../xrpl-host-functions"
diff --git a/crates/xrpl-host-functions-macros/src/errors.rs b/crates/xrpl-host-functions-macros/src/errors.rs
new file mode 100644
index 0000000000..82d80eb56c
--- /dev/null
+++ b/crates/xrpl-host-functions-macros/src/errors.rs
@@ -0,0 +1,12 @@
+/// Folds accumulated diagnostics into the single error a macro can return.
+///
+/// `syn::Error` is itself a collection: `combine` appends, and
+/// `into_compile_error` emits one `compile_error!` per recorded span. Folding
+/// instead of returning the first error means every mistake in a
+/// `host_functions!` block surfaces in one build rather than one per rebuild.
+pub(crate) fn combine(errors: Vec) -> Option {
+ errors.into_iter().reduce(|mut first, next| {
+ first.combine(next);
+ first
+ })
+}
diff --git a/crates/xrpl-host-functions-macros/src/lib.rs b/crates/xrpl-host-functions-macros/src/lib.rs
new file mode 100644
index 0000000000..a8eb4ca7b6
--- /dev/null
+++ b/crates/xrpl-host-functions-macros/src/lib.rs
@@ -0,0 +1,405 @@
+mod errors;
+mod parsed_host_function;
+
+use std::collections::HashSet;
+
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::{
+ TraitItemFn,
+ parse::{Parse, ParseStream},
+ parse2,
+};
+
+use parsed_host_function::ParsedHostFunction;
+
+/// Declares the wasm host ABI once, and generates everything that follows from it.
+///
+/// The input is a block of `fn` declarations, each carrying the gas cost the host
+/// charges before the call and the name the guest imports it under. Doc comments
+/// are kept and appear on the generated items.
+///
+/// This crate is an implementation detail of `xrpl-host-functions`, which
+/// hand-writes the types the declarations refer to and holds the one declaration
+/// block.
+///
+/// # What it generates
+///
+/// Three items, in the scope the block is written in:
+///
+/// - `pub trait HostFunctions`: one method per declaration, emitted verbatim —
+/// receiver, parameters, return type and doc comment exactly as written. An
+/// execution environment implements it; the rest of the expansion does not
+/// mention it.
+/// - `pub enum HostFunctionSpec`: one variant per declaration, named by
+/// PascalCasing the function name (`get_ledger_sqn` becomes `GetLedgerSqn`) and
+/// carrying that declaration's doc comment. Its `const fn wasm_name` and
+/// `const fn gas` are the ABI metadata, and `ALL` is every variant in
+/// declaration order — what a wasm engine iterates to build its import table.
+/// - `struct HostFnSpec`: private, one row of that metadata table. It exists only
+/// so `wasm_name` and `gas` read from a single `match` over the declarations,
+/// and never appears in a signature a caller can name.
+///
+/// The expansion introduces no other name and reaches for none: the only paths in
+/// it are `Self::Variant` and whatever the declarations themselves spell. So the
+/// block compiles wherever the types it names — `HostResult` above — resolve.
+///
+/// ```
+/// use xrpl_host_functions::HostResult;
+/// use xrpl_host_functions_macros::host_functions;
+///
+/// host_functions! {
+/// /// The sequence number of the ledger being built, as 4 little-endian bytes.
+/// #[gas = 60]
+/// #[wasm_name = "ldgr_index"]
+/// fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult;
+///
+/// /// Writes `msg` to the trace log.
+/// #[gas = 500]
+/// #[wasm_name = "trace_num"]
+/// fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
+/// }
+///
+/// // The trait's methods are the declarations, down to the `&self` receiver the
+/// // VM calls the host through.
+/// fn ledger_sqn(host: &dyn HostFunctions, out: &mut [u8]) -> HostResult {
+/// host.get_ledger_sqn(out)
+/// }
+///
+/// // The metadata is a `const` table, so gas and import names are available at
+/// // compile time rather than looked up at run time.
+/// const TRACE_GAS: u64 = HostFunctionSpec::TraceNum.gas();
+/// assert_eq!(TRACE_GAS, 500);
+///
+/// assert_eq!(HostFunctionSpec::GetLedgerSqn.wasm_name(), "ldgr_index");
+/// assert_eq!(
+/// HostFunctionSpec::ALL,
+/// &[HostFunctionSpec::GetLedgerSqn, HostFunctionSpec::TraceNum],
+/// );
+/// ```
+///
+/// A declaration must be a plain `fn` taking `&self` and returning
+/// `HostResult`, with no body and no generics: it maps to exactly one wasm
+/// import signature. Two declarations may not share a `wasm_name`, nor collapse to
+/// the same PascalCase variant.
+#[proc_macro]
+pub fn host_functions(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
+ expand(input.into())
+ .unwrap_or_else(syn::Error::into_compile_error)
+ .into()
+}
+
+fn expand(input: TokenStream) -> syn::Result {
+ let HostFunctionsInput { functions } = parse2(input)?;
+
+ let mut parsed = Vec::with_capacity(functions.len());
+ let mut errors = Vec::new();
+ for function in functions {
+ match ParsedHostFunction::parse(function) {
+ Ok(function) => parsed.push(function),
+ Err(error) => errors.push(error),
+ }
+ }
+ if let Some(error) = errors::combine(errors) {
+ return Err(error);
+ }
+ if let Some(error) = errors::combine(collisions(&parsed)) {
+ return Err(error);
+ }
+
+ Ok(generate(&parsed))
+}
+
+/// Names two declarations may not share, because the generated code would then
+/// fail to compile at a span the caller cannot see.
+fn collisions(functions: &[ParsedHostFunction]) -> Vec {
+ let mut errors = Vec::new();
+ let mut variants = HashSet::new();
+ let mut wasm_names = HashSet::new();
+
+ for function in functions {
+ if !variants.insert(function.variant.to_string()) {
+ errors.push(syn::Error::new_spanned(
+ &function.variant,
+ format!(
+ "another host function already becomes the `{}` variant",
+ function.variant
+ ),
+ ));
+ }
+ if !wasm_names.insert(function.wasm_name.value()) {
+ errors.push(syn::Error::new_spanned(
+ &function.wasm_name,
+ format!(
+ "another host function is already imported as `{}`",
+ function.wasm_name.value()
+ ),
+ ));
+ }
+ }
+
+ errors
+}
+
+fn generate(functions: &[ParsedHostFunction]) -> TokenStream {
+ let trait_methods = functions.iter().map(ParsedHostFunction::trait_method);
+ let variants = functions
+ .iter()
+ .map(ParsedHostFunction::variant_declaration);
+ let spec_arms = functions.iter().map(ParsedHostFunction::spec_arm);
+ let all = functions.iter().map(|function| &function.variant);
+
+ quote! {
+ /// The host side of the wasm ABI: one method per function a guest may
+ /// import.
+ ///
+ /// Implement it once per execution environment — the ledger host, a test
+ /// double, a benchmark fake — and a guest module cannot tell them apart.
+ /// Each method is one declaration from the `host_functions!` block, as
+ /// written; its `&self` receiver is not part of the ABI the guest sees,
+ /// so a host that must mutate does so behind interior mutability.
+ ///
+ /// # The output contract
+ ///
+ /// A method handed an `out` buffer **writes into it only when the whole
+ /// value fits, and returns the value's true length whether it fitted or
+ /// not.**
+ ///
+ /// The length is the value's, not the number of bytes written, because it
+ /// is how a guest that asked with too small a buffer learns the size to
+ /// ask for next time. The engine turns a length past the buffer into
+ /// `BufferTooSmall`, and one past the field cap into `DataFieldTooLarge`,
+ /// so a host needs to know neither.
+ ///
+ /// Writing nothing unless the value fits is the half only a host can hold
+ /// up. An engine can bound how many bytes are *writable* — and does, by
+ /// handing over a region clamped to the field cap — but it cannot take
+ /// back what a method already put there. A host that wrote a truncated
+ /// prefix and then reported the larger length would leave those bytes in
+ /// guest memory behind a refusal the guest is told to ignore.
+ pub trait HostFunctions {
+ #(#trait_methods)*
+ }
+
+ /// One row of the ABI table: what [`HostFunctionSpec::wasm_name`] and
+ /// [`HostFunctionSpec::gas`] read from.
+ ///
+ /// Private, and the only reason it exists is to keep both of them fed
+ /// from a single `match` over the declarations.
+ struct HostFnSpec {
+ name: &'static str,
+ gas: u64,
+ }
+
+ /// Identifies one host function, and is the compile-time source of its
+ /// ABI metadata.
+ ///
+ /// One variant per `host_functions!` declaration, named by converting the
+ /// function name to PascalCase. [`Self::ALL`] is the whole ABI, which is
+ /// what a wasm engine iterates to build its import table.
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ pub enum HostFunctionSpec {
+ #(#variants,)*
+ }
+
+ impl HostFunctionSpec {
+ /// Every host function, in the order declared.
+ ///
+ /// This is the complete import surface a guest may link against: a
+ /// function absent here cannot be called, and one present here must
+ /// be registered for a module that imports it to instantiate.
+ pub const ALL: &'static [Self] = &[#(Self::#all,)*];
+
+ /// This function's row of the ABI table.
+ const fn spec(self) -> HostFnSpec {
+ match self {
+ #(#spec_arms,)*
+ }
+ }
+
+ /// The name a guest imports this function under.
+ ///
+ /// A guest's import name must match this exactly, or the module
+ /// fails to instantiate. Usable in `const` context, so import lists
+ /// can be built at compile time.
+ pub const fn wasm_name(self) -> &'static str {
+ self.spec().name
+ }
+
+ /// Gas charged before the call runs, independent of its arguments.
+ ///
+ /// Consensus-relevant: two nodes that disagree on this value
+ /// disagree on transaction outcomes. Usable in `const` context, so
+ /// gas tables can be built at compile time.
+ pub const fn gas(self) -> u64 {
+ self.spec().gas
+ }
+ }
+ }
+}
+
+struct HostFunctionsInput {
+ functions: Vec,
+}
+
+impl Parse for HostFunctionsInput {
+ fn parse(input: ParseStream) -> syn::Result {
+ let mut functions = Vec::new();
+ while !input.is_empty() {
+ functions.push(input.parse()?);
+ }
+ Ok(HostFunctionsInput { functions })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_an_empty_block() {
+ expand(quote! {}).unwrap();
+ }
+
+ #[test]
+ fn reports_mistakes_from_every_function() {
+ let error = expand(quote! {
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+
+ #[gas = 2000]
+ fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>;
+ })
+ .expect_err("expected parsing to fail");
+
+ let messages: Vec<_> = error.into_iter().map(|error| error.to_string()).collect();
+ assert_eq!(messages.len(), 2, "{messages:?}");
+ assert!(messages[0].contains("missing `#[gas"), "{messages:?}");
+ assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}");
+ }
+
+ #[test]
+ fn propagates_syntax_errors() {
+ let error = expand(quote! { fn missing_semicolon() }).expect_err("expected a syntax error");
+ assert!(!error.to_string().is_empty());
+ }
+
+ /// The messages of every diagnostic recorded by one failed `expand`.
+ fn messages(input: TokenStream) -> Vec {
+ let Err(error) = expand(input) else {
+ panic!("expected expansion to fail");
+ };
+ error.into_iter().map(|error| error.to_string()).collect()
+ }
+
+ #[test]
+ fn generates_the_trait_the_enum_and_the_table() {
+ let generated = expand(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+
+ #[gas = 500]
+ #[wasm_name = "trace_num"]
+ fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
+ })
+ .unwrap()
+ .to_string();
+
+ for expected in [
+ "pub trait HostFunctions",
+ "fn get_ledger_sqn (& self) -> HostResult < [u8 ; 4] > ;",
+ "fn trace_num (& self , msg : & str , number : i64) -> HostResult < () > ;",
+ "pub enum HostFunctionSpec { GetLedgerSqn , TraceNum , }",
+ "pub const ALL : & 'static [Self] = & [Self :: GetLedgerSqn , Self :: TraceNum ,]",
+ // The table's row type is generated too, and stays private.
+ "struct HostFnSpec { name : & 'static str , gas : u64 , }",
+ "const fn spec (self) -> HostFnSpec",
+ "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }",
+ "pub const fn wasm_name (self) -> & 'static str",
+ "pub const fn gas (self) -> u64",
+ ] {
+ assert!(generated.contains(expected), "missing {expected:?}");
+ }
+ }
+
+ /// The expansion stands alone: every name in it is either generated here or
+ /// written in the declarations, so it cannot depend on the crate it lands in.
+ #[test]
+ fn names_no_crate_of_its_own() {
+ let generated = expand(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ })
+ .unwrap()
+ .to_string();
+
+ assert!(!generated.contains("xrpl_host_functions"), "{generated}");
+
+ // `Self::Variant` is the only path the expansion may build: anything else
+ // would reach out of the generated code. Doc comments spell paths without
+ // spaces (`Self::ALL`), so they do not match.
+ for (index, _) in generated.match_indices(" :: ") {
+ assert!(
+ generated[..index].ends_with("Self"),
+ "path out of the expansion at {index}: {generated}"
+ );
+ }
+ }
+
+ /// `spec` is an implementation detail of the two accessors, so it must not
+ /// become part of the ABI crate's public surface.
+ #[test]
+ fn keeps_the_table_row_private() {
+ let generated = expand(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ })
+ .unwrap()
+ .to_string();
+
+ assert!(!generated.contains("pub struct HostFnSpec"), "{generated}");
+ assert!(!generated.contains("pub const fn spec"), "{generated}");
+ }
+
+ #[test]
+ fn rejects_two_functions_that_share_a_wasm_name() {
+ let messages = messages(quote! {
+ #[gas = 60]
+ #[wasm_name = "trace"]
+ fn trace(&self, msg: &str) -> HostResult<()>;
+
+ #[gas = 70]
+ #[wasm_name = "trace"]
+ fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(
+ messages[0].contains("already imported as `trace`"),
+ "{messages:?}"
+ );
+ }
+
+ /// Names that differ only in underscores collapse to one enum variant.
+ #[test]
+ fn rejects_two_functions_that_share_a_variant() {
+ let messages = messages(quote! {
+ #[gas = 60]
+ #[wasm_name = "a"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+
+ #[gas = 70]
+ #[wasm_name = "b"]
+ fn get_ledger__sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(
+ messages[0].contains("`GetLedgerSqn` variant"),
+ "{messages:?}"
+ );
+ }
+}
diff --git a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs
new file mode 100644
index 0000000000..77f126dfa3
--- /dev/null
+++ b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs
@@ -0,0 +1,859 @@
+use proc_macro2::TokenStream;
+use quote::{ToTokens, format_ident, quote};
+use syn::{
+ Attribute, Ident, LitInt, LitStr, PathArguments, ReceiverKind, ReturnType, Safety, Signature,
+ TraitItemFn, Type, TypePath, parse::Parse,
+};
+
+use crate::errors;
+
+/// `#[gas = N]`: the base gas charged before the call runs.
+const GAS: &str = "gas";
+/// `#[wasm_name = "..."]`: the name the guest imports the function under.
+const WASM_NAME: &str = "wasm_name";
+/// `///` desugars to `#[doc = "..."]` before macro expansion.
+const DOC: &str = "doc";
+/// The alias every declaration returns its success type through.
+const HOST_RESULT: &str = "HostResult";
+
+/// One entry of a `host_functions!` block: its ABI metadata and its signature.
+pub(crate) struct ParsedHostFunction {
+ pub(crate) gas: u64,
+ /// Kept as the literal the user wrote, so diagnostics and the generated
+ /// string both carry that span.
+ pub(crate) wasm_name: LitStr,
+ /// Doc comments, in source order, to re-emit on the generated items.
+ pub(crate) docs: Vec,
+ /// The enum variant this declaration becomes, spanned at the function name.
+ pub(crate) variant: Ident,
+ pub(crate) signature: Signature,
+}
+
+impl ParsedHostFunction {
+ /// `#[doc …] fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;`
+ pub(crate) fn trait_method(&self) -> TokenStream {
+ let docs = &self.docs;
+ // The declaration is already a trait method: emitted verbatim, so what
+ // the block reads like is what the trait is.
+ let signature = &self.signature;
+
+ quote! {
+ #(#docs)*
+ #signature;
+ }
+ }
+
+ /// `#[doc …] GetLedgerSqn`
+ pub(crate) fn variant_declaration(&self) -> TokenStream {
+ let docs = &self.docs;
+ let variant = &self.variant;
+ quote! {
+ #(#docs)*
+ #variant
+ }
+ }
+
+ /// `Self::GetLedgerSqn => HostFnSpec { name: "ldgr_index", gas: 60u64 }`
+ pub(crate) fn spec_arm(&self) -> TokenStream {
+ let Self {
+ gas,
+ wasm_name,
+ variant,
+ ..
+ } = self;
+ quote! {
+ Self::#variant => HostFnSpec { name: #wasm_name, gas: #gas }
+ }
+ }
+
+ pub(crate) fn parse(function: TraitItemFn) -> syn::Result {
+ let mut gas = None;
+ let mut wasm_name = None;
+ let mut docs = Vec::new();
+ let mut errors = Vec::new();
+
+ // Tracked separately from `gas`/`wasm_name` so a malformed attribute is
+ // not also reported as a missing one.
+ let mut saw_gas = false;
+ let mut saw_wasm_name = false;
+
+ for attr in function.attrs {
+ if attr.path().is_ident(GAS) {
+ saw_gas = true;
+ if let Err(error) = int_value(&attr).and_then(|v| set_once(&mut gas, v, &attr)) {
+ errors.push(error);
+ }
+ } else if attr.path().is_ident(WASM_NAME) {
+ saw_wasm_name = true;
+ if let Err(error) = value::(&attr, "a string literal")
+ .and_then(|v| set_once(&mut wasm_name, v, &attr))
+ {
+ errors.push(error);
+ }
+ } else if attr.path().is_ident(DOC) {
+ docs.push(attr);
+ } else {
+ errors.push(syn::Error::new_spanned(
+ &attr,
+ format!("unexpected attribute `{}`", path_name(&attr)),
+ ));
+ }
+ }
+
+ if !saw_gas {
+ errors.push(syn::Error::new_spanned(
+ &function.sig.ident,
+ format!("missing `#[{GAS} = ...]` attribute"),
+ ));
+ }
+ if !saw_wasm_name {
+ errors.push(syn::Error::new_spanned(
+ &function.sig.ident,
+ format!("missing `#[{WASM_NAME} = \"...\"]` attribute"),
+ ));
+ }
+ if let Some(body) = &function.default {
+ errors.push(syn::Error::new_spanned(
+ body,
+ "a host function is implemented by the host, so it must not have a body",
+ ));
+ }
+ if !function.sig.generics.params.is_empty() || function.sig.generics.where_clause.is_some()
+ {
+ errors.push(syn::Error::new_spanned(
+ &function.sig.ident,
+ "a host function must not be generic: it maps to one wasm import signature",
+ ));
+ }
+ errors.extend(check_receiver(&function.sig).err());
+ errors.extend(check_return_type(&function.sig).err());
+ if let Some(name) = &wasm_name {
+ errors.extend(check_wasm_name(name).err());
+ }
+ reject_modifiers(&function.sig, &mut errors);
+
+ // A name whose PascalCase form is not a legal variant is reported here
+ // rather than emitted, which would either panic or fail downstream.
+ let variant = match variant_ident(&function.sig.ident) {
+ Ok(variant) => Some(variant),
+ Err(error) => {
+ errors.push(error);
+ None
+ }
+ };
+
+ if let Some(error) = errors::combine(errors) {
+ return Err(error);
+ }
+
+ let (Some(gas), Some(wasm_name), Some(variant)) = (gas, wasm_name, variant) else {
+ unreachable!("every absent field is reported above");
+ };
+
+ Ok(Self {
+ gas,
+ wasm_name,
+ docs,
+ variant,
+ signature: function.sig,
+ })
+ }
+}
+
+/// Every declaration carries a receiver, and it is always `&self`.
+///
+/// `&self` is the only receiver that can work: the VM reaches the host through a
+/// shared `&dyn HostFunctions` stored in the wasmi `Store`, and a host that needs
+/// to mutate does so behind interior mutability. The receiver is not part of the
+/// wasm ABI — the guest passes no `self` — so it is uniform across the block.
+fn check_receiver(signature: &Signature) -> syn::Result<()> {
+ let Some(receiver) = signature.receiver() else {
+ return Err(syn::Error::new_spanned(
+ &signature.ident,
+ format!(
+ "a host function must declare its receiver: `fn {}(&self, ...)`",
+ signature.ident
+ ),
+ ));
+ };
+
+ // `&self` and nothing else: not `&mut self`, not `self`/`mut self`, not a
+ // typed `self: Box`, and not a spelled-out lifetime.
+ if !matches!(receiver.kind, ReceiverKind::Reference(_, None, None)) {
+ return Err(syn::Error::new_spanned(
+ receiver,
+ "a host function's receiver must be exactly `&self`: the VM calls the host \
+ through a shared `&dyn HostFunctions`",
+ ));
+ }
+ Ok(())
+}
+
+/// Every declaration returns `HostResult`, including the ones that yield
+/// nothing (`HostResult<()>`).
+///
+/// One shape for every function is what lets a single dispatch adapter lower them
+/// all: lift the arguments out of guest memory, call the host, then turn `Ok(T)`
+/// into the wire's non-negative `i32` and `Err(e)` into a negative code or a trap.
+/// A function returning a bare `T` would need its own arm.
+fn check_return_type(signature: &Signature) -> syn::Result<()> {
+ const SHAPE: &str = "a host function must return `HostResult` — \
+ `HostResult<()>` if it yields nothing";
+
+ let ReturnType::Type(_, returned) = &signature.output else {
+ return Err(syn::Error::new_spanned(&signature.ident, SHAPE));
+ };
+
+ let Type::Path(TypePath {
+ qself: None, path, ..
+ }) = &**returned
+ else {
+ return Err(syn::Error::new_spanned(returned, SHAPE));
+ };
+ // The last segment only, so `HostResult` may be written qualified.
+ let Some(last) = path.segments.last() else {
+ return Err(syn::Error::new_spanned(returned, SHAPE));
+ };
+ if last.ident != HOST_RESULT {
+ return Err(syn::Error::new_spanned(returned, SHAPE));
+ }
+
+ // `HostResult` without its success type is `HostResult` the alias, which names
+ // no type; rustc's own message for that is unhelpfully far from the cause.
+ let PathArguments::AngleBracketed(arguments) = &last.arguments else {
+ return Err(syn::Error::new_spanned(
+ returned,
+ format!("`{HOST_RESULT}` needs its success type: `{HOST_RESULT}`"),
+ ));
+ };
+ if arguments.args.len() != 1 {
+ return Err(syn::Error::new_spanned(
+ arguments,
+ format!("`{HOST_RESULT}` takes exactly one type: `{HOST_RESULT}`"),
+ ));
+ }
+ Ok(())
+}
+
+/// `const`, `async`, `unsafe`/`safe` and `extern "…"` have no meaning in the
+/// wasm ABI, and would otherwise pass silently into the generated trait.
+fn reject_modifiers(signature: &Signature, errors: &mut Vec) {
+ const PLAIN: &str =
+ "a host function must be a plain `fn`: this modifier is not part of the wasm ABI";
+
+ if let Some(constness) = &signature.constness {
+ errors.push(syn::Error::new_spanned(constness, PLAIN));
+ }
+ if let Some(asyncness) = &signature.asyncness {
+ errors.push(syn::Error::new_spanned(asyncness, PLAIN));
+ }
+ match &signature.safety {
+ Safety::Default => {}
+ Safety::Safe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)),
+ Safety::Unsafe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)),
+ }
+ if let Some(abi) = &signature.abi {
+ errors.push(syn::Error::new_spanned(abi, PLAIN));
+ }
+}
+
+/// The wasm import name reaches the engine's import table verbatim, so it is
+/// held to what an import name can sanely be rather than to any string.
+fn check_wasm_name(name: &LitStr) -> syn::Result<()> {
+ let value = name.value();
+ if value.is_empty() {
+ return Err(syn::Error::new_spanned(
+ name,
+ "the wasm name must not be empty",
+ ));
+ }
+ if let Some(character) = value
+ .chars()
+ .find(|c| !c.is_ascii_alphanumeric() && *c != '_')
+ {
+ return Err(syn::Error::new_spanned(
+ name,
+ format!(
+ "a wasm name may only contain `A-Za-z0-9_`, but this one contains {character:?}"
+ ),
+ ));
+ }
+ Ok(())
+}
+
+/// The enum variant a declaration becomes: `get_ledger_sqn` -> `GetLedgerSqn`.
+///
+/// The result carries `ident`'s span, so anything the compiler says about the
+/// variant points at the declaration that produced it.
+fn variant_ident(ident: &Ident) -> syn::Result {
+ // `to_string` spells raw identifiers `r#type`; the `r#` is not part of the name.
+ let name = ident.to_string();
+ let name = name.strip_prefix("r#").unwrap_or(&name);
+
+ let mut pascal = String::with_capacity(name.len());
+ let mut capitalize = true;
+ for character in name.chars() {
+ if character == '_' {
+ capitalize = true;
+ } else if capitalize {
+ pascal.extend(character.to_uppercase());
+ capitalize = false;
+ } else {
+ pascal.push(character);
+ }
+ }
+
+ // A name of nothing but underscores leaves `pascal` empty; the original is
+ // already a legal identifier, so keep it.
+ if pascal.is_empty() {
+ return Ok(ident.clone());
+ }
+
+ // `Ident::new` panics on a leading digit (`_2fa` -> `2fa`) and silently
+ // accepts keyword spellings (`self_` -> `Self`), which then fails to parse
+ // where the variant is emitted. Parsing rejects both, without panicking.
+ if let Err(error) = syn::parse_str::(&pascal) {
+ return Err(syn::Error::new_spanned(
+ ident,
+ format!(
+ "this name becomes the enum variant `{pascal}`, which is not a valid \
+ variant name ({error}); rename the host function"
+ ),
+ ));
+ }
+ Ok(format_ident!("{pascal}", span = ident.span()))
+}
+
+/// Records `value`, or reports that the attribute appeared more than once.
+fn set_once(slot: &mut Option, value: T, attr: &Attribute) -> syn::Result<()> {
+ if slot.replace(value).is_some() {
+ return Err(syn::Error::new_spanned(
+ attr,
+ format!("duplicate `{}` attribute", path_name(attr)),
+ ));
+ }
+ Ok(())
+}
+
+/// The value of `#[name = ]`, parsed as `T`.
+///
+/// `expected` completes "`gas` expects …": syn's own message for the wrong kind
+/// of literal names neither the attribute nor what it wanted.
+fn value(attr: &Attribute, expected: &str) -> syn::Result {
+ let expr = &attr.meta.require_name_value()?.value;
+ syn::parse2(expr.to_token_stream()).map_err(|_| {
+ syn::Error::new_spanned(expr, format!("`{}` expects {expected}", path_name(attr)))
+ })
+}
+
+fn int_value(attr: &Attribute) -> syn::Result {
+ let int: LitInt = value(attr, "an integer literal")?;
+ // `LitInt` keeps the sign in its digits, so `base10_parse::` would
+ // report a negative value as "invalid digit found in string".
+ if int.base10_digits().starts_with('-') {
+ return Err(syn::Error::new_spanned(
+ int,
+ format!("`{}` must not be negative", path_name(attr)),
+ ));
+ }
+ int.base10_parse()
+}
+
+/// The attribute's path as written, for diagnostics: `gas`, or `foo::bar`.
+fn path_name(attr: &Attribute) -> String {
+ attr.path()
+ .segments
+ .iter()
+ .map(|segment| segment.ident.to_string())
+ .collect::>()
+ .join("::")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use syn::{Expr, ExprLit, Lit, parse_quote};
+
+ /// The message of every diagnostic recorded by one failed `parse`.
+ ///
+ /// `expect_err` is unavailable here: it needs `T: Debug`, and syn only
+ /// implements `Debug` for its AST types under the `extra-traits` feature.
+ fn messages(function: TraitItemFn) -> Vec {
+ let Err(error) = ParsedHostFunction::parse(function) else {
+ panic!("expected parsing to fail");
+ };
+ error.into_iter().map(|error| error.to_string()).collect()
+ }
+
+ fn doc_text(attr: &Attribute) -> String {
+ match &attr.meta.require_name_value().unwrap().value {
+ Expr::Lit(ExprLit {
+ lit: Lit::Str(text),
+ ..
+ }) => text.value(),
+ _ => panic!("doc attribute is not a string literal"),
+ }
+ }
+
+ #[test]
+ fn reads_gas_and_wasm_name() {
+ let parsed = ParsedHostFunction::parse(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ })
+ .unwrap();
+
+ assert_eq!(parsed.gas, 60);
+ assert_eq!(parsed.wasm_name.value(), "ldgr_index");
+ assert_eq!(parsed.signature.ident.to_string(), "get_ledger_sqn");
+ assert_eq!(parsed.variant.to_string(), "GetLedgerSqn");
+ assert!(parsed.docs.is_empty());
+ }
+
+ #[test]
+ fn derives_variant_names_from_function_names() {
+ for (function, variant) in [
+ ("get_ledger_sqn", "GetLedgerSqn"),
+ ("sha512_half", "Sha512Half"),
+ ("trace", "Trace"),
+ ("get_current_ledger_obj_field", "GetCurrentLedgerObjField"),
+ ("r#type", "Type"),
+ ("trace2", "Trace2"),
+ // Pathological, but must not panic: no letters to capitalize.
+ ("__", "__"),
+ ] {
+ let ident = format_ident!("{function}");
+ assert_eq!(
+ variant_ident(&ident).map(|v| v.to_string()).ok(),
+ Some(variant.to_owned()),
+ "{function}"
+ );
+ }
+ }
+
+ /// `_2fa` would PascalCase to `2fa`; building that `Ident` panics, and a
+ /// panic in a proc macro is reported with no useful span at all.
+ #[test]
+ fn rejects_a_name_that_becomes_a_leading_digit() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "two_factor"]
+ fn _2fa(&self) -> HostResult<()>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(
+ messages[0].contains("becomes the enum variant `2fa`"),
+ "{messages:?}"
+ );
+ }
+
+ /// `self_` PascalCases to `Self`, which `Ident::new` accepts and rustc then
+ /// rejects where the variant is emitted. `r#Self` is not a legal escape.
+ #[test]
+ fn rejects_a_name_that_becomes_a_keyword() {
+ for function in ["self_", "_self"] {
+ let ident = format_ident!("{function}");
+ let Err(error) = variant_ident(&ident) else {
+ panic!("expected `{function}` to be rejected");
+ };
+ assert!(
+ error.to_string().contains("variant `Self`"),
+ "{}",
+ error.to_string()
+ );
+ }
+ }
+
+ #[test]
+ fn rejects_negative_gas() {
+ let messages = messages(parse_quote! {
+ #[gas = -5]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert_eq!(messages[0], "`gas` must not be negative");
+ }
+
+ #[test]
+ fn rejects_unusable_wasm_names() {
+ let empty = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = ""]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(empty.len(), 1, "{empty:?}");
+ assert_eq!(empty[0], "the wasm name must not be empty");
+
+ let spaced = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(spaced.len(), 1, "{spaced:?}");
+ assert!(spaced[0].contains("may only contain"), "{spaced:?}");
+ }
+
+ #[test]
+ fn rejects_signature_modifiers() {
+ for declaration in [
+ quote! { unsafe fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
+ quote! { async fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
+ quote! { const fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
+ quote! { extern "C" fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
+ ] {
+ let function: TraitItemFn = syn::parse2(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ #declaration
+ })
+ .unwrap();
+
+ let messages = messages(function);
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(messages[0].contains("must be a plain `fn`"), "{messages:?}");
+ }
+ }
+
+ #[test]
+ fn trait_method_keeps_the_declared_receiver_and_ends_in_a_semicolon() {
+ let parsed = ParsedHostFunction::parse(parse_quote! {
+ /// Hashes `data`.
+ #[gas = 2000]
+ #[wasm_name = "sha512_half"]
+ fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>;
+ })
+ .unwrap();
+
+ // `///` reaches the macro as `#[doc = r"..."]`: rustc's lexer spells doc
+ // comments as raw string literals.
+ let method = parsed.trait_method().to_string();
+ assert!(
+ method.starts_with("# [doc = r\" Hashes `data`.\"]"),
+ "{method}"
+ );
+ assert!(
+ method
+ .contains("fn sha512_half (& self , data : & [u8]) -> HostResult < [u8 ; 32] > ;"),
+ "{method}"
+ );
+ }
+
+ #[test]
+ fn spec_arm_carries_the_name_and_the_gas() {
+ let parsed = ParsedHostFunction::parse(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ })
+ .unwrap();
+
+ assert_eq!(
+ parsed.spec_arm().to_string(),
+ "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }"
+ );
+ }
+
+ #[test]
+ fn keeps_doc_comments_in_source_order() {
+ let parsed = ParsedHostFunction::parse(parse_quote! {
+ /// First line.
+ ///
+ /// Third line.
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ })
+ .unwrap();
+
+ let docs: Vec<_> = parsed.docs.iter().map(doc_text).collect();
+ assert_eq!(docs, vec![" First line.", "", " Third line."]);
+ }
+
+ #[test]
+ fn preserves_parameters_and_return_type() {
+ let traced = ParsedHostFunction::parse(parse_quote! {
+ #[gas = 500]
+ #[wasm_name = "trace"]
+ fn trace(&self, msg: &str, data: &[u8], as_hex: bool) -> HostResult<()>;
+ })
+ .unwrap();
+ // The receiver is `inputs[0]`; the three wasm parameters follow it.
+ assert_eq!(traced.signature.inputs.len(), 4);
+ assert_eq!(
+ traced.signature.output.to_token_stream().to_string(),
+ "-> HostResult < () >"
+ );
+
+ let hashed = ParsedHostFunction::parse(parse_quote! {
+ #[gas = 2000]
+ #[wasm_name = "sha512_half"]
+ fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; HASH_LEN]>;
+ })
+ .unwrap();
+ assert_eq!(
+ hashed.signature.output.to_token_stream().to_string(),
+ "-> HostResult < [u8 ; HASH_LEN] >"
+ );
+ }
+
+ #[test]
+ fn reports_both_missing_attributes_at_once() {
+ let messages = messages(parse_quote! {
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 2);
+ assert!(messages[0].contains("missing `#[gas"), "{messages:?}");
+ assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}");
+ }
+
+ #[test]
+ fn names_the_unexpected_attribute() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[wsam_name = "typo"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ // The typo'd attribute, plus the `wasm_name` it failed to be.
+ assert_eq!(messages.len(), 2);
+ assert!(
+ messages.iter().any(|m| m.contains("`wsam_name`")),
+ "{messages:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_wrong_literal_types() {
+ let gas = messages(parse_quote! {
+ #[gas = "60"]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(gas.len(), 1, "{gas:?}");
+ assert!(
+ gas[0].contains("`gas` expects an integer literal"),
+ "{gas:?}"
+ );
+
+ let name = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = 7]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(name.len(), 1, "{name:?}");
+ assert!(
+ name[0].contains("`wasm_name` expects a string literal"),
+ "{name:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_gas_that_does_not_fit_in_u64() {
+ let messages = messages(parse_quote! {
+ #[gas = 99999999999999999999999]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(messages[0].contains("number too large"), "{messages:?}");
+ }
+
+ #[test]
+ fn rejects_attribute_shapes_other_than_name_value() {
+ let bare = messages(parse_quote! {
+ #[gas]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(bare.len(), 1, "{bare:?}");
+ assert!(bare[0].contains("gas = ..."), "{bare:?}");
+
+ let list = messages(parse_quote! {
+ #[gas(60)]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+ assert_eq!(list.len(), 1, "{list:?}");
+ }
+
+ #[test]
+ fn rejects_duplicate_attributes() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[gas = 70]
+ #[wasm_name = "ldgr_index"]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 2, "{messages:?}");
+ assert!(messages[0].contains("duplicate `gas`"), "{messages:?}");
+ assert!(
+ messages[1].contains("duplicate `wasm_name`"),
+ "{messages:?}"
+ );
+ }
+
+ /// A malformed attribute must not also be reported as an absent one.
+ #[test]
+ fn does_not_report_a_malformed_attribute_as_missing() {
+ let messages = messages(parse_quote! {
+ #[gas = "60"]
+ #[wasm_name = 7]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 2, "{messages:?}");
+ assert!(
+ !messages.iter().any(|m| m.contains("missing")),
+ "{messages:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_a_body() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> { Ok([0; 4]) }
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(messages[0].contains("must not have a body"), "{messages:?}");
+ }
+
+ #[test]
+ fn rejects_generics() {
+ let parameter = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult;
+ });
+ assert_eq!(parameter.len(), 1, "{parameter:?}");
+ assert!(
+ parameter[0].contains("must not be generic"),
+ "{parameter:?}"
+ );
+
+ let clause = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> where Self: Sized;
+ });
+ assert_eq!(clause.len(), 1, "{clause:?}");
+ }
+
+ #[test]
+ fn requires_a_receiver() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn() -> HostResult<[u8; 4]>;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(
+ messages[0].contains("must declare its receiver: `fn get_ledger_sqn(&self, ...)`"),
+ "{messages:?}"
+ );
+ }
+
+ /// Anything but `&self` would need a host the VM cannot hand out: it holds
+ /// one shared `&dyn HostFunctions` for the whole run.
+ #[test]
+ fn rejects_receivers_other_than_shared_self() {
+ for receiver in [
+ quote! { &mut self },
+ quote! { self },
+ quote! { mut self },
+ quote! { self: Box },
+ quote! { &'a self },
+ ] {
+ let function: TraitItemFn = syn::parse2(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(#receiver) -> HostResult<[u8; 4]>;
+ })
+ .unwrap_or_else(|_| panic!("`{receiver}` should parse"));
+
+ let messages = messages(function);
+ assert_eq!(messages.len(), 1, "`{receiver}`: {messages:?}");
+ assert!(
+ messages[0].contains("must be exactly `&self`"),
+ "`{receiver}`: {messages:?}"
+ );
+ }
+ }
+
+ /// A bare `T` return would need its own lowering arm, so the uniform shape is
+ /// required rather than inferred.
+ #[test]
+ fn rejects_returns_that_are_not_host_result() {
+ for output in [
+ quote! {},
+ quote! { -> () },
+ quote! { -> [u8; 4] },
+ quote! { -> i32 },
+ quote! { -> Result<[u8; 4], HostError> },
+ quote! { -> impl Iterator- },
+ ] {
+ let function: TraitItemFn = syn::parse2(quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) #output;
+ })
+ .unwrap_or_else(|_| panic!("`{output}` should parse"));
+
+ let messages = messages(function);
+ assert_eq!(messages.len(), 1, "`{output}`: {messages:?}");
+ assert!(
+ messages[0].contains("must return `HostResult`"),
+ "`{output}`: {messages:?}"
+ );
+ }
+ }
+
+ /// `HostResult` may be written qualified, since the trait method keeps whatever
+ /// path resolves where the block is written.
+ #[test]
+ fn accepts_a_qualified_host_result() {
+ let parsed = ParsedHostFunction::parse(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> xrpl_host_functions::HostResult<[u8; 4]>;
+ })
+ .unwrap();
+
+ assert!(
+ parsed
+ .trait_method()
+ .to_string()
+ .contains("xrpl_host_functions :: HostResult < [u8 ; 4] >"),
+ "{}",
+ parsed.trait_method()
+ );
+ }
+
+ /// `HostResult` with no success type names no type at all; rustc's own error
+ /// for that lands on the generated trait, far from the declaration.
+ #[test]
+ fn rejects_host_result_without_a_success_type() {
+ let messages = messages(parse_quote! {
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self) -> HostResult;
+ });
+
+ assert_eq!(messages.len(), 1, "{messages:?}");
+ assert!(
+ messages[0].contains("needs its success type"),
+ "{messages:?}"
+ );
+ }
+}
diff --git a/crates/xrpl-host-functions/Cargo.toml b/crates/xrpl-host-functions/Cargo.toml
new file mode 100644
index 0000000000..c08bb7d62f
--- /dev/null
+++ b/crates/xrpl-host-functions/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "xrpl-host-functions"
+version = "0.1.0"
+edition.workspace = true
+
+[dependencies]
+xrpl-host-functions-macros.path = "../xrpl-host-functions-macros"
diff --git a/crates/xrpl-host-functions/src/lib.rs b/crates/xrpl-host-functions/src/lib.rs
new file mode 100644
index 0000000000..9cb5a1d8b0
--- /dev/null
+++ b/crates/xrpl-host-functions/src/lib.rs
@@ -0,0 +1,508 @@
+//! The wasm host ABI: the one place it is declared.
+//!
+//! `host_functions!` turns the declaration block at the bottom of this file into the
+//! [`HostFunctions`] trait a host implements and the [`HostFunctionSpec`] table a
+//! wasm engine registers from.
+//!
+//! The split: hand-written here is the vocabulary the declarations are written in —
+//! [`HostError`], [`TraceDataType`], [`HostResult`], [`HASH_LEN`] — and everything
+//! derived from the declarations is generated. The expansion names nothing this file
+//! does not, so the two sides meet only in the block below.
+//!
+//! So this file is lists — error codes, trace data types, functions. The `macro_rules!`
+//! that expand the first two into enums live in `macros.rs`.
+
+#![no_std]
+
+#[macro_use]
+mod macros;
+
+// Not re-exported: the ABI is declared once, here, and this is the only call site.
+use xrpl_host_functions_macros::host_functions;
+
+host_errors! {
+ Unimplemented = -1,
+ FieldNotFound = -2,
+ BufferTooSmall = -3,
+ NoArray = -4,
+ NotLeafField = -5,
+ LocatorMalformed = -6,
+ SlotOutRange = -7,
+ SlotsFull = -8,
+ EmptySlot = -9,
+ LedgerObjNotFound = -10,
+ OutOfTransferLimit = -11,
+ DataFieldTooLarge = -12,
+ PointerOutOfBounds = -13,
+ NoMemExported = -14,
+ InvalidParams = -15,
+ InvalidAccount = -16,
+ InvalidField = -17,
+ IndexOutOfBounds = -18,
+ FloatInputMalformed = -19,
+ FloatComputationError = -20,
+ /// Internal fatal error.
+ /// User code will never see this error but keep it reserved to not rely on the value.
+ InternalFatal = -2147483648,
+}
+
+/// Convenience alias for the trait's fallible returns.
+pub type HostResult = Result;
+
+/// A `sha512Half` digest: the first 32 bytes of a SHA-512, as XRPL uses it.
+pub const HASH_LEN: usize = 32;
+
+trace_data_types! {
+ /// 8 little-endian bytes, rendered as a signed decimal.
+ Int64 = 1,
+ /// 8 little-endian bytes, rendered as an unsigned decimal.
+ Uint64 = 2,
+ /// A serialized XRPL float: 12 bytes, mantissa then exponent.
+ Xfloat = 3,
+ /// A 20-byte account ID, rendered as base58.
+ Account = 4,
+ /// A serialized `STAmount`.
+ Amount = 5,
+ /// Raw bytes, hex-encoded.
+ AsHex = 6,
+ /// Bytes rendered verbatim as text.
+ AsText = 7,
+}
+
+host_functions! {
+ /// The sequence number of the ledger being built, as 4 little-endian bytes.
+ #[gas = 60]
+ #[wasm_name = "ldgr_index"]
+ fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult;
+
+ /// The close time of the parent (last-closed) ledger, as 4 little-endian bytes.
+ #[gas = 60]
+ #[wasm_name = "parent_ldgr_time"]
+ fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult;
+
+ /// The hash of the parent (last-closed) ledger, as 32 bytes.
+ #[gas = 60]
+ #[wasm_name = "parent_ldgr_hash"]
+ fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult;
+
+ /// The base fee of the ledger being built, in drops, as 4 little-endian bytes.
+ #[gas = 60]
+ #[wasm_name = "base_fee"]
+ fn get_base_fee(&self, out: &mut [u8]) -> HostResult;
+
+ /// Whether an amendment is enabled. The input is either its 32-byte id or its name;
+ /// the answer is `1` if enabled and `0` if not.
+ #[gas = 100]
+ #[wasm_name = "amendment_enabled"]
+ fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult;
+
+ /// Load the ledger object with the given 32-byte id into a cache slot, so later
+ /// calls can read its fields. `cache_idx` selects the slot (1-based); `0` asks the
+ /// host to assign a free one. Answers the slot used.
+ #[gas = 5000]
+ #[wasm_name = "cache_le"]
+ fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult;
+
+ /// The serialized bytes of one field of the transaction being executed, selected
+ /// by its `SField` code.
+ #[gas = 70]
+ #[wasm_name = "tx_field"]
+ fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult;
+
+ /// The serialized bytes of one field of the current (escrow) ledger object.
+ #[gas = 70]
+ #[wasm_name = "home_le_field"]
+ fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult;
+
+ /// The serialized bytes of one field of a previously cached ledger object,
+ /// selected by its cache slot and the field's `SField` code.
+ #[gas = 70]
+ #[wasm_name = "le_field"]
+ fn get_ledger_obj_field(&self, cache_idx: i32, field: i32, out: &mut [u8]) -> HostResult;
+
+ /// The serialized bytes of a nested field of the transaction, reached by a
+ /// `locator`: a path of little-endian `i32` steps (so its byte length is a non-zero
+ /// multiple of 4).
+ #[gas = 110]
+ #[wasm_name = "tx_inner"]
+ fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The serialized bytes of a nested field of the current (escrow) ledger object,
+ /// reached by a `locator`, as with [`HostFunctions::get_tx_nested_field`].
+ #[gas = 110]
+ #[wasm_name = "home_le_inner"]
+ fn get_current_ledger_obj_nested_field(
+ &self,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The serialized bytes of a nested field of a previously cached ledger object,
+ /// selected by its cache slot and reached by a `locator`.
+ #[gas = 110]
+ #[wasm_name = "le_inner"]
+ fn get_ledger_obj_nested_field(
+ &self,
+ cache_idx: i32,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The number of elements in an array field of the transaction, selected by its
+ /// `SField` code. Answers the count directly; `NoArray` if the field is not an array.
+ #[gas = 40]
+ #[wasm_name = "tx_arr_len"]
+ fn get_tx_array_len(&self, field: i32) -> HostResult;
+
+ /// The number of elements in an array field of the current (escrow) ledger
+ /// object, as with [`HostFunctions::get_tx_array_len`].
+ #[gas = 40]
+ #[wasm_name = "home_le_arr_len"]
+ fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult;
+
+ /// The number of elements in an array field of a previously cached ledger object,
+ /// selected by its cache slot and `SField` code.
+ #[gas = 40]
+ #[wasm_name = "le_arr_len"]
+ fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult;
+
+ /// The number of elements in a nested array field of the transaction, reached by a
+ /// `locator`.
+ #[gas = 70]
+ #[wasm_name = "tx_inner_arr_len"]
+ fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult;
+
+ /// The number of elements in a nested array field of the current (escrow) ledger
+ /// object, reached by a `locator`, as with [`HostFunctions::get_tx_nested_array_len`].
+ #[gas = 70]
+ #[wasm_name = "home_le_inner_arr_len"]
+ fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult;
+
+ /// The number of elements in a nested array field of a previously cached ledger
+ /// object, selected by its cache slot and reached by a `locator`.
+ #[gas = 70]
+ #[wasm_name = "le_inner_arr_len"]
+ fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult;
+
+ /// Verify `signature` over `message` under `pubkey`. Answers `1` if the signature
+ /// is valid, `0` if not, or a negative error.
+ #[gas = 300]
+ #[wasm_name = "check_sig"]
+ fn check_signature(
+ &self,
+ message: &[u8],
+ signature: &[u8],
+ pubkey: &[u8],
+ ) -> HostResult;
+
+ /// The 32-byte ledger key (keylet) of an account's `AccountRoot`, computed from a
+ /// 20-byte account id.
+ #[gas = 350]
+ #[wasm_name = "accountroot_id"]
+ fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of an AMM, computed from its two assets. Each asset is a byte
+ /// slice whose length selects its kind (24 = MPT, 20 = XRP, 40 = issued currency +
+ /// issuer).
+ #[gas = 450]
+ #[wasm_name = "amm_id"]
+ fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `Check`, computed from a 20-byte account id and its
+ /// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern.
+ #[gas = 350]
+ #[wasm_name = "check_id"]
+ fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `Credential`, computed from the 20-byte subject and
+ /// issuer account ids and a credential-type byte string.
+ #[gas = 350]
+ #[wasm_name = "credential_id"]
+ fn credential_keylet(
+ &self,
+ subject: &[u8],
+ issuer: &[u8],
+ credential_type: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of a `Delegate` object, computed from the 20-byte account and
+ /// the account it authorizes.
+ #[gas = 350]
+ #[wasm_name = "delegate_id"]
+ fn delegate_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of a `DepositPreauth`, computed from the 20-byte account and
+ /// the account it authorizes to deposit.
+ #[gas = 350]
+ #[wasm_name = "deposit_preauth_id"]
+ fn deposit_preauth_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of an account's `DID`, computed from its 20-byte account id.
+ #[gas = 350]
+ #[wasm_name = "did_id"]
+ fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of an `Escrow`, computed from the 20-byte owner account and
+ /// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
+ /// pattern.
+ #[gas = 350]
+ #[wasm_name = "escrow_id"]
+ fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `RippleState` (trust line), computed from two 20-byte
+ /// account ids and a 20-byte currency.
+ #[gas = 400]
+ #[wasm_name = "trustline_id"]
+ fn trust_line_keylet(
+ &self,
+ account1: &[u8],
+ account2: &[u8],
+ currency: &[u8],
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of an `MPTokenIssuance`, computed from the 20-byte issuer
+ /// account and its sequence number. `seq` is the guest's `u32` carried as its `i32`
+ /// bit pattern.
+ #[gas = 350]
+ #[wasm_name = "mpt_issuance_id"]
+ fn mptoken_issuance_keylet(
+ &self,
+ issuer: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of an `MPToken`, computed from a 24-byte MPT issuance id and
+ /// the 20-byte holder account.
+ #[gas = 500]
+ #[wasm_name = "mptoken_id"]
+ fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of an `NFTokenOffer`, computed from the 20-byte owner account
+ /// and its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
+ /// pattern.
+ #[gas = 350]
+ #[wasm_name = "nft_offer_id"]
+ fn nftoken_offer_keylet(
+ &self,
+ account: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of an `Offer`, computed from the 20-byte owner account and
+ /// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
+ /// pattern.
+ #[gas = 350]
+ #[wasm_name = "offer_id"]
+ fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of an `Oracle`, computed from the 20-byte owner account and
+ /// its document id. `doc_id` is the guest's `u32` carried as its `i32` bit pattern.
+ #[gas = 350]
+ #[wasm_name = "oracle_id"]
+ fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `PayChannel`, computed from the 20-byte source account,
+ /// the 20-byte destination account, and the channel's sequence number. `seq` is the
+ /// guest's `u32` carried as its `i32` bit pattern.
+ #[gas = 350]
+ #[wasm_name = "paychan_id"]
+ fn paychannel_keylet(
+ &self,
+ account: &[u8],
+ destination: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of a `PermissionedDomain`, computed from the 20-byte owner
+ /// account and its sequence number. `seq` is the guest's `u32` carried as its `i32`
+ /// bit pattern.
+ #[gas = 350]
+ #[wasm_name = "permissioned_domain_id"]
+ fn permissioned_domain_keylet(
+ &self,
+ account: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// The 32-byte keylet of a `SignerList`, computed from its 20-byte owner account.
+ #[gas = 350]
+ #[wasm_name = "signers_id"]
+ fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `Ticket`, computed from the 20-byte owner account and
+ /// its ticket sequence number. `seq` is the guest's `u32` carried as its `i32` bit
+ /// pattern.
+ #[gas = 350]
+ #[wasm_name = "ticket_id"]
+ fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult;
+
+ /// The 32-byte keylet of a `Vault`, computed from the 20-byte owner account and its
+ /// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern.
+ #[gas = 350]
+ #[wasm_name = "vault_id"]
+ fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult;
+
+ /// The XRPL `sha512Half` of `data`: the first [`HASH_LEN`] bytes of its SHA-512.
+ #[gas = 2000]
+ #[wasm_name = "sha512_half"]
+ fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// Writes `msg` to the trace log, followed by `data` rendered as `data_type` says.
+ ///
+ /// The one declaration whose wasm function has **no result**: this node's own log
+ /// is its only effect, so a guest is told nothing. An `Err` from a host therefore
+ /// reaches it in no form, and only the host-fatal ones do anything at all.
+ ///
+ /// It is also the one declaration that is **not** the wasm parameter order.
+ /// `data_type` is the third wasm parameter, between the two regions, because that
+ /// is where the guest stdlib declares it; `register.rs` takes the arguments in wasm
+ /// order and calls this in declaration order.
+ #[gas = 30]
+ #[wasm_name = "trace"]
+ fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()>;
+
+ /// Stores `data` as the current object's data field, replacing whatever was there,
+ /// and returns the number of bytes stored; `DataFieldTooLarge` if it exceeds the
+ /// host's limit.
+ #[gas = 1000]
+ #[wasm_name = "set_data"]
+ fn update_data(&self, data: &[u8]) -> HostResult;
+
+ /// The URI of the `NFToken` with id `nft_id` (32 bytes) held by the 20-byte
+ /// `account`.
+ #[gas = 5000]
+ #[wasm_name = "nft_uri"]
+ fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The 20-byte issuer account encoded in the `NFToken` id `nft_id` (32 bytes).
+ #[gas = 70]
+ #[wasm_name = "nft_issuer"]
+ fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The taxon encoded in the `NFToken` id `nft_id` (32 bytes), as four little-endian
+ /// bytes.
+ #[gas = 60]
+ #[wasm_name = "nft_taxon"]
+ fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult;
+
+ /// The flags encoded in the `NFToken` id `nft_id` (32 bytes).
+ #[gas = 60]
+ #[wasm_name = "nft_flags"]
+ fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult;
+
+ /// The transfer fee encoded in the `NFToken` id `nft_id` (32 bytes).
+ #[gas = 60]
+ #[wasm_name = "nft_xfer_fee"]
+ fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult;
+
+ /// The sequence number encoded in the `NFToken` id `nft_id` (32 bytes), as four
+ /// little-endian bytes.
+ #[gas = 60]
+ #[wasm_name = "nft_serial"]
+ fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult;
+
+ // A "float" here is an XRPL `Number` in its serialized form: a byte blob the guest
+ // holds opaquely and hands back to these functions. Inputs and outputs that are
+ // floats are byte regions; `mode` is the rounding mode, a scalar the guest chooses.
+
+ /// A float built from the signed integer `x` under rounding `mode`.
+ #[gas = 100]
+ #[wasm_name = "float_from_int"]
+ fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// A float built from the unsigned integer in the 8-byte region `x` under rounding
+ /// `mode`.
+ #[gas = 130]
+ #[wasm_name = "float_from_uint"]
+ fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// A float built from the serialized `STAmount` in `amount` under rounding `mode`.
+ #[gas = 150]
+ #[wasm_name = "float_from_stamount"]
+ fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// A float built from the serialized `STNumber` in `number` under rounding `mode`.
+ #[gas = 150]
+ #[wasm_name = "float_from_stnumber"]
+ fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float `x` rounded to a signed integer under rounding `mode`, as eight
+ /// little-endian bytes.
+ #[gas = 130]
+ #[wasm_name = "float_to_int"]
+ fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float `x` split into its mantissa (eight little-endian bytes) and its exponent
+ /// (four little-endian bytes), each written to its own output region.
+ #[gas = 130]
+ #[wasm_name = "float_to_mant_exp"]
+ fn float_to_mant_exp(
+ &self,
+ x: &[u8],
+ mantissa_out: &mut [u8],
+ exponent_out: &mut [u8],
+ ) -> HostResult;
+
+ /// A float built from `mantissa` and `exponent` under rounding `mode`.
+ #[gas = 100]
+ #[wasm_name = "float_from_mant_exp"]
+ fn float_from_mant_exp(
+ &self,
+ mantissa: i64,
+ exponent: i32,
+ mode: i32,
+ out: &mut [u8],
+ ) -> HostResult;
+
+ /// Compares floats `x` and `y`, returning a negative, zero, or positive scalar as
+ /// `x` is less than, equal to, or greater than `y`.
+ #[gas = 80]
+ #[wasm_name = "float_cmp"]
+ fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult;
+
+ /// The float sum `x + y` under rounding `mode`.
+ #[gas = 160]
+ #[wasm_name = "float_add"]
+ fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float difference `x - y` under rounding `mode`.
+ #[gas = 160]
+ #[wasm_name = "float_sub"]
+ fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float product `x * y` under rounding `mode`.
+ #[gas = 300]
+ #[wasm_name = "float_mult"]
+ fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float quotient `x / y` under rounding `mode`.
+ #[gas = 300]
+ #[wasm_name = "float_div"]
+ fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The `n`-th root of the float `x` under rounding `mode`.
+ #[gas = 5500]
+ #[wasm_name = "float_root"]
+ fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult;
+
+ /// The float `x` raised to the power `n` under rounding `mode`.
+ #[gas = 5500]
+ #[wasm_name = "float_pow"]
+ fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult;
+}
diff --git a/crates/xrpl-host-functions/src/macros.rs b/crates/xrpl-host-functions/src/macros.rs
new file mode 100644
index 0000000000..b077526784
--- /dev/null
+++ b/crates/xrpl-host-functions/src/macros.rs
@@ -0,0 +1,102 @@
+//! The `macro_rules!` behind the two hand-listed enums, [`crate::HostError`] and
+//! [`crate::TraceDataType`].
+//!
+//! Each takes one list of `Variant = code,` and expands the enum together with the
+//! `ALL`/`code`/`from_code` set that must not fall behind it. The lists themselves stay
+//! in `lib.rs`, beside the `host_functions!` block.
+
+/// Declares [`crate::HostError`] from one list: the variants, `HostError::ALL` and
+/// `HostError::from_code`'s table all expand from the codes given.
+///
+/// One list is what makes `ALL` complete. Rust cannot enumerate an enum's
+/// variants — an exhaustive `match` forces an arm per variant but gives nothing to
+/// iterate — so a hand-written `ALL` beside a hand-written enum could only be kept
+/// in step by review, and `ALL`'s whole purpose is to be the set a test can trust.
+/// A code added to the list gains its `ALL` entry and its `from_code` arm by
+/// construction. `HostFunctionSpec::ALL` is complete the same way, from the
+/// `host_functions!` block.
+macro_rules! host_errors {
+ ($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
+ /// Error codes a host function may return.
+ ///
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ #[repr(i32)]
+ pub enum HostError {
+ $($(#[$doc])* $variant = $code,)+
+ }
+
+ impl HostError {
+ /// Every error a host function may return, in code order.
+ ///
+ /// The complete set, and complete by construction: a wasm engine's
+ /// split between the codes it hands the guest and the conditions it
+ /// traps on is a decision per variant, so the test that checks the
+ /// split iterates this and a code added to the ABI cannot slip past it.
+ pub const ALL: &'static [HostError] = &[$(HostError::$variant,)+];
+
+ /// The negative wire value a failed call returns. Every code but
+ /// `InternalFatal` is one a guest reads off that value.
+ #[inline]
+ pub const fn code(self) -> i32 {
+ self as i32
+ }
+
+ /// Reconstruct a `HostError` from its wire code.
+ ///
+ /// A code this ABI does not define is `InternalFatal`: an answer the
+ /// caller cannot act on is the call not having been served, and that is
+ /// the variant which says so. Positive values are not errors at all and go
+ /// the same way, since this is reached only once a negative return has
+ /// been read as a failure.
+ pub const fn from_code(code: i32) -> HostError {
+ match code {
+ $($code => HostError::$variant,)+
+ _ => HostError::InternalFatal,
+ }
+ }
+ }
+ };
+}
+
+/// Declares [`crate::TraceDataType`] from one list, so `TraceDataType::ALL`,
+/// `TraceDataType::code` and `TraceDataType::from_code` cannot fall behind the
+/// variants — the reason `host_errors!` above is written this way.
+macro_rules! trace_data_types {
+ ($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
+ /// How [`HostFunctions::trace`] is to read its data buffer.
+ ///
+ /// The discriminants are wire values shared with the guest stdlib: append only,
+ /// never renumber. They start at 1, so a zeroed argument names no type rather
+ /// than the first one.
+ ///
+ /// This is the declaration a guest and a host both compile against. The host
+ /// side needs a second one — `cxx` cannot be a dependency here, since this
+ /// crate also links into the guest — so `xrpl-wasm-vm-ffi` declares a shared
+ /// enum for C++ and converts, exhaustively, from this.
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ #[repr(i32)]
+ pub enum TraceDataType {
+ $($(#[$doc])* $variant = $code,)+
+ }
+
+ impl TraceDataType {
+ /// Every data type a guest may name, in code order.
+ pub const ALL: &'static [TraceDataType] = &[$(TraceDataType::$variant,)+];
+
+ /// The wire value a guest passes to name this type.
+ #[inline]
+ pub const fn code(self) -> i32 {
+ self as i32
+ }
+
+ /// The type `code` names, or `None`: the engine drops a call it cannot
+ /// read rather than guessing at a rendering the guest did not ask for.
+ pub const fn from_code(code: i32) -> Option {
+ match code {
+ $($code => Some(TraceDataType::$variant),)+
+ _ => None,
+ }
+ }
+ }
+ };
+}
diff --git a/crates/xrpl-host-functions/tests/expansion_hygiene.rs b/crates/xrpl-host-functions/tests/expansion_hygiene.rs
new file mode 100644
index 0000000000..32854bfd72
--- /dev/null
+++ b/crates/xrpl-host-functions/tests/expansion_hygiene.rs
@@ -0,0 +1,34 @@
+//! `host_functions!` must work outside the crate that declares the ABI: the only
+//! names its expansion needs are the ones the declarations themselves spell.
+
+use xrpl_host_functions::HostResult;
+use xrpl_host_functions_macros::host_functions;
+
+host_functions! {
+ /// Answers with the number it was given.
+ #[gas = 7]
+ #[wasm_name = "ping"]
+ fn ping(&self, number: i32) -> HostResult;
+}
+
+struct Host;
+
+impl HostFunctions for Host {
+ fn ping(&self, number: i32) -> HostResult {
+ Ok(number)
+ }
+}
+
+#[test]
+fn the_generated_table_stands_on_its_own() {
+ assert_eq!(HostFunctionSpec::ALL.len(), 1);
+ assert_eq!(HostFunctionSpec::Ping.wasm_name(), "ping");
+ assert_eq!(HostFunctionSpec::Ping.gas(), 7);
+}
+
+/// The generated trait is implementable from another crate, which is the point of
+/// declaring the ABI in a library at all.
+#[test]
+fn the_generated_trait_is_implementable_here() {
+ assert_eq!(Host.ping(3), Ok(3));
+}
diff --git a/crates/xrpl-host-functions/tests/generated_abi.rs b/crates/xrpl-host-functions/tests/generated_abi.rs
new file mode 100644
index 0000000000..f2358195cb
--- /dev/null
+++ b/crates/xrpl-host-functions/tests/generated_abi.rs
@@ -0,0 +1,1006 @@
+//! Exercises the API that `host_functions!` generates, not the macro itself:
+//! the `HostFunctions` trait is implementable and callable both directly and
+//! through `&dyn`, and the generated `HostFunctionSpec` and `TraceDataType`
+//! tables agree with the declarations in `src/lib.rs`. The macro's own parsing
+//! and diagnostics are covered by the unit tests in `xrpl-host-functions-macros`.
+
+use std::cell::RefCell;
+use std::collections::HashSet;
+
+use xrpl_host_functions::{
+ HASH_LEN, HostError, HostFunctionSpec, HostFunctions, HostResult, TraceDataType,
+};
+
+/// Records what it was asked to do; enough to prove the trait is usable.
+///
+/// Every method takes `&self`, so a host that records anything keeps it behind
+/// interior mutability.
+#[derive(Default)]
+struct FakeHost {
+ traced: RefCell>,
+}
+
+/// The contract every byte-producing host function follows: write only if the
+/// value fits, and report its true length either way, so the engine can turn a
+/// value that doesn't fit into `BufferTooSmall` without the host knowing the
+/// guest's buffer size.
+fn put(out: &mut [u8], value: &[u8]) -> HostResult {
+ if let Some(dst) = out.get_mut(..value.len()) {
+ dst.copy_from_slice(value);
+ }
+ Ok(value.len())
+}
+
+impl HostFunctions for FakeHost {
+ fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult {
+ put(out, &7u32.to_le_bytes())
+ }
+
+ fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult {
+ put(out, &9u32.to_le_bytes())
+ }
+
+ fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult {
+ put(out, &[0xab; HASH_LEN])
+ }
+
+ fn get_base_fee(&self, out: &mut [u8]) -> HostResult {
+ put(out, &10u32.to_le_bytes())
+ }
+
+ /// Returns a flag rather than bytes, and reads its input: enabled unless empty.
+ fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult {
+ Ok(i32::from(!amendment.is_empty()))
+ }
+
+ /// Returns a slot: the requested one, or slot 1 when asked to pick.
+ fn cache_ledger_obj(&self, _obj_id: &[u8], cache_idx: i32) -> HostResult {
+ Ok(if cache_idx == 0 { 1 } else { cache_idx })
+ }
+
+ /// A field getter over the transaction; fails on a negative selector.
+ fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult {
+ if field < 0 {
+ return Err(HostError::FieldNotFound);
+ }
+ put(out, &[field as u8])
+ }
+
+ /// Fails on a field it doesn't know, so the error channel is exercised too.
+ fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult {
+ if field < 0 {
+ return Err(HostError::FieldNotFound);
+ }
+ put(out, &[field as u8])
+ }
+
+ /// A field getter over a cached object, keyed by slot and selector.
+ fn get_ledger_obj_field(
+ &self,
+ cache_idx: i32,
+ field: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ if cache_idx <= 0 || field < 0 {
+ return Err(HostError::FieldNotFound);
+ }
+ put(out, &[cache_idx as u8, field as u8])
+ }
+
+ /// A nested-field getter over the transaction, keyed by the locator bytes.
+ fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult {
+ if locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ put(out, &[locator[0], locator.len() as u8])
+ }
+
+ /// The same, over the current ledger object.
+ fn get_current_ledger_obj_nested_field(
+ &self,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ put(out, &[locator.len() as u8, locator[0]])
+ }
+
+ /// The same, over a cached object keyed by slot.
+ fn get_ledger_obj_nested_field(
+ &self,
+ cache_idx: i32,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if cache_idx <= 0 || locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ put(out, &[cache_idx as u8, locator[0]])
+ }
+
+ /// A scalar-in, scalar-out count; `NoArray` on a negative selector.
+ fn get_tx_array_len(&self, field: i32) -> HostResult {
+ if field < 0 {
+ return Err(HostError::NoArray);
+ }
+ Ok(field)
+ }
+
+ /// The same, over the current ledger object.
+ fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult {
+ if field < 0 {
+ return Err(HostError::NoArray);
+ }
+ Ok(field + 1)
+ }
+
+ /// The same, over a cached object keyed by slot.
+ fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult {
+ if cache_idx <= 0 || field < 0 {
+ return Err(HostError::NoArray);
+ }
+ Ok(cache_idx + field)
+ }
+
+ /// A nested array-length getter, keyed by the locator bytes.
+ fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult {
+ if locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ Ok(locator.len() as i32)
+ }
+
+ /// The same, over the current ledger object.
+ fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult {
+ if locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ Ok(locator.len() as i32 + 1)
+ }
+
+ /// The same, over a cached object keyed by slot.
+ fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult {
+ if cache_idx <= 0 || locator.is_empty() {
+ return Err(HostError::LocatorMalformed);
+ }
+ Ok(cache_idx + locator.len() as i32)
+ }
+
+ /// Reads three regions and returns a verdict: valid unless the signature is empty.
+ fn check_signature(
+ &self,
+ _message: &[u8],
+ signature: &[u8],
+ _pubkey: &[u8],
+ ) -> HostResult {
+ Ok(i32::from(!signature.is_empty()))
+ }
+
+ /// A keylet getter: reads an account, writes a 32-byte keylet; `InvalidAccount`
+ /// on an empty account.
+ fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// A two-asset keylet getter; `InvalidParams` if the two assets are equal.
+ fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult {
+ if asset1 == asset2 {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[asset1.len() as u8; HASH_LEN])
+ }
+
+ /// A keylet from an account and a sequence; `InvalidAccount` on an empty account.
+ fn check_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// A keylet from subject, issuer, and credential type; `InvalidAccount` if either
+ /// account is empty, `InvalidParams` if the type is empty.
+ fn credential_keylet(
+ &self,
+ subject: &[u8],
+ issuer: &[u8],
+ credential_type: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if subject.is_empty() || issuer.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ if credential_type.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[subject[0]; HASH_LEN])
+ }
+
+ /// A keylet from two accounts; `InvalidAccount` if either is empty, `InvalidParams`
+ /// if they are equal.
+ fn delegate_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if account.is_empty() || authorize.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ if account == authorize {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same two-account shape, for a `DepositPreauth`.
+ fn deposit_preauth_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if account.is_empty() || authorize.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ if account == authorize {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[authorize[0]; HASH_LEN])
+ }
+
+ /// A single-account keylet, for a `DID`.
+ fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The account-and-sequence shape, for an `Escrow`.
+ fn escrow_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// A keylet from two accounts and a currency; `InvalidAccount` if either account
+ /// is empty, `InvalidParams` if they are equal or the currency is empty.
+ fn trust_line_keylet(
+ &self,
+ account1: &[u8],
+ account2: &[u8],
+ currency: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ if account1.is_empty() || account2.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ if account1 == account2 || currency.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[account1[0]; HASH_LEN])
+ }
+
+ /// The issuer-and-sequence shape, for an `MPTokenIssuance`.
+ fn mptoken_issuance_keylet(
+ &self,
+ issuer: &[u8],
+ _seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ if issuer.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[issuer[0]; HASH_LEN])
+ }
+
+ /// A keylet from an MPT id and a holder; `InvalidParams` if the id is empty,
+ /// `InvalidAccount` if the holder is empty.
+ fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult {
+ if mptid.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ if holder.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[mptid[0]; HASH_LEN])
+ }
+
+ /// The account-and-sequence shape, for an `NFTokenOffer`.
+ fn nftoken_offer_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same account-and-sequence shape, for an `Offer`.
+ fn offer_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same account-and-scalar shape, for an `Oracle` keyed by document id.
+ fn oracle_keylet(&self, account: &[u8], _doc_id: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// A two-account-and-sequence shape, for a `PayChannel`; `InvalidAccount` if
+ /// either account is empty.
+ fn paychannel_keylet(
+ &self,
+ account: &[u8],
+ destination: &[u8],
+ _seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ if account.is_empty() || destination.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same account-and-sequence shape, for a `PermissionedDomain`.
+ fn permissioned_domain_keylet(
+ &self,
+ account: &[u8],
+ _seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The account-only shape, for a `SignerList`.
+ fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same account-and-sequence shape, for a `Ticket`.
+ fn ticket_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// The same account-and-sequence shape, for a `Vault`.
+ fn vault_keylet(&self, account: &[u8], _seq: i32, out: &mut [u8]) -> HostResult {
+ if account.is_empty() {
+ return Err(HostError::InvalidAccount);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult {
+ let mut digest = [0; HASH_LEN];
+ digest[0] = data.len() as u8;
+ put(out, &digest)
+ }
+
+ fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()> {
+ self.traced
+ .borrow_mut()
+ .push(format!("{msg}/{data_type:?}/{}", data.len()));
+ Ok(())
+ }
+
+ /// Reads a data blob and returns the count of bytes stored.
+ fn update_data(&self, data: &[u8]) -> HostResult {
+ Ok(data.len() as i32)
+ }
+
+ /// Reads an account and an nft id, writes a byte value; `InvalidParams` if either
+ /// is empty.
+ fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ if account.is_empty() || nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[account[0]; HASH_LEN])
+ }
+
+ /// Reads an nft id, writes a byte value; `InvalidParams` on an empty id.
+ fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ if nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[nft_id[0]; HASH_LEN])
+ }
+
+ /// The same, for the taxon.
+ fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ if nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &nft_id[0].to_le_bytes())
+ }
+
+ /// Reads an nft id and returns a scalar; `InvalidParams` on an empty id.
+ fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult {
+ if nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ Ok(i32::from(nft_id[0]))
+ }
+
+ /// The same, for the transfer fee.
+ fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult {
+ if nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ Ok(i32::from(nft_id[0]))
+ }
+
+ /// The same byte-output shape, for the sequence number.
+ fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ if nft_id.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &nft_id[0].to_le_bytes())
+ }
+
+ /// A scalar-in float: writes the low byte of `x` as a stand-in float.
+ fn float_from_int(&self, x: i64, _mode: i32, out: &mut [u8]) -> HostResult {
+ put(out, &[x as u8])
+ }
+
+ /// A byte-in float; `InvalidParams` on an empty region.
+ fn float_from_uint(&self, x: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// The same, for a serialized amount.
+ fn float_from_stamount(&self, amount: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if amount.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[amount[0]])
+ }
+
+ /// The same, for a serialized number.
+ fn float_from_stnumber(&self, number: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if number.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[number[0]])
+ }
+
+ /// A float rounded to an integer, written as bytes.
+ fn float_to_int(&self, x: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// Writes a mantissa (its first byte) and an exponent (its first byte) to two
+ /// regions, returning their combined length.
+ fn float_to_mant_exp(
+ &self,
+ x: &[u8],
+ mantissa_out: &mut [u8],
+ exponent_out: &mut [u8],
+ ) -> HostResult {
+ if x.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ let m = put(mantissa_out, &[x[0]])?;
+ let e = put(exponent_out, &[x[0]])?;
+ Ok(m + e)
+ }
+
+ /// A two-scalar-in float.
+ fn float_from_mant_exp(
+ &self,
+ mantissa: i64,
+ _exponent: i32,
+ _mode: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ put(out, &[mantissa as u8])
+ }
+
+ /// Reads two floats and returns a scalar; `InvalidParams` if either is empty.
+ fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult {
+ if x.is_empty() || y.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ Ok(i32::from(x[0]) - i32::from(y[0]))
+ }
+
+ /// A binary float operator; `InvalidParams` if either operand is empty.
+ fn float_add(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() || y.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// The same shape, for subtraction.
+ fn float_subtract(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() || y.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// The same shape, for multiplication.
+ fn float_multiply(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() || y.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// The same shape, for division.
+ fn float_divide(&self, x: &[u8], y: &[u8], _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() || y.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// A one-float-and-integer operator; `InvalidParams` on an empty operand.
+ fn float_root(&self, x: &[u8], _n: i32, _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+
+ /// The same shape, for exponentiation.
+ fn float_power(&self, x: &[u8], _n: i32, _mode: i32, out: &mut [u8]) -> HostResult {
+ if x.is_empty() {
+ return Err(HostError::InvalidParams);
+ }
+ put(out, &[x[0]])
+ }
+}
+
+#[test]
+fn the_trait_is_implementable() {
+ let host = FakeHost::default();
+ let mut out = [0u8; HASH_LEN];
+
+ assert_eq!(host.get_ledger_sqn(&mut out), Ok(4));
+ assert_eq!(out[..4], [7, 0, 0, 0]);
+ assert_eq!(host.get_parent_ledger_time(&mut out), Ok(4));
+ assert_eq!(out[..4], [9, 0, 0, 0]);
+ assert_eq!(host.get_parent_ledger_hash(&mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 0xab);
+ assert_eq!(host.get_base_fee(&mut out), Ok(4));
+ assert_eq!(out[..4], [10, 0, 0, 0]);
+ assert_eq!(host.is_amendment_enabled(&[1; 32]), Ok(1));
+ assert_eq!(host.is_amendment_enabled(&[]), Ok(0));
+ assert_eq!(host.cache_ledger_obj(&[1; 32], 0), Ok(1));
+ assert_eq!(host.cache_ledger_obj(&[1; 32], 5), Ok(5));
+ assert_eq!(host.get_tx_field(5, &mut out), Ok(1));
+ assert_eq!(out[0], 5);
+ assert_eq!(host.get_current_ledger_obj_field(3, &mut out), Ok(1));
+ assert_eq!(out[0], 3);
+ assert_eq!(host.get_ledger_obj_field(2, 4, &mut out), Ok(2));
+ assert_eq!(out[..2], [2, 4]);
+ assert_eq!(host.get_tx_nested_field(&[9, 0, 0, 0], &mut out), Ok(2));
+ assert_eq!(out[..2], [9, 4]);
+ assert_eq!(
+ host.get_current_ledger_obj_nested_field(&[9, 0, 0, 0], &mut out),
+ Ok(2)
+ );
+ assert_eq!(out[..2], [4, 9]);
+ assert_eq!(
+ host.get_ledger_obj_nested_field(3, &[9, 0, 0, 0], &mut out),
+ Ok(2)
+ );
+ assert_eq!(out[..2], [3, 9]);
+ assert_eq!(host.get_tx_array_len(3), Ok(3));
+ assert_eq!(host.get_tx_array_len(-1), Err(HostError::NoArray));
+ assert_eq!(host.get_current_ledger_obj_array_len(3), Ok(4));
+ assert_eq!(
+ host.get_current_ledger_obj_array_len(-1),
+ Err(HostError::NoArray)
+ );
+ assert_eq!(host.get_ledger_obj_array_len(2, 3), Ok(5));
+ assert_eq!(host.get_ledger_obj_array_len(0, 3), Err(HostError::NoArray));
+ assert_eq!(host.get_tx_nested_array_len(&[9, 0, 0, 0]), Ok(4));
+ assert_eq!(
+ host.get_tx_nested_array_len(&[]),
+ Err(HostError::LocatorMalformed)
+ );
+ assert_eq!(
+ host.get_current_ledger_obj_nested_array_len(&[9, 0, 0, 0]),
+ Ok(5)
+ );
+ assert_eq!(
+ host.get_current_ledger_obj_nested_array_len(&[]),
+ Err(HostError::LocatorMalformed)
+ );
+ assert_eq!(
+ host.get_ledger_obj_nested_array_len(2, &[9, 0, 0, 0]),
+ Ok(6)
+ );
+ assert_eq!(
+ host.get_ledger_obj_nested_array_len(0, &[9, 0, 0, 0]),
+ Err(HostError::LocatorMalformed)
+ );
+ assert_eq!(host.check_signature(b"msg", b"sig", b"pk"), Ok(1));
+ assert_eq!(host.check_signature(b"msg", b"", b"pk"), Ok(0));
+ assert_eq!(host.account_keylet(&[7; 20], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.account_keylet(&[], &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.amm_keylet(&[1; 20], &[2; 40], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 20);
+ assert_eq!(
+ host.amm_keylet(&[1; 20], &[1; 20], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(host.check_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.check_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.credential_keylet(&[7; 20], &[8; 20], b"cred", &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.credential_keylet(&[], &[8; 20], b"cred", &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.delegate_keylet(&[7; 20], &[8; 20], &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.delegate_keylet(&[], &[8; 20], &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.deposit_preauth_keylet(&[7; 20], &[8; 20], &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 8);
+ assert_eq!(
+ host.deposit_preauth_keylet(&[7; 20], &[7; 20], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(host.did_keylet(&[7; 20], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.did_keylet(&[], &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.escrow_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.escrow_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.trust_line_keylet(&[7; 20], &[8; 20], &[1; 20], &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.trust_line_keylet(&[7; 20], &[7; 20], &[1; 20], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(
+ host.mptoken_issuance_keylet(&[7; 20], 5, &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.mptoken_issuance_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.mptoken_keylet(&[9; 24], &[8; 20], &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 9);
+ assert_eq!(
+ host.mptoken_keylet(&[], &[8; 20], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(
+ host.nftoken_offer_keylet(&[7; 20], 5, &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.nftoken_offer_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.offer_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.offer_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.oracle_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.oracle_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.paychannel_keylet(&[7; 20], &[8; 20], 5, &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.paychannel_keylet(&[7; 20], &[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(
+ host.permissioned_domain_keylet(&[7; 20], 5, &mut out),
+ Ok(HASH_LEN)
+ );
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.permissioned_domain_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.signer_list_keylet(&[7; 20], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.signer_list_keylet(&[], &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.ticket_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.ticket_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.vault_keylet(&[7; 20], 5, &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.vault_keylet(&[], 5, &mut out),
+ Err(HostError::InvalidAccount)
+ );
+ assert_eq!(host.sha512_half(b"abc", &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 3);
+ assert_eq!(host.trace("hello", b"xy", TraceDataType::AsHex), Ok(()));
+ assert_eq!(host.update_data(b"abcd"), Ok(4));
+ assert_eq!(host.get_nft(&[7; 20], &[9; 32], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 7);
+ assert_eq!(
+ host.get_nft(&[], &[9; 32], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(host.get_nft_issuer(&[9; 32], &mut out), Ok(HASH_LEN));
+ assert_eq!(out[0], 9);
+ assert_eq!(
+ host.get_nft_issuer(&[], &mut out),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(host.get_nft_taxon(&[9; 32], &mut out), Ok(1));
+ assert_eq!(host.get_nft_flags(&[9; 32]), Ok(9));
+ assert_eq!(host.get_nft_flags(&[]), Err(HostError::InvalidParams));
+ assert_eq!(host.get_nft_transfer_fee(&[9; 32]), Ok(9));
+ assert_eq!(host.get_nft_sequence(&[9; 32], &mut out), Ok(1));
+ assert_eq!(host.float_from_int(5, 0, &mut out), Ok(1));
+ assert_eq!(host.float_from_uint(&[3; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_from_stamount(&[3; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_from_stnumber(&[3; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_to_int(&[3; 8], 0, &mut out), Ok(1));
+ let mut mant = [0u8; 8];
+ let mut exp = [0u8; 4];
+ assert_eq!(host.float_to_mant_exp(&[3; 8], &mut mant, &mut exp), Ok(2));
+ assert_eq!(host.float_from_mant_exp(5, 0, 0, &mut out), Ok(1));
+ assert_eq!(host.float_compare(&[9; 8], &[4; 8]), Ok(5));
+ assert_eq!(
+ host.float_compare(&[], &[4; 8]),
+ Err(HostError::InvalidParams)
+ );
+ assert_eq!(host.float_add(&[3; 8], &[4; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_subtract(&[3; 8], &[4; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_multiply(&[3; 8], &[4; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_divide(&[3; 8], &[4; 8], 0, &mut out), Ok(1));
+ assert_eq!(host.float_root(&[3; 8], 2, 0, &mut out), Ok(1));
+ assert_eq!(host.float_power(&[3; 8], 2, 0, &mut out), Ok(1));
+
+ assert_eq!(*host.traced.borrow(), ["hello/AsHex/2"]);
+}
+
+/// The error channel every declaration carries: an `Err` the VM turns into the
+/// wire's negative return code.
+#[test]
+fn a_failing_call_reports_its_error_code() {
+ let host = FakeHost::default();
+ let mut out = [0u8; 8];
+
+ assert_eq!(
+ host.get_current_ledger_obj_field(-1, &mut out),
+ Err(HostError::FieldNotFound)
+ );
+ assert_eq!(HostError::FieldNotFound.code(), -2);
+}
+
+/// A host reports the value's true length even when it cannot write it, which is
+/// what lets the engine answer `BufferTooSmall` on the guest's behalf.
+#[test]
+fn a_short_buffer_still_reports_the_true_length() {
+ let host = FakeHost::default();
+ let mut out = [0u8; 2];
+
+ assert_eq!(host.get_ledger_sqn(&mut out), Ok(4));
+ assert_eq!(
+ out,
+ [0, 0],
+ "nothing is written when the value does not fit"
+ );
+}
+
+/// The VM reaches the host as one shared trait object held in the wasmi `Store`,
+/// which is what the `&self` receivers are for.
+#[test]
+fn the_trait_is_callable_through_a_shared_trait_object() {
+ let fake = FakeHost::default();
+ let host: &dyn HostFunctions = &fake;
+ let mut out = [0u8; 4];
+
+ assert_eq!(host.get_ledger_sqn(&mut out), Ok(4));
+ assert_eq!(
+ host.trace("count", &1i64.to_le_bytes(), TraceDataType::Int64),
+ Ok(())
+ );
+
+ assert_eq!(*fake.traced.borrow(), ["count/Int64/8"]);
+}
+
+/// The whole table, written out: the one place the ABI's wire names and gas costs
+/// appear as literals, and a deliberate change-detector, since both are consensus
+/// input. Everything else reads `HostFunctionSpec::gas()` instead.
+///
+/// `ALL` is in declaration order, so comparing the whole vec pins the order and the
+/// membership too.
+#[test]
+fn the_spec_table_matches_the_declarations() {
+ let table: Vec<(&str, u64)> = HostFunctionSpec::ALL
+ .iter()
+ .map(|function| (function.wasm_name(), function.gas()))
+ .collect();
+
+ assert_eq!(
+ table,
+ [
+ ("ldgr_index", 60),
+ ("parent_ldgr_time", 60),
+ ("parent_ldgr_hash", 60),
+ ("base_fee", 60),
+ ("amendment_enabled", 100),
+ ("cache_le", 5000),
+ ("tx_field", 70),
+ ("home_le_field", 70),
+ ("le_field", 70),
+ ("tx_inner", 110),
+ ("home_le_inner", 110),
+ ("le_inner", 110),
+ ("tx_arr_len", 40),
+ ("home_le_arr_len", 40),
+ ("le_arr_len", 40),
+ ("tx_inner_arr_len", 70),
+ ("home_le_inner_arr_len", 70),
+ ("le_inner_arr_len", 70),
+ ("check_sig", 300),
+ ("accountroot_id", 350),
+ ("amm_id", 450),
+ ("check_id", 350),
+ ("credential_id", 350),
+ ("delegate_id", 350),
+ ("deposit_preauth_id", 350),
+ ("did_id", 350),
+ ("escrow_id", 350),
+ ("trustline_id", 400),
+ ("mpt_issuance_id", 350),
+ ("mptoken_id", 500),
+ ("nft_offer_id", 350),
+ ("offer_id", 350),
+ ("oracle_id", 350),
+ ("paychan_id", 350),
+ ("permissioned_domain_id", 350),
+ ("signers_id", 350),
+ ("ticket_id", 350),
+ ("vault_id", 350),
+ ("sha512_half", 2000),
+ ("trace", 30),
+ ("set_data", 1000),
+ ("nft_uri", 5000),
+ ("nft_issuer", 70),
+ ("nft_taxon", 60),
+ ("nft_flags", 60),
+ ("nft_xfer_fee", 60),
+ ("nft_serial", 60),
+ ("float_from_int", 100),
+ ("float_from_uint", 130),
+ ("float_from_stamount", 150),
+ ("float_from_stnumber", 150),
+ ("float_to_int", 130),
+ ("float_to_mant_exp", 130),
+ ("float_from_mant_exp", 100),
+ ("float_cmp", 80),
+ ("float_add", 160),
+ ("float_sub", 160),
+ ("float_mult", 300),
+ ("float_div", 300),
+ ("float_root", 5500),
+ ("float_pow", 5500),
+ ]
+ );
+}
+
+/// The other half of the wire vocabulary, and the same change-detector argument: the
+/// codes are what a guest passes, so they are pinned as literals here. `ALL` is in code
+/// order, so the round trip pins the discriminants and not just the membership.
+#[test]
+fn every_trace_data_type_survives_the_wire() {
+ let codes: Vec = TraceDataType::ALL.iter().map(|t| t.code()).collect();
+
+ assert_eq!(codes, [1, 2, 3, 4, 5, 6, 7]);
+ for &data_type in TraceDataType::ALL {
+ assert_eq!(TraceDataType::from_code(data_type.code()), Some(data_type));
+ }
+}
+
+/// A code no declaration names is refused rather than read as a neighbouring type.
+/// Zero is the one worth naming: it is what a guest sends by omission.
+#[test]
+fn an_unnamed_trace_data_type_code_is_refused() {
+ for code in [0, -1, 8, i32::MAX, i32::MIN] {
+ assert_eq!(TraceDataType::from_code(code), None, "code {code}");
+ }
+}
+
+/// `ALL` is what a wasm engine iterates to register imports, so no two declarations
+/// may collapse to the same wire name. The table above pins membership and order;
+/// this adds only uniqueness, and restates nothing.
+#[test]
+fn every_variant_appears_in_all_exactly_once() {
+ let names: HashSet<&str> = HostFunctionSpec::ALL
+ .iter()
+ .map(|function| function.wasm_name())
+ .collect();
+
+ assert_eq!(names.len(), HostFunctionSpec::ALL.len());
+}
+
+/// Both accessors are `const`, so an engine can build its import and gas tables at
+/// compile time rather than on every invocation. The assertions sit in `const`
+/// blocks so they are checked while compiling, which is the claim; the values
+/// themselves are pinned above.
+#[test]
+fn the_table_is_usable_in_const_context() {
+ const NAME: &str = HostFunctionSpec::Trace.wasm_name();
+ const GAS: u64 = HostFunctionSpec::Trace.gas();
+
+ const { assert!(!NAME.is_empty()) };
+ const { assert!(GAS > 0) };
+}
diff --git a/crates/xrpl-host-functions/tests/host_errors.rs b/crates/xrpl-host-functions/tests/host_errors.rs
new file mode 100644
index 0000000000..7e77fcdc56
--- /dev/null
+++ b/crates/xrpl-host-functions/tests/host_errors.rs
@@ -0,0 +1,102 @@
+//! Exercises what `host_errors!` generates: the wire codes, the set
+//! [`HostError::ALL`] names, and the round trip between them.
+//!
+//! The codes are consensus input — they are what a guest reads off a failed host
+//! call — so they are pinned here as literals and derived everywhere else.
+
+use xrpl_host_functions::HostError;
+
+/// The whole set, written out in the order `ALL` gives it: the one place the wire
+/// codes appear as literals, and a deliberate change-detector, since a code that
+/// moves changes what every deployed guest is told.
+#[test]
+fn the_error_table_matches_the_declarations() {
+ let table: Vec<(HostError, i32)> = HostError::ALL
+ .iter()
+ .map(|&error| (error, error.code()))
+ .collect();
+
+ assert_eq!(
+ table,
+ [
+ (HostError::Unimplemented, -1),
+ (HostError::FieldNotFound, -2),
+ (HostError::BufferTooSmall, -3),
+ (HostError::NoArray, -4),
+ (HostError::NotLeafField, -5),
+ (HostError::LocatorMalformed, -6),
+ (HostError::SlotOutRange, -7),
+ (HostError::SlotsFull, -8),
+ (HostError::EmptySlot, -9),
+ (HostError::LedgerObjNotFound, -10),
+ (HostError::OutOfTransferLimit, -11),
+ (HostError::DataFieldTooLarge, -12),
+ (HostError::PointerOutOfBounds, -13),
+ (HostError::NoMemExported, -14),
+ (HostError::InvalidParams, -15),
+ (HostError::InvalidAccount, -16),
+ (HostError::InvalidField, -17),
+ (HostError::IndexOutOfBounds, -18),
+ (HostError::FloatInputMalformed, -19),
+ (HostError::FloatComputationError, -20),
+ (HostError::InternalFatal, i32::MIN),
+ ]
+ );
+}
+
+/// The guest-facing set is `-1 ..= -20` and nothing else: those entries are xrpld's
+/// `HostFunctionError`, and each is a code some contract may read.
+///
+/// `InternalFatal` is the one deliberate exception, exempted by name rather than by
+/// widening the range: a condition with no number a contract can act on needs no number
+/// in the range a contract reads, and holding it at `i32::MIN` is what keeps it from
+/// ever colliding with a code appended to xrpld's list.
+#[test]
+fn every_code_but_the_sentinel_is_in_the_shared_range() {
+ let shared: Vec = HostError::ALL
+ .iter()
+ .copied()
+ .filter(|&error| error != HostError::InternalFatal)
+ .collect();
+
+ let outside: Vec = shared
+ .iter()
+ .copied()
+ .filter(|error| !(-20..=-1).contains(&error.code()))
+ .collect();
+
+ assert!(outside.is_empty(), "outside -1..=-20: {outside:?}");
+ assert_eq!(shared.len(), 20);
+ assert_eq!(HostError::InternalFatal.code(), i32::MIN);
+ assert_eq!(HostError::ALL.len(), 21);
+}
+
+/// Every code a guest can be handed comes back as the error that produced it, so a
+/// caller reading a negative return value recovers the condition and not a
+/// neighbouring one. The table above pins the numbers; this adds only the round
+/// trip.
+#[test]
+fn every_wire_code_round_trips_back_to_its_error() {
+ for &error in HostError::ALL {
+ assert_eq!(HostError::from_code(error.code()), error, "{error:?}");
+ }
+}
+
+/// A code from outside the set is `InternalFatal`: a host answering something this ABI
+/// does not define has not served the call, whatever it meant by it, and success is not
+/// an error at all.
+///
+/// `-21` is the code xrpld would append next, so it is the one that decides whether a
+/// list this crate has not caught up with reaches a guest or stops the run. `i32::MIN +
+/// 1` is next to the sentinel and unassigned, which is what makes the sentinel a value
+/// rather than a range.
+#[test]
+fn a_code_outside_the_set_is_internal_fatal() {
+ for code in [-21, i32::MIN + 1, 0, 1, i32::MAX] {
+ assert_eq!(
+ HostError::from_code(code),
+ HostError::InternalFatal,
+ "{code}"
+ );
+ }
+}
diff --git a/crates/hello_world/Cargo.toml b/crates/xrpl-wasm-testkit/Cargo.toml
similarity index 57%
rename from crates/hello_world/Cargo.toml
rename to crates/xrpl-wasm-testkit/Cargo.toml
index 2e5a329c9a..06c1e7c366 100644
--- a/crates/hello_world/Cargo.toml
+++ b/crates/xrpl-wasm-testkit/Cargo.toml
@@ -1,10 +1,11 @@
[package]
-name = "rs-hello_world"
+name = "xrpl-wasm-testkit"
version = "0.1.0"
edition.workspace = true
[lib]
-crate-type = ["staticlib"]
+crate-type = ["staticlib", "rlib"]
[dependencies]
cxx.workspace = true
+wat = "1"
diff --git a/crates/xrpl-wasm-testkit/src/lib.rs b/crates/xrpl-wasm-testkit/src/lib.rs
new file mode 100644
index 0000000000..f503294c59
--- /dev/null
+++ b/crates/xrpl-wasm-testkit/src/lib.rs
@@ -0,0 +1,49 @@
+//! Assembles WebAssembly text for the C++ test suite. **Test-only.**
+//!
+//! A crate of its own rather than an entry on `xrpl-wasm-vm-ffi`, and the separation is the
+//! point. The engine pins `wasmi = { default-features = false }` precisely so a text
+//! assembler cannot reach the consensus path — wasmi's `wat` feature is on by default and
+//! makes `Module::new` accept text as readily as binary, which would make a transaction's
+//! validity a build flag. Putting `compile_wat` on the production bridge would link `wat`
+//! into xrpld even if nothing called it.
+//!
+//! Linked only into `xrpl_tests`, never into `libxrpl` or `xrpld`, so "no assembler in the
+//! shipped node" is a property of the link graph rather than a flag someone can flip.
+#![deny(rustdoc::broken_intra_doc_links)]
+
+#[cxx::bridge(namespace = "rs::wasm_testkit")]
+mod ffi {
+ extern "Rust" {
+ /// Assemble `wat` to a wasm module.
+ ///
+ /// Throws `rust::Error` on invalid input, which is what a test wants: a typo in a
+ /// fixture should fail the test that holds it, at the line that holds it.
+ fn compile_wat(wat: &str) -> Result>;
+ }
+}
+
+fn compile_wat(wat: &str) -> Result, wat::Error> {
+ wat::parse_str(wat)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::compile_wat;
+
+ #[test]
+ fn a_module_assembles_to_something_beginning_with_the_wasm_magic() {
+ let wasm = compile_wat("(module)").expect("assembles");
+
+ assert_eq!(&wasm[..4], b"\0asm");
+ }
+
+ #[test]
+ fn a_typo_is_an_error_rather_than_a_module() {
+ let error = compile_wat("(module (func (export").expect_err("must not assemble");
+
+ assert!(
+ !error.to_string().is_empty(),
+ "the error has to say something"
+ );
+ }
+}
diff --git a/crates/xrpl-wasm-vm-ffi/Cargo.toml b/crates/xrpl-wasm-vm-ffi/Cargo.toml
new file mode 100644
index 0000000000..c301eb707a
--- /dev/null
+++ b/crates/xrpl-wasm-vm-ffi/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "xrpl-wasm-vm-ffi"
+version = "0.1.0"
+edition.workspace = true
+
+[lib]
+crate-type = ["staticlib", "rlib"]
+
+[dependencies]
+cxx.workspace = true
+xrpl-host-functions = { path = "../xrpl-host-functions" }
+xrpl-wasm-vm = { path = "../xrpl-wasm-vm" }
diff --git a/crates/xrpl-wasm-vm-ffi/src/lib.rs b/crates/xrpl-wasm-vm-ffi/src/lib.rs
new file mode 100644
index 0000000000..0b2b965472
--- /dev/null
+++ b/crates/xrpl-wasm-vm-ffi/src/lib.rs
@@ -0,0 +1,1293 @@
+//! The cxx bridge between the escrow wasm engine and xrpld.
+//!
+//! Three crossings:
+//!
+//! - **In:** C++ calls `run_escrow`, once per escrow finish.
+//! - **Back out:** that run's host calls leave through the C++ `HostContext`, which
+//! `CxxHost` presents to the engine as an ordinary [`HostFunctions`] implementor.
+//! - **In only:** C++ screens a module with `check_escrow`. Screening needs no host,
+//! so nothing comes back out.
+//!
+//! The ABI the host calls speak is declared once, in `xrpl-host-functions`, so neither
+//! side of this file gets to restate a signature.
+//!
+//! **Neither language may unwind into the other**, and the two halves of that are
+//! not symmetric:
+//!
+//! - A **Rust panic** is caught here, by `guarded`. Letting one reach C++ is
+//! undefined behaviour; `[profile.release]` turns overflow checks on, so this is a
+//! live path and not a formality.
+//! - A **C++ exception** is stopped on the C++ side: every `HostContext` method is
+//! `noexcept` and catches its own. That is what makes `guarded` sufficient — see
+//! its documentation.
+//!
+//! Everything hand-written here is private, so the names above are code spans rather
+//! than links, and `cargo doc` needs `--document-private-items` to show any of it.
+//! That is also why this crate, unlike `xrpl-wasm-vm`, does not
+//! `deny(unreachable_pub)`: cxx's expansion is `pub` throughout by necessity, leaving
+//! the lint nothing but generated code to fire on.
+#![deny(rustdoc::broken_intra_doc_links)]
+
+use std::any::Any;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use xrpl_host_functions::{HostError, HostFunctions, HostResult, TraceDataType};
+use xrpl_wasm_vm::{CheckError, RunError, RunFailure, RunOutcome, check, run};
+
+/// [`guarded`] must be able to stop an unwind. Under `panic = "abort"` it cannot,
+/// and every arithmetic overflow in the engine becomes a node crash instead of a
+/// `tecINTERNAL`.
+#[cfg(panic = "abort")]
+compile_error!(
+ "xrpl-wasm-vm-ffi requires panic=unwind: run_escrow catches panics rather than \
+ letting them cross into C++"
+);
+
+#[cxx::bridge(namespace = "rs::wasm_vm")]
+mod ffi {
+ /// Which outcome a run had — one variant per way [`run`] can end, so the caller
+ /// maps a status to a TER rather than reading a message.
+ #[derive(Debug, Hash)]
+ #[repr(i32)]
+ enum RunStatus {
+ /// The entry point returned.
+ Ok,
+ /// `wasm` is not a valid module under this engine's configuration.
+ Compile,
+ /// The module would not instantiate.
+ Instantiate,
+ /// No export of that name with signature `() -> i32`.
+ EntryPoint,
+ /// Gas exhausted, by the guest's instructions or a host call's charge.
+ OutOfGas,
+ /// The host could not serve a call, including any exception it caught.
+ Internal,
+ /// A host call had no linear memory to work in.
+ NoMemory,
+ /// The guest trapped.
+ Trap,
+ /// The engine panicked. A defect in this crate or the one below it.
+ Panic,
+ }
+
+ /// A run's outcome, flattened: cxx enums carry no payload, so the status, the
+ /// cost and the description travel side by side.
+ struct RunResult {
+ status: RunStatus,
+ /// What the entry point returned. Meaningful only when `status` is `Ok`.
+ result: i32,
+ /// Gas consumed. The whole limit when gas ran out; `0` when the module never
+ /// ran, or when the cost could not be trusted (`Internal`, `Panic`).
+ gas_used: u64,
+ /// The engine's own description of the outcome, for the log. Empty on `Ok`.
+ detail: String,
+ }
+
+ /// Why a module cannot be run — one variant per way [`check`] can refuse it,
+ /// so the caller maps a status to a TER rather than reading a message.
+ #[derive(Debug, Hash)]
+ #[repr(i32)]
+ enum CheckStatus {
+ /// The module compiles, imports only what the engine serves, and exports
+ /// the entry point as `() -> i32`.
+ Ok,
+ /// `wasm` is not a valid module under this engine's configuration.
+ Compile,
+ /// An import the engine does not define: another module namespace, a name
+ /// that is not a host function, or one imported as something else.
+ Import,
+ /// No export of that name with signature `() -> i32`.
+ EntryPoint,
+ /// The module asks for more linear memory than the engine grants.
+ Memory,
+ /// The module asks for a larger table than the engine grants.
+ Table,
+ /// The engine panicked. A defect in this crate or the one below it, and
+ /// not a fault in the module — which is why it is a status of its own
+ /// rather than one more way a contract can be malformed.
+ Panic,
+ }
+
+ /// A check's verdict. No cost, because nothing was executed.
+ struct CheckResult {
+ status: CheckStatus,
+ /// The engine's own description of the refusal, for the log. Empty on
+ /// `Ok`.
+ detail: String,
+ }
+
+ /// How `HostContext::trace` is to read its data buffer.
+ ///
+ /// **Declared here so that C++ does not declare it.** A shared enum is emitted into
+ /// the generated header as `xrpl::TraceDataType`, which is the definition
+ /// `HostContext.cpp` switches on — so the variants and their wire values are
+ /// written once, in Rust, for both languages.
+ ///
+ /// It is not the same type as [`xrpl_host_functions::TraceDataType`], and cannot
+ /// be: the ABI crate is `no_std` with no dependencies so that it also links into
+ /// the guest, and `cxx` is neither. [`crossed`] converts, in a `match` that is
+ /// exhaustive over the ABI's enum — so a data type added there fails to compile
+ /// until it is added here, which is the drift check the hand-written C++ copy
+ /// never had.
+ #[namespace = "xrpl"]
+ #[derive(Debug, Hash)]
+ #[repr(i32)]
+ enum TraceDataType {
+ Int64 = 1,
+ Uint64 = 2,
+ Xfloat = 3,
+ Account = 4,
+ Amount = 5,
+ AsHex = 6,
+ AsText = 7,
+ }
+
+ extern "Rust" {
+ /// Run `wasm`'s `function_name` export with `gas` fuel, servicing host calls
+ /// through `host`.
+ ///
+ /// Reports every outcome as a [`RunStatus`] and **never throws**: an
+ /// exception is a poor interface for a condition the caller has to turn into
+ /// a TER anyway, and a panic reaching C++ would be undefined behaviour.
+ ///
+ /// `gas` is the run's whole budget. `0` is a run that cannot execute an
+ /// instruction; the C++ front refuses it as `temBAD_AMOUNT` before calling
+ /// here, so it is not given a status of its own.
+ fn run_escrow(host: &HostContext, wasm: &[u8], gas: u64, function_name: &str) -> RunResult;
+
+ /// Screen `wasm` before it can reach the ledger: whether [`run_escrow`]
+ /// would refuse it before the guest's first instruction.
+ ///
+ /// Takes no host, no gas and no store — the verdict comes from the
+ /// compiled module alone, which is what makes it callable from a
+ /// transaction's preflight, where there is no ledger to serve a host call
+ /// from. **Never throws**, for the same reason [`run_escrow`] does not.
+ fn check_escrow(wasm: &[u8], function_name: &str) -> CheckResult;
+ }
+
+ unsafe extern "C++" {
+ include!("xrpl/tx/wasm/HostContext.h");
+
+ /// The C++ side of the ABI: one method per host function, forwarding to
+ /// `xrpl::HostFunctions`.
+ ///
+ /// Every method is `noexcept` and catches everything, so a host call cannot
+ /// unwind into the engine.
+ ///
+ /// `cxx_name` on each method below is not cosmetic: the declarations keep the
+ /// ABI's names here and rippled's camelBack over there, so neither side has
+ /// to spell the other's convention.
+ #[namespace = "xrpl"]
+ type HostContext;
+
+ /// A byte-producing call is handed `out` and returns the value's **true
+ /// length**, writing it only if the whole value fits. Returning a length past
+ /// `out` is how a guest learns the size to ask for; the engine turns it into
+ /// `BufferTooSmall`, so C++ never needs to know the guest's capacity.
+ ///
+ /// A negative return is a `HostError` code.
+ #[namespace = "xrpl"]
+ #[cxx_name = "getLedgerSqn"]
+ fn get_ledger_sqn(self: &HostContext, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getParentLedgerTime"]
+ fn get_parent_ledger_time(self: &HostContext, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getParentLedgerHash"]
+ fn get_parent_ledger_hash(self: &HostContext, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getBaseFee"]
+ fn get_base_fee(self: &HostContext, out: &mut [u8]) -> i32;
+
+ /// Reads the amendment (id or name) and answers `1`/`0`, or a negative
+ /// `HostError` code.
+ #[namespace = "xrpl"]
+ #[cxx_name = "isAmendmentEnabled"]
+ fn is_amendment_enabled(self: &HostContext, amendment: &[u8]) -> i32;
+
+ /// Caches the object with `obj_id` in slot `cache_idx` (`0` = pick one) and
+ /// answers the slot used, or a negative `HostError` code.
+ #[namespace = "xrpl"]
+ #[cxx_name = "cacheLedgerObj"]
+ fn cache_ledger_obj(self: &HostContext, obj_id: &[u8], cache_idx: i32) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getTxField"]
+ fn get_tx_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getCurrentLedgerObjField"]
+ fn get_current_ledger_obj_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getLedgerObjField"]
+ fn get_ledger_obj_field(
+ self: &HostContext,
+ cache_idx: i32,
+ field: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getTxNestedField"]
+ fn get_tx_nested_field(self: &HostContext, locator: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getCurrentLedgerObjNestedField"]
+ fn get_current_ledger_obj_nested_field(
+ self: &HostContext,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getLedgerObjNestedField"]
+ fn get_ledger_obj_nested_field(
+ self: &HostContext,
+ cache_idx: i32,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ /// Answers the array's element count directly, or a negative `HostError` code.
+ #[namespace = "xrpl"]
+ #[cxx_name = "getTxArrayLen"]
+ fn get_tx_array_len(self: &HostContext, field: i32) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getCurrentLedgerObjArrayLen"]
+ fn get_current_ledger_obj_array_len(self: &HostContext, field: i32) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getLedgerObjArrayLen"]
+ fn get_ledger_obj_array_len(self: &HostContext, cache_idx: i32, field: i32) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getTxNestedArrayLen"]
+ fn get_tx_nested_array_len(self: &HostContext, locator: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getCurrentLedgerObjNestedArrayLen"]
+ fn get_current_ledger_obj_nested_array_len(self: &HostContext, locator: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getLedgerObjNestedArrayLen"]
+ fn get_ledger_obj_nested_array_len(
+ self: &HostContext,
+ cache_idx: i32,
+ locator: &[u8],
+ ) -> i32;
+
+ /// Answers `1`/`0` for a valid/invalid signature, or a negative `HostError`.
+ #[namespace = "xrpl"]
+ #[cxx_name = "checkSignature"]
+ fn check_signature(
+ self: &HostContext,
+ message: &[u8],
+ signature: &[u8],
+ pubkey: &[u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "accountKeylet"]
+ fn account_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "ammKeylet"]
+ fn amm_keylet(self: &HostContext, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "checkKeylet"]
+ fn check_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "credentialKeylet"]
+ fn credential_keylet(
+ self: &HostContext,
+ subject: &[u8],
+ issuer: &[u8],
+ credential_type: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "delegateKeylet"]
+ fn delegate_keylet(
+ self: &HostContext,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "depositPreauthKeylet"]
+ fn deposit_preauth_keylet(
+ self: &HostContext,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "didKeylet"]
+ fn did_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "escrowKeylet"]
+ fn escrow_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "trustLineKeylet"]
+ fn trust_line_keylet(
+ self: &HostContext,
+ account1: &[u8],
+ account2: &[u8],
+ currency: &[u8],
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "mptokenIssuanceKeylet"]
+ fn mptoken_issuance_keylet(
+ self: &HostContext,
+ issuer: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "mptokenKeylet"]
+ fn mptoken_keylet(self: &HostContext, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "nftokenOfferKeylet"]
+ fn nftoken_offer_keylet(
+ self: &HostContext,
+ account: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "offerKeylet"]
+ fn offer_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "oracleKeylet"]
+ fn oracle_keylet(self: &HostContext, account: &[u8], doc_id: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "paychannelKeylet"]
+ fn paychannel_keylet(
+ self: &HostContext,
+ account: &[u8],
+ destination: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "permissionedDomainKeylet"]
+ fn permissioned_domain_keylet(
+ self: &HostContext,
+ account: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "signerListKeylet"]
+ fn signer_list_keylet(self: &HostContext, account: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "ticketKeylet"]
+ fn ticket_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "vaultKeylet"]
+ fn vault_keylet(self: &HostContext, account: &[u8], seq: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "sha512Half"]
+ fn sha512_half(self: &HostContext, data: &[u8], out: &mut [u8]) -> i32;
+
+ /// Renders `data` as `data_type` says and writes it to this node's log with
+ /// `msg`. Answers nothing at all: the guest's wasm function has no result, and
+ /// C++ swallows a malformed buffer rather than reporting it, so there is no
+ /// failure for this side to encode.
+ ///
+ /// The engine has already refused a code that names no type, so what crosses
+ /// here is always one of the variants.
+ #[namespace = "xrpl"]
+ fn trace(self: &HostContext, msg: &str, data: &[u8], data_type: TraceDataType);
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "updateData"]
+ fn update_data(self: &HostContext, data: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFT"]
+ fn get_nft(self: &HostContext, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFTIssuer"]
+ fn get_nft_issuer(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFTTaxon"]
+ fn get_nft_taxon(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFTFlags"]
+ fn get_nft_flags(self: &HostContext, nft_id: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFTTransferFee"]
+ fn get_nft_transfer_fee(self: &HostContext, nft_id: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "getNFTSequence"]
+ fn get_nft_sequence(self: &HostContext, nft_id: &[u8], out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatFromInt"]
+ fn float_from_int(self: &HostContext, x: i64, mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatFromUint"]
+ fn float_from_uint(self: &HostContext, x: &[u8], mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatFromSTAmount"]
+ fn float_from_stamount(self: &HostContext, amount: &[u8], mode: i32, out: &mut [u8])
+ -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatFromSTNumber"]
+ fn float_from_stnumber(self: &HostContext, number: &[u8], mode: i32, out: &mut [u8])
+ -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatToInt"]
+ fn float_to_int(self: &HostContext, x: &[u8], mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatToMantExp"]
+ fn float_to_mant_exp(
+ self: &HostContext,
+ x: &[u8],
+ mantissa_out: &mut [u8],
+ exponent_out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatFromMantExp"]
+ fn float_from_mant_exp(
+ self: &HostContext,
+ mantissa: i64,
+ exponent: i32,
+ mode: i32,
+ out: &mut [u8],
+ ) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatCompare"]
+ fn float_compare(self: &HostContext, x: &[u8], y: &[u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatAdd"]
+ fn float_add(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatSubtract"]
+ fn float_subtract(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8])
+ -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatMultiply"]
+ fn float_multiply(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8])
+ -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatDivide"]
+ fn float_divide(self: &HostContext, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatRoot"]
+ fn float_root(self: &HostContext, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> i32;
+
+ #[namespace = "xrpl"]
+ #[cxx_name = "floatPower"]
+ fn float_power(self: &HostContext, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> i32;
+ }
+}
+
+/// Sized carrier for the [`HostFunctions`] implementation.
+///
+/// [`ffi::HostContext`] is an opaque C++ type and therefore `!Sized`, so it cannot
+/// be coerced to `&dyn HostFunctions` itself.
+struct CxxHost<'a> {
+ ctx: &'a ffi::HostContext,
+}
+
+/// A byte-producing call's answer: the value's true length, or its error code.
+///
+/// The conversion *is* the sign test — it fails on exactly the negative values — so
+/// there is no cast to argue about.
+///
+/// A named function rather than a `From` impl, and not by preference: every type
+/// involved — `i32`, `Result`, `HostError` — is foreign to this crate, so the orphan
+/// rule forbids the impl.
+fn bytes_written(n: i32) -> HostResult {
+ usize::try_from(n).map_err(|_| HostError::from_code(n))
+}
+
+/// The ABI's data type as the shared enum C++ was given a definition of.
+///
+/// A `match` rather than a cast through `code()`: the cast would compile for a variant
+/// nobody added to [`ffi::TraceDataType`] and hand C++ a value its `switch` does not
+/// name. This is the whole reason the two lists cannot drift.
+fn crossed(data_type: TraceDataType) -> ffi::TraceDataType {
+ match data_type {
+ TraceDataType::Int64 => ffi::TraceDataType::Int64,
+ TraceDataType::Uint64 => ffi::TraceDataType::Uint64,
+ TraceDataType::Xfloat => ffi::TraceDataType::Xfloat,
+ TraceDataType::Account => ffi::TraceDataType::Account,
+ TraceDataType::Amount => ffi::TraceDataType::Amount,
+ TraceDataType::AsHex => ffi::TraceDataType::AsHex,
+ TraceDataType::AsText => ffi::TraceDataType::AsText,
+ }
+}
+
+/// A call whose answer is a scalar the guest reads directly (a flag, a slot index):
+/// a non-negative value is that answer, a negative one its error code.
+fn scalar(n: i32) -> HostResult {
+ if n < 0 {
+ return Err(HostError::from_code(n));
+ }
+ Ok(n)
+}
+
+impl HostFunctions for CxxHost<'_> {
+ fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_ledger_sqn(out))
+ }
+
+ fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_parent_ledger_time(out))
+ }
+
+ fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_parent_ledger_hash(out))
+ }
+
+ fn get_base_fee(&self, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_base_fee(out))
+ }
+
+ fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult {
+ scalar(self.ctx.is_amendment_enabled(amendment))
+ }
+
+ fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult {
+ scalar(self.ctx.cache_ledger_obj(obj_id, cache_idx))
+ }
+
+ fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_tx_field(field, out))
+ }
+
+ fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_current_ledger_obj_field(field, out))
+ }
+
+ fn get_ledger_obj_field(
+ &self,
+ cache_idx: i32,
+ field: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.get_ledger_obj_field(cache_idx, field, out))
+ }
+
+ fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_tx_nested_field(locator, out))
+ }
+
+ fn get_current_ledger_obj_nested_field(
+ &self,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.get_current_ledger_obj_nested_field(locator, out))
+ }
+
+ fn get_ledger_obj_nested_field(
+ &self,
+ cache_idx: i32,
+ locator: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(
+ self.ctx
+ .get_ledger_obj_nested_field(cache_idx, locator, out),
+ )
+ }
+
+ fn get_tx_array_len(&self, field: i32) -> HostResult {
+ scalar(self.ctx.get_tx_array_len(field))
+ }
+
+ fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult {
+ scalar(self.ctx.get_current_ledger_obj_array_len(field))
+ }
+
+ fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult {
+ scalar(self.ctx.get_ledger_obj_array_len(cache_idx, field))
+ }
+
+ fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult {
+ scalar(self.ctx.get_tx_nested_array_len(locator))
+ }
+
+ fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult {
+ scalar(self.ctx.get_current_ledger_obj_nested_array_len(locator))
+ }
+
+ fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult {
+ scalar(self.ctx.get_ledger_obj_nested_array_len(cache_idx, locator))
+ }
+
+ fn check_signature(&self, message: &[u8], signature: &[u8], pubkey: &[u8]) -> HostResult {
+ scalar(self.ctx.check_signature(message, signature, pubkey))
+ }
+
+ fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.account_keylet(account, out))
+ }
+
+ fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.amm_keylet(asset1, asset2, out))
+ }
+
+ fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.check_keylet(account, seq, out))
+ }
+
+ fn credential_keylet(
+ &self,
+ subject: &[u8],
+ issuer: &[u8],
+ credential_type: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(
+ self.ctx
+ .credential_keylet(subject, issuer, credential_type, out),
+ )
+ }
+
+ fn delegate_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.delegate_keylet(account, authorize, out))
+ }
+
+ fn deposit_preauth_keylet(
+ &self,
+ account: &[u8],
+ authorize: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.deposit_preauth_keylet(account, authorize, out))
+ }
+
+ fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.did_keylet(account, out))
+ }
+
+ fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.escrow_keylet(account, seq, out))
+ }
+
+ fn trust_line_keylet(
+ &self,
+ account1: &[u8],
+ account2: &[u8],
+ currency: &[u8],
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(
+ self.ctx
+ .trust_line_keylet(account1, account2, currency, out),
+ )
+ }
+
+ fn mptoken_issuance_keylet(
+ &self,
+ issuer: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.mptoken_issuance_keylet(issuer, seq, out))
+ }
+
+ fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.mptoken_keylet(mptid, holder, out))
+ }
+
+ fn nftoken_offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.nftoken_offer_keylet(account, seq, out))
+ }
+
+ fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.offer_keylet(account, seq, out))
+ }
+
+ fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.oracle_keylet(account, doc_id, out))
+ }
+
+ fn paychannel_keylet(
+ &self,
+ account: &[u8],
+ destination: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.paychannel_keylet(account, destination, seq, out))
+ }
+
+ fn permissioned_domain_keylet(
+ &self,
+ account: &[u8],
+ seq: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.permissioned_domain_keylet(account, seq, out))
+ }
+
+ fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.signer_list_keylet(account, out))
+ }
+
+ fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.ticket_keylet(account, seq, out))
+ }
+
+ fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.vault_keylet(account, seq, out))
+ }
+
+ fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.sha512_half(data, out))
+ }
+
+ fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()> {
+ self.ctx.trace(msg, data, crossed(data_type));
+ Ok(())
+ }
+
+ fn update_data(&self, data: &[u8]) -> HostResult {
+ scalar(self.ctx.update_data(data))
+ }
+
+ fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_nft(account, nft_id, out))
+ }
+
+ fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_nft_issuer(nft_id, out))
+ }
+
+ fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_nft_taxon(nft_id, out))
+ }
+
+ fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult {
+ scalar(self.ctx.get_nft_flags(nft_id))
+ }
+
+ fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult {
+ scalar(self.ctx.get_nft_transfer_fee(nft_id))
+ }
+
+ fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.get_nft_sequence(nft_id, out))
+ }
+
+ fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_from_int(x, mode, out))
+ }
+
+ fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_from_uint(x, mode, out))
+ }
+
+ fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_from_stamount(amount, mode, out))
+ }
+
+ fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_from_stnumber(number, mode, out))
+ }
+
+ fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_to_int(x, mode, out))
+ }
+
+ fn float_to_mant_exp(
+ &self,
+ x: &[u8],
+ mantissa_out: &mut [u8],
+ exponent_out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.float_to_mant_exp(x, mantissa_out, exponent_out))
+ }
+
+ fn float_from_mant_exp(
+ &self,
+ mantissa: i64,
+ exponent: i32,
+ mode: i32,
+ out: &mut [u8],
+ ) -> HostResult {
+ bytes_written(self.ctx.float_from_mant_exp(mantissa, exponent, mode, out))
+ }
+
+ fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult {
+ scalar(self.ctx.float_compare(x, y))
+ }
+
+ fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_add(x, y, mode, out))
+ }
+
+ fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_subtract(x, y, mode, out))
+ }
+
+ fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_multiply(x, y, mode, out))
+ }
+
+ fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_divide(x, y, mode, out))
+ }
+
+ fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_root(x, n, mode, out))
+ }
+
+ fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult {
+ bytes_written(self.ctx.float_power(x, n, mode, out))
+ }
+}
+
+fn run_escrow(
+ host: &ffi::HostContext,
+ wasm: &[u8],
+ gas: u64,
+ function_name: &str,
+) -> ffi::RunResult {
+ guarded(
+ || {
+ let host = CxxHost { ctx: host };
+ run(wasm, gas, &host, function_name).into()
+ },
+ ffi::RunResult::panicked,
+ )
+}
+
+fn check_escrow(wasm: &[u8], function_name: &str) -> ffi::CheckResult {
+ guarded(
+ || check(wasm, function_name).into(),
+ ffi::CheckResult::panicked,
+ )
+}
+
+impl ffi::RunResult {
+ /// A run the engine panicked in.
+ ///
+ /// The cost is not reported: a panicking run's meter is not evidence of
+ /// anything, and `0` says "unknown" where a number would say "this is what it
+ /// owed".
+ fn panicked(detail: String) -> ffi::RunResult {
+ ffi::RunResult {
+ status: ffi::RunStatus::Panic,
+ result: 0,
+ gas_used: 0,
+ detail,
+ }
+ }
+}
+
+impl ffi::CheckResult {
+ /// A check the engine panicked in.
+ fn panicked(detail: String) -> ffi::CheckResult {
+ ffi::CheckResult {
+ status: ffi::CheckStatus::Panic,
+ detail,
+ }
+ }
+}
+
+/// Run `body`, handing a panic to `panicked` rather than letting it unwind into
+/// C++.
+///
+/// **Why catching here is enough.** An unwind can only be caught where every frame
+/// between the panic and the catch is Rust, and every frame here is: the engine and
+/// wasmi are Rust, and a host call cannot start a C++ unwind because each
+/// `HostContext` method is `noexcept` and catches everything. So the only unwind
+/// that can reach this frame started in Rust, and this stops it.
+///
+/// [`AssertUnwindSafe`] is sound because nothing survives to be observed in a torn
+/// state: the store, the linker and the host wrapper are all dropped on the way out,
+/// and the one thing that outlives the call — the C++ `HostContext` — is only ever
+/// touched through those `noexcept` methods, which either complete or report.
+///
+/// Generic over the result so both crossings share the one catch: the two answer
+/// with different structs, and a second `catch_unwind` is the last thing this file
+/// should have two of.
+fn guarded(body: impl FnOnce() -> T, panicked: impl FnOnce(String) -> T) -> T {
+ catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| panicked(panic_detail(&*payload)))
+}
+
+/// The panic's message, for the log.
+///
+/// A `panic!` payload is a `&str` or a `String`; anything else is a `panic_any` that
+/// nothing below this crate makes, and it still has to produce a line.
+fn panic_detail(payload: &(dyn Any + Send)) -> String {
+ let message = payload
+ .downcast_ref::<&str>()
+ .copied()
+ .or_else(|| payload.downcast_ref::().map(String::as_str))
+ .unwrap_or("payload is not a string");
+ format!("panicked: {message}")
+}
+
+/// The engine's two-channel result on the one struct cxx can carry.
+///
+/// A `From` rather than a named function because the mapping is total and there is
+/// only one of it: every field of the wire struct is decided by the outcome, so
+/// there is no second reading for a name to distinguish.
+impl From> for ffi::RunResult {
+ fn from(result: Result) -> ffi::RunResult {
+ match result {
+ Ok(RunOutcome { result, fuel_used }) => ffi::RunResult {
+ status: ffi::RunStatus::Ok,
+ result,
+ gas_used: fuel_used,
+ detail: String::new(),
+ },
+ // `fuel_used` is carried on both channels by construction, so a failed
+ // run reports its cost here without this having to decide what one is.
+ Err(RunFailure { error, fuel_used }) => ffi::RunResult {
+ status: ffi::RunStatus::from(&error),
+ result: 0,
+ gas_used: fuel_used,
+ detail: error.to_string(),
+ },
+ }
+ }
+}
+
+/// The status a [`RunError`] crosses as.
+///
+/// Exhaustive rather than closed with a wildcard: an outcome added to the engine has
+/// to be given a status — and therefore a TER on the far side — before this compiles.
+impl From<&RunError> for ffi::RunStatus {
+ fn from(error: &RunError) -> ffi::RunStatus {
+ match error {
+ RunError::Compile(_) => ffi::RunStatus::Compile,
+ RunError::Instantiate(_) => ffi::RunStatus::Instantiate,
+ RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint,
+ RunError::OutOfGas => ffi::RunStatus::OutOfGas,
+ RunError::Internal => ffi::RunStatus::Internal,
+ RunError::NoMemory => ffi::RunStatus::NoMemory,
+ RunError::Trap(_) => ffi::RunStatus::Trap,
+ }
+ }
+}
+
+/// A verdict on the wire. No cost to carry, so `Ok` is the empty description.
+impl From> for ffi::CheckResult {
+ fn from(result: Result<(), CheckError>) -> ffi::CheckResult {
+ match result {
+ Ok(()) => ffi::CheckResult {
+ status: ffi::CheckStatus::Ok,
+ detail: String::new(),
+ },
+ Err(error) => ffi::CheckResult {
+ status: ffi::CheckStatus::from(&error),
+ detail: error.to_string(),
+ },
+ }
+ }
+}
+
+/// The status a [`CheckError`] crosses as, exhaustive for the same reason
+/// [`ffi::RunStatus`]'s conversion is.
+impl From<&CheckError> for ffi::CheckStatus {
+ fn from(error: &CheckError) -> ffi::CheckStatus {
+ match error {
+ CheckError::Compile(_) => ffi::CheckStatus::Compile,
+ CheckError::Import(_) => ffi::CheckStatus::Import,
+ CheckError::EntryPoint(_) => ffi::CheckStatus::EntryPoint,
+ CheckError::Memory(_) => ffi::CheckStatus::Memory,
+ CheckError::Table(_) => ffi::CheckStatus::Table,
+ }
+ }
+}
+
+/// These tests reach none of the `extern "C++"` methods, which is what lets the test
+/// binary link at all: the C++ side of the bridge exists only in the CMake build, so
+/// a test that called one would fail to link rather than fail.
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ok(result: i32, fuel_used: u64) -> ffi::RunResult {
+ let outcome: Result = Ok(RunOutcome { result, fuel_used });
+ outcome.into()
+ }
+
+ fn failed(error: RunError, fuel_used: u64) -> ffi::RunResult {
+ let outcome: Result = Err(RunFailure { error, fuel_used });
+ outcome.into()
+ }
+
+ #[test]
+ fn a_completed_run_carries_its_value_and_its_cost() {
+ let crossed = ok(5, 1234);
+
+ assert_eq!(crossed.status, ffi::RunStatus::Ok);
+ assert_eq!(crossed.result, 5);
+ assert_eq!(crossed.gas_used, 1234);
+ assert_eq!(crossed.detail, "", "a completed run has nothing to explain");
+ }
+
+ /// The cost is the point: a contract that burns its gas and traps is charged.
+ #[test]
+ fn a_failed_run_carries_its_cost_and_the_engines_own_words() {
+ let crossed = failed(RunError::Trap("unreachable".to_string()), 900);
+
+ assert_eq!(crossed.status, ffi::RunStatus::Trap);
+ assert_eq!(crossed.gas_used, 900);
+ assert_eq!(crossed.detail, "trap: unreachable");
+ assert_eq!(crossed.result, 0, "a failed run returned no value");
+ }
+
+ /// The `RunError` set as the test *expects* it, not as the conversion reports it:
+ /// deriving it from the code under test would make the assertion vacuous.
+ fn every_run_error() -> Vec {
+ vec![
+ RunError::Compile(String::new()),
+ RunError::Instantiate(String::new()),
+ RunError::EntryPoint(String::new()),
+ RunError::OutOfGas,
+ RunError::Internal,
+ RunError::NoMemory,
+ RunError::Trap(String::new()),
+ ]
+ }
+
+ /// Distinct statuses, because the TER map on the far side reads nothing else. Two
+ /// outcomes sharing one status would silently collapse two TERs into one.
+ #[test]
+ fn every_run_error_crosses_as_a_status_of_its_own() {
+ let mut seen = Vec::new();
+ for error in every_run_error() {
+ let status = ffi::RunStatus::from(&error);
+ assert!(
+ !seen.contains(&status),
+ "{error:?} shares {status:?} with an earlier outcome"
+ );
+ seen.push(status);
+ }
+ }
+
+ /// `Ok` is the one status no failure may take: the far side reads it as "the
+ /// contract returned", and would then read `result` off a run that produced none.
+ #[test]
+ fn no_failure_crosses_as_success() {
+ for error in every_run_error() {
+ assert_ne!(
+ ffi::RunStatus::from(&error),
+ ffi::RunStatus::Ok,
+ "{error:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn a_panic_becomes_a_status_instead_of_an_unwind() {
+ let crossed = guarded(|| panic!("the engine came apart"), ffi::RunResult::panicked);
+
+ assert_eq!(crossed.status, ffi::RunStatus::Panic);
+ assert_eq!(crossed.detail, "panicked: the engine came apart");
+ assert_eq!(crossed.gas_used, 0, "a panicking run reports no cost");
+ }
+
+ /// A formatted `panic!` payload is a `String` rather than a `&str`, so both
+ /// downcasts are load-bearing.
+ #[test]
+ fn a_formatted_panic_keeps_its_message() {
+ let overflowed = 3;
+ let crossed = guarded(
+ || panic!("gas underflowed by {overflowed}"),
+ ffi::RunResult::panicked,
+ );
+
+ assert_eq!(crossed.detail, "panicked: gas underflowed by 3");
+ }
+
+ #[test]
+ fn a_panic_with_no_message_still_reports_one() {
+ let crossed = guarded(|| std::panic::panic_any(7u32), ffi::RunResult::panicked);
+
+ assert_eq!(crossed.status, ffi::RunStatus::Panic);
+ assert_eq!(crossed.detail, "panicked: payload is not a string");
+ }
+
+ #[test]
+ fn a_run_that_does_not_panic_is_untouched() {
+ let crossed = guarded(|| ok(1, 2), ffi::RunResult::panicked);
+
+ assert_eq!(crossed.status, ffi::RunStatus::Ok);
+ assert_eq!(crossed.result, 1);
+ assert_eq!(crossed.gas_used, 2);
+ }
+
+ /// [`crossed`] being exhaustive makes the two lists hold the same *variants*;
+ /// this makes them hold the same *numbers*, which is what actually crosses. A
+ /// `match` arm pointed at the wrong variant would pass the compiler and fail
+ /// here.
+ ///
+ /// Over `TraceDataType::ALL`, so it is the whole set rather than a sample: a data
+ /// type added to the ABI arrives already asserted against the shared enum.
+ #[test]
+ fn every_data_type_crosses_as_the_same_wire_value() {
+ for &data_type in TraceDataType::ALL {
+ assert_eq!(
+ crossed(data_type).repr,
+ data_type.code(),
+ "{data_type:?} crosses as a different value than the ABI gives it"
+ );
+ }
+ }
+
+ #[test]
+ fn a_negative_answer_is_an_error_code_and_a_length_is_a_length() {
+ assert_eq!(bytes_written(32), Ok(32));
+ assert_eq!(bytes_written(0), Ok(0));
+ assert_eq!(bytes_written(-3), Err(HostError::BufferTooSmall));
+ assert_eq!(bytes_written(-14), Err(HostError::NoMemExported));
+ assert_eq!(scalar(1), Ok(1));
+ assert_eq!(scalar(0), Ok(0));
+ assert_eq!(scalar(-2), Err(HostError::FieldNotFound));
+ }
+
+ #[test]
+ fn a_caught_cxx_exception_arrives_as_internal_fatal() {
+ assert_eq!(bytes_written(i32::MIN), Err(HostError::InternalFatal));
+ }
+
+ /// A code the ABI does not define goes the same way, so a C++ list this crate has
+ /// not caught up with stops the run rather than reaching the guest.
+ #[test]
+ fn an_undefined_code_arrives_as_internal_fatal() {
+ assert_eq!(bytes_written(-21), Err(HostError::InternalFatal));
+ }
+
+ // -----------------------------------------------------------------------
+ // The check crossing
+ //
+ // `check_escrow` takes no host, so unlike `run_escrow` it can be called
+ // outright here — the modules are hand-written bytes because this crate has
+ // no assembler and needs none for two of them.
+ // -----------------------------------------------------------------------
+
+ /// The smallest valid module: the eight-byte header and nothing else. It
+ /// compiles and imports nothing, so it reaches the entry-point stage.
+ const EMPTY_MODULE: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
+
+ #[test]
+ fn a_module_that_does_not_compile_crosses_as_compile() {
+ let crossed = check_escrow(b"not wasm", "escrow_finish");
+
+ assert_eq!(crossed.status, ffi::CheckStatus::Compile);
+ assert!(
+ crossed.detail.starts_with("compile: "),
+ "{}",
+ crossed.detail
+ );
+ }
+
+ /// The whole crossing, end to end: a real module through the real engine, with
+ /// the refusal the C++ side will log.
+ #[test]
+ fn a_module_without_the_entry_point_crosses_as_entry_point() {
+ let crossed = check_escrow(&EMPTY_MODULE, "escrow_finish");
+
+ assert_eq!(crossed.status, ffi::CheckStatus::EntryPoint);
+ assert_eq!(crossed.detail, "no entry point 'escrow_finish'");
+ }
+
+ /// The `CheckError` set as the test *expects* it, not as the conversion reports
+ /// it: deriving it from the code under test would make the assertion vacuous.
+ fn every_check_error() -> Vec {
+ vec![
+ CheckError::Compile(String::new()),
+ CheckError::Import(String::new()),
+ CheckError::EntryPoint(String::new()),
+ CheckError::Memory(String::new()),
+ CheckError::Table(String::new()),
+ ]
+ }
+
+ /// Distinct statuses, because the TER map on the far side reads nothing else.
+ #[test]
+ fn every_check_error_crosses_as_a_status_of_its_own() {
+ let mut seen = Vec::new();
+ for error in every_check_error() {
+ let status = ffi::CheckStatus::from(&error);
+ assert!(
+ !seen.contains(&status),
+ "{error:?} shares {status:?} with an earlier refusal"
+ );
+ seen.push(status);
+ }
+ }
+
+ /// `Ok` is the one status no refusal may take: the far side reads it as
+ /// `tesSUCCESS` and would let the module through.
+ #[test]
+ fn no_refusal_crosses_as_success() {
+ for error in every_check_error() {
+ assert_ne!(
+ ffi::CheckStatus::from(&error),
+ ffi::CheckStatus::Ok,
+ "{error:?}"
+ );
+ }
+ }
+
+ /// A panic during a check is its own status rather than one more malformed
+ /// module: the far side answers a node-local failure, not `temBAD_WASM`.
+ #[test]
+ fn a_panic_during_a_check_becomes_a_status_instead_of_an_unwind() {
+ let crossed = guarded(
+ || panic!("the checker came apart"),
+ ffi::CheckResult::panicked,
+ );
+
+ assert_eq!(crossed.status, ffi::CheckStatus::Panic);
+ assert_eq!(crossed.detail, "panicked: the checker came apart");
+ }
+}
diff --git a/crates/xrpl-wasm-vm/Cargo.toml b/crates/xrpl-wasm-vm/Cargo.toml
new file mode 100644
index 0000000000..21a5a6f608
--- /dev/null
+++ b/crates/xrpl-wasm-vm/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "xrpl-wasm-vm"
+version = "0.1.0"
+edition.workspace = true
+
+[dependencies]
+wasmi = { version = "1.1.0", default-features = false, features = ["std"] }
+xrpl-host-functions = { path = "../xrpl-host-functions" }
+
+[dev-dependencies]
+wat = "1"
diff --git a/crates/xrpl-wasm-vm/src/abi.rs b/crates/xrpl-wasm-vm/src/abi.rs
new file mode 100644
index 0000000000..da7108d2c4
--- /dev/null
+++ b/crates/xrpl-wasm-vm/src/abi.rs
@@ -0,0 +1,827 @@
+use crate::region::Region;
+use crate::vm::{MAX_FIELD_BYTES, VmState};
+use core::ops::Range;
+use wasmi::{Caller, Memory};
+use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult};
+
+/// A condition that stops the run. It is a property of the run rather than an answer
+/// to a call, so it reaches no guest and carries no wire code — which is why it is
+/// not a [`HostError`]: no host can report one and no contract can read one.
+///
+/// The three are the outcomes a host call can end a run with, and
+/// `From for RunError` in `vm.rs` is where each gets its name.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Fault {
+ /// This call's charge would take the meter below zero. The guest exhausting the
+ /// meter with its own instructions reaches [`crate::vm::RunError::OutOfGas`] by
+ /// wasmi's `OutOfFuel` trap instead, never through here.
+ OutOfGas,
+ /// The call could not be served: either the host said so, or this engine's own
+ /// fuel meter did not answer.
+ Internal,
+ /// There is no linear memory to work in — the module exports none, or the call
+ /// came from a start section, which runs before there is an instance.
+ NoMemory,
+}
+
+/// How a host call fails: with a code the guest reads off the return value, or with a
+/// [`Fault`] that stops the run.
+///
+/// **The variant picks the channel.** [`to_wire`] reads it rather than asking a
+/// predicate, so the two cannot disagree, and a [`FatalHostError`] cannot be built
+/// around something a guest was supposed to see.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum CallError {
+ Code(HostError),
+ Fatal(Fault),
+}
+
+/// A host call's result inside the engine: [`HostResult`] plus the faults only the
+/// engine can raise.
+pub(crate) type CallResult = Result;
+
+/// Which channel a host's answer takes, decided once, here.
+///
+/// Three codes stop the run instead of reaching the contract that asked. Each says the
+/// call was not served at all — the host could not do it, it has not been wired, or
+/// there is nowhere to put the answer — and a contract has no business interpreting
+/// any of them, so it is told nothing and the run ends. Every other code is the
+/// contract's to read.
+impl From for CallError {
+ fn from(error: HostError) -> CallError {
+ match error {
+ HostError::InternalFatal => CallError::Fatal(Fault::Internal),
+ HostError::Unimplemented => CallError::Fatal(Fault::Internal),
+ HostError::NoMemExported => CallError::Fatal(Fault::NoMemory),
+ code => CallError::Code(code),
+ }
+ }
+}
+
+/// The payload a trap carries so [`crate::vm::run`] can name the outcome without
+/// parsing a message. Holds a [`Fault`], so by construction no guest-visible code can
+/// leave through this channel.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct FatalHostError(pub(crate) Fault);
+
+impl wasmi::errors::HostError for FatalHostError {}
+
+impl core::fmt::Display for FatalHostError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "host call refused: {:?}", self.0)
+ }
+}
+
+/// Charge the call's gas, run its body, put the result on the wire. The one path
+/// every registered closure takes, so gas cannot be forgotten.
+pub(crate) fn charged(
+ caller: &mut Caller<'_, VmState<'_>>,
+ op: HostFunctionSpec,
+ body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult,
+) -> Result {
+ to_wire(charge(caller, op.gas()).and_then(|()| body(caller)))
+}
+
+/// [`charged`] for a call the guest gets no answer from: its wasm function has no
+/// result, so a soft error has nowhere to go and is dropped. The gas is charged first
+/// and charged whatever happens after, so the cost is all such a call leaves behind.
+///
+/// Only `trace` takes this path.
+pub(crate) fn charged_unreported(
+ caller: &mut Caller<'_, VmState<'_>>,
+ op: HostFunctionSpec,
+ body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult<()>,
+) -> Result<(), wasmi::Error> {
+ dropped(charge(caller, op.gas()).and_then(|()| body(caller)))
+}
+
+/// [`to_wire`] for a call with no result: there is no return value to encode a code
+/// in, so it is dropped. A [`Fault`] still stops the run — that is a property of the
+/// run, not an answer to the call.
+fn dropped(result: CallResult<()>) -> Result<(), wasmi::Error> {
+ match result {
+ Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))),
+ _ => Ok(()),
+ }
+}
+
+fn to_wire(result: CallResult) -> Result {
+ match result {
+ Ok(value) => Ok(value),
+ Err(CallError::Code(error)) => Ok(error.code()),
+ Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))),
+ }
+}
+
+/// Deduct `cost` fuel; [`Fault::OutOfGas`] if it would go negative.
+///
+/// A meter that will not answer is this crate's own defect, not the contract's, so it
+/// is [`Fault::Internal`] rather than a number a guest could act on.
+fn charge(caller: &mut Caller<'_, T>, cost: u64) -> CallResult<()> {
+ let remaining = caller
+ .get_fuel()
+ .map_err(|_| CallError::Fatal(Fault::Internal))?;
+ match remaining.checked_sub(cost) {
+ Some(left) => caller
+ .set_fuel(left)
+ .map_err(|_| CallError::Fatal(Fault::Internal)),
+ None => {
+ let _ = caller.set_fuel(0);
+ Err(CallError::Fatal(Fault::OutOfGas))
+ }
+ }
+}
+
+fn charge_transfer(state: &VmState<'_>, n: usize) -> Result<(), HostError> {
+ let n = n as u64;
+ let remaining = state.transfer_budget.get();
+ match remaining.checked_sub(n) {
+ Some(left) => {
+ state.transfer_budget.set(left);
+ Ok(())
+ }
+ None => Err(HostError::OutOfTransferLimit),
+ }
+}
+
+fn memory(caller: &Caller<'_, VmState<'_>>) -> CallResult {
+ caller
+ .data()
+ .memory
+ .ok_or(CallError::Fatal(Fault::NoMemory))
+}
+
+/// [`Region::read`] of the guest's memory, for a call that reads and writes nothing
+/// back (`trace`).
+pub(crate) fn read_borrowed<'a>(
+ caller: &'a Caller<'_, VmState<'_>>,
+ input: Region,
+) -> CallResult<&'a [u8]> {
+ let mem = memory(caller)?;
+ Ok(input.read(mem.data(caller))?)
+}
+
+/// Decode a guest `u32` argument — a keylet's sequence number or document id — from
+/// its four little-endian bytes, carried on to the host as its `i32` bit pattern.
+///
+/// The ABI transports these as a 4-byte region rather than a wasm scalar (the guest
+/// SDK passes `seq.to_le_bytes()`), so the region must be exactly four bytes;
+/// `InvalidParams` otherwise.
+pub(crate) fn read_u32_arg(bytes: &[u8]) -> HostResult {
+ let arr: [u8; 4] = bytes.try_into().map_err(|_| HostError::InvalidParams)?;
+ Ok(i32::from_le_bytes(arr))
+}
+
+/// Service a call whose answer is bytes, written straight into the guest's output
+/// region.
+///
+/// **`fill` returns the value's true length, not what it wrote**: a host holding 64
+/// bytes and offered room for 4 writes nothing and answers `64`, which is how the
+/// guest learns the size to ask for. So `n` is bounded by neither the region, the
+/// cap, nor the budget, and all three checks below are reachable.
+pub(crate) fn write_into(
+ caller: &mut Caller<'_, VmState<'_>>,
+ out: Region,
+ fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult,
+) -> CallResult {
+ let range = out.range()?;
+ let cap = range.len();
+ let mem = memory(caller)?;
+ let host: &dyn HostFunctions = caller.data().host;
+ let budget = usize::try_from(caller.data().transfer_budget.get()).unwrap_or(usize::MAX);
+ let buf = mem
+ .data_mut(&mut *caller)
+ .get_mut(range)
+ .ok_or(HostError::PointerOutOfBounds)?;
+ let buf = &mut buf[..cap.min(MAX_FIELD_BYTES).min(budget)];
+
+ let n = fill(host, buf)?;
+
+ if n > MAX_FIELD_BYTES {
+ return Err(HostError::DataFieldTooLarge.into());
+ }
+ if n > cap {
+ return Err(HostError::BufferTooSmall.into());
+ }
+ charge_transfer(caller.data(), n)?;
+ #[expect(
+ clippy::cast_possible_truncation,
+ clippy::cast_possible_wrap,
+ reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32"
+ )]
+ let n = n as i32;
+ Ok(n)
+}
+
+/// Service a call that reads guest memory and writes bytes back to it: the host
+/// fills the run's output buffer, which is copied to the guest once every rule has
+/// passed.
+///
+/// `call` gets the guest's whole memory, so it can borrow any number of input
+/// regions with [`Region::read`] — which a `&mut` view of that memory would forbid.
+/// That is why the answer goes through a buffer instead of straight into the guest
+/// as [`write_into`]'s does.
+///
+/// **The host is never told the guest's capacity**: it is offered the whole buffer
+/// and reports the value's true length, so the fit is decided here, with nothing yet
+/// in guest memory. A refused value therefore reaches it in no part.
+///
+/// The output is judged after the inputs, so a call with both bad reports the
+/// input's verdict. `NoMemExported` precedes both: there is no memory to validate a
+/// region against.
+pub(crate) fn write_buffered(
+ caller: &mut Caller<'_, VmState<'_>>,
+ out: Region,
+ call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8]) -> HostResult,
+) -> CallResult