Merge ripple/smart-escrow

This commit is contained in:
Sergey Kuznetsov
2026-09-14 13:55:45 +01:00
20 changed files with 1527 additions and 4677 deletions

View File

@@ -143,9 +143,11 @@ Linear, at roughly **800 bytes per compile**. Within the suite this is why every
7.9 GB at 25 repetitions, after which every later case in the binary failed to compile — 720 errored
rows, all blaming cases that were innocent.
**Outside the suite it is worth a look.** A validator compiles twice per programmable-escrow
transaction against that same static engine. Whether that is unbounded growth in production depends
on wasmi internals not checked here — this is the C++-visible symptom, not a diagnosis.
**Outside the suite it is worth a look.** A validator compiles once to screen an `EscrowCreate`
and again for every `EscrowFinish` that runs the contract — with no module cache between them, and
once per apply attempt rather than once per transaction — all against that same static engine.
Whether that is unbounded growth in production depends on wasmi internals not checked here (wasmi
2.0.0, wasmparser 0.228): this is the C++-visible symptom, not a diagnosis.
## Gotchas, each of which has already cost someone an afternoon

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,155 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// WASM binary format constants and helpers for building test modules
namespace wasm_constants {
// Magic + version header
uint8_t const kWasmHeader[] = {
0x00,
0x61,
0x73,
0x6d, // magic: \0asm
0x01,
0x00,
0x00,
0x00 // version: 1
};
// Type section: () -> ()
uint8_t const kTypeEmptyFunc[] = {0x01, 0x04, 0x01, 0x60, 0x00, 0x00};
// Function section: one function using type 0
uint8_t const kFuncTypE0[] = {0x03, 0x02, 0x01, 0x00};
// Export section: export func 0 as "escrow_finish"
uint8_t const kExportFinish[] = {
0x07,
0x11,
0x01,
0x0d,
'e',
's',
'c',
'r',
'o',
'w',
'_',
'f',
'i',
'n',
'i',
's',
'h',
0x00,
0x00};
// Empty function body: 0 locals, end
uint8_t const kEmptyBody[] = {0x00, 0x0b};
// Data segment offset: i32.const 0, end
uint8_t const kDataOffsetZero[] = {0x41, 0x00, 0x0b};
// Section IDs
uint8_t const kSectionMemory = 0x05;
uint8_t const kSectionCode = 0x0a;
uint8_t const kSectionData = 0x0b;
// Instructions
uint8_t const kInstrNop = 0x01;
uint8_t const kInstrEnd = 0x0b;
// Fill byte for data section bloat
uint8_t const kDataFillByte = 0xEE;
// Generator for WASM module with large code section (many NOPs)
std::vector<uint8_t>
generateCodeBlob(uint32_t numInstructions);
// Generator for WASM module with large data section
std::vector<uint8_t>
generateDataBlob(uint32_t dataSize);
} // namespace wasm_constants
extern std::string const kLedgerSqnWasmHex;
extern std::string const kAllHostFunctionsWasmHex;
extern std::string const kAllKeyletsWasmHex;
extern std::string const kCodecovTestsWasmHex;
extern std::string const kFibWasmHex;
extern std::string const kFloatTestsWasmHex;
extern std::string const kFloat0Hex;
extern std::string const kDisabledFloatHex;
extern std::string const kMemoryPointerAtLimitHex;
extern std::string const kMemoryPointerOverLimitHex;
extern std::string const kMemoryOffsetOverLimitHex;
extern std::string const kMemoryEndOfWordOverLimitHex;
extern std::string const kMemoryGrow0To1PageHex;
extern std::string const kMemoryGrow1To0PageHex;
extern std::string const kMemoryLastByteOf8MbHex;
extern std::string const kMemoryGrow1MoreThan8MbHex;
extern std::string const kMemoryGrow0MoreThan8MbHex;
extern std::string const kMemoryInit1MoreThan8MbHex;
extern std::string const kMemoryNegativeAddressHex;
extern std::string const kTable64ElementsHex;
extern std::string const kTable65ElementsHex;
extern std::string const kTable2TablesHex;
extern std::string const kTable0ElementsHex;
extern std::string const kTableUintMaxHex;
extern std::string const kProposalMutableGlobalHex;
extern std::string const kProposalGcStructNewHex;
extern std::string const kProposalMultiValueHex;
extern std::string const kProposalSignExtHex;
extern std::string const kProposalFloatToIntHex;
extern std::string const kProposalBulkMemoryHex;
extern std::string const kProposalRefTypesHex;
extern std::string const kProposalTailCallHex;
extern std::string const kProposalExtendedConstHex;
extern std::string const kProposalMultiMemoryHex;
extern std::string const kProposalCustomPageSizesHex;
extern std::string const kProposalMemory64Hex;
extern std::string const kProposalWideArithmeticHex;
extern std::string const kTrapDivideBy0Hex;
extern std::string const kTrapIntOverflowHex;
extern std::string const kTrapUnreachableHex;
extern std::string const kTrapNullCallHex;
extern std::string const kTrapFuncSigMismatchHex;
extern std::string const kWasiGetTimeHex;
extern std::string const kWasiPrintHex;
extern std::string const kBadMagicNumberHex;
extern std::string const kBadVersionNumberHex;
extern std::string const kLyingHeaderHex;
extern std::string const kNeverEndingNumberHex;
extern std::string const kVectorLieHex;
extern std::string const kSectionOrderingHex;
extern std::string const kGhostPayloadHex;
extern std::string const kJunkAfterSectionHex;
extern std::string const kInvalidSectionIdHex;
extern std::string const kLocalVariableBombHex;
extern std::string const kDeepRecursionHex;
extern std::string const kInfiniteLoopWasmHex;
extern std::string const kStartLoopHex;
extern std::string const kBadAlignWasmHex;
extern std::string const kThousandParamsHex;
extern std::string const kThousand1ParamsHex;
extern std::string const kLocals10kHex;
extern std::string const kFunctions5kHex;
extern std::string const kOpcReservedHex;
extern std::string const kImpExpHex;
extern std::string const kUpdateDataWasmHex;

View File

@@ -1,11 +0,0 @@
#include <stdint.h>
int32_t set_data(uint8_t const *, int32_t);
int escrow_finish()
{
uint8_t buf[] = "Data";
set_data(buf, sizeof(buf) - 1);
return -256;
}

View File

@@ -179,6 +179,10 @@ private:
*/
class TestServiceRegistry : public ServiceRegistry
{
public:
/**
* @brief The fee settings a test environment starts with.
*/
static Fees
defaultFees()
{
@@ -189,6 +193,7 @@ class TestServiceRegistry : public ServiceRegistry
return fees;
}
private:
TestLogs logs_{beast::Severity::Warning};
boost::asio::io_context ioContext_;
TestFamily family_{logs_.journal("TestFamily")};
@@ -500,6 +505,15 @@ public:
return fees_;
}
/**
* @brief Override the fee settings the transactors see.
*/
void
setFees(Fees const& fees)
{
fees_ = fees;
}
// Temporary: Get the underlying Application
Application&
getApp() override

View File

