diff --git a/src/benchmarks/libxrpl/wasm/Limits.cpp b/src/benchmarks/libxrpl/wasm/Limits.cpp new file mode 100644 index 0000000000..cb92126f09 --- /dev/null +++ b/src/benchmarks/libxrpl/wasm/Limits.cpp @@ -0,0 +1,506 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test::bench { +namespace { + +// What `bytecodeSizeLimit` costs a run's `gasLimit`, and what the pair of them costs a validator. +// +// wasmi 2.0 bills **7 fuel per byte of reached function body** as it lowers that body to IR, out of +// the run's own fuel. The charge lands on the first call into each function, so it is paid during +// the run rather than at preflight — which couples the two limits: `7 x reached-code-size` is a +// floor a contract pays before executing one instruction of its own. README.md has the decision +// rule these cases are meant to feed. +// +// Every case pins `->Iterations(...)`; see the note at the top of Vm.cpp. + +// `Config.h`'s defaults. Hard-coded because `src/xrpld` is not on a libxrpl benchmark's include +// path — keep in step with `Config::gasLimit` and `Config::bytecodeSizeLimit`. +constexpr std::int64_t kDefaultGasLimit = 1'000'000; +constexpr std::int64_t kDefaultBytecodeSizeLimit = 100'000; + +// Filler bodies per module, held constant across the sweep so that what varies with the target size +// is body *bytes* and not function count — the rate under test is per byte. Sixteen keeps every +// body well inside wasmparser's per-function ceiling even at the largest size swept. +constexpr std::size_t kFillerFunctions = 16; + +// `check_sig` calls the size sweep's entry point makes. Constant across the sweep, so it moves +// `charged_gas` by a fixed amount and never its slope; small, so it does not swamp the wall time +// the sweep is there to show. `worstCase` below is where the count is turned up instead. +constexpr int kSweepWorkCalls = 4; + +// `check_sig` and not `sha512_half`, which is the intuitive choice and the wrong one. Measured on +// an Apple-silicon Release build, `sha512_half` at its 1 KiB maximum input costs 589 ns and +// `check_sig` 14,576 ns — 25x more, and charged 300 gas against a suggested 26,188. It is both the +// most expensive thing a contract can ask for and the most underpriced, which is exactly the +// combination a limit has to be chosen against. +constexpr std::string_view kImport = + R"( (import "host_lib" "check_sig" (func $check_sig (param i32 i32 i32 i32 i32 i32) (result i32))) +)"; + +constexpr int kMessageOffset = 0; +constexpr int kSignatureOffset = 256; +constexpr int kPubkeyOffset = 512; + +// The signed message seeded into guest memory, and the call that verifies it. Offsets and shape +// follow host_functions/CheckSignature.cpp; a wrong argument would take the rejection path, which +// is far cheaper and would quietly halve every number here. +std::string const& +workData() +{ + static auto const kData = [] { + auto const& m = Fixtures::instance().signedMessage(); + return dataSegment(kMessageOffset, m.message) + dataSegment(kSignatureOffset, m.signature) + + dataSegment(kPubkeyOffset, m.publicKey); + }(); + return kData; +} + +std::string const& +workCall() +{ + static auto const kCall = [] { + auto const& m = Fixtures::instance().signedMessage(); + return std::format( + " (local.set $r (call $check_sig (i32.const {}) (i32.const {}) (i32.const {}) " + "(i32.const {}) (i32.const {}) (i32.const {})))\n", + kMessageOffset, + m.message.size(), + kSignatureOffset, + m.signature.size(), + kPubkeyOffset, + m.publicKey.size()); + }(); + return kCall; +} + +// A function that is `deadBytes` bigger than it needs to be, and no more expensive to run. +// +// Everything after the `return` is unreachable, so executing this body costs two instructions +// whatever its size — but *translating* it walks the whole body, and the fuel charged is the length +// of the body slice. That separation is the point: it isolates the translation rate from execution, +// and it is also the adversarial shape, since a contract maximizing translation cost per byte would +// look exactly like this. +std::string +fillerWat(std::size_t index, std::size_t deadBytes) +{ + auto out = std::format(" (func $f{0} (result i32)\n (return (i32.const {0}))\n ", index); + out.reserve(out.size() + (deadBytes * 5) + 32); + for (auto i = 0uz; i < deadBytes; ++i) + { + // One byte of body each (opcode 0x01), so the binary grows by exactly `deadBytes`. + out += "(nop)"; + } + out += "\n (i32.const 0))\n"; + return out; +} + +// The entry point: call the first `reached` fillers, forcing each to be translated, then verify +// `calls` signatures. +// +// `compilation_mode` is `LazyTranslation` (vm.rs), so a declared-but-never-called function is +// validated at compile time and translated never. Calling it is what makes its bytes cost fuel, +// which is why `reached` is a parameter rather than "all of them". +std::string +entryWat(std::size_t reached, int calls) +{ + auto out = std::string{ + " (func (export \"escrow_finish\") (result i32)\n" + " (local $r i32)\n"}; + for (auto i = 0uz; i < reached; ++i) + { + out += std::format(" (drop (call $f{}))\n", i); + } + for (auto i = 0; i < calls; ++i) + { + out += workCall(); + } + out += " (local.get $r))\n"; + return out; +} + +std::string +moduleWat(std::size_t deadTotal, std::size_t reached, int calls) +{ + auto out = std::format("(module\n{} (memory (export \"memory\") 1)\n{}", kImport, workData()); + auto const each = deadTotal / kFillerFunctions; + auto const extra = deadTotal % kFillerFunctions; + for (auto i = 0uz; i < kFillerFunctions; ++i) + { + out += fillerWat(i, each + (i == 0 ? extra : 0)); + } + out += entryWat(reached, calls); + out += ")\n"; + return out; +} + +// The largest module of this shape that fits in `targetBytes`. +// +// Each `(nop)` is one binary byte, so size is `base + deadTotal` up to the odd byte where a LEB +// length prefix widens; solving is therefore one guess and a correction rather than a search. The +// loop is bounded and keeps the best under-target candidate, so a shape whose overhead moves does +// not turn into a hang. +// +// Padding is what makes the work count and the size limit independent: adding a `check_sig` call +// displaces nops rather than growing the module, so a run's translation charge stays pinned to the +// size limit while its execution charge varies. +Bytes const& +moduleOfSize(std::int64_t targetBytes, std::size_t reached, int calls) +{ + // Cached so that `--benchmark_repetitions` re-enters a case without re-assembling megabytes of + // WAT, and so `worstCase`'s probes and the case they size are built once between them. + static auto cache = std::map, Bytes>{}; + auto const key = std::tuple{targetBytes, reached, calls}; + if (auto const found = cache.find(key); found != cache.end()) + { + return found->second; + } + + auto dead = std::size_t{0}; + auto candidate = assembleWat(moduleWat(dead, reached, calls)); + if (static_cast(candidate.size()) > targetBytes) + { + fixtureFailed("the module's fixed overhead already exceeds the target size"); + } + + auto best = candidate; + for (auto round = 0; round < 8; ++round) + { + auto const diff = targetBytes - static_cast(candidate.size()); + if (diff == 0) + { + break; + } + if (diff < 0 && -diff > static_cast(dead)) + { + break; + } + dead = static_cast(static_cast(dead) + diff); + candidate = assembleWat(moduleWat(dead, reached, calls)); + if (static_cast(candidate.size()) <= targetBytes && + candidate.size() > best.size()) + { + best = candidate; + } + } + + return cache.emplace(key, std::move(best)).first->second; +} + +// One run at a caller-chosen budget. `timeRun` always passes `kBenchGas`, which is the right +// default everywhere else and wrong here: `worstCase` is about what fits inside a real `gasLimit`. +Timing +timeRunWithGas(HostFunctions& host, Bytes const& wasm, std::int64_t gas) +{ + auto const start = std::chrono::steady_clock::now(); + auto outcome = runEscrowWasm(wasm, host, gas); + auto const elapsed = std::chrono::steady_clock::now() - start; + + benchmark::DoNotOptimize(outcome); + return { + .seconds = std::chrono::duration(elapsed).count(), + .gas = outcome.has_value() ? outcome->cost : std::int64_t{0}}; +} + +// Fuel a run spends on nothing but being large: this module's charge minus that of a same-sized +// module with no filler reached. +// +// Both are solved to the same target, so they differ only in which bodies get translated and in the +// entry point's `call` instructions. The difference is the translation charge plus the handful of +// fuel those calls cost. +std::int64_t +translationGas(std::int64_t targetBytes, int calls, std::int64_t chargedGas) +{ + auto host = Fixtures::instance().host(); + auto const twin = runEscrowWasm(moduleOfSize(targetBytes, 0, calls), *host, kBenchGas); + return twin.has_value() ? chargedGas - twin->cost : 0; +} + +// Accumulate a whole-run case and turn it into counters. +// +// Unlike the host-function harness this subtracts nothing: the subject is the whole run, and the +// question is what that run costs against the two ceilings it has to fit inside. +class SizedRun +{ +public: + SizedRun(benchmark::State& state, std::int64_t moduleBytes, std::int64_t gasLimit) + : state_{state}, moduleBytes_{moduleBytes}, gasLimit_{gasLimit} + { + } + + void + add(Timing const& timing) + { + state_.SetIterationTime(timing.seconds); + total_ += timing.seconds; + sumSquares_ += timing.seconds * timing.seconds; + gasTotal_ += static_cast(timing.gas); + ++rounds_; + } + + // `translationOf` is the target size to price translation against, or zero to leave the + // translation counters off — they cost an extra untimed run to produce. + void + report(std::int64_t translationOf, int calls) + { + if (rounds_ == 0) + { + return; + } + + auto const count = static_cast(rounds_); + auto const mean = total_ / count; + auto const variance = std::max(0.0, (sumSquares_ / count) - (mean * mean)); + auto const spread = mean > 0.0 ? std::sqrt(variance) / mean : 0.0; + auto const charged = gasTotal_ / count; + + auto const& calibration = Calibration::instance(); + auto const perGas = calibration.secondsPerGas(); + auto const equivalent = perGas > 0.0 ? mean / perGas : 0.0; + + state_.counters["module_bytes"] = static_cast(moduleBytes_); + state_.counters["charged_gas"] = charged; + state_.counters["gas_per_byte"] = + moduleBytes_ > 0 ? charged / static_cast(moduleBytes_) : 0.0; + state_.counters["ns_per_op"] = mean * 1e9; + // The run's wall time in the units the fuel meter uses, so a charge and its cost can be + // compared. Unlike the host-function cases this covers stages the guest is *not* charged + // for — compile, instantiate, teardown — so it reads high against `charged_gas` by + // construction. + state_.counters["gas_equivalent"] = equivalent; + // Above 1, the run is billed more fuel than its wall time is worth at the rate a guest + // instruction is priced at; below 1, less. + state_.counters["charge_ratio"] = equivalent > 0.0 ? charged / equivalent : 0.0; + // **The answer to the limits question.** At 100 the contract's whole budget goes to getting + // itself running, and past 100 it cannot be finished at all. + state_.counters["pct_gas_limit"] = 100.0 * charged / static_cast(gasLimit_); + + if (translationOf > 0) + { + auto const translation = static_cast( + translationGas(translationOf, calls, static_cast(charged))); + state_.counters["translation_gas"] = translation; + state_.counters["translation_share"] = charged > 0.0 ? translation / charged : 0.0; + } + + auto const caseStdErr = spread / std::sqrt(count); + auto const perGasErr = calibration.secondsPerGasRelStdErr(); + auto const totalErr = std::sqrt((caseStdErr * caseStdErr) + (perGasErr * perGasErr)); + state_.counters["rel_error"] = totalErr; + state_.counters["unreliable"] = totalErr > kMaxRelativeSpread ? 1 : 0; + } + +private: + benchmark::State& state_; + std::int64_t moduleBytes_{}; + std::int64_t gasLimit_{}; + double total_{}; + double sumSquares_{}; + double gasTotal_{}; + std::int64_t rounds_{}; +}; + +// One whole `runEscrowWasm` per iteration, unamortized: compile, instantiate, translate on first +// call, run, read the meter. +void +benchmarkSizedRun( + benchmark::State& state, + Bytes const& wasm, + std::int64_t gas, + std::int64_t gasLimit, + std::int64_t translationOf, + int calls) +{ + // Force the shared calibration before the clock starts, as `benchmarkRun` does. + [[maybe_unused]] auto const& calibration = Calibration::instance(); + + auto probe = Fixtures::instance().host(); + auto const check = runEscrowWasm(wasm, *probe, gas); + if (!check.has_value()) + { + state.SkipWithError("the benchmarked contract did not run to completion"); + return; + } + // A soft host error still completes the run, and the rejection path is far cheaper than the + // verification — the case would report a confident number for work it never did. + if (check->result <= 0) + { + state.SkipWithError( + "check_sig answered " + std::to_string(check->result) + + "; the case would be measuring the rejection path, not the verification"); + return; + } + + auto run = SizedRun{state, static_cast(wasm.size()), gasLimit}; + for (auto _ : state) + { + auto host = Fixtures::instance().host(); + run.add(timeRunWithGas(*host, wasm, gas)); + } + run.report(translationOf, calls); +} + +void +sweep(benchmark::State& state, std::int64_t targetBytes, std::size_t reached) +{ + benchmarkSizedRun( + state, + moduleOfSize(targetBytes, reached, kSweepWorkCalls), + kBenchGas, + kDefaultGasLimit, + reached > 0 ? targetBytes : 0, + kSweepWorkCalls); +} + +// Every declared function reached, so the module pays translation on all of its bytes. The worst +// case a size limit has to survive, and the row to read against `gasLimit`. +void +fullyReached(benchmark::State& state) +{ + sweep(state, state.range(0), kFillerFunctions); +} +BENCHMARK(fullyReached) + ->UseManualTime() + ->Iterations(kBenchIterations) + ->Arg(25'000) + ->Arg(50'000) + ->Arg(kDefaultBytecodeSizeLimit) + ->Arg(kMaxBytecodeSizeLimit); + +// The same sizes with nothing reached: validated, never translated, so none of those bytes are +// charged. Against `fullyReached` per size, this separates what a big module *costs* the validator +// from what it *bills* the contract — the first is wall time nobody pays for, the second comes out +// of `gasLimit`. +void +neverReached(benchmark::State& state) +{ + sweep(state, state.range(0), 0); +} +BENCHMARK(neverReached) + ->UseManualTime() + ->Iterations(kBenchIterations) + ->Arg(25'000) + ->Arg(50'000) + ->Arg(kDefaultBytecodeSizeLimit) + ->Arg(kMaxBytecodeSizeLimit); + +// At the votable size ceiling, sweeping how much of the module a run actually reaches. +// +// `7 x bytecodeSizeLimit` is the bound, not the bill: a real contract runs one path and leaves the +// rest untranslated. This is the sweep to argue a *default* from, where `fullyReached` at +// `kMaxBytecodeSizeLimit` is the one to argue a *maximum* from. `Arg` is sixteenths of the module. +void +partiallyReached(benchmark::State& state) +{ + auto const reached = static_cast(state.range(0)); + sweep(state, kMaxBytecodeSizeLimit, reached); + state.counters["reached_pct"] = 100.0 * static_cast(reached) / kFillerFunctions; +} +BENCHMARK(partiallyReached) + ->UseManualTime() + ->Iterations(kBenchIterations) + ->Arg(1) + ->Arg(4) + ->Arg(8) + ->Arg(12); + +// How many `check_sig` calls a fully-reached module of `sizeLimit` bytes can still afford inside +// `gasLimit`, once translation has taken its cut. +// +// Fuel is exact and the charge is linear in the call count, so two probes solve it: the slope is +// the per-call charge, the intercept is everything the run pays for existing. One call is then +// backed off if rounding put the answer over, which it can when a `data` segment or a LEB prefix +// shifts under the added instructions. +int +affordableCalls(std::int64_t sizeLimit, std::int64_t gasLimit) +{ + auto chargeFor = [&](int calls) { + auto host = Fixtures::instance().host(); + auto const outcome = + runEscrowWasm(moduleOfSize(sizeLimit, kFillerFunctions, calls), *host, kBenchGas); + if (!outcome.has_value()) + { + fixtureFailed("the worst-case probe did not run to completion"); + } + return outcome->cost; + }; + + static constexpr auto kLow = 4; + static constexpr auto kHigh = 20; + auto const low = chargeFor(kLow); + auto const high = chargeFor(kHigh); + auto const perCall = static_cast(high - low) / (kHigh - kLow); + if (perCall <= 0.0) + { + fixtureFailed("the worst-case probes disagree about what a call costs"); + } + auto const fixed = static_cast(low) - (kLow * perCall); + + auto calls = static_cast(std::max(0.0, (static_cast(gasLimit) - fixed) / perCall)); + while (calls > 0 && chargeFor(calls) > gasLimit) + { + --calls; + } + return calls; +} + +// The whole point of the file: the longest a validator can be made to spend on one `EscrowFinish` +// at a given `(gasLimit, bytecodeSizeLimit)` pair. A maximal, fully-reached module that spends +// every remaining unit of fuel on the most underpriced call there is. +// +// Only pairs that can actually run are listed. Size voted to `kMaxBytecodeSizeLimit` against the +// default `gasLimit` is not among them: `fullyReached/200000` charges more than that budget holds, +// so such a contract cannot be finished at all. The two limits are voted independently through +// `FeeVoteImpl`, so that pairing is reachable — see README.md. +void +worstCase(benchmark::State& state) +{ + auto const gasLimit = state.range(0); + auto const sizeLimit = state.range(1); + + auto const calls = affordableCalls(sizeLimit, gasLimit); + if (calls <= 0) + { + state.SkipWithError("translation alone exhausts this gas limit; no work fits"); + return; + } + + benchmarkSizedRun( + state, + moduleOfSize(sizeLimit, kFillerFunctions, calls), + gasLimit, + gasLimit, + sizeLimit, + calls); + state.counters["check_sig_calls"] = calls; + state.counters["ms_per_op"] = state.counters["ns_per_op"] / 1e6; +} +BENCHMARK(worstCase) + ->UseManualTime() + ->Iterations(kBenchIterations) + ->Args({kDefaultGasLimit, kDefaultBytecodeSizeLimit}) + ->Args({kMaxGasLimit, kMaxBytecodeSizeLimit}) + ->Args({kMaxGasLimit, kDefaultBytecodeSizeLimit}); + +} // namespace +} // namespace xrpl::test::bench diff --git a/src/benchmarks/libxrpl/wasm/README.md b/src/benchmarks/libxrpl/wasm/README.md index 7f714ff621..95e569b125 100644 --- a/src/benchmarks/libxrpl/wasm/README.md +++ b/src/benchmarks/libxrpl/wasm/README.md @@ -132,6 +132,89 @@ Pin `->Iterations(...)`: automatic sizing targets a wall-clock budget, not a com so the cheap cases get six-figure counts and `/4096` a handful — leaving no row comparable to another or to the last run. +## Setting the two limits (`Limits.cpp`) + +`Vm.cpp` prices a run's overhead in wall time, none of which a contract is charged for. +**Translation is the exception**: wasmi 2.0 bills **7 fuel per byte of reached function body** as +it lowers that body to IR, out of the run's own fuel, on the first call into each function. So +`gasLimit` and `bytecodeSizeLimit` are not independent — `7 × reached-code-size` is a floor a +contract pays before executing one instruction of its own, and these cases measure where that floor +lands against both ceilings. + +| case | measures | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `fullyReached/bytes` | a module of `bytes` with every function called. The worst case a size limit has to survive | +| `neverReached/bytes` | the same sizes with nothing called: validated, never translated. The wall time a validator eats for free | +| `partiallyReached/n` | at `kMaxBytecodeSizeLimit`, `n` sixteenths of the module reached. `7 × bytecodeSizeLimit` is the bound, not the bill | +| `worstCase/gas/size` | a fully-reached module of `size` spending every remaining unit of `gas` on the most underpriced call there is | + +On top of the `Vm.cpp` counters these report `charged_gas` (what the fuel meter billed, translation +included), `translation_gas` and `translation_share` (against a same-sized module with nothing +reached), and the one that answers the gas question: **`pct_gas_limit`**. At 100 the contract's +entire budget goes to getting itself running; past 100 it cannot be finished at all. `charge_ratio` +is `charged_gas / gas_equivalent` — below 1 partly because the run's compile and instantiate stages +are timed here and charged to nobody, and partly because of what `worstCase` is built to show. + +The filler bodies are dead code after a `return`: translated in full, executed in two instructions. +That isolates the rate from execution, and it is also the shape an attacker would send, since it +maximizes translation cost per byte. Padding is what keeps work and size independent — adding a call +to the entry point displaces nops rather than growing the module. + +**The expensive call is `check_sig`, not `sha512_half`.** Measured here, `sha512_half` at its 1 KiB +maximum costs 589 ns and `check_sig` 14,576 ns — 25x more, charged 300 gas against a suggested +26,188 (`price_ratio` 0.011). It is simultaneously the most expensive thing a contract can ask for +and the most underpriced, which is what `worstCase` exploits. + +### The decision rule + +A fully-reached module of `B` bytes charges `7·B` almost exactly; execution and scaffolding are +rounding error next to it. Measured on an Apple-silicon Release build: + +| case | `charged_gas` | `pct_gas_limit` | `ns_per_op` | +| --------------------- | ------------: | --------------: | -----------: | +| `fullyReached/25000` | 173,442 | 17.3 | 243 µs | +| `fullyReached/50000` | 348,442 | 34.8 | 431 µs | +| `fullyReached/100000` | 698,442 | 69.8 | 792 µs | +| `fullyReached/200000` | 1,398,442 | **139.8** | 1.49 ms | +| `neverReached/*` | 1,808 | 0.2 | 125 – 470 µs | + +(`pct_gas_limit` is against the 1,000,000 default in both cases.) + +So the quantity a limit pair has to be chosen against is the **ratio** `gasLimit / +bytecodeSizeLimit`. Below 7 a full-size contract cannot run at all; at 7 it runs and can do nothing. +Both of today's pairs sit at 10, which spends 70% of the budget before the contract starts. The two +limits are voted independently through `FeeVoteImpl`, so the pairing is not guaranteed: size voted +to `kMaxBytecodeSizeLimit` with gas left at its default is the 139.8% row, a contract that cannot be +finished on that network at all. + +`neverReached` bills a **constant** 1,808 whatever the module size, while its wall time grows 4x +across the sweep. Validation is real work charged to nobody, so a size limit is a wall-time decision +independently of any gas question. + +### What actually bounds the wall time + +`worstCase` is the row to size a network against — the longest one `EscrowFinish` can be made to +take at a given pair: + +| pair (gas / size) | `check_sig_calls` | `ms_per_op` | `translation_share` | `charge_ratio` | +| ------------------- | ----------------: | ----------: | ------------------: | -------------: | +| 1,000,000 / 100,000 | 983 | 15.6 | 0.57 | 0.039 | +| 2,000,000 / 200,000 | 1,957 | 31.1 | 0.57 | 0.039 | +| 2,000,000 / 100,000 | 4,229 | **64.9** | 0.07 | 0.019 | + +The third row is the one to read twice. A **smaller** module is worse, because the gas translation +does not take is gas available for calls that are underpriced 90x — so raising `gasLimit` without +repricing `check_sig` costs more wall time than raising `bytecodeSizeLimit` does. `charge_ratio` +0.019 says it directly: that run is billed one fiftieth of what its wall time is worth at the rate a +guest instruction pays. + +Translation is therefore the lesser of the two problems, and the two levers are not interchangeable: +`bytecodeSizeLimit` bounds a cost that is _charged_, while `gasLimit` bounds one that is not. And +the 7 is about right — `fullyReached/200000` minus `neverReached/200000` is 1.02 ms of translation +over 200,000 bytes, or 5.1 ns/byte, which at this machine's ~0.61 ns per gas is 8.4 gas/byte of real +cost against 7 charged. Retuning it would move the limits question very little. Fix the +host-function prices first. + ## Gotchas, each of which has already cost someone an afternoon - **The wasm ABI is not the trait's argument order.** `float_add(x, y, mode, out)` in Rust is @@ -156,8 +239,9 @@ another or to the last run. One `.cpp` per host function under `host_functions/`, mirroring `src/tests/libxrpl/tx/wasm/host_functions/`, so adding a host function is a two-file checklist rather than a judgement call. `Crossing.cpp` holds the harness's own reference points, `Vm.cpp` the -per-run overhead around them, `WasmBench.*` the measurement machinery, `BenchFixtures.*` the shared -ledger (one ledger, funded once, for the whole binary). +per-run overhead around them, `Limits.cpp` the `gasLimit`/`bytecodeSizeLimit` pairing, `WasmBench.*` +the measurement machinery, `BenchFixtures.*` the shared ledger (one ledger, funded once, for the +whole binary). The ledger and real host come from `xrpl.testkit.wasm` — a framework-free library built alongside the tests — so this target links **no GTest and no GMock**. See