From accd0cac6ca7788d43eef6502af5106d01cfd1eb Mon Sep 17 00:00:00 2001 From: TimothyBanks Date: Mon, 10 Aug 2026 16:06:05 -0400 Subject: [PATCH] feat: Hook up amendment_enabled host function --- crates/xrpl-host-functions/src/lib.rs | 7 +++++ .../tests/generated_abi.rs | 8 ++++++ crates/xrpl-wasm-vm-ffi/src/lib.rs | 23 +++++++++++++++ crates/xrpl-wasm-vm/src/abi.rs | 3 ++ crates/xrpl-wasm-vm/src/register.rs | 14 ++++++++++ crates/xrpl-wasm-vm/tests/budgets.rs | 5 ++++ crates/xrpl-wasm-vm/tests/host_calls.rs | 27 ++++++++++++++++++ crates/xrpl-wasm-vm/tests/preflight.rs | 3 +- crates/xrpl-wasm-vm/tests/support/mod.rs | 18 ++++++++++++ include/xrpl/tx/wasm/HostContext.h | 6 ++++ src/libxrpl/tx/wasm/HostContext.cpp | 28 +++++++++++++++++++ 11 files changed, 141 insertions(+), 1 deletion(-) diff --git a/crates/xrpl-host-functions/src/lib.rs b/crates/xrpl-host-functions/src/lib.rs index 6eb070bf09..2fb728e728 100644 --- a/crates/xrpl-host-functions/src/lib.rs +++ b/crates/xrpl-host-functions/src/lib.rs @@ -121,6 +121,13 @@ host_functions! { #[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. Unlike the getters, this + /// reads an input region and returns the flag directly rather than writing bytes. + #[gas = 100] + #[wasm_name = "amendment_enabled"] + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult; + /// The serialized bytes of one field of the current (escrow) ledger object. #[gas = 70] #[wasm_name = "home_le_field"] diff --git a/crates/xrpl-host-functions/tests/generated_abi.rs b/crates/xrpl-host-functions/tests/generated_abi.rs index f026c31001..f3ba86c99f 100644 --- a/crates/xrpl-host-functions/tests/generated_abi.rs +++ b/crates/xrpl-host-functions/tests/generated_abi.rs @@ -43,6 +43,11 @@ impl HostFunctions for FakeHost { 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())) + } + /// 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 { @@ -83,6 +88,8 @@ fn the_trait_is_implementable() { 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.get_current_ledger_obj_field(3, &mut out), Ok(1)); assert_eq!(out[0], 3); assert_eq!(host.sha512_half(b"abc", &mut out), Ok(HASH_LEN)); @@ -156,6 +163,7 @@ fn the_spec_table_matches_the_declarations() { ("parent_ldgr_time", 60), ("parent_ldgr_hash", 60), ("base_fee", 60), + ("amendment_enabled", 100), ("home_le_field", 70), ("sha512_half", 2000), ("trace", 500), diff --git a/crates/xrpl-wasm-vm-ffi/src/lib.rs b/crates/xrpl-wasm-vm-ffi/src/lib.rs index 999515c918..6314cbad74 100644 --- a/crates/xrpl-wasm-vm-ffi/src/lib.rs +++ b/crates/xrpl-wasm-vm-ffi/src/lib.rs @@ -171,6 +171,12 @@ mod ffi { #[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; + #[namespace = "xrpl"] #[cxx_name = "getCurrentLedgerObjField"] fn get_current_ledger_obj_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32; @@ -219,6 +225,15 @@ fn reported(n: i32) -> HostResult<()> { Ok(()) } +/// A call whose answer is the scalar the guest reads directly (a flag): a +/// non-negative value is that answer, a negative one its error code. +fn flag(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)) @@ -236,6 +251,10 @@ impl HostFunctions for CxxHost<'_> { bytes_written(self.ctx.get_base_fee(out)) } + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult { + flag(self.ctx.is_amendment_enabled(amendment)) + } + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult { bytes_written(self.ctx.get_current_ledger_obj_field(field, out)) } @@ -534,6 +553,9 @@ mod tests { assert_eq!(bytes_written(-3), Err(HostError::BufferTooSmall)); assert_eq!(reported(0), Ok(())); assert_eq!(reported(-14), Err(HostError::NoMemExported)); + assert_eq!(flag(1), Ok(1)); + assert_eq!(flag(0), Ok(0)); + assert_eq!(flag(-2), Err(HostError::FieldNotFound)); } /// An exception caught on the C++ side arrives as `-1`, which has to reach the @@ -543,6 +565,7 @@ mod tests { fn a_caught_cxx_exception_arrives_as_internal() { assert_eq!(bytes_written(-1), Err(HostError::Internal)); assert_eq!(reported(-1), Err(HostError::Internal)); + assert_eq!(flag(-1), Err(HostError::Internal)); } // ----------------------------------------------------------------------- diff --git a/crates/xrpl-wasm-vm/src/abi.rs b/crates/xrpl-wasm-vm/src/abi.rs index f5998af76c..02f284cfdc 100644 --- a/crates/xrpl-wasm-vm/src/abi.rs +++ b/crates/xrpl-wasm-vm/src/abi.rs @@ -197,6 +197,9 @@ mod tests { fn get_base_fee(&self, _out: &mut [u8]) -> HostResult { unreachable!("no unit test in this module calls the host") } + fn is_amendment_enabled(&self, _amendment: &[u8]) -> HostResult { + unreachable!("no unit test in this module calls the host") + } fn get_current_ledger_obj_field(&self, _field: i32, _out: &mut [u8]) -> HostResult { unreachable!("no unit test in this module calls the host") } diff --git a/crates/xrpl-wasm-vm/src/register.rs b/crates/xrpl-wasm-vm/src/register.rs index 6dad076d90..7de7b569bb 100644 --- a/crates/xrpl-wasm-vm/src/register.rs +++ b/crates/xrpl-wasm-vm/src/register.rs @@ -74,6 +74,20 @@ pub(crate) fn register_host_functions( }) }, ), + HostFunctionSpec::IsAmendmentEnabled => linker.func_wrap( + HOST_MODULE, + op.wasm_name(), + |mut caller: Caller<'_, VmState<'_>>, + ptr: i32, + len: i32| + -> Result { + charged(&mut caller, HostFunctionSpec::IsAmendmentEnabled, |c| { + let host = c.data().host; + let amendment = read_borrowed(c, Region::new(ptr, len))?; + host.is_amendment_enabled(amendment) + }) + }, + ), HostFunctionSpec::GetCurrentLedgerObjField => linker.func_wrap( HOST_MODULE, op.wasm_name(), diff --git a/crates/xrpl-wasm-vm/tests/budgets.rs b/crates/xrpl-wasm-vm/tests/budgets.rs index 3896edaa10..7542748e8b 100644 --- a/crates/xrpl-wasm-vm/tests/budgets.rs +++ b/crates/xrpl-wasm-vm/tests/budgets.rs @@ -71,6 +71,11 @@ fn call_for(op: HostFunctionSpec) -> Call { "(call $base_fee (i32.const 0) (i32.const 4))", 2, ), + HostFunctionSpec::IsAmendmentEnabled => ( + import::AMENDMENT_ENABLED, + "(call $amendment_enabled (i32.const 0) (i32.const 32))", + 2, + ), HostFunctionSpec::GetCurrentLedgerObjField => ( import::HOME_LE_FIELD, "(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4))", diff --git a/crates/xrpl-wasm-vm/tests/host_calls.rs b/crates/xrpl-wasm-vm/tests/host_calls.rs index 45ed5c68c7..3ebd26b321 100644 --- a/crates/xrpl-wasm-vm/tests/host_calls.rs +++ b/crates/xrpl-wasm-vm/tests/host_calls.rs @@ -93,6 +93,33 @@ fn base_fee_writes_the_fee_where_the_guest_asked() { assert_eq!(status(&wat, &host), 4, "the byte count"); } +/// A call that reads an input region and returns a scalar flag, rather than writing +/// bytes to an output region: the amendment reaches the host, and its verdict comes +/// back as the call's status. +#[test] +fn amendment_enabled_reads_the_input_and_returns_the_flag() { + let host = FakeHost::new(); // enabled by default + + let wat = module( + &[import::AMENDMENT_ENABLED, ONE_PAGE], + "(call $amendment_enabled (i32.const 64) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 1, "the enabled flag"); + assert_eq!( + *host.amendments_asked.borrow(), + [vec![0u8; 32]], + "the 32-byte region reached the host" + ); + + // A host that reports the amendment disabled answers 0 — a value, not an error. + let host = FakeHost::new().answering_amendment_enabled(Ok(0)); + let wat = module( + &[import::AMENDMENT_ENABLED, ONE_PAGE], + "(call $amendment_enabled (i32.const 0) (i32.const 32))", + ); + assert_eq!(status(&wat, &host), 0, "the disabled flag"); +} + /// The output region is wherever the guest points, not a fixed address. #[test] fn the_output_region_is_the_pointer_the_guest_gave() { diff --git a/crates/xrpl-wasm-vm/tests/preflight.rs b/crates/xrpl-wasm-vm/tests/preflight.rs index f8eb7bca4a..01af4598b5 100644 --- a/crates/xrpl-wasm-vm/tests/preflight.rs +++ b/crates/xrpl-wasm-vm/tests/preflight.rs @@ -98,11 +98,12 @@ fn a_disabled_feature_does_not_pass() { /// Every host function the ABI declares, spelled as a guest imports it. The count /// is asserted against the ABI so a function added to it cannot be left out here. -const ALL_IMPORTS: [&str; 8] = [ +const ALL_IMPORTS: [&str; 9] = [ import::LDGR_INDEX, import::PARENT_LDGR_TIME, import::PARENT_LDGR_HASH, import::BASE_FEE, + import::AMENDMENT_ENABLED, import::HOME_LE_FIELD, import::SHA512_HALF, import::TRACE, diff --git a/crates/xrpl-wasm-vm/tests/support/mod.rs b/crates/xrpl-wasm-vm/tests/support/mod.rs index a4da822a6b..d9d01a5bd1 100644 --- a/crates/xrpl-wasm-vm/tests/support/mod.rs +++ b/crates/xrpl-wasm-vm/tests/support/mod.rs @@ -109,6 +109,10 @@ pub struct FakeHost { pub parent_ledger_hash: Answer, /// What `get_base_fee` answers. pub base_fee: Answer, + /// What `is_amendment_enabled` answers, whatever amendment it is given. + pub amendment_enabled: HostResult, + /// Every amendment `is_amendment_enabled` was asked about. + pub amendments_asked: RefCell>>, /// What `get_current_ledger_obj_field` answers, by field selector. An /// unlisted selector answers `FieldNotFound`. pub fields: HashMap, @@ -134,6 +138,9 @@ impl Default for FakeHost { parent_ledger_hash: Answer::filler(32), // A distinct value again, so no getter can pass by reading another's answer. base_fee: Answer::bytes(10u32.to_le_bytes()), + // Enabled by default; the id-or-name dispatch is the host's job, not the ABI's. + amendment_enabled: Ok(1), + amendments_asked: RefCell::new(Vec::new()), fields: HashMap::new(), digest: Answer::filler(32), fields_asked: RefCell::new(Vec::new()), @@ -168,6 +175,11 @@ impl FakeHost { self } + pub fn answering_amendment_enabled(mut self, answer: HostResult) -> FakeHost { + self.amendment_enabled = answer; + self + } + pub fn answering_field(mut self, field: i32, answer: Answer) -> FakeHost { self.fields.insert(field, answer); self @@ -200,6 +212,11 @@ impl HostFunctions for FakeHost { self.base_fee.fill(out) } + fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult { + self.amendments_asked.borrow_mut().push(amendment.to_vec()); + self.amendment_enabled + } + fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult { self.fields_asked.borrow_mut().push(field); match self.fields.get(&field) { @@ -245,6 +262,7 @@ pub mod import { pub const PARENT_LDGR_HASH: &str = r#"(import "host_lib" "parent_ldgr_hash" (func $parent_ldgr_hash (param i32 i32) (result i32)))"#; pub const BASE_FEE: &str = r#"(import "host_lib" "base_fee" (func $base_fee (param i32 i32) (result i32)))"#; + pub const AMENDMENT_ENABLED: &str = r#"(import "host_lib" "amendment_enabled" (func $amendment_enabled (param i32 i32) (result i32)))"#; pub const HOME_LE_FIELD: &str = r#"(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))"#; pub const SHA512_HALF: &str = r#"(import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32)))"#; pub const TRACE: &str = diff --git a/include/xrpl/tx/wasm/HostContext.h b/include/xrpl/tx/wasm/HostContext.h index 7f3767dd41..8c5b08ddd9 100644 --- a/include/xrpl/tx/wasm/HostContext.h +++ b/include/xrpl/tx/wasm/HostContext.h @@ -53,6 +53,12 @@ public: [[nodiscard]] std::int32_t getBaseFee(rust::Slice out) const noexcept; + // The amendment is either a 32-byte id or a name; a 32-byte input is tried as an + // id first and falls back to a name lookup. Answers 1 or 0, or a negative + // `HostFunctionError` code. + [[nodiscard]] std::int32_t + isAmendmentEnabled(rust::Slice amendment) const noexcept; + [[nodiscard]] std::int32_t getCurrentLedgerObjField(std::int32_t field, rust::Slice out) const noexcept; diff --git a/src/libxrpl/tx/wasm/HostContext.cpp b/src/libxrpl/tx/wasm/HostContext.cpp index c67387024f..2a932061de 100644 --- a/src/libxrpl/tx/wasm/HostContext.cpp +++ b/src/libxrpl/tx/wasm/HostContext.cpp @@ -107,6 +107,34 @@ HostContext::getBaseFee(rust::Slice out) const noexcept }); } +std::int32_t +HostContext::isAmendmentEnabled(rust::Slice amendment) const noexcept +{ + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + // A 32-byte input may be an amendment id; try that first and fall through to + // a name lookup if it is not an enabled amendment - the 32 bytes could spell + // a name instead. + if (amendment.size() == uint256::size()) + { + auto const enabled = + hostFunctions_.isAmendmentEnabled(uint256::fromVoid(amendment.data())); + if (enabled && *enabled == 1) + return *enabled; + } + + if (amendment.size() > 64) + return hfErrorToInt(HostFunctionError::DataFieldTooLarge); + + auto const name = + std::string_view(reinterpret_cast(amendment.data()), amendment.size()); + auto const enabled = hostFunctions_.isAmendmentEnabled(name); + if (!enabled) + return hfErrorToInt(enabled.error()); + + return *enabled; + }); +} + std::int32_t HostContext::getCurrentLedgerObjField(std::int32_t field, rust::Slice out) const noexcept