@@ -16,6 +16,8 @@
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol_autogen/ledger_entries/AccountRoot.h>
#include <xrpl/protocol_autogen/ledger_entries/RippleState.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
@@ -24,6 +26,7 @@
#include <helpers/Account.h>
#include <helpers/IOU.h>
#include <helpers/TestServiceRegistry.h>
#include <cstdint>
#include <memory>
@@ -55,11 +58,21 @@ allFeatures()
return kFeatures;
}
//------------------------------------------------------------------------------
// TxTest free helpers
//------------------------------------------------------------------------------
std::uint32_t
closeTimeOffset(TxTest const& env, std::uint32_t seconds)
{
return static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count()) + seconds;
}
//------------------------------------------------------------------------------
// TxTest
//------------------------------------------------------------------------------
TxTest::TxTest(std::optional<FeatureBitset> features)
TxTest::TxTest(std::optional<FeatureBitset> features, std::optional<Fees> feesOverride)
{
// Convert FeatureBitset to unordered_set for Rules constructor
auto const featureBits = features.value_or(allFeatures());
@@ -68,8 +81,9 @@ TxTest::TxTest(std::optional<FeatureBitset> features)
// Create rules with the specified features
rules_.emplace(featureSet_);
// Default fees for testing
Fees const fees{XRPAmount{10}, XRPAmount{10000000}, XRPAmount{2000000}};
// One fee set for both the view and the registry.
Fees const fees = feesOverride.value_or(TestServiceRegistry::defaultFees());
registry_.setFees(fees);
// Create a genesis ledger as the base
closedLedger_ = std::make_shared<Ledger>(
@@ -155,6 +169,18 @@ TxTest::getAccountRoot(AccountID const& id) const
return ledger_entries::AccountRoot{std::const_pointer_cast<SLE const>(sle)};
}
std::uint32_t
TxTest::getOwnerCount(AccountID const& id) const
{
return getAccountRoot(id).getOwnerCount();
}
XRPAmount
TxTest::getXrpBalance(AccountID const& id) const
{
return getAccountRoot(id).getBalance().xrp();
}
OpenView&
TxTest::getOpenLedger()
{
@@ -194,6 +220,7 @@ TxTest::close()
for (auto const& tx : pendingTxs_)
txSet.insert(tx);
closedMetadata_.clear();
{
OpenView accum(&*newLedger);
for (auto const& [key, tx] : txSet)
@@ -203,6 +230,11 @@ TxTest::close()
{
throw std::runtime_error("TxTest::close: failed to apply transaction");
}
// `accum` is not an open view, so this is the apply that produces metadata.
if (result.metadata.has_value())
{
closedMetadata_.emplace(tx->getTransactionID(), *std::move(result).metadata);
}
}
accum.apply(*newLedger);
}
@@ -218,6 +250,17 @@ TxTest::close()
std::make_shared<OpenView>(kOpenLedger, closedLedger_.get(), *rules_, closedLedger_);
}
std::optional<TxMeta>
TxTest::getMetadata(uint256 const& txId) const
{
auto const it = closedMetadata_.find(txId);
if (it == std::end(closedMetadata_))
{
return std::nullopt;
}
return it->second;
}
void
TxTest::advanceTime(NetClock::duration duration)
{

View File

@@ -4,12 +4,12 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/hash/uhash.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
@@ -30,6 +30,7 @@
#include <cmath>
#include <concepts>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
@@ -163,6 +164,19 @@ struct TxResult
std::shared_ptr<STTx const> tx; ///< Pointer to the submitted transaction.
};
/**
* @brief Result of a transaction submission that has been closed into a ledger.
*
* `TxResult::metadata` is always `std::nullopt`, because metadata is only built for a view
* that is not open. This is what `TxTest::submitAndClose` returns instead: the result code
* paired with the metadata that closing produced.
*/
struct ClosedResult
{
TER ter; ///< The transaction engine result code.
std::optional<TxMeta> meta; ///< Metadata from the close, absent if none was produced.
};
/**
* @brief A lightweight transaction testing harness.
*
@@ -191,8 +205,16 @@ public:
*
* @param features Optional set of features to enable. If not specified,
* uses all testable amendments.
* @param fees Optional fee settings. If not specified, uses
* `TestServiceRegistry::defaultFees()`. Applied to **both** the genesis
* ledger and the service registry, because transactors read limits from the
* registry (`ctx.registry.get().getFees()`) while `calculateBaseFee` reads
* `view.fees()` — a test whose fees disagree across the two is testing a
* state no ledger can be in.
*/
explicit TxTest(std::optional<FeatureBitset> features = std::nullopt);
explicit TxTest(
std::optional<FeatureBitset> features = std::nullopt,
std::optional<Fees> fees = std::nullopt);
/**
* @brief Check if a feature is enabled.
@@ -218,17 +240,22 @@ public:
* @tparam T A type derived from TransactionBuilderBase.
* @param builder The transaction builder.
* @param signer The account to sign with.
* @param fee The fee to pay. The 10 drop default is below what some transactions
* require: an `EscrowCreate` carrying `sfBytecode` owes
* `base * 10 + 5 * bytecodeBytes` (`EscrowCreate::calculateBaseFee`), and an
* `EscrowFinish` carrying `sfGas` owes the allowance priced at `gasPrice`.
* Those submissions would fail on the fee rather than on whatever they meant
* to test, so they must pass one explicitly.
* @return TxResult containing the result code, applied status, and metadata.
*/
template <typename T>
requires std::
derived_from<std::decay_t<T>, transactions::TransactionBuilderBase<std::decay_t<T>>>
[[nodiscard]] TxResult
submit(T&& builder, Account const& signer)
submit(T&& builder, Account const& signer, XRPAmount fee = XRPAmount{10})
{
auto const& obj = builder.getSTObject();
auto accountId = obj[sfAccount];
// Only set sequence if not using a ticket (ticket sets sequence to 0)
if (!obj.isFieldPresent(sfTicketSequence))
{
builder.setSequence(getAccountRoot(accountId).getSequence());
@@ -237,10 +264,35 @@ public:
{
builder.setSequence(0);
}
builder.setFee(XRPAmount(10));
builder.setFee(fee);
return submit(builder.build(signer.pk(), signer.sk()).getSTTx());
}
/**
* @brief Submit a transaction, then close the ledger and return its metadata.
*
* Metadata comes into being at `close`, not at `submit` (see `close`), so any assertion
* about `sfGasUsed`, `sfVMReturnCode`, or a delivered amount needs this three-step
* sequence rather than `submit` alone. Closing also advances time by one close
* interval, which matters to a test sensitive to a `FinishAfter` or an expiry.
*
* @tparam T A type derived from TransactionBuilderBase.
* @param builder The transaction builder.
* @param signer The account to sign with.
* @param fee The fee to pay; see `submit` for when the default is not enough.
* @return The result code and the metadata produced by the close.
*/
template <typename T>
requires std::
derived_from<std::decay_t<T>, transactions::TransactionBuilderBase<std::decay_t<T>>>
[[nodiscard]] ClosedResult
submitAndClose(T&& builder, Account const& signer, XRPAmount fee = XRPAmount{10})
{
auto const result = submit(std::forward<T>(builder), signer, fee);
close();
return ClosedResult{.ter = result.ter, .meta = getMetadata(result.tx->getTransactionID())};
}
/**
* @brief Submit a transaction to the open ledger.
*
@@ -281,6 +333,28 @@ public:
[[nodiscard]] ledger_entries::AccountRoot
getAccountRoot(AccountID const& id) const;
/**
* @brief Get an account's owner count.
* @param id The account ID.
* @return The number of ledger objects the account owns.
* @throws std::runtime_error if the account does not exist.
*/
[[nodiscard]] std::uint32_t
getOwnerCount(AccountID const& id) const;
/**
* @brief Get an account's XRP balance.
*
* The IOU overload of `getBalance` covers trust lines; this covers the account's own
* drops, which is what a fee- or reserve-sensitive test needs to assert on.
*
* @param id The account ID.
* @return The balance in drops.
* @throws std::runtime_error if the account does not exist.
*/
[[nodiscard]] XRPAmount
getXrpBalance(AccountID const& id) const;
/**
* @brief Get the current open ledger view.
* @return A mutable reference to the open ledger.
@@ -307,10 +381,26 @@ public:
*
* Creates a new closed ledger from the current open ledger.
* All pending transactions are re-applied in canonical order.
*
* @note This is where transaction **metadata** comes into being: it is only built for a
* view that is not open (`ApplyStateTable::apply`), so `submit` cannot return any.
* Each closed transaction's metadata is retained for `getMetadata`.
*/
void
close();
/**
* @brief Get the metadata of a transaction in the most recently closed ledger.
*
* Metadata is a property of a *closed* ledger, so the sequence is submit → `close` →
* `getMetadata`. Only the latest close is retained.
*
* @param txId The transaction's ID (`TxResult::tx->getTransactionID()`).
* @return The metadata, or `std::nullopt` if that transaction was not in the last close.
*/
[[nodiscard]] std::optional<TxMeta>
getMetadata(uint256 const& txId) const;
/**
* @brief Advance time without closing the ledger.
*
@@ -345,9 +435,14 @@ public:
/**
* @brief Get the service registry.
*
* Returns the concrete test type so a test can reach its setters — `setFees` in
* particular, for the cases that need a limit to change *after* setup, which the
* constructor's `fees` parameter cannot express.
*
* @return A reference to the service registry.
*/
ServiceRegistry&
TestServiceRegistry&
getServiceRegistry()
{
return registry_;
@@ -365,10 +460,52 @@ private:
*/
std::vector<std::shared_ptr<STTx const>> pendingTxs_;
/**
* Metadata from the most recent close, keyed by transaction ID. Replaced each close.
*/
std::map<uint256, TxMeta> closedMetadata_;
/**
* Current time (can be advanced arbitrarily for testing).
*/
NetClock::time_point now_;
};
//------------------------------------------------------------------------------
// TxTest free helpers
//------------------------------------------------------------------------------
/**
* @brief A ledger-close-time deadline `seconds` in the future.
*
* Time fields on the wire are `std::uint32_t` seconds since the XRPL epoch, while the
* environment reports a `NetClock::time_point`. Every `CancelAfter` / `FinishAfter` needs
* the same cast, and getting it wrong yields a deadline in the past — which a transactor
* reports as `temBAD_EXPIRATION`, a failure that looks like the case under test.
*
* @param env The environment whose close time the deadline is relative to.
* @param seconds How far past the current close time the deadline should sit.
* @return The deadline, as a transaction field expects it.
*/
[[nodiscard]] std::uint32_t
closeTimeOffset(TxTest const& env, std::uint32_t seconds);
/**
* @brief Create and fund several accounts with the same balance.
*
* @code
* createAccounts(env, XRP(5'000), alice, carol);
* @endcode
*
* @param env The environment to create the accounts in.
* @param xrp The initial balance for each account.
* @param accounts The accounts to create.
*/
template <std::same_as<Account>... Accounts>
void
createAccounts(TxTest& env, XRPAmount xrp, Accounts const&... accounts)
{
(env.createAccount(accounts, xrp), ...);
}
} // namespace xrpl::test

