feat: Handle Smart Escrow's Bytecode, Data and Gas fields

Add the transaction handling for `EscrowCreate.Bytecode`/`Data` and
`EscrowFinish.Gas`, gated on `featureSmartEscrow`. The field and result-code
plumbing landed in #8157.

EscrowCreate:
* `Bytecode` joins `FinishAfter` and `Condition` as a way to say how an escrow
  completes, but always needs a `CancelAfter`: nothing else can free the funds
  if the contract never accepts.
* `Data` requires `Bytecode` and is capped at `kMaxWasmDataLength`.
* Bytecode is capped at the voted `BytecodeSizeLimit`, and refused outright
  when fee voting has zeroed `GasLimit` or `BytecodeSizeLimit`.
* The fee is ten base fees plus five drops per byte.
* The owner reserve is one increment per 500 bytes beyond the first 500, on
  top of the increment every escrow costs. EscrowCancel and EscrowFinish
  refund what was taken.

EscrowFinish:
* `Gas` is bounded by the voted `GasLimit` and costs `GasPrice` micro-drops
  each.
* `Gas` and the escrow's `Bytecode` must both be present or both absent
  (tefBYTECODE_NOT_INCLUDED / tefNO_BYTECODE).
* The destination and deposit-preauth checks move ahead of the condition
  check, so a contract never runs against a destination that cannot receive.

The WASM engine is not in this build, so nothing runs the bytecode: creating,
funding and cancelling a Smart Escrow works, and finishing one reports
tecFAILED_PROCESSING at the TODO where the contract would run. The gas and
return-code metadata, tecBYTECODE_REJECTED, and the `Data` a rejected contract
leaves behind all arrive with the engine. Reachable only under
`featureSmartEscrow`, which is `Supported::No`.
This commit is contained in:
Mayukha Vadari
2026-09-10 15:33:42 -04:00
parent 4cca58490e
commit 6015d56632
9 changed files with 994 additions and 34 deletions

View File

@@ -25,6 +25,8 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstdint>
namespace xrpl {
template <ValidIssueType T>
@@ -272,4 +274,19 @@ escrowUnlockApplyHelper<MPTIssue>(
journal);
}
/**
* Owner count an escrow costs.
*
* An escrow carrying bytecode costs one increment per 500 bytes beyond the
* first 500, on top of the single increment every escrow costs.
*/
template <class T>
int32_t
calculateAdditionalReserve(T const& finishFunction)
{
if (!finishFunction)
return 1;
return 1 + (finishFunction->size() / 500);
}
} // namespace xrpl

View File

@@ -376,6 +376,11 @@ constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono:
*/
constexpr std::uint8_t kMaxAssetCheckDepth = 5;
/**
* Maximum length of a Data field in Escrow object that can be updated by WASM code.
*/
constexpr std::size_t kMaxWasmDataLength = 1 * 1024; // 1KB
/**
* A ledger index.
*/

View File

@@ -20,11 +20,14 @@ public:
{
}
static bool
checkExtraFeatures(PreflightContext const& ctx);
static TxConsequences
makeTxConsequences(PreflightContext const& ctx);
static bool
checkExtraFeatures(PreflightContext const& ctx);
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static NotTEC
preflight(PreflightContext const& ctx);

View File

@@ -169,11 +169,13 @@ EscrowCancel::doApply()
auto const sle = ctx_.view().peek(keylet::account(account));
STAmount const amount = slep->getFieldAmount(sfAmount);
auto const reserveToSubtract = calculateAdditionalReserve((*slep)[~sfBytecode]);
// The return can re-create a holding the owner deleted while the escrow
// was pending; the removed escrow must not be counted against its reserve.
bool const recycleReserve = ctx_.view().rules().enabled(fixCleanup3_4_0);
if (recycleReserve)
decreaseOwnerCountForObject(ctx_.view(), sle, slep, 1, ctx_.journal);
decreaseOwnerCountForObject(ctx_.view(), sle, slep, reserveToSubtract, ctx_.journal);
// Transfer amount back to the owner
if (isXRP(amount))
@@ -219,7 +221,7 @@ EscrowCancel::doApply()
}
if (!recycleReserve)
decreaseOwnerCountForObject(ctx_.view(), sle, slep, 1, ctx_.journal);
decreaseOwnerCountForObject(ctx_.view(), sle, slep, reserveToSubtract, ctx_.journal);
// Remove escrow from ledger
ctx_.view().erase(slep);

View File

