fix: Port smart escrow tests to new design

This commit is contained in:
TimothyBanks
2026-09-08 20:58:16 -04:00
parent 4aa695f7d9
commit a4d1bd1a55
20 changed files with 1597 additions and 4494 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,17 @@ private:
*/
class TestServiceRegistry : public ServiceRegistry
{
public:
/**
* @brief The fee settings a test environment starts with.
*
* Public and static because `TxTest` seeds its **genesis ledger** from the same values.
* The two have to agree: a transactor reads its limits from the registry
* (`EscrowCreate` checks `bytecodeSizeLimit` via `ctx.registry.get().getFees()`) but
* `Transactor::calculateBaseFee` reads `view.fees()`. Seeding them separately once left
* `gasPrice` at 1'000'000 in the registry and 0 in the view, which silently collapsed
* `EscrowFinish`'s gas-allowance fee to a single drop.
*/
static Fees
defaultFees()
{
@@ -189,6 +200,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 +512,26 @@ public:
return fees_;
}
/**
* @brief Override the fee settings the transactors see.
*
* For tests about a limit rather than about a transaction: `EscrowCreate` screens
* `sfBytecode` against `bytecodeSizeLimit` from here, so a size test sets it directly
* instead of standing up fee voting.
*
* @note This writes the `Fees` fields directly and so is **not** bounded by
* `kMaxBytecodeSizeLimit` / `kMaxGasLimit`, which only constrain config parsing
* (`Config.cpp`) and `FeeVoteImpl`. Do not use it to test behaviour above those
* ceilings: no ledger can reach such a configuration, so any expectation set there
* is unfalsifiable in production. Prefer `TxTest`'s constructor when the whole
* environment wants one fee set, so the view and the registry stay in step.
*/
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>
@@ -59,7 +62,7 @@ allFeatures()
// 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 +71,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 — see the constructor's doc comment.
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 +159,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 +210,7 @@ TxTest::close()
for (auto const& tx : pendingTxs_)
txSet.insert(tx);
closedMetadata_.clear();
{
OpenView accum(&*newLedger);
for (auto const& [key, tx] : txSet)
@@ -203,6 +220,9 @@ 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 +238,15 @@ 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 == closedMetadata_.end())
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>
@@ -191,8 +192,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.
@@ -241,6 +250,41 @@ public:
return submit(builder.build(signer.pk(), signer.sk()).getSTTx());
}
/**
* @brief Submit a transaction from a builder, paying an explicit fee.
*
* The overload above pays a flat 10 drops, which 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.
*
* @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.
* @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, XRPAmount fee)
{
auto const& obj = builder.getSTObject();
auto accountId = obj[sfAccount];
if (!obj.isFieldPresent(sfTicketSequence))
{
builder.setSequence(getAccountRoot(accountId).getSequence());
}
else
{
builder.setSequence(0);
}
builder.setFee(fee);
return submit(builder.build(signer.pk(), signer.sk()).getSTTx());
}
/**
* @brief Submit a transaction to the open ledger.
*
@@ -281,6 +325,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 +373,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 +427,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,6 +452,11 @@ 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).
*/

View File

@@ -6,28 +6,29 @@ 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;
`xrpl.bench.wasm` links no GTest and no GMock at all.
@@ -83,8 +84,18 @@ against the same spec. That would not catch a drift where both diverge on an amb
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.
**Transactor-level (L5) tests** now live in `transactor/`, over `TxTest` — which runs the real
pipeline (preflight → preclaim → doApply → invariants) without needing an `Application`. They
cover what the earlier `EscrowSmart_test.cpp` did on Beast: `set_data` persistence through a
`tecBYTECODE_REJECTED`, `sfGasUsed` / `sfVMReturnCode` in metadata, owner-reserve accounting for
a bytecode-bearing escrow, the `bytecodeSizeLimit` boundary, and the gas-allowance fee.
Two things to know before adding to that folder:
- **Metadata only exists after `close()`.** `ApplyStateTable::apply` builds it for a view that is
not open, so `TxResult::metadata` from `submit` is always `nullopt`. The idiom is submit →
`close()` → `TxTest::getMetadata(txId)`.
- **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,88 @@
#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.
//
// The contracts are WAT rather than compiled hex on purpose. The suite this replaced
// shipped hex built from C by an external toolchain, and when the engine moved to Rust and
// began serving host functions from `host_lib` instead of `env`, every one of those
// fixtures started failing import screening — with no way to regenerate them short of
// installing a wasi-sdk. Text assembles here, so an ABI change is a one-line edit.
// 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"};
// Returns 0, which `EscrowFinish` reads as a contract-defined rejection.
inline constexpr auto kRejects = std::string_view{R"wat(
(module
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(i32.const 0)))
)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,175 @@
#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(out.end(), payload.begin(), payload.end());
}
// 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(body.end(), code.begin(), code.end());
body.push_back(kOpcodeEnd);
appendU32Leb(out, static_cast<std::uint32_t>(body.size()));
out.insert(out.end(), body.begin(), body.end());
}
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(payload.end(), {kTypeFunc, 0x00, 0x00});
payload.insert(payload.end(), {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(payload.end(), 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(payload.end(), kMemory.begin(), kMemory.end());
payload.push_back(0x02); // export kind: memory
payload.push_back(0x00); // memory index
}
appendU32Leb(payload, static_cast<std::uint32_t>(escrowFunctionName.size()));
payload.insert(payload.end(), escrowFunctionName.begin(), escrowFunctionName.end());
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(dataPayload.end(), {kOpcodeI32Const, 0x00, kOpcodeEnd}); // offset 0
appendU32Leb(dataPayload, dataBytes);
dataPayload.insert(dataPayload.end(), dataBytes, kDataFillByte);
appendSection(out, kSectionData, dataPayload);
return out;
}
} // namespace xrpl::test