View File

@@ -1,60 +1,60 @@
# WASM host-function tests — layering
These tests are deliberately **layered**: each layer isolates one thing, so a failure points at
one place instead of "somewhere in the stack." If a folder looks thin, the breadth it seems to be
These tests are deliberately **layered**: each isolates one thing, so a failure points at one
place instead of "somewhere in the stack." If a folder looks thin, the breadth it seems to be
missing lives in a sibling layer.
## The layers
| Layer | Location | host | VM | ledger | Answers |
| ------------------------------------- | --------------------------------------------------- | ---- | --- | ------ | ----------------------------------------------------------------------------------- |
| Engine / gas / limits / ABI | `crates/xrpl-wasm-vm`, `crates/xrpl-host-functions` | mock | ✓ | ✗ | gas, transfer budget, memory/field limits, preflight, VM limits, generated ABI |
| `host_context/` (`HostContextTest`) | `.../host_context` | mock | ✗ | ✗ | the `HostContext` marshalling shim alone (byte order, buffer sizing, `SField` xlat) |
| `host_calls/` (`HostCallTest`) | `.../host_calls` | mock | ✓ | ✗ | per-function **wire contract** — what the host was asked, what came back |
| `host_functions/` (`RealHostFixture`) | `.../host_functions` | real | ✗ | real | each function's **actual answer** vs. a real `TxTest` ledger |
| `e2e/` (`RealVmTest`) | `.../e2e` | real | ✓ | real | **full-stack integration** — VM + `HostContext` + real impl + real ledger |
| Layer | Location | host | VM | ledger | Answers |
| ------------------------------------- | --------------------------------------------------- | ---- | --- | ------ | ---------------------------------------------------------------------------------------- |
| Engine / gas / limits / ABI | `crates/xrpl-wasm-vm`, `crates/xrpl-host-functions` | mock | ✓ | ✗ | gas, transfer budget, memory/field limits, preflight, VM limits, generated ABI |
| `host_context/` (`HostContextTest`) | `.../host_context` | mock | ✗ | ✗ | the `HostContext` marshalling shim alone (byte order, buffer sizing, `SField` xlat) |
| `host_calls/` (`HostCallTest`) | `.../host_calls` | mock | ✓ | ✗ | per-function **wire contract** — what the host was asked, what came back |
| `host_functions/` (`RealHostFixture`) | `.../host_functions` | real | ✗ | real | each function's **actual answer** vs. a real `TxTest` ledger |
| `e2e/` (`RealVmTest`) | `.../e2e` | real | ✓ | real | **full-stack integration** — VM + `HostContext` + real impl + real ledger |
| `transactor/` (`TxTest`) | `.../transactor` | real | ✓ | real | the **transactor** around a contract — fees, reserves, limits, what each failure reports |
Run the C++ side with:
```bash
./build/xrpl_tests --gtest_filter='*Impl.*:*Call.*:*E2e.*:WasmVMTest.*:WasmVMDeathTest.*:PreflightTest.*'
./build/xrpl_tests --gtest_filter='*Impl.*:*Call.*:*E2e.*:WasmVMTest.*:WasmVMDeathTest.*:PreflightTest.*:BytecodeSize.*:BytecodePreflight.*:FinishFailures.*:BytecodeRun.*:GasFees.*:DataOnReject.*'
```
(707 tests, 136 suites.) The engine-level coverage is Rust: `cd crates && cargo test`.
(744 tests, 142 suites.) The engine-level coverage is Rust: `cd crates && cargo test`.
## `fixtures/` — split by whether it needs a test framework
| | |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No GTest** — the `xrpl.testkit.wasm` library | `WasmLedger` (real genesis ledger + the real host over it), `WasmRun` (WAT assembler), `NftSetup`, `FloatConstants` |
| **GTest** → `xrpl_tests` | `RealHostFixture` (`: testing::Test, WasmLedger` + `expectValue`/`expectError`/`expectKeyletMatches`), `FloatFixture`, `NFTFixture`, `MockHostFunctions`, `WasmFixture`, `RealVmTest`, `HostContextFixture` |
| | |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No GTest** — the `xrpl.testkit.wasm` library | `WasmLedger` (real genesis ledger + the real host over it), `WasmRun` (WAT assembler), `NftSetup`, `FloatConstants` |
| **GTest** → `xrpl_tests` | `RealHostFixture` (`: testing::Test, WasmLedger` + `expectValue`/`expectError`/`expectKeyletMatches`), `FloatFixture`, `NFTFixture`, `MockHostFunctions`, `WasmFixture`, `RealVmTest`, `HostContextFixture`, `EscrowWasm` (transactor contracts + fee arithmetic), `ModuleBuilder` |
A benchmark wants a ledger and a host, not GTest's lifecycle. Both binaries link the library;
The split exists because a benchmark wants a ledger and a host, not GTest's lifecycle:
`xrpl.bench.wasm` links no GTest and no GMock at all.
Setup steps in `WasmLedger` and `NftSetup` **throw** (`fixtureFailed`) rather than using `EXPECT_`.
Not stylistic: an `EXPECT_` outside a running test is recorded and discarded, so a benchmark whose
escrow was never created would still run its host call, take the not-found path, and report a
cheap, plausible, completely wrong price. **If you add a setup step that can fail, throw.**
An `EXPECT_` outside a running test is recorded and discarded, so a benchmark whose escrow was
never created would still run its host call, take the not-found path, and report a cheap,
plausible, completely wrong price. **If you add a setup step that can fail, throw.**
## Gas calibration
The benchmarks that price these host functions live in `src/benchmarks/libxrpl/wasm/`, mirroring
this tree one file per function, and have their own README. They link `xrpl.testkit.wasm` (above)
for the ledger and host, and no test framework.
The benchmarks pricing these host functions live in `src/benchmarks/libxrpl/wasm/`, mirroring this
tree one file per function, with their own README.
## What `e2e/` covers — the rule
**`e2e/` covers every marshalling shape and cross-call convention exactly once. It does not cover
every function.** That is a completeness claim on the axis e2e uniquely tests, not a sample.
`host_calls` pins what the bridge _asks_ with a _canned_ answer; `host_functions` pins what the
real impl _answers_. The type system guarantees they agree on signatures. Nothing guarantees they
`host_calls` pins what the bridge _asks_ with a canned answer; `host_functions` pins what the real
impl _answers_. The type system guarantees they agree on signatures, but nothing guarantees they
agree on **conventions** — units, endianness, buffer layout — because in neither test does a real
guest write bytes a real host reads. That is exactly the `seq`-as-little-endian-region bug: every
internal test passed, and it was caught by cross-checking the guest SDK.
guest write bytes a real host reads. That is the `seq`-as-little-endian-region bug: every internal
test passed, and it was caught by cross-checking the guest SDK.
Convention mismatch is a property of a call's **shape**, not of the function. All 19 keylets share
Convention mismatch is a property of a call's **shape**, not of the function — all 19 keylets share
one shape, so a 19th keylet e2e proves nothing the 1st did. The inventory is meant to be exhaustive:
| Shape / convention | Covered by | Why it is its own row |
@@ -75,16 +75,18 @@ breadth lives in `host_functions/` and `host_calls/`, one case each.
## Out of scope
**The guest SDK** (`xrpl-std` / `xrpl-escrow`, external `xrpl-wasm-stdlib` repo) is not exercised
here — that is the SDK repo's own suite. These tests hand-write the ABI in WAT (raw imports,
literal field codes, hand-built byte layouts), deliberately bypassing all SDK code. Agreement is
verified _transitively_: the SDK repo tests the SDK against the ABI spec, this repo tests the host
against the same spec. That would not catch a drift where both diverge on an ambiguous point;
closing it needs a **cross-repo integration test** (compiled guests against a real host) in CI
where the Rust→wasm toolchain exists.
**The guest SDK** (`xrpl-std` / `xrpl-escrow`, external `xrpl-wasm-stdlib` repo) is the SDK repo's
own suite. These tests hand-write the ABI in WAT (raw imports, literal field codes, hand-built byte
layouts), deliberately bypassing all SDK code. Agreement is verified _transitively_: the SDK repo
tests the SDK against the ABI spec, this repo tests the host against the same spec. That would not
catch a drift where both diverge on an ambiguous point; closing it needs a **cross-repo integration
test** (compiled guests against a real host) in CI, where the Rust→wasm toolchain exists.
**Transactor-level (L5) tests** are deferred: the redesign does not yet wire `runEscrowWasm` into
the `EscrowFinish` transactor, so there is no caller under `src/xrpld`. When it is wired, these
need a home as C++ transactor tests over a real `Env` — `set_data` persistence (including on
`tecBYTECODE_REJECTED`), `sfGasUsed` / `sfVMReturnCode` in transaction metadata, and owner-reserve
accounting for a bytecode-bearing escrow. The layers here deliberately stop at the VM boundary.
## Adding to `transactor/`
Things to know:
- **Fees live in two places and must agree.** A transactor reads its limits from the service
registry (`ctx.registry.get().getFees()`), while `calculateBaseFee` reads `view.fees()`. Pass a
`Fees` to `TxTest`'s constructor to set both; reach for `getServiceRegistry().setFees` only when
a limit has to change _after_ setup.