@@ -6,9 +6,11 @@
#include <xrpl/conditions/Condition.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/EscrowHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
@@ -129,6 +131,19 @@ escrowCreatePreflightHelper<MPTIssue>(PreflightContext const& ctx)
return tesSUCCESS;
}
XRPAmount
EscrowCreate::calculateBaseFee(ReadView const& view, STTx const& tx)
{
XRPAmount txnFees{Transactor::calculateBaseFee(view, tx)};
if (tx.isFieldPresent(sfBytecode))
{
// 10 base fees for the transaction (1 is in
// `Transactor::calculateBaseFee`), plus 5 drops per byte
txnFees += 9 * view.fees().base + 5 * tx[sfBytecode].size();
}
return txnFees;
}
NotTEC
EscrowCreate::preflight(PreflightContext const& ctx)
{
@@ -160,12 +175,19 @@ EscrowCreate::preflight(PreflightContext const& ctx)
ctx.tx[sfCancelAfter] <= ctx.tx[sfFinishAfter])
return temBAD_EXPIRATION;
if (ctx.tx.isFieldPresent(sfBytecode) && !ctx.tx.isFieldPresent(sfCancelAfter))
return temBAD_EXPIRATION;
// In the absence of a FinishAfter, the escrow can be finished
// immediately, which can be confusing. When creating an escrow,
// we want to ensure that either a FinishAfter time is explicitly
// specified or a completion condition is attached.
if (!ctx.tx[~sfFinishAfter] && !ctx.tx[~sfCondition])
if (!ctx.tx[~sfFinishAfter] && !ctx.tx[~sfCondition] && !ctx.tx[~sfBytecode])
{
JLOG(ctx.j.debug()) << "Must have at least one of FinishAfter, "
"Condition, or Bytecode.";
return temMALFORMED;
}
if (auto const cb = ctx.tx[~sfCondition])
{
@@ -181,6 +203,41 @@ EscrowCreate::preflight(PreflightContext const& ctx)
}
}
if (ctx.tx.isFieldPresent(sfData))
{
if (!ctx.tx.isFieldPresent(sfBytecode))
{
JLOG(ctx.j.debug()) << "EscrowCreate with Data requires Bytecode";
return temMALFORMED;
}
auto const data = ctx.tx.getFieldVL(sfData);
if (data.size() > kMaxWasmDataLength)
{
JLOG(ctx.j.debug()) << "EscrowCreate.Data bad size " << data.size();
return temMALFORMED;
}
}
if (ctx.tx.isFieldPresent(sfBytecode))
{
auto const fees(ctx.registry.get().getFees());
if (fees.bytecodeSizeLimit == 0 || fees.gasLimit == 0)
{
JLOG(ctx.j.debug()) << "WASM runtime deactivated by fee voting";
return temTEMP_DISABLED;
}
auto const code = ctx.tx.getFieldVL(sfBytecode);
if (code.empty() || code.size() > fees.bytecodeSizeLimit)
{
JLOG(ctx.j.debug()) << "EscrowCreate.Bytecode bad size " << code.size();
return temMALFORMED;
}
// TODO(SmartEscrow): screen the module itself here - that it compiles,
// and that it imports and exports what the engine can serve - once the
// WASM engine lands. Until then any blob of the right size is taken.
}
return tesSUCCESS;
}
@@ -439,6 +496,7 @@ EscrowCreate::doApply()
// Check reserve and funds availability
STAmount const amount{ctx_.tx[sfAmount]};
auto const reserveToAdd = calculateAdditionalReserve(ctx_.tx[~sfBytecode]);
auto const balance = sle->getFieldAmount(sfBalance).xrp();
// First check: whoever is on the hook for the new owner increment
@@ -446,8 +504,8 @@ EscrowCreate::doApply()
// validates the sponsor's reserve + remaining credit. When
// unsponsored this hits the source branch and validates the
// source's pre-lock balance against base + (currentOC+1)*increment.
if (auto const ret =
checkReserve(ctx_.getApplyViewContext(), sle, balance, {.ownerCountDelta = 1}, j_);
if (auto const ret = checkReserve(
ctx_.getApplyViewContext(), sle, balance, {.ownerCountDelta = reserveToAdd}, j_);
!isTesSuccess(ret))
return ret;
@@ -491,6 +549,8 @@ EscrowCreate::doApply()
(*slep)[~sfCancelAfter] = ctx_.tx[~sfCancelAfter];
(*slep)[~sfFinishAfter] = ctx_.tx[~sfFinishAfter];
(*slep)[~sfDestinationTag] = ctx_.tx[~sfDestinationTag];
(*slep)[~sfBytecode] = ctx_.tx[~sfBytecode];
(*slep)[~sfData] = ctx_.tx[~sfData];
if (ctx_.view().rules().enabled(fixIncludeKeyletFields))
{
@@ -558,7 +618,7 @@ EscrowCreate::doApply()
}
// increment owner count
increaseOwnerCount(ctx_.getApplyViewContext(), sle, 1, ctx_.journal);
increaseOwnerCount(ctx_.getApplyViewContext(), sle, reserveToAdd, ctx_.journal);
addSponsorToLedgerEntry(ctx_.getApplyViewContext(), slep);
ctx_.view().update(sle);
return tesSUCCESS;

View File

@@ -18,6 +18,7 @@
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Concepts.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
@@ -31,6 +32,8 @@
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
#include <cstdint>
#include <optional>
#include <system_error>
#include <variant>
@@ -83,7 +86,29 @@ EscrowFinish::preflight(PreflightContext const& ctx)
// If you specify a condition, then you must also specify
// a fulfillment.
if (static_cast<bool>(cb) != static_cast<bool>(fb))
{
JLOG(ctx.j.debug()) << "Condition != Fulfillment";
return temMALFORMED;
}
if (auto const allowance = ctx.tx[~sfGas]; allowance)
{
auto const fees(ctx.registry.get().getFees());
if (fees.gasLimit == 0)
{
JLOG(ctx.j.debug()) << "WASM runtime deactivated by fee voting";
return temTEMP_DISABLED;
}
if (*allowance == 0)
{
return temBAD_LIMIT;
}
if (*allowance > fees.gasLimit)
{
JLOG(ctx.j.debug()) << "Gas too large: " << *allowance;
return temBAD_LIMIT;
}
}
return tesSUCCESS;
}
@@ -132,7 +157,15 @@ EscrowFinish::calculateBaseFee(ReadView const& view, STTx const& tx)
{
extraFee += view.fees().base * (32 + (fb->size() / 16));
}
if (std::optional<uint64_t> const allowance = tx[~sfGas]; allowance)
{
// The extra fee is the allowance in drops, rounded up to the nearest
// whole drop.
// Integer math rounds down by default, so we add 1 to round up.
uint64_t const allowanceFee =
(((*allowance) * view.fees().gasPrice) / microDropsPerDrop) + 1;
extraFee += allowanceFee;
}
return Transactor::calculateBaseFee(view, tx) + extraFee;
}
@@ -208,26 +241,51 @@ EscrowFinish::preclaim(PreclaimContext const& ctx)
return err;
}
if (ctx.view.rules().enabled(featureTokenEscrow))
if (ctx.view.rules().enabled(featureTokenEscrow) ||
ctx.view.rules().enabled(featureSmartEscrow))
{
// this check is done in doApply before this amendment is enabled
auto const seqProxy = SeqProxy::rawSequence(ctx.tx[sfOfferSequence]);
auto const k = keylet::escrow(ctx.tx[sfOwner], seqProxy);
auto const slep = ctx.view.read(k);
if (!slep)
return tecNO_TARGET;
AccountID const dest = (*slep)[sfDestination];
STAmount const amount = (*slep)[sfAmount];
if (!isXRP(amount))
if (ctx.view.rules().enabled(featureSmartEscrow))
{
if (auto const ret = std::visit(
[&]<typename T>(T const&) {
return escrowFinishPreclaimHelper<T>(ctx, dest, amount);
},
amount.asset().value());
!isTesSuccess(ret))
return ret;
if (slep->isFieldPresent(sfBytecode))
{
if (!ctx.tx.isFieldPresent(sfGas))
{
JLOG(ctx.j.debug()) << "Bytecode requires Gas";
return tefBYTECODE_NOT_INCLUDED;
}
}
else
{
if (ctx.tx.isFieldPresent(sfGas))
{
JLOG(ctx.j.debug()) << "Bytecode not present, "
"Gas present";
return tefNO_BYTECODE;
}
}
}
if (ctx.view.rules().enabled(featureTokenEscrow))
{
AccountID const dest = (*slep)[sfDestination];
STAmount const amount = (*slep)[sfAmount];
if (!isXRP(amount))
{
if (auto const ret = std::visit(
[&]<typename T>(T const&) {
return escrowFinishPreclaimHelper<T>(ctx, dest, amount);
},
amount.asset().value());
!isTesSuccess(ret))
return ret;
}
}
}
return tesSUCCESS;
@@ -241,7 +299,8 @@ EscrowFinish::doApply()
auto const slep = ctx_.view().peek(k);
if (!slep)
{
if (ctx_.view().rules().enabled(featureTokenEscrow))
if (ctx_.view().rules().enabled(featureTokenEscrow) ||
ctx_.view().rules().enabled(featureSmartEscrow))
return tecINTERNAL; // LCOV_EXCL_LINE
return tecNO_TARGET;
@@ -259,6 +318,20 @@ EscrowFinish::doApply()
if ((*slep)[~sfCancelAfter] && after(now, (*slep)[sfCancelAfter]))
return tecNO_PERMISSION;
AccountID const destID = (*slep)[sfDestination];
auto const sled = ctx_.view().peek(keylet::account(destID));
if (ctx_.view().rules().enabled(featureSmartEscrow))
{
// NOTE: Escrow payments cannot be used to fund accounts.
if (!sled)
return tecNO_DST;
if (auto err =
verifyDepositPreauth(ctx_.tx, ctx_.view(), accountID_, destID, sled, ctx_.journal);
!isTesSuccess(err))
return err;
}
// Check cryptocondition fulfillment
{
auto const id = ctx_.tx.getTransactionID();
@@ -312,16 +385,33 @@ EscrowFinish::doApply()
return tecCRYPTOCONDITION_ERROR;
}
// NOTE: Escrow payments cannot be used to fund accounts.
AccountID const destID = (*slep)[sfDestination];
auto const sled = ctx_.view().peek(keylet::account(destID));
if (!sled)
return tecNO_DST;
if (!ctx_.view().rules().enabled(featureSmartEscrow))
{
// NOTE: Escrow payments cannot be used to fund accounts.
if (!sled)
return tecNO_DST;
if (auto err =
verifyDepositPreauth(ctx_.tx, ctx_.view(), accountID_, destID, sled, ctx_.journal);
!isTesSuccess(err))
return err;
if (auto err =
verifyDepositPreauth(ctx_.tx, ctx_.view(), accountID_, destID, sled, ctx_.journal);
!isTesSuccess(err))
return err;
}
// Execute custom release function
if ((*slep)[~sfBytecode])
{
// TODO(SmartEscrow): run the escrow's contract here once the WASM
// engine lands. That change also brings with it the reporting the run
// produces - the gas it consumed and the code it returned, in the
// transaction metadata - and the tecBYTECODE_REJECTED result that lets
// a rejected escrow survive with the data the contract wrote.
//
// Until then an escrow carrying bytecode can be created, funded and
// cancelled, but not finished. Reachable only under featureSmartEscrow,
// which is Supported::No.
JLOG(j_.debug()) << "EscrowFinish: no WASM engine to run the escrow's bytecode";
return tecFAILED_PROCESSING;
}
AccountID const account = (*slep)[sfAccount];
@@ -349,13 +439,15 @@ EscrowFinish::doApply()
}
}
auto const reserveToSubtract = calculateAdditionalReserve((*slep)[~sfBytecode]);
// Delivery can auto-create the destination's holding; the removed escrow
// must not be counted against its reserve. The two share a reserve payer
// for a self-escrow, or when one sponsor covers both.
bool const recycleReserve =
ctx_.view().rules().enabled(featureSponsor) || ctx_.view().rules().enabled(fixCleanup3_4_0);
if (recycleReserve)
decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, ctx_.journal);
decreaseOwnerCountForObject(ctx_.view(), account, slep, reserveToSubtract, ctx_.journal);
STAmount const amount = slep->getFieldAmount(sfAmount);
// Transfer amount to destination
@@ -407,7 +499,7 @@ EscrowFinish::doApply()
ctx_.view().update(sled);
if (!recycleReserve)
decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, ctx_.journal);
decreaseOwnerCountForObject(ctx_.view(), account, slep, reserveToSubtract, ctx_.journal);
// Remove escrow from ledger
ctx_.view().erase(slep);

