Add self to host functions trat

This commit is contained in:
Sergey Kuznetsov
2026-07-29 10:59:00 +01:00
parent d8d1ec46dc
commit 2cc8b87c87
5 changed files with 168 additions and 88 deletions

View File

@@ -15,9 +15,9 @@ use parsed_host_function::ParsedHostFunction;
/// Declares the wasm host ABI once, and generates everything that follows from it.
///
/// The input is a block of bare `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.
/// 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.
///
/// ```
/// use xrpl_host_functions_macros::host_functions;
@@ -26,19 +26,19 @@ use parsed_host_function::ParsedHostFunction;
/// /// The sequence number of the ledger being built.
/// #[gas = 60]
/// #[wasm_name = "ldgr_index"]
/// fn get_ledger_sqn() -> [u8; 4];
/// fn get_ledger_sqn(&self) -> [u8; 4];
///
/// /// Writes `msg` to the trace log.
/// #[gas = 500]
/// #[wasm_name = "trace_num"]
/// fn trace_num(msg: &str, number: i64);
/// fn trace_num(&self, msg: &str, number: i64);
/// }
///
/// // A `HostFunctions` trait, with a `&mut self` receiver added:
/// // A `HostFunctions` trait, holding the declarations verbatim:
/// struct Host;
/// impl HostFunctions for Host {
/// fn get_ledger_sqn(&mut self) -> [u8; 4] { 7u32.to_le_bytes() }
/// fn trace_num(&mut self, _msg: &str, _number: i64) {}
/// fn get_ledger_sqn(&self) -> [u8; 4] { 7u32.to_le_bytes() }
/// fn trace_num(&self, _msg: &str, _number: i64) {}
/// }
///
/// // A `HostFunctionSpec` enum carrying the ABI metadata as a `const` table:
@@ -47,9 +47,9 @@ use parsed_host_function::ParsedHostFunction;
/// assert_eq!(HostFunctionSpec::ALL.len(), 2);
/// ```
///
/// A declaration must be a plain `fn` with no receiver, 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.
/// A declaration must be a plain `fn` taking `&self`, 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())
@@ -123,9 +123,9 @@ fn generate(functions: &[ParsedHostFunction]) -> TokenStream {
///
/// 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 a declaration from the `host_functions!` block with a
/// `&mut self` receiver added; the receiver is not part of the ABI the
/// guest sees.
/// 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.
pub trait HostFunctions {
#(#trait_methods)*
}
@@ -217,10 +217,10 @@ mod tests {
fn reports_mistakes_from_every_function() {
let error = expand(quote! {
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
#[gas = 2000]
fn sha512_half(data: &[u8]) -> [u8; 32];
fn sha512_half(&self, data: &[u8]) -> [u8; 32];
})
.expect_err("expected parsing to fail");
@@ -249,19 +249,19 @@ mod tests {
let generated = expand(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
#[gas = 500]
#[wasm_name = "trace_num"]
fn trace_num(msg: &str, number: i64);
fn trace_num(&self, msg: &str, number: i64);
})
.unwrap()
.to_string();
for expected in [
"pub trait HostFunctions",
"fn get_ledger_sqn (& mut self) -> [u8 ; 4] ;",
"fn trace_num (& mut self , msg : & str , number : i64) ;",
"fn get_ledger_sqn (& self) -> [u8 ; 4] ;",
"fn trace_num (& self , msg : & str , number : i64) ;",
"pub struct HostFnSpec",
"pub name : & 'static str",
"pub gas : u64",
@@ -279,11 +279,11 @@ mod tests {
let messages = messages(quote! {
#[gas = 60]
#[wasm_name = "trace"]
fn trace(msg: &str);
fn trace(&self, msg: &str);
#[gas = 70]
#[wasm_name = "trace"]
fn trace_num(msg: &str, number: i64);
fn trace_num(&self, msg: &str, number: i64);
});
assert_eq!(messages.len(), 1, "{messages:?}");
@@ -299,11 +299,11 @@ mod tests {
let messages = messages(quote! {
#[gas = 60]
#[wasm_name = "a"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
#[gas = 70]
#[wasm_name = "b"]
fn get_ledger__sqn() -> [u8; 4];
fn get_ledger__sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 1, "{messages:?}");

View File

@@ -1,7 +1,7 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Attribute, Expr, ExprLit, Ident, Lit, LitStr, Safety, Signature, TraitItemFn, parse_quote,
Attribute, Expr, ExprLit, Ident, Lit, LitStr, ReceiverKind, Safety, Signature, TraitItemFn,
};
use crate::errors;
@@ -27,14 +27,12 @@ pub(crate) struct ParsedHostFunction {
}
impl ParsedHostFunction {
/// `#[doc …] fn get_ledger_sqn(&mut self) -> [u8; 4];`
/// `#[doc …] fn get_ledger_sqn(&self) -> [u8; 4];`
pub(crate) fn trait_method(&self) -> TokenStream {
let docs = &self.docs;
// The receiver is not part of the wasm ABI, so declarations omit it and
// only the trait needs one.
let mut signature = self.signature.clone();
signature.inputs.insert(0, parse_quote!(&self));
// 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)*
@@ -124,12 +122,7 @@ impl ParsedHostFunction {
"a host function must not be generic: it maps to one wasm import signature",
));
}
if let Some(receiver) = function.sig.receiver() {
errors.push(syn::Error::new_spanned(
receiver,
"the receiver is added by the macro; declare only the wasm parameters",
));
}
errors.extend(check_receiver(&function.sig).err());
if let Some(name) = &wasm_name {
errors.extend(check_wasm_name(name).err());
}
@@ -163,6 +156,35 @@ impl ParsedHostFunction {
}
}
/// 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<Self>`, 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(())
}
/// `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<syn::Error>) {
@@ -339,7 +361,7 @@ mod tests {
let parsed = ParsedHostFunction::parse(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
})
.unwrap();
@@ -378,7 +400,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "two_factor"]
fn _2fa();
fn _2fa(&self);
});
assert_eq!(messages.len(), 1, "{messages:?}");
@@ -410,7 +432,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = -5]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 1, "{messages:?}");
@@ -422,7 +444,7 @@ mod tests {
let empty = messages(parse_quote! {
#[gas = 60]
#[wasm_name = ""]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(empty.len(), 1, "{empty:?}");
assert_eq!(empty[0], "the wasm name must not be empty");
@@ -430,7 +452,7 @@ mod tests {
let spaced = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(spaced.len(), 1, "{spaced:?}");
assert!(spaced[0].contains("may only contain"), "{spaced:?}");
@@ -439,10 +461,10 @@ mod tests {
#[test]
fn rejects_signature_modifiers() {
for declaration in [
quote! { unsafe fn get_ledger_sqn() -> [u8; 4]; },
quote! { async fn get_ledger_sqn() -> [u8; 4]; },
quote! { const fn get_ledger_sqn() -> [u8; 4]; },
quote! { extern "C" fn get_ledger_sqn() -> [u8; 4]; },
quote! { unsafe fn get_ledger_sqn(&self) -> [u8; 4]; },
quote! { async fn get_ledger_sqn(&self) -> [u8; 4]; },
quote! { const fn get_ledger_sqn(&self) -> [u8; 4]; },
quote! { extern "C" fn get_ledger_sqn(&self) -> [u8; 4]; },
] {
let function: TraitItemFn = syn::parse2(quote! {
#[gas = 60]
@@ -458,12 +480,12 @@ mod tests {
}
#[test]
fn trait_method_takes_a_receiver_and_ends_in_a_semicolon() {
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(data: &[u8]) -> [u8; 32];
fn sha512_half(&self, data: &[u8]) -> [u8; 32];
})
.unwrap();
@@ -475,7 +497,7 @@ mod tests {
"{method}"
);
assert!(
method.contains("fn sha512_half (& mut self , data : & [u8]) -> [u8 ; 32] ;"),
method.contains("fn sha512_half (& self , data : & [u8]) -> [u8 ; 32] ;"),
"{method}"
);
}
@@ -485,7 +507,7 @@ mod tests {
let parsed = ParsedHostFunction::parse(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
})
.unwrap();
@@ -503,7 +525,7 @@ mod tests {
/// Third line.
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
})
.unwrap();
@@ -516,16 +538,17 @@ mod tests {
let traced = ParsedHostFunction::parse(parse_quote! {
#[gas = 500]
#[wasm_name = "trace"]
fn trace(msg: &str, data: &[u8], as_hex: bool);
fn trace(&self, msg: &str, data: &[u8], as_hex: bool);
})
.unwrap();
assert_eq!(traced.signature.inputs.len(), 3);
// The receiver is `inputs[0]`; the three wasm parameters follow it.
assert_eq!(traced.signature.inputs.len(), 4);
assert!(matches!(traced.signature.output, syn::ReturnType::Default));
let hashed = ParsedHostFunction::parse(parse_quote! {
#[gas = 2000]
#[wasm_name = "sha512_half"]
fn sha512_half(data: &[u8]) -> [u8; 32];
fn sha512_half(&self, data: &[u8]) -> [u8; 32];
})
.unwrap();
assert!(matches!(hashed.signature.output, syn::ReturnType::Type(..)));
@@ -534,7 +557,7 @@ mod tests {
#[test]
fn reports_both_missing_attributes_at_once() {
let messages = messages(parse_quote! {
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 2);
@@ -547,7 +570,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = 60]
#[wsam_name = "typo"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
// The typo'd attribute, plus the `wasm_name` it failed to be.
@@ -563,7 +586,7 @@ mod tests {
let gas = messages(parse_quote! {
#[gas = "60"]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(gas.len(), 1, "{gas:?}");
assert!(
@@ -574,7 +597,7 @@ mod tests {
let name = messages(parse_quote! {
#[gas = 60]
#[wasm_name = 7]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(name.len(), 1, "{name:?}");
assert!(
@@ -588,7 +611,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = 99999999999999999999999]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 1, "{messages:?}");
@@ -600,7 +623,7 @@ mod tests {
let bare = messages(parse_quote! {
#[gas]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(bare.len(), 1, "{bare:?}");
assert!(bare[0].contains("gas = ..."), "{bare:?}");
@@ -608,7 +631,7 @@ mod tests {
let list = messages(parse_quote! {
#[gas(60)]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(list.len(), 1, "{list:?}");
}
@@ -620,7 +643,7 @@ mod tests {
#[gas = 70]
#[wasm_name = "ldgr_index"]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 2, "{messages:?}");
@@ -637,7 +660,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = "60"]
#[wasm_name = 7]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
});
assert_eq!(messages.len(), 2, "{messages:?}");
@@ -652,7 +675,7 @@ mod tests {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4] { [0; 4] }
fn get_ledger_sqn(&self) -> [u8; 4] { [0; 4] }
});
assert_eq!(messages.len(), 1, "{messages:?}");
@@ -664,7 +687,7 @@ mod tests {
let parameter = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn<T>() -> T;
fn get_ledger_sqn<T>(&self) -> T;
});
assert_eq!(parameter.len(), 1, "{parameter:?}");
assert!(
@@ -675,20 +698,50 @@ mod tests {
let clause = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4] where Self: Sized;
fn get_ledger_sqn(&self) -> [u8; 4] where Self: Sized;
});
assert_eq!(clause.len(), 1, "{clause:?}");
}
#[test]
fn rejects_an_explicit_receiver() {
fn requires_a_receiver() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> [u8; 4];
fn get_ledger_sqn() -> [u8; 4];
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(messages[0].contains("receiver"), "{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<Self> },
quote! { &'a self },
] {
let function: TraitItemFn = syn::parse2(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(#receiver) -> [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:?}"
);
}
}
}

View File

@@ -87,21 +87,21 @@ pub const HASH_LEN: usize = 32;
host_functions! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> [u8; 4];
fn get_ledger_sqn(&self) -> [u8; 4];
#[gas = 70]
#[wasm_name = "home_le_field"]
fn get_current_ledger_obj_field(field: i32) -> Vec<u8>;
fn get_current_ledger_obj_field(&self, field: i32) -> Vec<u8>;
#[gas = 2000]
#[wasm_name = "sha512_half"]
fn sha512_half(data: &[u8]) -> [u8; 32];
fn sha512_half(&self, data: &[u8]) -> [u8; 32];
#[gas = 500]
#[wasm_name = "trace"]
fn trace(msg: &str, data: &[u8], as_hex: bool);
fn trace(&self, msg: &str, data: &[u8], as_hex: bool);
#[gas = 500]
#[wasm_name = "trace_num"]
fn trace_num(msg: &str, number: i64);
fn trace_num(&self, msg: &str, number: i64);
}

View File

@@ -1,41 +1,48 @@
//! Exercises what `host_functions!` generates: the trait is implementable and
//! the spec table agrees with the declarations in `src/lib.rs`.
use std::cell::RefCell;
use xrpl_host_functions::{HASH_LEN, HostFnSpec, HostFunctionSpec, HostFunctions};
/// 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: Vec<String>,
traced: RefCell<Vec<String>>,
}
impl HostFunctions for FakeHost {
fn get_ledger_sqn(&mut self) -> [u8; 4] {
fn get_ledger_sqn(&self) -> [u8; 4] {
7u32.to_le_bytes()
}
fn get_current_ledger_obj_field(&mut self, field: i32) -> Vec<u8> {
fn get_current_ledger_obj_field(&self, field: i32) -> Vec<u8> {
vec![field as u8]
}
fn sha512_half(&mut self, data: &[u8]) -> [u8; HASH_LEN] {
fn sha512_half(&self, data: &[u8]) -> [u8; HASH_LEN] {
let mut digest = [0; HASH_LEN];
digest[0] = data.len() as u8;
digest
}
fn trace(&mut self, msg: &str, data: &[u8], as_hex: bool) {
self.traced.push(format!("{msg}/{}/{as_hex}", data.len()));
fn trace(&self, msg: &str, data: &[u8], as_hex: bool) {
self.traced
.borrow_mut()
.push(format!("{msg}/{}/{as_hex}", data.len()));
}
fn trace_num(&mut self, msg: &str, number: i64) {
self.traced.push(format!("{msg}={number}"));
fn trace_num(&self, msg: &str, number: i64) {
self.traced.borrow_mut().push(format!("{msg}={number}"));
}
}
#[test]
fn the_trait_is_implementable() {
let mut host = FakeHost::default();
let host = FakeHost::default();
assert_eq!(host.get_ledger_sqn(), [7, 0, 0, 0]);
assert_eq!(host.get_current_ledger_obj_field(3), vec![3]);
@@ -43,7 +50,20 @@ fn the_trait_is_implementable() {
host.trace("hello", b"xy", true);
host.trace_num("count", -1);
assert_eq!(host.traced, ["hello/2/true", "count=-1"]);
assert_eq!(*host.traced.borrow(), ["hello/2/true", "count=-1"]);
}
/// 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;
assert_eq!(host.get_ledger_sqn(), [7, 0, 0, 0]);
host.trace_num("count", 1);
assert_eq!(*fake.traced.borrow(), ["count=1"]);
}
#[test]

View File

@@ -25,6 +25,10 @@ only for reference. Anything we need about the old semantics is recoverable with
`host_functions! { ... }` generates the `HostFunctions` trait + the
`HostFunctionSpec` enum (wasm import name + gas per function). Also `HostError`.
**This crate is the single source of truth for the ABI.**
Each declaration spells its receiver — always `&self`, checked by the macro, so
a declaration reads exactly as the trait method it becomes. `&self` is what lets
the VM hold the host as one shared `&dyn HostFunctions` in the wasmi `Store`; a
host that needs to mutate uses interior mutability.
- `crates/xrpl-host-functions-macros/` — the `host_functions!` proc macro.
- `crates/xrpl-wasm-vm/` — the wasmi wrapper: `vm.rs` (engine/store/run),
`abi.rs` (gas + transfer-limit + guest-memory marshaling), `register.rs`
@@ -129,6 +133,7 @@ That is the entire gap.
```
params:
&self -> nothing (receiver, not part of the ABI)
i32, bool -> i32 (bool: nonzero = true)
i64 -> i64
&[u8], &str -> i32 ptr, i32 len const uint8_t*, int32_t
@@ -338,10 +343,12 @@ useful for comparison and for the gas assertions in `Wasm_test.cpp` — not gosp
## Current state (2026-07-29)
`crates/` does **not** compile: the macro-generated trait (value-returning,
infallible) and the uncommitted VM code (fill-caller's-buffer, `HostResult<usize>`,
`&dyn`) are two different ABI shapes. Every call site in `register.rs` is affected,
as are the macro's own doctests and `xrpl-host-functions/tests/generated_abi.rs`
(which still use `&mut self` and value returns).
infallible) and the VM code (fill-caller's-buffer, `HostResult<usize>`) are two
different ABI shapes. The 8 errors are all in `xrpl-wasm-vm` every byte-returning
call site in `register.rs`, plus `?` on the infallible `trace`/`trace_num`.
`xrpl-host-functions` and `xrpl-host-functions-macros` are green (`cargo test` +
`clippy`): 28 macro tests, 5 ABI tests, 1 doctest.
Resolving that shape is the immediate work. Per the mechanism note above, the
value-returning direction (plus `HostResult<T>`) is the one that composes with