View File

@@ -0,0 +1,49 @@
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <helpers/TxTest.h>
#include <cstdint>
#include <format>
#include <string>
namespace xrpl::test {
std::string
gatedOnLedgerSqn(std::uint32_t threshold)
{
return std::format(
R"wat(
(module
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(drop (call $ldgr_index (i32.const 0) (i32.const 4)))
(if (result i32) (i32.ge_u (i32.load (i32.const 0)) (i32.const {}))
(then (i32.const 5))
(else (i32.const 0)))))
)wat",
threshold);
}
XRPAmount
escrowCreateFee(TxTest const& env, Bytes const& bytecode)
{
return (env.getOpenLedger().fees().base * 10) +
XRPAmount{static_cast<std::int64_t>(bytecode.size()) * 5};
}
XRPAmount
escrowFinishFee(TxTest const& env, std::uint32_t allowance)
{
auto const& fees = env.getOpenLedger().fees();
// Integer division rounds down, so the transactor adds one drop; match it exactly or
// the submission fails on the fee rather than on what it meant to test.
auto const gasFee = ((std::uint64_t{allowance} * fees.gasPrice) / microDropsPerDrop) + 1;
return fees.base + XRPAmount{static_cast<std::int64_t>(gasFee)};
}
} // namespace xrpl::test

View File