View File

@@ -0,0 +1,704 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/balance.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/escrow.h>
#include <test/jtx/fee.h>
#include <test/jtx/noop.h>
#include <test/jtx/ter.h>
#include <xrpld/core/Config.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/StartUpType.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
namespace xrpl::test {
/**
* Smart Escrow's `Bytecode`, `Data` and `Gas` fields.
*
* The WASM engine that runs an escrow's bytecode is not in this build, so the
* `Bytecode` field is opaque here: `EscrowCreate` weighs its size and nothing
* reads it, and `EscrowFinish` has nothing to run it with. Everything around
* the run is testable, and is tested below - the preflight and preclaim rules,
* the fee the bytecode costs, the owner reserve it occupies, and the refund of
* that reserve.
*
* What waits on the engine: a successful finish, tecBYTECODE_REJECTED, the
* gas and return code the run reports in the transaction metadata, and the
* `Data` a rejected contract leaves behind.
*/
struct EscrowSmart_test : public beast::unit_test::Suite
{
// A blob of `bytes` bytes, as hex. Opens with the WASM preamble - "\0asm"
// and version 1 - so a reader can see what the field is meant to carry,
// but nothing in this build parses it.
static std::string
bytecodeOfSize(std::size_t bytes)
{
static std::string const preamble = "0061736D01000000";
std::string hex = preamble;
hex.append(bytes * 2 - preamble.size(), 'A');
return hex;
}
// What EscrowCreate charges for `bytecodeHex`: ten base fees, plus five
// drops per byte.
static XRPAmount
createFeeFor(jtx::Env const& env, std::string const& bytecodeHex)
{
return env.current()->fees().base * 10 + bytecodeHex.size() / 2 * 5;
}
// What EscrowFinish charges for `gas`.
static XRPAmount
finishFeeFor(jtx::Env const& env, std::uint64_t gas)
{
return env.current()->fees().base +
(gas * env.current()->fees().gasPrice) / microDropsPerDrop + 1;
}
void
testCreatePreflight(FeatureBitset features)
{
testcase("EscrowCreate preflight");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
auto const bytecode = bytecodeOfSize(64);
{
// featureSmartEscrow disabled
Env env(*this, features - featureSmartEscrow);
env.fund(XRP(5000), alice, carol);
XRPAmount const txnFees = env.current()->fees().base + 1000;
auto const escrowCreate = escrow::create(alice, carol, XRP(1000));
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temDISABLED));
env.close();
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
escrow::Data("00112233"),
Fee(txnFees),
Ter(temDISABLED));
env.close();
}
{
// Bytecode > max length
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->fees.bytecodeSizeLimit = 10; // 10 bytes
return cfg;
}),
features);
XRPAmount const txnFees = env.current()->fees().base + 1000;
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
// 11-byte string
std::string const longBytecode = "00112233445566778899AA";
env(escrowCreate,
escrow::Bytecode(longBytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temMALFORMED));
env.close();
}
{
// gas limit set to 0
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
// WASM runtime disabled
cfg->fees.gasLimit = 0;
return cfg;
}),
features);
XRPAmount const txnFees = env.current()->fees().base + 1000;
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temTEMP_DISABLED));
env.close();
}
{
// size limit set to 0
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->fees.bytecodeSizeLimit = 0; // WASM upload disabled
return cfg;
}),
features);
XRPAmount const txnFees = env.current()->fees().base + 1000;
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
// 1-byte string
env(escrowCreate,
escrow::Bytecode("AA"),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temTEMP_DISABLED));
env.close();
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temTEMP_DISABLED));
env.close();
}
{
// Data without Bytecode
Env env(*this, features);
XRPAmount const txnFees = env.current()->fees().base + 100000;
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
std::string const data(4, 'A');
env(escrowCreate,
escrow::Data(data),
escrow::kFinishTime(env.now() + 100s),
Fee(txnFees),
Ter(temMALFORMED));
env.close();
}
{
// Data > max length
Env env(*this, features);
XRPAmount const txnFees = env.current()->fees().base + 100000;
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
// string of length (kMaxWasmDataLength + 1) * 2
std::string const longData((kMaxWasmDataLength + 1) * 2, 'B');
env(escrowCreate,
escrow::Data(longData),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(txnFees),
Ter(temMALFORMED));
env.close();
}
// Bytecode joins FinishAfter and Condition as a way to say how the
// escrow completes, but it always needs a CancelAfter: nothing else
// can free the funds if the contract never accepts.
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->startUp = StartUpType::Fresh;
return cfg;
}),
features);
env.fund(XRP(5000), alice, carol);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
XRPAmount const txnFees = createFeeFor(env, bytecode);
// Success situations
{
// Bytecode + CancelAfter
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 20s),
Fee(txnFees));
env.close();
}
{
// Bytecode + Condition + CancelAfter
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 30s),
escrow::kCondition(escrow::kCb1),
Fee(txnFees));
env.close();
}
{
// Bytecode + FinishAfter + CancelAfter
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 40s),
escrow::kFinishTime(env.now() + 2s),
Fee(txnFees));
env.close();
}
{
// Bytecode + Data + CancelAfter
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::Data("00112233"),
escrow::kCancelTime(env.now() + 50s),
Fee(txnFees));
env.close();
}
// Failure situations (i.e. all other combinations)
{
// only Bytecode
env(escrowCreate, escrow::Bytecode(bytecode), Fee(txnFees), Ter(temBAD_EXPIRATION));
env.close();
}
{
// Bytecode + FinishAfter
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kFinishTime(env.now() + 2s),
Fee(txnFees),
Ter(temBAD_EXPIRATION));
env.close();
}
{
// Bytecode + Condition
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCondition(escrow::kCb1),
Fee(txnFees),
Ter(temBAD_EXPIRATION));
env.close();
}
{
// Bytecode 0 length
env(escrowCreate,
escrow::Bytecode(""),
escrow::kCancelTime(env.now() + 60s),
Fee(txnFees),
Ter(temMALFORMED));
env.close();
}
{
// Not enough fees
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 70s),
Fee(txnFees - 1),
Ter(telINSUF_FEE_P));
env.close();
}
}
void
testCreateFeeAndReserve(FeatureBitset features)
{
testcase("EscrowCreate fee and reserve");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
{
// The fee scales with the bytecode: ten base fees plus five drops
// per byte, and a drop short of it is refused.
Env env(*this, features);
env.fund(XRP(5000), alice, carol);
env.close();
auto const bytecode = bytecodeOfSize(1'000);
auto const escrowCreate = escrow::create(alice, carol, XRP(500));
auto const fee = createFeeFor(env, bytecode);
BEAST_EXPECT(fee == env.current()->fees().base * 10 + 5'000);
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(fee - 1),
Ter(telINSUF_FEE_P));
env.close();
auto const seq = env.seq(alice);
env(escrowCreate,
escrow::Bytecode(bytecode),
escrow::Data("00112233"),
escrow::kCancelTime(env.now() + 100s),
Fee(fee));
env.close();
// The ledger entry carries both fields.
auto const sle = env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)));
if (BEAST_EXPECT(sle))
{
BEAST_EXPECT(sle->isFieldPresent(sfBytecode));
BEAST_EXPECT(strHex(sle->getFieldVL(sfBytecode)) == bytecode);
BEAST_EXPECT(strHex(sle->getFieldVL(sfData)) == "00112233");
}
}
{
// The reserve is one owner increment per 500 bytes beyond the
// first 500, on top of the increment every escrow costs.
Env env(*this, features);
env.fund(XRP(5000), alice, carol);
env.close();
std::uint32_t expectedOwnerCount = 0;
for (auto const size :
{std::size_t{64},
std::size_t{500},
std::size_t{501},
std::size_t{1'000},
std::size_t{1'001}})
{
auto const bytecode = bytecodeOfSize(size);
env(escrow::create(alice, carol, XRP(100)),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 1000s),
Fee(createFeeFor(env, bytecode)));
env.close();
expectedOwnerCount += 1 + size / 500;
BEAST_EXPECTS(
env.ownerCount(alice) == expectedOwnerCount,
std::to_string(size) + " bytes: " + std::to_string(env.ownerCount(alice)));
}
}
{
// A bytecode escrow the owner cannot reserve is refused, where the
// same escrow without bytecode would have been accepted.
Env env(*this, features);
// Base 200 XRP + 50 XRP per owner object, from the unit-test
// config. Enough for one owner object, not for three.
env.fund(XRP(300), alice, carol);
env.close();
auto const bytecode = bytecodeOfSize(1'000);
env(escrow::create(alice, carol, XRP(20)),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(createFeeFor(env, bytecode)),
Ter(tecINSUFFICIENT_RESERVE));
env.close();
BEAST_EXPECT(env.ownerCount(alice) == 0);
// The same escrow without bytecode costs one increment and fits.
env(escrow::create(alice, carol, XRP(20)),
escrow::kFinishTime(env.now() + 10s),
escrow::kCancelTime(env.now() + 100s));
env.close();
BEAST_EXPECT(env.ownerCount(alice) == 1);
}
}
void
testFinishPreflight(FeatureBitset features)
{
testcase("EscrowFinish preflight");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
{
// featureSmartEscrow disabled
Env env(*this, features - featureSmartEscrow);
env.fund(XRP(5000), alice, carol);
env(escrow::finish(carol, alice, 1),
Fee(env.current()->fees().base + 1000),
escrow::Gas(4),
Ter(temDISABLED));
env.close();
}
{
// Gas > gas limit
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->fees.gasLimit = 1'000; // in gas
return cfg;
}),
features);
env.fund(XRP(5000), alice, carol);
// Run past the flag ledger so that a Fee change vote occurs and
// updates FeeSettings. (It also activates all supported
// amendments.)
for (auto i = env.current()->seq(); i <= 257; ++i)
env.close();
auto const gas = 1'001;
env(escrow::finish(carol, alice, 1),
Fee(finishFeeFor(env, gas)),
escrow::Gas(gas),
Ter(temBAD_LIMIT));
}
{
// Gas of 0
Env env(*this, features);
env.fund(XRP(5000), alice, carol);
env.close();
env(escrow::finish(carol, alice, 1),
Fee(env.current()->fees().base + 1000),
escrow::Gas(0),
Ter(temBAD_LIMIT));
}
{
// WASM compute disabled after the escrow was created.
//
// The Escrow ledger object is added by hand, bypassing normal
// transaction processing: the config cannot be changed mid-test,
// and a Smart Escrow cannot be created while the gas limit is 0.
Env env{*this, envconfig([](std::unique_ptr<Config> cfg) {
cfg->fees.gasLimit = 0;
return cfg;
})};
env.fund(XRP(1000), alice);
env.close();
auto const seq = env.seq(alice);
auto const keylet = keylet::escrow(alice.id(), SeqProxy::rawSequence(seq));
env(noop(alice)); // to align sequence numbers
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
auto sle = std::make_shared<SLE>(keylet);
sle->setAccountID(sfAccount, alice.id());
sle->setFieldAmount(sfAmount, XRP(100));
sle->setFieldU32(sfCancelAfter, 110);
sle->setAccountID(sfDestination, alice.id());
sle->setFieldVL(sfBytecode, strUnHex(bytecodeOfSize(64)).value());
sle->setFieldU32(sfFlags, 0);
sle->setFieldU64(sfOwnerNode, 0);
uint256 tmp;
BEAST_EXPECT(tmp.parseHex(
"F63D1A452A96C19EFD77901FB37D236C59EAA746771A6"
"85D1BBA57A2238B9401"));
sle->setFieldH256(sfPreviousTxnID, tmp);
sle->setFieldU32(sfPreviousTxnLgrSeq, 4);
sle->setFieldU32(sfSequence, seq);
view.rawInsert(sle);
return true;
});
BEAST_EXPECT(env.le(keylet));
env(escrow::finish(alice, alice, seq),
escrow::Gas(1000),
Fee(env.current()->fees().base + 1000),
Ter(temTEMP_DISABLED));
}
}
void
testFinishBytecodePairing(FeatureBitset features)
{
testcase("EscrowFinish Bytecode and Gas must agree");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
Env env(*this, features);
// Run past the flag ledger so that a Fee change vote occurs and
// updates FeeSettings. (It also activates all supported amendments.)
for (auto i = env.current()->seq(); i <= 257; ++i)
env.close();
env.fund(XRP(5000), alice, carol);
auto const bytecode = bytecodeOfSize(64);
auto const seq = env.seq(alice);
env(escrow::create(alice, carol, XRP(500)),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(createFeeFor(env, bytecode)));
env.close();
{
// Bytecode on the escrow, no Gas on the finish
env(escrow::finish(carol, alice, seq), Ter(tefBYTECODE_NOT_INCLUDED));
}
{
// Gas on the finish, no Bytecode on the escrow
auto const plainSeq = env.seq(alice);
env(escrow::create(alice, carol, XRP(500)),
escrow::kFinishTime(env.now() + 10s),
escrow::kCancelTime(env.now() + 100s));
env.close();
auto const gas = 100;
env(escrow::finish(carol, alice, plainSeq),
Fee(finishFeeFor(env, gas)),
escrow::Gas(gas),
Ter(tefNO_BYTECODE));
}
}
void
testFinishFee(FeatureBitset features)
{
testcase("EscrowFinish fee");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
Env env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->fees.gasPrice = 1'000'000; // 1 drop per gas
return cfg;
}),
features);
// Run past the flag ledger so that a Fee change vote occurs and
// updates FeeSettings. (It also activates all supported amendments.)
for (auto i = env.current()->seq(); i <= 257; ++i)
env.close();
env.fund(XRP(5000), alice, carol);
auto const bytecode = bytecodeOfSize(64);
auto const seq = env.seq(alice);
env(escrow::create(alice, carol, XRP(1000)),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 100s),
Fee(createFeeFor(env, bytecode)));
env.close();
// A large gas allowance costs more than the allowance itself, and does
// not wrap.
auto const gas = 996'433;
auto const fee = finishFeeFor(env, gas);
BEAST_EXPECT(fee.drops() > gas);
// Intentional low value to test overflow handling
env(escrow::finish(carol, alice, seq),
Fee(drops(30)),
escrow::Gas(gas),
Ter(telINSUF_FEE_P));
env(escrow::finish(carol, alice, seq), Fee(fee - 1), escrow::Gas(gas), Ter(telINSUF_FEE_P));
// TODO(SmartEscrow): tesSUCCESS once the WASM engine lands. Until then
// the fee is charged and the run reports that it could not happen.
env(escrow::finish(carol, alice, seq),
Fee(fee),
escrow::Gas(gas),
Ter(tecFAILED_PROCESSING));
}
void
testFinishWithoutEngine(FeatureBitset features)
{
testcase("EscrowFinish has no WASM engine");
using namespace jtx;
using namespace std::chrono;
Account const alice{"alice"};
Account const carol{"carol"};
Env env(*this, features);
env.fund(XRP(5000), alice, carol);
env.close();
auto const bytecode = bytecodeOfSize(1'000);
auto const seq = env.seq(alice);
env(escrow::create(alice, carol, XRP(500)),
escrow::Bytecode(bytecode),
escrow::kCancelTime(env.now() + 20s),
Fee(createFeeFor(env, bytecode)));
env.close();
BEAST_EXPECT(env.ownerCount(alice) == 3);
// TODO(SmartEscrow): the contract decides this once the WASM engine
// lands - tesSUCCESS on accept, tecBYTECODE_REJECTED on reject.
auto const gas = 1'000;
env(escrow::finish(carol, alice, seq),
Fee(finishFeeFor(env, gas)),
escrow::Gas(gas),
Ter(tecFAILED_PROCESSING));
env.close();
// The escrow and its reserve survive.
BEAST_EXPECT(env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
BEAST_EXPECT(env.ownerCount(alice) == 3);
// Cancelling returns the whole reserve, not just one increment.
env.close(30s);
env(escrow::cancel(carol, alice, seq));
env.close();
BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
BEAST_EXPECT(env.ownerCount(alice) == 0);
}
void
testWithFeats(FeatureBitset features)
{
testCreatePreflight(features);
testCreateFeeAndReserve(features);
testFinishPreflight(features);
testFinishBytecodePairing(features);
testFinishFee(features);
testFinishWithoutEngine(features);
}
public:
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{testableAmendments()};
testWithFeats(all);
}
};
BEAST_DEFINE_TESTSUITE(EscrowSmart, app, xrpl);
} // namespace xrpl::test