View File

@@ -0,0 +1,45 @@
#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.
//
// One function however large, deliberately. wasmparser defines
// `MAX_WASM_FUNCTION_SIZE` = 128 KiB, but nothing on this path enforces it: a single body
// of a million instructions preflights clean (pinned by
// `BytecodeSize.ASingleFunctionBodyIsNotSeparatelyCapped`). So there is no second limit to
// design around, and splitting the `nop`s across functions would only obscure that.
//
// 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,219 @@
#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 <cstdint>
#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"};
// Deadlines are relative to the environment's close time, which starts at genesis.
static std::uint32_t
after(TxTest const& env, std::uint32_t seconds)
{
return static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count()) + seconds;
}
// 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(after(env, 100));
return builder;
}
};
TEST_F(BytecodePreflight, BytecodeIsRefusedWhileSmartEscrowIsDisabled)
{
TxTest env{allFeatures() - featureSmartEscrow};
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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;
TxTest env{std::nullopt, fees};
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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;
TxTest env{std::nullopt, fees};
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
auto const wasm = assembleWat(kReadsLedgerSqn);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter,
temTEMP_DISABLED);
}
TEST_F(BytecodePreflight, EmptyBytecodeIsRefused)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
auto const wasm = assembleWat(kImportsUnknownHostFunction);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter,
temINVALID_BYTECODE);
}
TEST_F(BytecodePreflight, DataWithoutBytecodeIsRefused)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
builder.setData(makeSlice(Bytes{0x41, 0x41, 0x41, 0x41}));
builder.setCancelAfter(after(env, 100));
EXPECT_EQ(env.submit(builder, alice, XRPAmount{100'000}).ter, temMALFORMED);
}
TEST_F(BytecodePreflight, DataPastItsMaximumIsRefused)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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(after(env, 2));
EXPECT_EQ(env.submit(withFinish, alice, fee).ter, temBAD_EXPIRATION);
}
// The success side, and the reason this file could not exist before: these cases need a
// module that actually passes screening, which the old compiled fixtures stopped doing.
TEST_F(BytecodePreflight, BytecodeWithACancelTimeIsAccepted)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
auto const wasm = assembleWat(kReadsLedgerSqn);
EXPECT_EQ(
env.submit(escrowCreate(env, wasm), alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
}
TEST_F(BytecodePreflight, BytecodeWithAFinishAndCancelTimeIsAccepted)
{
TxTest env;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
auto const wasm = assembleWat(kReadsLedgerSqn);
auto builder = escrowCreate(env, wasm);
builder.setFinishAfter(after(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;
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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,211 @@
#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: a
// predicate over ledger state that is false, then true, with the escrow surviving the
// rejections and being destroyed on approval.
constexpr std::uint32_t kAllowance = 10'000;
// The preimage-sha256 condition/fulfillment pair the Beast suite used. Copied rather than
// shared because `src/test/jtx` is not linked here, and they are inert constants.
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"};
void
SetUp() override
{
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
}
std::uint32_t
now() const
{
return static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count());
}
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(now() + 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};
}
struct Finished
{
TER ter;
std::optional<TxMeta> meta;
};
[[nodiscard]] Finished
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));
}
auto const result = env.submit(builder, carol, fee);
env.close();
return Finished{.ter = result.ter, .meta = env.getMetadata(result.tx->getTransactionID())};
}
bool
escrowExists(std::uint32_t seq) const
{
return env.getOpenLedger().read(keylet::escrow(alice, SeqProxy::rawSequence(seq))) !=
nullptr;
}
};
// The whole point of a programmable escrow: it refuses while its condition is false, and
// releases once the ledger makes it true — without anyone resubmitting anything different.
TEST_F(BytecodeRun, AContractRejectsUntilItsConditionHoldsThenReleases)
{
auto const threshold = currentSeq() + 3;
auto const wasm = assembleWat(gatedOnLedgerSqn(threshold));
auto const created = createEscrow(wasm);
// Below the threshold: rejected, and the escrow survives to be tried again.
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();
// At the threshold: approved, and the escrow is gone.
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));
}
// The reserve a contract costs is released with it.
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);
// One increment for the escrow, plus one per 500 bytes of contract
// (`calculateAdditionalReserve`).
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);
}
// Creating a contract-bearing escrow costs the escrowed amount plus the fee, and the
// destination is untouched until it releases.
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));
}
// A condition and a contract are both gates, and the condition is the outer one: 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));
// With the fulfillment, both gates open.
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,148 @@
#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/XRPAmount.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/ModuleBuilder.h>
#include <chrono>
#include <cstdint>
#include <optional>
namespace xrpl::test {
namespace {
// What a contract of a given size costs to submit: ten base fees plus five drops a byte
// (`EscrowCreate::calculateBaseFee`). Paying it exactly keeps a size test failing on the
// size rather than on the fee.
XRPAmount
createFee(TxTest const& env, Bytes const& bytecode)
{
return (env.getOpenLedger().fees().base * 10) +
XRPAmount{static_cast<std::int64_t>(bytecode.size()) * 5};
}
TER
createEscrowWith(TxTest& env, Account const& account, Bytes const& bytecode)
{
using namespace std::chrono_literals;
auto builder = transactions::EscrowCreateBuilder{account, account, STAmount{XRP(1'000)}};
builder.setBytecode(makeSlice(bytecode));
builder.setCancelAfter(
static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count() + 100));
return env.submit(builder, account, createFee(env, bytecode)).ter;
}
// An account rich enough for the owner reserve a large contract demands: one increment per
// 500 bytes (`calculateAdditionalReserve`), so 200 KB costs 401 increments — 802 XRP at the
// default 2 XRP increment.
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 ever
// reaches the engine. These pin that boundary, which is the one limit standing between an
// attacker-chosen module size and the *unmetered* work of compiling it: nothing charges for
// compilation, so size is the only thing bounding it.
// The sweep's own footing: the builders have to produce something the engine accepts, or
// every "too big" result below would be indistinguishable from "malformed".
TEST(BytecodeSize, TheBuildersProduceAModuleTheEngineAccepts)
{
TxTest env;
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)
{
TxTest env;
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)
{
TxTest env;
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 size that counts is the module's, not the code's: a module made large by a data
// segment is screened the same way, so the limit cannot be walked around by moving the
// bulk out of the code section.
TEST(BytecodeSize, ADataSegmentCountsTowardTheLimit)
{
TxTest env;
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. Raising it has to actually admit the module it
// now covers — otherwise some *other* cap is really in charge and the setting is decorative.
TEST(BytecodeSize, RaisingTheLimitAdmitsALargerModule)
{
auto fees = TestServiceRegistry::defaultFees();
fees.bytecodeSizeLimit = kMaxBytecodeSizeLimit;
TxTest env{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);
}
// `bytecodeSizeLimit` is the **only** thing bounding how much there is to compile.
//
// wasmparser defines `MAX_WASM_FUNCTION_SIZE` = 128 KiB, so it would be reasonable to
// assume a single function body is separately capped and that the size limit is a
// belt-and-braces second line. It is not: nothing on this path enforces that constant, and
// a lone body of a million instructions is accepted. Since compilation is unmetered — no
// gas is charged for it, and it happens once to screen the `EscrowCreate` and again on
// every `EscrowFinish` — the size limit is load-bearing on its own.
//
// If this ever starts failing, a second cap has appeared: good news, but the sweep above
// stops being the whole story and this comment is wrong.
TEST(BytecodeSize, ASingleFunctionBodyIsNotSeparatelyCapped)
{
TxTest const env;
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,141 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/Fees.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 <gtest/gtest.h>
#include <helpers/Account.h>
#include <helpers/TxTest.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <cstdint>
#include <optional>
#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
{
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(
static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count() + 1'000));
auto const fee = (env.getOpenLedger().fees().base * 10) +
XRPAmount{static_cast<std::int64_t>(wasm.size()) * 5};
ASSERT_EQ(env.submit(builder, alice, fee).ter, tesSUCCESS);
env.close();
}
// Submits the finish and closes, because metadata only exists for a closed ledger.
// Returns the result alongside its metadata.
struct Finished
{
TER ter;
std::optional<TxMeta> meta;
};
[[nodiscard]] Finished
finish()
{
auto builder = transactions::EscrowFinishBuilder{alice, alice, escrowSeq};
builder.setGas(kAllowance);
auto const& fees = env.getOpenLedger().fees();
auto const gasFee = XRPAmount{
static_cast<std::int64_t>(
(std::uint64_t{kAllowance} * fees.gasPrice) / microDropsPerDrop) +
1};
auto const result = env.submit(builder, alice, fees.base + gasFee);
env.close();
return Finished{.ter = result.ter, .meta = env.getMetadata(result.tx->getTransactionID())};
}
};
// 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,220 @@
#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 one
// reports. The distinctions matter to a client: a rejection, a fault, and running out of gas
// are three different outcomes, and only one of them carries a return code.
struct FinishFailures : testing::Test
{
TxTest env;
Account const alice{"alice"};
Account const carol{"carol"};
void
SetUp() override
{
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
}
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(
static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count()) + 1'000);
EXPECT_EQ(env.submit(builder, alice, escrowCreateFee(env, wasm)).ter, tesSUCCESS);
env.close();
return seq;
}
// An escrow with no contract at all, for the "gas without bytecode" case.
std::uint32_t
createPlainEscrow()
{
auto const seq = env.getAccountRoot(alice).getSequence();
auto const now = static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count());
auto builder = transactions::EscrowCreateBuilder{alice, carol, STAmount{XRP(500)}};
// A contract-free escrow needs a `FinishAfter` or a condition — `CancelAfter` alone
// is `temMALFORMED`, because nothing would ever release it.
builder.setFinishAfter(now + 1);
builder.setCancelAfter(now + 1'000);
EXPECT_EQ(env.submit(builder, alice, XRPAmount{100'000}).ter, tesSUCCESS);
env.close();
env.close(); // past the finish time
return seq;
}
struct Finished
{
TER ter;
std::optional<TxMeta> meta;
};
[[nodiscard]] Finished
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);
auto const result = env.submit(builder, carol, fee);
env.close();
return Finished{.ter = result.ter, .meta = env.getMetadata(result.tx->getTransactionID())};
}
};
TEST_F(FinishFailures, FinishIsRefusedWhileSmartEscrowIsDisabled)
{
TxTest disabled{allFeatures() - featureSmartEscrow};
disabled.createAccount(alice, XRP(5'000));
disabled.createAccount(carol, XRP(5'000));
auto builder = transactions::EscrowFinishBuilder{carol, alice, 1};
builder.setGas(4);
EXPECT_EQ(disabled.submit(builder, carol, XRPAmount{100'000}).ter, temDISABLED);
}
// The allowance is bounded by the voted gas limit, so a contract cannot buy unbounded
// execution by simply 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. The old Beast test had to hand-insert an escrow
// ledger entry to reach this, because jtx cannot change its config mid-test; here the escrow
// is created normally and the limit drops afterwards.
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 for 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);
}
// Gas on an escrow that has no contract: the transaction is about a thing that isn't there.
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);
}
// Running out of gas: essentially the whole allowance is consumed, and there is no return
// code because the contract never reached a return.
//
// "Essentially" because the meter stops at the last instruction it could afford, which for
// this loop leaves a few units unspent — the reported figure is what was really burned, not
// the allowance rounded up. The band is what distinguishes 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: it reports the gas actually burned — less than the
// whole allowance, which is what distinguishes it from running out — 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,108 @@
#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 {
// The gas allowance is paid for in drops up front, and the conversion is the arithmetic most
// likely to go wrong: 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 a big allowance costs a big fee.
// Close to the default gas limit of 1'000'000, so the product is as large as the transactor
// will ever be asked to compute.
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
{
env.createAccount(alice, XRP(5'000));
env.createAccount(carol, XRP(5'000));
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(
static_cast<std::uint32_t>(env.getCloseTime().time_since_epoch().count()) + 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;
}
};
// The fee owed dwarfs the allowance's own magnitude, so a token payment cannot cover it. If
// the product ever wrapped, this is the test that would notice: 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);
}
// And the exact fee is sufficient — otherwise the two refusals above would prove nothing,
// since any fee at all might be being rejected.
TEST_F(GasFees, TheExactFeeIsAccepted)
{
EXPECT_EQ(finishPaying(escrowFinishFee(env, kBigAllowance)), tesSUCCESS);
}
// Only what the contract actually burned is charged against the allowance — 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.submit(builder, carol, escrowFinishFee(env, kBigAllowance));
ASSERT_EQ(result.ter, tesSUCCESS);
env.close();
auto const meta = env.getMetadata(result.tx->getTransactionID());
ASSERT_TRUE(meta.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
auto const obj = meta->getAsObject();
ASSERT_TRUE(obj.isFieldPresent(sfGasUsed));
EXPECT_LT(obj.getFieldU32(sfGasUsed), kBigAllowance);
EXPECT_EQ(obj.getFieldI32(sfVMReturnCode), 5);
}
} // namespace
} // namespace xrpl::test