@@ -0,0 +1,74 @@
#pragma once
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <helpers/TxTest.h>
#include <cstdint>
#include <string>
#include <string_view>
namespace xrpl::test {
// Contracts and fee arithmetic shared by the transactor-level escrow tests.
// Reads the ledger sequence and returns 5. A minimal *working* contract: it makes a real
// host call, so it exercises more than validation, but what it returns is uninteresting.
inline constexpr auto kReadsLedgerSqn = std::string_view{R"wat(
(module
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(drop (call $ldgr_index (i32.const 0) (i32.const 4)))
(i32.const 5)))
)wat"};
// Traps. A fault rather than a rejection: no return code, and nothing it wrote survives.
inline constexpr auto kTraps = std::string_view{R"wat(
(module
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(unreachable)))
)wat"};
// Loops forever, so the only way it stops is by exhausting its gas allowance.
inline constexpr auto kLoopsForever = std::string_view{R"wat(
(module
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(loop $forever (br $forever))
(i32.const 1)))
)wat"};
// Imports a host function that does not exist, so screening refuses it. Well-formed wasm —
// the refusal is about the import list, not the bytes.
inline constexpr auto kImportsUnknownHostFunction = std::string_view{R"wat(
(module
(import "host_lib" "bad" (func $bad (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(call $bad)))
)wat"};
// A contract that approves only once the ledger has reached `threshold`: returns 5 at or
// past it, 0 (a rejection) before.
//
// The escrow's release condition is thus a real predicate over ledger state that changes
// from false to true while the escrow sits there — the shape the whole feature exists for,
// and the one a fixed contract cannot express. Built at runtime because the threshold has
// to be chosen relative to the environment's current sequence.
std::string
gatedOnLedgerSqn(std::uint32_t threshold);
// What an `EscrowCreate` carrying this bytecode must pay: ten base fees plus five drops a
// byte (`EscrowCreate::calculateBaseFee`).
XRPAmount
escrowCreateFee(TxTest const& env, Bytes const& bytecode);
// What an `EscrowFinish` carrying this gas allowance must pay: the base fee plus the
// allowance priced at `gasPrice`, rounded up (`EscrowFinish::calculateBaseFee`).
XRPAmount
escrowFinishFee(TxTest const& env, std::uint32_t allowance);
} // namespace xrpl::test

View File

@@ -0,0 +1,177 @@
#include <tx/wasm/fixtures/ModuleBuilder.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <xrpl/tx/wasm/WasmVM.h>
#include <cstdint>
#include <string_view>
namespace xrpl::test {
namespace {
// Section ids, from the binary format's fixed table.
constexpr std::uint8_t kSectionType = 0x01;
constexpr std::uint8_t kSectionFunction = 0x03;
constexpr std::uint8_t kSectionMemory = 0x05;
constexpr std::uint8_t kSectionExport = 0x07;
constexpr std::uint8_t kSectionCode = 0x0A;
constexpr std::uint8_t kSectionData = 0x0B;
constexpr std::uint8_t kOpcodeNop = 0x01;
constexpr std::uint8_t kOpcodeEnd = 0x0B;
constexpr std::uint8_t kOpcodeI32Const = 0x41;
constexpr std::uint8_t kTypeI32 = 0x7F;
constexpr std::uint8_t kTypeFunc = 0x60;
constexpr std::uint32_t kPageBytes = 65'536;
// Anything that isn't obviously zero-filled is 0xEE, so a dump of a failing module shows at
// a glance which bytes are padding.
constexpr std::uint8_t kDataFillByte = 0xEE;
void
appendU32Leb(Bytes& out, std::uint32_t value)
{
do
{
auto byte = static_cast<std::uint8_t>(value & 0x7F);
value >>= 7;
if (value != 0U)
{
byte |= 0x80;
}
out.push_back(byte);
} while (value != 0U);
}
void
appendSection(Bytes& out, std::uint8_t section, Bytes const& payload)
{
out.push_back(section);
appendU32Leb(out, static_cast<std::uint32_t>(payload.size()));
out.insert(std::end(out), std::begin(payload), std::end(payload));
}
// A function body: no locals, `code`, `end` — prefixed by its own byte length.
void
appendBody(Bytes& out, Bytes const& code)
{
auto body = Bytes{0x00}; // local declaration count
body.insert(std::end(body), std::begin(code), std::end(code));
body.push_back(kOpcodeEnd);
appendU32Leb(out, static_cast<std::uint32_t>(body.size()));
out.insert(std::end(out), std::begin(body), std::end(body));
}
Bytes
header()
{
return Bytes{0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00}; // "\0asm", version 1
}
// Two types: `() -> ()` for filler functions, `() -> i32` for the entry point.
constexpr std::uint8_t kTypeVoid = 0;
constexpr std::uint8_t kTypeReturnsI32 = 1;
void
appendTypeSection(Bytes& out)
{
auto payload = Bytes{0x02}; // two types
payload.insert(std::end(payload), {kTypeFunc, 0x00, 0x00});
payload.insert(std::end(payload), {kTypeFunc, 0x00, 0x01, kTypeI32});
appendSection(out, kSectionType, payload);
}
// `fillerCount` functions of type `() -> ()`, then the entry point of type `() -> i32`.
void
appendFunctionSection(Bytes& out, std::uint32_t fillerCount)
{
auto payload = Bytes{};
appendU32Leb(payload, fillerCount + 1);
payload.insert(std::end(payload), fillerCount, kTypeVoid);
payload.push_back(kTypeReturnsI32);
appendSection(out, kSectionFunction, payload);
}
// Export the entry point, which is the last function declared.
void
appendExportSection(Bytes& out, std::uint32_t fillerCount, bool exportMemory)
{
auto payload = Bytes{};
appendU32Leb(payload, exportMemory ? 2 : 1);
if (exportMemory)
{
static constexpr auto kMemory = std::string_view{"memory"};
appendU32Leb(payload, static_cast<std::uint32_t>(kMemory.size()));
payload.insert(std::end(payload), std::begin(kMemory), std::end(kMemory));
payload.push_back(0x02); // export kind: memory
payload.push_back(0x00); // memory index
}
appendU32Leb(payload, static_cast<std::uint32_t>(escrowFunctionName.size()));
payload.insert(std::end(payload), std::begin(escrowFunctionName), std::end(escrowFunctionName));
payload.push_back(0x00); // export kind: function
appendU32Leb(payload, fillerCount);
appendSection(out, kSectionExport, payload);
}
// `i32.const 1` — a completed run that the transactor reads as success.
Bytes
entryPointCode()
{
return Bytes{kOpcodeI32Const, 0x01};
}
} // namespace
Bytes
codeHeavyModule(std::uint32_t instructionCount)
{
// One filler function holding every `nop`, plus the entry point.
constexpr std::uint32_t kFillerCount = 1;
auto out = header();
appendTypeSection(out);
appendFunctionSection(out, kFillerCount);
appendExportSection(out, kFillerCount, /*exportMemory*/ false);
auto codePayload = Bytes{};
appendU32Leb(codePayload, kFillerCount + 1);
appendBody(codePayload, Bytes(instructionCount, kOpcodeNop));
appendBody(codePayload, entryPointCode());
appendSection(out, kSectionCode, codePayload);
return out;
}
Bytes
dataHeavyModule(std::uint32_t dataBytes)
{
auto out = header();
appendTypeSection(out);
appendFunctionSection(out, /*fillerCount*/ 0);
auto memoryPayload = Bytes{0x01, 0x00}; // one memory, minimum-only limits
appendU32Leb(memoryPayload, (dataBytes + kPageBytes - 1) / kPageBytes);
appendSection(out, kSectionMemory, memoryPayload);
appendExportSection(out, /*fillerCount*/ 0, /*exportMemory*/ true);
auto codePayload = Bytes{0x01}; // one function body
appendBody(codePayload, entryPointCode());
appendSection(out, kSectionCode, codePayload);
auto dataPayload = Bytes{0x01, 0x00}; // one segment, memory 0
dataPayload.insert(std::end(dataPayload), {kOpcodeI32Const, 0x00, kOpcodeEnd}); // offset 0
appendU32Leb(dataPayload, dataBytes);
dataPayload.insert(std::end(dataPayload), dataBytes, kDataFillByte);
appendSection(out, kSectionData, dataPayload);
return out;
}
} // namespace xrpl::test

View File

@@ -0,0 +1,47 @@
#pragma once
#include <xrpl/tx/wasm/WasmCommon.h>
#include <cstdint>
namespace xrpl::test {
// Modules built to a *byte size* rather than to a behaviour.
//
// Everything else in this tree writes WAT, which is the right default: it says what the
// contract does. These two say only how big it is, for the transactor's `bytecodeSizeLimit`
// screening, where the boundary cases sit five bytes apart (99'950 accepted, 99'955
// refused). Assembling text cannot hit a byte count on the nose, and the WAT for a
// hundred thousand `nop`s would be a ~500 KB string, so these emit the binary directly.
//
// Both produce a module that *passes preflight* when it is under the size limit: a real
// `escrow_finish` exported with type `() -> i32`. That is not incidental — screening
// checks the entry point's signature (`PreflightTest.EntryPointOfTheWrongTypeIsRefused`),
// so a module that got it wrong would be refused for that reason at every size and the
// sweep would measure nothing.
// A module of `instructionCount` `nop`s in a single function, doing nothing.
//
// All of them in one function, deliberately: there appears to be no per-function size limit
// below the module limit, so one function may occupy the whole module. `wasmparser` defines
// `MAX_WASM_FUNCTION_SIZE` = 128 KiB, which looks like such a limit, but a single body of a
// million instructions preflights clean — pinned by
// `BytecodeSize.ASingleFunctionBodyIsNotSeparatelyCapped`. Splitting the `nop`s across
// functions would imply a constraint that is not there, and would make the byte count the
// boundary tests depend on harder to predict.
//
// The returned module is a few dozen bytes larger than `instructionCount` (the sections
// around the code). Callers that care about an exact total should measure `.size()` rather
// than assume it.
Bytes
codeHeavyModule(std::uint32_t instructionCount);
// A module carrying `dataBytes` bytes in a data segment, with memory declared to fit.
//
// The size lands in a data section rather than a code section, so the two builders
// together separate "large because there is a lot to translate" from "large because there
// is a lot to copy".
Bytes
dataHeavyModule(std::uint32_t dataBytes);
} // namespace xrpl::test

View File

@@ -0,0 +1,198 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TestServiceRegistry.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <optional>
#include <string>
namespace xrpl::test {
namespace {
// What `EscrowCreate` refuses before a contract ever runs. Everything here is decided in
// preflight, so the shape of the transaction is the whole subject: no ledger state matters
// beyond the account existing.
struct BytecodePreflight : testing::Test
{
Account const alice{"alice"};
Account const carol{"carol"};
// An `EscrowCreate` with everything a valid one needs, for callers to spoil one field at
// a time. `cancelAfter` is set because without an expiry the transaction is refused for
// that reason first, and every bytecode case would report `temBAD_EXPIRATION` instead of
// what it meant to check.
transactions::EscrowCreateBuilder
escrowCreate(TxTest const& env, Bytes const& bytecode)
{
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
builder.setBytecode(makeSlice(bytecode));
builder.setCancelAfter(closeTimeOffset(env, 100));
return builder;
}
};
TEST_F(BytecodePreflight, BytecodeIsRefusedWhileSmartEscrowIsDisabled)
{
auto env = TxTest{allFeatures() - featureSmartEscrow};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
auto const fee = escrowCreateFee(env, wasm);
EXPECT_EQ(env.submit(escrowCreate(env, wasm), alice, fee).ter, temDISABLED);
// Also with a data field, which is the other half of the feature's surface.
auto builder = escrowCreate(env, wasm);
builder.setData(makeSlice(Bytes{0x00, 0x11, 0x22, 0x33}));
EXPECT_EQ(env.submit(builder, alice, fee).ter, temDISABLED);
}
// A zero limit is how fee voting turns the runtime off, and it has to be distinguishable
// from "your contract is too big" — `temTEMP_DISABLED` says come back later, `temMALFORMED`
// says never.
TEST_F(BytecodePreflight, AZeroSizeLimitDisablesUploadsRatherThanRejectingThem)
{
auto fees = TestServiceRegistry::defaultFees();
fees.bytecodeSizeLimit = 0;
auto env = TxTest{std::nullopt, fees};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter,
temTEMP_DISABLED);
}
TEST_F(BytecodePreflight, AZeroGasLimitDisablesUploads)
{
auto fees = TestServiceRegistry::defaultFees();
fees.gasLimit = 0;
auto env = TxTest{std::nullopt, fees};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter,
temTEMP_DISABLED);
}
TEST_F(BytecodePreflight, EmptyBytecodeIsRefused)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
EXPECT_EQ(
env.submit(escrowCreate(env, Bytes{}), alice, escrowCreateFee(env, Bytes{})).ter,
temMALFORMED);
}
// Screening reaches into the module: this one is structurally valid wasm that asks for a
// host function nobody serves.
TEST_F(BytecodePreflight, BytecodeImportingAnUnknownHostFunctionIsRefused)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kImportsUnknownHostFunction);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter,
temINVALID_BYTECODE);
}
TEST_F(BytecodePreflight, DataWithoutBytecodeIsRefused)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
builder.setData(makeSlice(Bytes{0x41, 0x41, 0x41, 0x41}));
builder.setCancelAfter(closeTimeOffset(env, 100));
EXPECT_EQ(env.submit(builder, alice, XRPAmount{100'000}).ter, temMALFORMED);
}
TEST_F(BytecodePreflight, DataPastItsMaximumIsRefused)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
auto builder = escrowCreate(env, wasm);
builder.setData(makeSlice(Bytes(kMaxWasmDataLength + 1, 0x42)));
EXPECT_EQ(env.submit(builder, alice, XRPAmount{100'000}).ter, temMALFORMED);
}
// A contract needs a deadline. Without `CancelAfter` the escrow could never be reclaimed if
// the contract never approves, so every combination lacking it is refused — including the
// ones that look complete because they carry a `FinishAfter` or a condition.
TEST_F(BytecodePreflight, BytecodeWithoutACancelTimeIsRefused)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
auto const fee = escrowCreateFee(env, wasm);
auto bare = [&] {
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
builder.setBytecode(makeSlice(wasm));
return builder;
};
EXPECT_EQ(env.submit(bare(), alice, fee).ter, temBAD_EXPIRATION);
auto withFinish = bare();
withFinish.setFinishAfter(closeTimeOffset(env, 2));
EXPECT_EQ(env.submit(withFinish, alice, fee).ter, temBAD_EXPIRATION);
}
TEST_F(BytecodePreflight, BytecodeWithACancelTimeIsAccepted)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
}
TEST_F(BytecodePreflight, BytecodeWithAFinishAndCancelTimeIsAccepted)
{
auto env = TxTest{};
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
auto builder = escrowCreate(env, wasm);
builder.setFinishAfter(closeTimeOffset(env, 2));
EXPECT_EQ(env.submit(builder, alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
}
// The per-byte charge is enforced, not advisory. One drop short is refused — which also
// confirms the fee helper the other tests rely on is computing the real number rather than
// something merely generous.
TEST_F(BytecodePreflight, AFeeOneDropShortIsRefused)
{
TxTest env;
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
auto const fee = escrowCreateFee(env, wasm);
EXPECT_EQ(env.submit(escrowCreate(env, wasm), alice, fee - XRPAmount{1}).ter, telINSUF_FEE_P);
}
} // namespace
} // namespace xrpl::test

View File

@@ -0,0 +1,192 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/protocol_autogen/transactions/EscrowFinish.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <array>
#include <cstdint>
#include <optional>
namespace xrpl::test {
namespace {
// A contract deciding whether an escrow releases, end to end through the transactor. The
// other transactor files are about refusals; this one is about the feature working.
constexpr std::uint32_t kAllowance = 10'000;
// A preimage-sha256 pair, copied from jtx because `src/test/jtx` is not linked here.
constexpr auto kFulfillment = std::array<std::uint8_t, 4>{{0xA0, 0x02, 0x80, 0x00}};
constexpr auto kCondition = std::array<std::uint8_t, 39>{
{0xA0, 0x25, 0x80, 0x20, 0xE3, 0xB0, 0xC4, 0x42, 0x98, 0xFC, 0x1C, 0x14, 0x9A,
0xFB, 0xF4, 0xC8, 0x99, 0x6F, 0xB9, 0x24, 0x27, 0xAE, 0x41, 0xE4, 0x64, 0x9B,
0x93, 0x4C, 0xA4, 0x95, 0x99, 0x1B, 0x78, 0x52, 0xB8, 0x55, 0x81, 0x01, 0x00}};
struct BytecodeRun : testing::Test
{
TxTest env;
Account const alice{"alice"};
Account const carol{"carol"};
BytecodeRun()
{
createAccounts(env, XRP(5'000), alice, carol);
}
std::uint32_t
currentSeq() const
{
return env.getOpenLedger().header().seq;
}
struct Created
{
std::uint32_t seq;
XRPAmount fee;
};
Created
createEscrow(Bytes const& wasm, bool withCondition = false)
{
auto const seq = env.getAccountRoot(alice).getSequence();
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(1'000)}};
builder.setBytecode(makeSlice(wasm));
builder.setCancelAfter(closeTimeOffset(env, 1'000));
if (withCondition)
{
builder.setCondition(makeSlice(kCondition));
}
auto const fee = escrowCreateFee(env, wasm);
EXPECT_EQ(env.submit(builder, alice, fee).ter, tesSUCCESS);
env.close();
return Created{.seq = seq, .fee = fee};
}
[[nodiscard]] ClosedResult
finish(std::uint32_t seq, bool withFulfillment = false)
{
auto builder = transactions::EscrowFinishBuilder{carol, alice, seq};
builder.setGas(kAllowance);
auto fee = escrowFinishFee(env, kAllowance);
if (withFulfillment)
{
builder.setCondition(makeSlice(kCondition));
builder.setFulfillment(makeSlice(kFulfillment));
fee += env.getOpenLedger().fees().base * (32 + (kFulfillment.size() / 16));
}
return env.submitAndClose(builder, carol, fee);
}
bool
escrowExists(std::uint32_t seq) const
{
return env.getOpenLedger().read(keylet::escrow(alice, SeqProxy::rawSequence(seq))) !=
nullptr;
}
};
// The whole point: it refuses while its predicate is false and releases once the ledger
// makes it true, with nothing resubmitted differently.
TEST_F(BytecodeRun, AContractRejectsUntilItsConditionHoldsThenReleases)
{
auto const threshold = currentSeq() + 3;
auto const wasm = assembleWat(gatedOnLedgerSqn(threshold));
auto const created = createEscrow(wasm);
ASSERT_LT(currentSeq(), threshold);
auto const rejected = finish(created.seq);
EXPECT_EQ(rejected.ter, tecBYTECODE_REJECTED);
EXPECT_TRUE(escrowExists(created.seq)) << "a rejected escrow must survive";
ASSERT_TRUE(rejected.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
EXPECT_EQ(rejected.meta->getAsObject().getFieldI32(sfVMReturnCode), 0);
while (currentSeq() < threshold)
{
env.close();
}
auto const approved = finish(created.seq);
EXPECT_EQ(approved.ter, tesSUCCESS);
EXPECT_FALSE(escrowExists(created.seq)) << "an approved escrow must be destroyed";
ASSERT_TRUE(approved.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const meta = approved.meta->getAsObject();
EXPECT_EQ(meta.getFieldI32(sfVMReturnCode), 5);
EXPECT_TRUE(meta.isFieldPresent(sfGasUsed));
}
TEST_F(BytecodeRun, TheBytecodeReserveIsHeldWhileTheEscrowLivesAndReleasedWhenItGoes)
{
EXPECT_EQ(env.getOwnerCount(alice), 0U);
auto const threshold = currentSeq() + 2;
auto const wasm = assembleWat(gatedOnLedgerSqn(threshold));
auto const created = createEscrow(wasm);
// `calculateAdditionalReserve`: one increment for the escrow, plus one per 500 bytes.
auto const expected = 1U + static_cast<std::uint32_t>(wasm.size() / 500);
EXPECT_EQ(env.getOwnerCount(alice), expected);
while (currentSeq() < threshold)
{
env.close();
}
ASSERT_EQ(finish(created.seq).ter, tesSUCCESS);
EXPECT_EQ(env.getOwnerCount(alice), 0U);
}
TEST_F(BytecodeRun, CreatingChargesTheAmountAndTheFee)
{
auto const before = env.getXrpBalance(alice);
auto const wasm = assembleWat(gatedOnLedgerSqn(currentSeq() + 2));
auto const created = createEscrow(wasm);
EXPECT_EQ(env.getXrpBalance(alice), before - XRP(1'000) - created.fee);
EXPECT_EQ(env.getXrpBalance(carol), XRP(5'000));
}
// The condition is the outer gate: without a fulfillment the contract is never reached, even
// though it would have approved.
TEST_F(BytecodeRun, AConditionIsCheckedBeforeTheContractRuns)
{
auto const threshold = currentSeq() + 2;
auto const wasm = assembleWat(gatedOnLedgerSqn(threshold));
auto const created = createEscrow(wasm, /*withCondition*/ true);
while (currentSeq() < threshold)
{
env.close();
}
EXPECT_EQ(finish(created.seq).ter, tecCRYPTOCONDITION_ERROR);
EXPECT_TRUE(escrowExists(created.seq));
auto const approved = finish(created.seq, /*withFulfillment*/ true);
EXPECT_EQ(approved.ter, tesSUCCESS);
EXPECT_FALSE(escrowExists(created.seq));
}
} // namespace
} // namespace xrpl::test

View File

@@ -0,0 +1,122 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <xrpl/tx/wasm/WasmVM.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TestServiceRegistry.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/ModuleBuilder.h>
#include <optional>
namespace xrpl::test {
namespace {
TER
createEscrowWith(TxTest& env, Account const& account, Bytes const& bytecode)
{
auto builder = transactions::EscrowCreateBuilder{account, account, STAmount{XRP(1'000)}};
builder.setBytecode(makeSlice(bytecode));
builder.setCancelAfter(closeTimeOffset(env, 100));
return env.submit(builder, account, escrowCreateFee(env, bytecode)).ter;
}
// Rich enough for the owner reserve a 200 KB contract demands: 401 increments, 802 XRP.
Account
fundedAccount(TxTest& env)
{
auto const alice = Account{"alice"};
env.createAccount(alice, XRP(2'000'000));
return alice;
}
} // namespace
// The transactor screens `sfBytecode` against `bytecodeSizeLimit` before the module reaches
// the engine. Compilation is unmetered, so that limit is the only thing bounding it.
// Footing for the rest: without this, "too big" and "malformed" are indistinguishable.
TEST(BytecodeSize, TheBuildersProduceAModuleTheEngineAccepts)
{
auto env = TxTest{};
auto const alice = fundedAccount(env);
EXPECT_EQ(createEscrowWith(env, alice, codeHeavyModule(1'000)), tesSUCCESS);
EXPECT_EQ(createEscrowWith(env, alice, dataHeavyModule(1'000)), tesSUCCESS);
}
TEST(BytecodeSize, AModuleUnderTheLimitIsAccepted)
{
auto env = TxTest{};
auto const alice = fundedAccount(env);
auto const wasm = codeHeavyModule(90'000);
ASSERT_LT(wasm.size(), env.getOpenLedger().fees().bytecodeSizeLimit);
EXPECT_EQ(createEscrowWith(env, alice, wasm), tesSUCCESS);
}
TEST(BytecodeSize, AModuleOverTheLimitIsRefused)
{
auto env = TxTest{};
auto const alice = fundedAccount(env);
auto const wasm = codeHeavyModule(110'000);
ASSERT_GT(wasm.size(), env.getOpenLedger().fees().bytecodeSizeLimit);
EXPECT_EQ(createEscrowWith(env, alice, wasm), temMALFORMED);
}
// The limit is on the module, not the code section — moving the bulk into a data segment
// does not walk around it.
TEST(BytecodeSize, ADataSegmentCountsTowardTheLimit)
{
auto env = TxTest{};
auto const alice = fundedAccount(env);
auto const wasm = dataHeavyModule(110'000);
ASSERT_GT(wasm.size(), env.getOpenLedger().fees().bytecodeSizeLimit);
EXPECT_EQ(createEscrowWith(env, alice, wasm), temMALFORMED);
}
// The limit is a fee setting, so it moves. If raising it admits nothing new, some other cap
// is really in charge.
TEST(BytecodeSize, RaisingTheLimitAdmitsALargerModule)
{
auto fees = TestServiceRegistry::defaultFees();
fees.bytecodeSizeLimit = kMaxBytecodeSizeLimit;
auto env = TxTest{std::nullopt, fees};
auto const alice = fundedAccount(env);
auto const wasm = codeHeavyModule(150'000);
ASSERT_GT(wasm.size(), TestServiceRegistry::defaultFees().bytecodeSizeLimit);
ASSERT_LT(wasm.size(), kMaxBytecodeSizeLimit);
EXPECT_EQ(createEscrowWith(env, alice, wasm), tesSUCCESS);
}
// No per-function limit sits below the module limit: one function may occupy the entire
// module. `wasmparser` defines `MAX_WASM_FUNCTION_SIZE` = 128 KiB, but nothing on this path
// appears to enforce it — a lone body of a million instructions is accepted.
//
// So `bytecodeSizeLimit` is not defence in depth; it is the only bound on how much there is
// to compile, and raising it raises the worst case with nothing behind it. A failure here
// means a second limit has appeared, and that reasoning needs revisiting.
TEST(BytecodeSize, ASingleFunctionBodyIsNotSeparatelyCapped)
{
auto const wasm = codeHeavyModule(1'000'000);
ASSERT_GT(wasm.size(), 128U * 1024U) << "the module must exceed MAX_WASM_FUNCTION_SIZE";
EXPECT_EQ(preflightEscrowWasm(wasm, beast::Journal{beast::Journal::getNullSink()}), tesSUCCESS);
}
} // namespace xrpl::test

View File

@@ -0,0 +1,122 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/protocol_autogen/transactions/EscrowFinish.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <cstdint>
#include <string>
#include <string_view>
namespace xrpl::test {
namespace {
// Writes "Data" and then rejects. `set_data` stores through the host, and the `-256` return
// puts `EscrowFinish` on its `reValue <= 0` path — a contract-defined rejection, distinct
// from a fault. The escrow survives, so whether the write survives with it is the question.
constexpr auto kWritesThenRejects = std::string_view{R"wat(
(module
(import "host_lib" "set_data" (func $set_data (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "Data")
(func (export "escrow_finish") (result i32)
(drop (call $set_data (i32.const 0) (i32.const 4)))
(i32.const -256)))
)wat"};
constexpr std::int32_t kRejectCode = -256;
// Enough to run the contract; the test is about persistence, not budgets.
constexpr std::uint32_t kAllowance = 100'000;
struct DataOnReject : testing::Test
{
TxTest env;
Account const alice{"alice"};
std::uint32_t escrowSeq{};
void
SetUp() override
{
testing::Test::SetUp();
env.createAccount(alice, XRP(5'000));
auto const wasm = assembleWat(kWritesThenRejects);
escrowSeq = env.getAccountRoot(alice).getSequence();
auto builder = transactions::EscrowCreateBuilder{alice, alice, STAmount{XRP(1'000)}};
builder.setBytecode(makeSlice(wasm));
builder.setCancelAfter(closeTimeOffset(env, 1'000));
ASSERT_EQ(env.submit(builder, alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
env.close();
}
[[nodiscard]] ClosedResult
finish()
{
auto builder = transactions::EscrowFinishBuilder{alice, alice, escrowSeq};
builder.setGas(kAllowance);
return env.submitAndClose(builder, alice, escrowFinishFee(env, kAllowance));
}
};
// The point of the whole shape: a contract that rejects can still leave a record of why.
// `EscrowFinish` writes `sfData` *before* returning `tecBYTECODE_REJECTED`, and a `tec`
// keeps its ledger changes, so the escrow survives carrying what the contract wrote.
TEST_F(DataOnReject, ARejectingContractStillPersistsItsData)
{
auto const result = finish();
EXPECT_EQ(result.ter, tecBYTECODE_REJECTED);
auto const sle =
env.getOpenLedger().read(keylet::escrow(alice, SeqProxy::rawSequence(escrowSeq)));
ASSERT_NE(sle, nullptr) << "a rejected finish must leave the escrow in place";
ASSERT_TRUE(sle->isFieldPresent(sfData));
auto const data = sle->getFieldVL(sfData);
EXPECT_EQ(std::string(data.begin(), data.end()), "Data") << strHex(data);
}
// The reject code reaches the metadata, which is the only way a client learns *which*
// rejection it was — every contract-defined reject shares one TER.
TEST_F(DataOnReject, TheRejectCodeIsReportedInTheMetadata)
{
auto const result = finish();
ASSERT_TRUE(result.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const meta = result.meta->getAsObject();
ASSERT_TRUE(meta.isFieldPresent(sfVMReturnCode));
EXPECT_EQ(meta.getFieldI32(sfVMReturnCode), kRejectCode);
}
// Gas is reported even though the run ended in a rejection: the engine has a trustworthy
// number whenever the contract ran to completion, and a reject is a completed run.
TEST_F(DataOnReject, GasIsChargedAndReportedForARejectedRun)
{
auto const result = finish();
ASSERT_TRUE(result.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const meta = result.meta->getAsObject();
ASSERT_TRUE(meta.isFieldPresent(sfGasUsed));
auto const used = meta.getFieldU32(sfGasUsed);
EXPECT_GT(used, 0U);
EXPECT_LE(used, kAllowance) << "the engine cannot spend more than it was given";
}
} // namespace
} // namespace xrpl::test

View File

@@ -0,0 +1,198 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/protocol_autogen/transactions/EscrowFinish.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TestServiceRegistry.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <cstdint>
#include <optional>
#include <string_view>
namespace xrpl::test {
namespace {
// The ways an `EscrowFinish` against a contract-bearing escrow fails, and what each reports.
// A rejection, a fault, and running out of gas are three outcomes; only one carries a return
// code.
struct FinishFailures : testing::Test
{
TxTest env;
Account const alice{"alice"};
Account const carol{"carol"};
FinishFailures()
{
createAccounts(env, XRP(5'000), alice, carol);
}
std::uint32_t
createEscrow(std::string_view wat)
{
auto const wasm = assembleWat(wat);
auto const seq = env.getAccountRoot(alice).getSequence();
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
builder.setBytecode(makeSlice(wasm));
builder.setCancelAfter(closeTimeOffset(env, 1'000));
EXPECT_EQ(env.submit(builder, alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
env.close();
return seq;
}
std::uint32_t
createPlainEscrow()
{
auto const seq = env.getAccountRoot(alice).getSequence();
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
// A contract-free escrow needs a `FinishAfter` or a condition; `CancelAfter` alone is
// `temMALFORMED`.
builder.setFinishAfter(closeTimeOffset(env, 1));
builder.setCancelAfter(closeTimeOffset(env, 1'000));
EXPECT_EQ(env.submit(builder, alice, XRPAmount{100'000}).ter, tesSUCCESS);
env.close();
env.close(); // past the finish time
return seq;
}
[[nodiscard]] ClosedResult
finish(std::uint32_t seq, std::optional<std::uint32_t> allowance, XRPAmount fee)
{
auto builder = transactions::EscrowFinishBuilder{carol, alice, seq};
if (allowance)
{
builder.setGas(*allowance);
}
return env.submitAndClose(builder, carol, fee);
}
};
TEST_F(FinishFailures, FinishIsRefusedWhileSmartEscrowIsDisabled)
{
auto disabled = TxTest{allFeatures() - featureSmartEscrow};
createAccounts(disabled, XRP(5'000), alice, carol);
auto builder = transactions::EscrowFinishBuilder{carol, alice, 1};
builder.setGas(4);
EXPECT_EQ(disabled.submit(builder, carol, XRPAmount{100'000}).ter, temDISABLED);
}
// Execution cannot be bought unbounded just by asking for it.
TEST_F(FinishFailures, AnAllowancePastTheGasLimitIsRefused)
{
auto fees = TestServiceRegistry::defaultFees();
fees.gasLimit = 1'000;
env.getServiceRegistry().setFees(fees);
auto builder = transactions::EscrowFinishBuilder{carol, alice, 1};
builder.setGas(1'001);
EXPECT_EQ(env.submit(builder, carol, XRPAmount{10'000'000}).ter, temBAD_LIMIT);
}
// A zero gas limit turns the runtime off.
TEST_F(FinishFailures, AZeroGasLimitDisablesFinishing)
{
auto const seq = createEscrow(kReadsLedgerSqn);
auto fees = TestServiceRegistry::defaultFees();
fees.gasLimit = 0;
env.getServiceRegistry().setFees(fees);
auto builder = transactions::EscrowFinishBuilder{carol, alice, seq};
builder.setGas(1'000);
EXPECT_EQ(env.submit(builder, carol, XRPAmount{10'000'000}).ter, temTEMP_DISABLED);
}
TEST_F(FinishFailures, AFinishWithoutAGasFieldIsRefused)
{
auto const seq = createEscrow(kReadsLedgerSqn);
EXPECT_EQ(finish(seq, std::nullopt, XRPAmount{100'000}).ter, tefBYTECODE_NOT_INCLUDED);
}
TEST_F(FinishFailures, AZeroAllowanceIsRefused)
{
auto const seq = createEscrow(kReadsLedgerSqn);
auto builder = transactions::EscrowFinishBuilder{carol, alice, seq};
builder.setGas(0);
EXPECT_EQ(env.submit(builder, carol, XRPAmount{100'000}).ter, temBAD_LIMIT);
}
// The allowance is paid up front, so under-paying is caught before anything runs.
TEST_F(FinishFailures, AFeeThatDoesNotCoverTheAllowanceIsRefused)
{
auto const seq = createEscrow(kReadsLedgerSqn);
constexpr std::uint32_t kAllowance = 1'000;
auto const fee = escrowFinishFee(env, kAllowance) - XRPAmount{1};
EXPECT_EQ(finish(seq, kAllowance, fee).ter, telINSUF_FEE_P);
}
TEST_F(FinishFailures, GasAgainstAnEscrowWithoutBytecodeIsRefused)
{
auto const seq = createPlainEscrow();
constexpr std::uint32_t kAllowance = 100;
EXPECT_EQ(finish(seq, kAllowance, escrowFinishFee(env, kAllowance)).ter, tefNO_BYTECODE);
}
// A band rather than an equality: the meter stops at the last instruction it could afford,
// leaving a few units unspent. That band is what separates this from a trap, which stops
// early and reports a small fraction.
TEST_F(FinishFailures, RunningOutOfGasConsumesEssentiallyTheWholeAllowanceAndReportsNoReturnCode)
{
auto const seq = createEscrow(kLoopsForever);
constexpr std::uint32_t kAllowance = 10'000;
auto const result = finish(seq, kAllowance, escrowFinishFee(env, kAllowance));
EXPECT_EQ(result.ter, tecOUT_OF_GAS);
ASSERT_TRUE(result.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const meta = result.meta->getAsObject();
ASSERT_TRUE(meta.isFieldPresent(sfGasUsed));
auto const used = meta.getFieldU32(sfGasUsed);
EXPECT_LE(used, kAllowance) << "the engine cannot spend more than it was given";
EXPECT_GT(used, kAllowance - (kAllowance / 100));
EXPECT_FALSE(meta.isFieldPresent(sfVMReturnCode));
}
// A trap is a fault, not a rejection: gas actually burned, and no return code.
TEST_F(FinishFailures, ATrapReportsPartialGasAndNoReturnCode)
{
auto const seq = createEscrow(kTraps);
constexpr std::uint32_t kAllowance = 1'000;
auto const result = finish(seq, kAllowance, escrowFinishFee(env, kAllowance));
EXPECT_EQ(result.ter, tecFAILED_PROCESSING);
ASSERT_TRUE(result.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const meta = result.meta->getAsObject();
ASSERT_TRUE(meta.isFieldPresent(sfGasUsed));
EXPECT_LT(meta.getFieldU32(sfGasUsed), kAllowance);
EXPECT_FALSE(meta.isFieldPresent(sfVMReturnCode));
}
} // namespace
} // namespace xrpl::test

View File

@@ -0,0 +1,99 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
#include <xrpl/protocol_autogen/transactions/EscrowFinish.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/EscrowWasm.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <cstdint>
namespace xrpl::test {
namespace {
// allowance × gasPrice is a product of two 32-bit values, so a narrow intermediate would wrap
// and let a large allowance be bought for almost nothing. These pin that it costs a big fee.
// Near the default gas limit, so the product is as large as the transactor ever computes.
constexpr std::uint32_t kBigAllowance = 996'433;
struct GasFees : testing::Test
{
TxTest env;
Account const alice{"alice"};
Account const carol{"carol"};
std::uint32_t escrowSeq{};
void
SetUp() override
{
testing::Test::SetUp();
createAccounts(env, XRP(5'000), alice, carol);
auto const wasm = assembleWat(kReadsLedgerSqn);
escrowSeq = env.getAccountRoot(alice).getSequence();
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(1'000)}};
builder.setBytecode(makeSlice(wasm));
builder.setCancelAfter(closeTimeOffset(env, 1'000));
ASSERT_EQ(env.submit(builder, alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
env.close();
}
[[nodiscard]] TER
finishPaying(XRPAmount fee)
{
auto builder = transactions::EscrowFinishBuilder{carol, alice, escrowSeq};
builder.setGas(kBigAllowance);
return env.submit(builder, carol, fee).ter;
}
};
// If the product ever wrapped, this is the test that notices: 30 drops would start looking
// sufficient.
TEST_F(GasFees, ALargeAllowanceCannotBeBoughtForAFewDrops)
{
auto const owed = escrowFinishFee(env, kBigAllowance);
ASSERT_GT(owed.drops(), kBigAllowance) << "the fee must scale with the allowance";
EXPECT_EQ(finishPaying(XRPAmount{30}), telINSUF_FEE_P);
}
TEST_F(GasFees, AFeeOneDropShortOfTheAllowanceIsRefused)
{
EXPECT_EQ(finishPaying(escrowFinishFee(env, kBigAllowance) - XRPAmount{1}), telINSUF_FEE_P);
}
// Otherwise the two refusals above prove nothing: any fee at all might be rejected.
TEST_F(GasFees, TheExactFeeIsAccepted)
{
EXPECT_EQ(finishPaying(escrowFinishFee(env, kBigAllowance)), tesSUCCESS);
}
// Asking for a near-limit budget does not mean spending it.
TEST_F(GasFees, OnlyTheGasActuallyUsedIsReported)
{
auto builder = transactions::EscrowFinishBuilder{carol, alice, escrowSeq};
builder.setGas(kBigAllowance);
auto const result = env.submitAndClose(builder, carol, escrowFinishFee(env, kBigAllowance));
ASSERT_EQ(result.ter, tesSUCCESS);
ASSERT_TRUE(result.meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const obj = result.meta->getAsObject();
ASSERT_TRUE(obj.isFieldPresent(sfGasUsed));
EXPECT_LT(obj.getFieldU32(sfGasUsed), kBigAllowance);
EXPECT_EQ(obj.getFieldI32(sfVMReturnCode), 5);
}
} // namespace
} // namespace xrpl::test