View File

@@ -1482,7 +1482,7 @@ struct Escrow_test : public beast::unit_test::Suite
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const dillon{"dillon "};
Account const dillon{"dillon"};
Account const zelda{"zelda"};
char const credType[] = "abcde";
@@ -1639,6 +1639,8 @@ public:
FeatureBitset const all{testableAmendments()};
testWithFeats(all);
testWithFeats(all - featureTokenEscrow);
testWithFeats(all - featureSmartEscrow);
testWithFeats(all - featureTokenEscrow - featureSmartEscrow);
testTags(all - fixIncludeKeyletFields);
}
};

View File

@@ -2,8 +2,11 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/JTx.h>
#include <test/jtx/TestHelpers.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Rate.h>
@@ -11,7 +14,10 @@
#include <xrpl/protocol/STAmount.h>
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
/**
* Escrow operations.
@@ -86,4 +92,73 @@ auto const kCondition = JTxFieldWrapper<BlobField>(sfCondition);
auto const kFulfillment = JTxFieldWrapper<BlobField>(sfFulfillment);
struct Bytecode
{
private:
std::string value_;
public:
explicit Bytecode(std::string func) : value_(std::move(func))
{
}
explicit Bytecode(Slice const& func) : value_(strHex(func))
{
}
template <size_t N>
explicit Bytecode(std::array<std::uint8_t, N> const& f) : Bytecode(makeSlice(f))
{
}
void
operator()(Env&, JTx& jt) const
{
jt.jv[sfBytecode.jsonName] = value_;
}
};
struct Data
{
private:
std::string value_;
public:
explicit Data(std::string func) : value_(std::move(func))
{
}
explicit Data(Slice const& func) : value_(strHex(func))
{
}
template <size_t N>
explicit Data(std::array<std::uint8_t, N> const& f) : Data(makeSlice(f))
{
}
void
operator()(Env&, JTx& jt) const
{
jt.jv[sfData.jsonName] = value_;
}
};
struct Gas
{
private:
std::uint32_t value_;
public:
explicit Gas(std::uint32_t const& value) : value_(value)
{
}
void
operator()(Env&, JTx& jt) const
{
jt.jv[sfGas.jsonName] = value_;
}
};
} // namespace xrpl::test::jtx::escrow