From 6015d5663256e596abf6bd50972c79ad2aa47af7 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 10 Sep 2026 15:33:42 -0400 Subject: [PATCH] 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`. --- include/xrpl/ledger/helpers/EscrowHelpers.h | 17 + include/xrpl/protocol/Protocol.h | 5 + .../xrpl/tx/transactors/escrow/EscrowCreate.h | 7 +- .../tx/transactors/escrow/EscrowCancel.cpp | 6 +- .../tx/transactors/escrow/EscrowCreate.cpp | 68 +- .../tx/transactors/escrow/EscrowFinish.cpp | 142 +++- src/test/app/EscrowSmart_test.cpp | 704 ++++++++++++++++++ src/test/app/Escrow_test.cpp | 4 +- src/test/jtx/escrow.h | 75 ++ 9 files changed, 994 insertions(+), 34 deletions(-) create mode 100644 src/test/app/EscrowSmart_test.cpp diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 062443cd92..6615908b4a 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -25,6 +25,8 @@ #include #include +#include + namespace xrpl { template @@ -272,4 +274,19 @@ escrowUnlockApplyHelper( 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 +int32_t +calculateAdditionalReserve(T const& finishFunction) +{ + if (!finishFunction) + return 1; + return 1 + (finishFunction->size() / 500); +} + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea456..b677275efc 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -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. */ diff --git a/include/xrpl/tx/transactors/escrow/EscrowCreate.h b/include/xrpl/tx/transactors/escrow/EscrowCreate.h index b8e22aa24e..ea14986288 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowCreate.h +++ b/include/xrpl/tx/transactors/escrow/EscrowCreate.h @@ -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); diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp index 32b56b909f..9128d0767f 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp @@ -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); diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp index 589ad70230..282732a68a 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -129,6 +131,19 @@ escrowCreatePreflightHelper(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; diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 59bec8ca4c..67176d7789 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,8 @@ #include #include +#include +#include #include #include @@ -83,7 +86,29 @@ EscrowFinish::preflight(PreflightContext const& ctx) // If you specify a condition, then you must also specify // a fulfillment. if (static_cast(cb) != static_cast(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 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( - [&](T const&) { - return escrowFinishPreclaimHelper(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( + [&](T const&) { + return escrowFinishPreclaimHelper(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); diff --git a/src/test/app/EscrowSmart_test.cpp b/src/test/app/EscrowSmart_test.cpp new file mode 100644 index 0000000000..0b919022fd --- /dev/null +++ b/src/test/app/EscrowSmart_test.cpp @@ -0,0 +1,704 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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 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 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 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 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 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 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(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 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 diff --git a/src/test/app/Escrow_test.cpp b/src/test/app/Escrow_test.cpp index 8a0d651004..510c4bdc6d 100644 --- a/src/test/app/Escrow_test.cpp +++ b/src/test/app/Escrow_test.cpp @@ -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); } }; diff --git a/src/test/jtx/escrow.h b/src/test/jtx/escrow.h index 68737c2e35..cbd8f81b90 100644 --- a/src/test/jtx/escrow.h +++ b/src/test/jtx/escrow.h @@ -2,8 +2,11 @@ #include #include +#include #include +#include +#include #include #include #include @@ -11,7 +14,10 @@ #include #include +#include #include +#include +#include /** * Escrow operations. @@ -86,4 +92,73 @@ auto const kCondition = JTxFieldWrapper(sfCondition); auto const kFulfillment = JTxFieldWrapper(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 + explicit Bytecode(std::array 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 + explicit Data(std::array 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