Compare commits

...

6 Commits

Author SHA1 Message Date
RichardAH
4efcfaa43e Merge branch 'dev' into contrib-update 2025-10-21 14:19:40 +10:00
tequ
6fa6a96e3a Introduce StartTime in CronSet and improve next execution scheduling (#596) 2025-10-21 14:17:53 +10:00
Alloy Networks
cbd7cd98ff Update CONTRIBUTING.md
Co-authored-by: tequ <git@tequ.dev>
2025-10-21 06:44:17 +03:00
Alloy Networks
913a4da31b Update CONTRIBUTING.md 2025-10-20 19:01:51 +03:00
RichardAH
b0fcd36bcd import_vl_keys logic fix (flap fix) (#588) 2025-10-18 16:27:05 +10:00
RichardAH
1ec31e79c9 Cron (on ledger cronjobs) (#590)
Co-authored-by: tequ <git@tequ.dev>
2025-10-17 18:45:16 +10:00
33 changed files with 1659 additions and 35 deletions

View File

@@ -457,6 +457,7 @@ target_sources (rippled PRIVATE
src/ripple/app/tx/impl/CreateCheck.cpp
src/ripple/app/tx/impl/CreateOffer.cpp
src/ripple/app/tx/impl/CreateTicket.cpp
src/ripple/app/tx/impl/Cron.cpp
src/ripple/app/tx/impl/DeleteAccount.cpp
src/ripple/app/tx/impl/DepositPreauth.cpp
src/ripple/app/tx/impl/Escrow.cpp
@@ -474,6 +475,7 @@ target_sources (rippled PRIVATE
src/ripple/app/tx/impl/Payment.cpp
src/ripple/app/tx/impl/Remit.cpp
src/ripple/app/tx/impl/SetAccount.cpp
src/ripple/app/tx/impl/SetCron.cpp
src/ripple/app/tx/impl/SetHook.cpp
src/ripple/app/tx/impl/SetRemarks.cpp
src/ripple/app/tx/impl/SetRegularKey.cpp
@@ -734,6 +736,7 @@ if (tests)
src/test/app/BaseFee_test.cpp
src/test/app/Check_test.cpp
src/test/app/ClaimReward_test.cpp
src/test/app/Cron_test.cpp
src/test/app/Clawback_test.cpp
src/test/app/CrossingLimits_test.cpp
src/test/app/DeliverMin_test.cpp
@@ -898,6 +901,7 @@ if (tests)
src/test/jtx/impl/amount.cpp
src/test/jtx/impl/balance.cpp
src/test/jtx/impl/check.cpp
src/test/jtx/impl/cron.cpp
src/test/jtx/impl/delivermin.cpp
src/test/jtx/impl/deposit.cpp
src/test/jtx/impl/envconfig.cpp

View File

@@ -176,10 +176,11 @@ existing maintainer without a vote.
## Current Maintainers
* [Richard Holland](https://github.com/RichardAH) (XRPL Labs + XRP Ledger Foundation)
* [Denis Angell](https://github.com/dangell7) (XRPL Labs + XRP Ledger Foundation)
* [Wietse Wind](https://github.com/WietseWind) (XRPL Labs + XRP Ledger Foundation)
* [Richard Holland](https://github.com/RichardAH) (XRPL Labs + INFTF)
* [Denis Angell](https://github.com/dangell7) (XRPL Labs + INFTF)
* [Wietse Wind](https://github.com/WietseWind) (XRPL Labs + INFTF)
* [tequ](https://github.com/tequdev) (Independent + INFTF)
[1]: https://docs.github.com/en/get-started/quickstart/contributing-to-projects
[2]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits
[2]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits

View File

@@ -62,6 +62,9 @@
#define sfEmitGeneration ((2U << 16U) + 46U)
#define sfLockCount ((2U << 16U) + 49U)
#define sfFirstNFTokenSequence ((2U << 16U) + 50U)
#define sfStartTime ((2U << 16U) + 93U)
#define sfRepeatCount ((2U << 16U) + 94U)
#define sfDelaySeconds ((2U << 16U) + 95U)
#define sfXahauActivationLgrSeq ((2U << 16U) + 96U)
#define sfImportSequence ((2U << 16U) + 97U)
#define sfRewardTime ((2U << 16U) + 98U)
@@ -129,6 +132,7 @@
#define sfGovernanceFlags ((5U << 16U) + 99U)
#define sfGovernanceMarks ((5U << 16U) + 98U)
#define sfEmittedTxnID ((5U << 16U) + 97U)
#define sfCron ((5U << 16U) + 95U)
#define sfAmount ((6U << 16U) + 1U)
#define sfBalance ((6U << 16U) + 2U)
#define sfLimitAmount ((6U << 16U) + 3U)
@@ -236,4 +240,4 @@
#define sfActiveValidators ((15U << 16U) + 95U)
#define sfImportVLKeys ((15U << 16U) + 94U)
#define sfHookEmissions ((15U << 16U) + 93U)
#define sfAmounts ((15U << 16U) + 92U)
#define sfAmounts ((15U << 16U) + 92U)

View File

@@ -31,6 +31,8 @@
#define ttURITOKEN_BUY 47
#define ttURITOKEN_CREATE_SELL_OFFER 48
#define ttURITOKEN_CANCEL_SELL_OFFER 49
#define ttCRON 92
#define ttCRON_SET 93
#define ttREMIT 95
#define ttGENESIS_MINT 96
#define ttIMPORT 97
@@ -40,4 +42,4 @@
#define ttFEE 101
#define ttUNL_MODIFY 102
#define ttEMIT_FAILURE 103
#define ttUNL_REPORT 104
#define ttUNL_REPORT 104

View File

@@ -75,6 +75,11 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv)
switch (tt)
{
case ttCRON: {
ADD_TSH(tx.getAccountID(sfOwner), tshWEAK);
break;
}
case ttREMIT: {
if (destAcc)
ADD_TSH(*destAcc, tshSTRONG);

View File

@@ -1477,6 +1477,64 @@ TxQ::accept(Application& app, OpenView& view)
}
}
// Inject cron transactions, if any
if (view.rules().enabled(featureCron))
{
uint32_t currentTime =
view.parentCloseTime().time_since_epoch().count();
uint256 klStart = keylet::cron(0, AccountID(beast::zero)).key;
uint256 const klEnd =
keylet::cron(currentTime + 1, AccountID(beast::zero)).key;
std::set<AccountID> cronAccs;
auto counter = 0;
// include max 128 cron txns in the ledger
while (++counter < 129 && klStart < klEnd)
{
std::optional<uint256 const> next = view.succ(klStart, klEnd);
if (!next.has_value())
break;
Keylet kl{ltANY, *next};
if (view.exists(kl))
{
auto sle = view.read(kl);
if (safe_cast<LedgerEntryType>(
sle->getFieldU16(sfLedgerEntryType)) == ltCRON)
{
// valid cron object, add it to the list
cronAccs.emplace(sle->getAccountID(sfOwner));
}
}
klStart = *next;
}
auto const seq = view.info().seq;
// insert Cron pseudos for each of the accs we need to ping
for (AccountID const& id : cronAccs)
{
STTx cronTx(ttCRON, [=](auto& obj) {
obj[sfAccount] = AccountID();
obj[sfLedgerSequence] = seq;
obj[sfOwner] = id;
});
uint256 txID = cronTx.getTransactionID();
auto s = std::make_shared<ripple::Serializer>();
cronTx.add(*s);
app.getHashRouter().setFlags(txID, SF_PRIVATE2);
app.getHashRouter().setFlags(txID, SF_EMITTED);
view.rawTxInsert(txID, std::move(s), nullptr);
ledgerChanged = true;
}
}
// Inject emitted transactions if any
if (view.rules().enabled(featureHooks))
do

View File

@@ -94,21 +94,6 @@ Change::preflight(PreflightContext const& ctx)
"of sfImportVLKey, sfActiveValidator";
return temMALFORMED;
}
// if we do specify import_vl_keys in config then we won't approve keys
// that aren't on our list
if (ctx.tx.isFieldPresent(sfImportVLKey) &&
!ctx.app.config().IMPORT_VL_KEYS.empty())
{
auto const& inner = const_cast<ripple::STTx&>(ctx.tx)
.getField(sfImportVLKey)
.downcast<STObject>();
auto const pk = inner.getFieldVL(sfPublicKey);
std::string const strPk = strHex(makeSlice(pk));
if (ctx.app.config().IMPORT_VL_KEYS.find(strPk) ==
ctx.app.config().IMPORT_VL_KEYS.end())
return telIMPORT_VL_KEY_NOT_RECOGNISED;
}
}
return tesSUCCESS;
@@ -168,9 +153,42 @@ Change::preclaim(PreclaimContext const& ctx)
return tesSUCCESS;
case ttAMENDMENT:
case ttUNL_MODIFY:
case ttUNL_REPORT:
case ttEMIT_FAILURE:
return tesSUCCESS;
case ttUNL_REPORT: {
if (!ctx.tx.isFieldPresent(sfImportVLKey) ||
ctx.app.config().IMPORT_VL_KEYS.empty())
return tesSUCCESS;
// if we do specify import_vl_keys in config then we won't approve
// keys that aren't on our list and/or aren't in the ledger object
auto const& inner = const_cast<ripple::STTx&>(ctx.tx)
.getField(sfImportVLKey)
.downcast<STObject>();
auto const pkBlob = inner.getFieldVL(sfPublicKey);
std::string const strPk = strHex(makeSlice(pkBlob));
if (ctx.app.config().IMPORT_VL_KEYS.find(strPk) !=
ctx.app.config().IMPORT_VL_KEYS.end())
return tesSUCCESS;
auto const pkType = publicKeyType(makeSlice(pkBlob));
if (!pkType)
return tefINTERNAL;
PublicKey const pk(makeSlice(pkBlob));
// check on ledger
if (auto const unlRep = ctx.view.read(keylet::UNLReport());
unlRep && unlRep->isFieldPresent(sfImportVLKeys))
{
auto const& vlKeys = unlRep->getFieldArray(sfImportVLKeys);
for (auto const& k : vlKeys)
if (PublicKey(k[sfPublicKey]) == pk)
return tesSUCCESS;
}
return telIMPORT_VL_KEY_NOT_RECOGNISED;
}
default:
return temUNKNOWN;
}

View File

@@ -0,0 +1,189 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/app/tx/impl/Cron.h>
#include <ripple/basics/Log.h>
#include <ripple/core/Config.h>
#include <ripple/ledger/View.h>
#include <ripple/protocol/Feature.h>
#include <ripple/protocol/Indexes.h>
#include <ripple/protocol/PublicKey.h>
#include <ripple/protocol/Quality.h>
#include <ripple/protocol/TxFlags.h>
#include <ripple/protocol/st.h>
namespace ripple {
TxConsequences
Cron::makeTxConsequences(PreflightContext const& ctx)
{
return TxConsequences{ctx.tx, TxConsequences::normal};
}
NotTEC
Cron::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featureCron))
return temDISABLED;
auto const ret = preflight0(ctx);
if (!isTesSuccess(ret))
return ret;
auto account = ctx.tx.getAccountID(sfAccount);
if (account != beast::zero)
{
JLOG(ctx.j.warn()) << "Cron: Bad source id";
return temBAD_SRC_ACCOUNT;
}
// No point in going any further if the transaction fee is malformed.
auto const fee = ctx.tx.getFieldAmount(sfFee);
if (!fee.native() || fee != beast::zero)
{
JLOG(ctx.j.warn()) << "Cron: invalid fee";
return temBAD_FEE;
}
if (!ctx.tx.getSigningPubKey().empty() || !ctx.tx.getSignature().empty() ||
ctx.tx.isFieldPresent(sfSigners))
{
JLOG(ctx.j.warn()) << "Cron: Bad signature";
return temBAD_SIGNATURE;
}
if (ctx.tx.getFieldU32(sfSequence) != 0 ||
ctx.tx.isFieldPresent(sfPreviousTxnID))
{
JLOG(ctx.j.warn()) << "Cron: Bad sequence";
return temBAD_SEQUENCE;
}
return tesSUCCESS;
}
TER
Cron::preclaim(PreclaimContext const& ctx)
{
if (!ctx.view.rules().enabled(featureCron))
return temDISABLED;
return tesSUCCESS;
}
TER
Cron::doApply()
{
auto& view = ctx_.view();
auto const& tx = ctx_.tx;
AccountID const& id = tx.getAccountID(sfOwner);
auto sle = view.peek(keylet::account(id));
if (!sle)
{
// should never happen... but might in a race condition with acc delete
JLOG(j_.warn()) << "Cron: sfOwner account missing. " << id;
return tesSUCCESS;
}
if (!sle->isFieldPresent(sfCron))
{
JLOG(j_.warn()) << "Cron: sfCron missing from account " << id;
return tefINTERNAL;
}
uint256 ptr = sle->getFieldH256(sfCron);
Keylet klOld{ltCRON, ptr};
auto sleCron = view.peek(klOld);
if (!sleCron)
{
JLOG(j_.warn()) << "Cron: Cron object missing for account " << id;
return tesSUCCESS;
}
uint32_t delay = sleCron->getFieldU32(sfDelaySeconds);
uint32_t recur = sleCron->getFieldU32(sfRepeatCount);
uint32_t lastStartTime = sleCron->getFieldU32(sfStartTime);
// do all this sanity checking before we modify the ledger...
uint32_t afterTime = lastStartTime + delay;
if (afterTime < lastStartTime)
return tefINTERNAL;
// in all circumstances the Cron object is deleted...
// if there are further crons to do then a new one is created at the next
// time point
if (!view.dirRemove(
keylet::ownerDir(id), (*sleCron)[sfOwnerNode], klOld, false))
return tefBAD_LEDGER;
view.erase(sleCron);
if (recur == 0)
{
// already at last execution, stop here
adjustOwnerCount(view, sle, -1, j_);
sle->makeFieldAbsent(sfCron);
view.update(sle);
return tesSUCCESS;
}
// more executions remain, so create a new object
Keylet klCron = keylet::cron(afterTime, id);
// insert into owner dir, we don't need to check reserve because we've just
// deleted an object
auto const page =
view.dirInsert(keylet::ownerDir(id), klCron, describeOwnerDir(id));
if (!page)
return tecDIR_FULL;
sleCron = std::make_shared<SLE>(klCron);
sleCron->setFieldU64(sfOwnerNode, *page);
sleCron->setFieldU32(sfDelaySeconds, delay);
sleCron->setFieldU32(sfRepeatCount, recur - 1);
sleCron->setFieldU32(sfStartTime, afterTime);
sleCron->setAccountID(sfOwner, id);
sle->setFieldH256(sfCron, klCron.key);
view.insert(sleCron);
view.update(sle);
return tesSUCCESS;
}
void
Cron::preCompute()
{
assert(account_ == beast::zero);
}
XRPAmount
Cron::calculateBaseFee(ReadView const& view, STTx const& tx)
{
return XRPAmount{0};
}
} // namespace ripple

View File

@@ -0,0 +1,60 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_TX_CRON_H_INCLUDED
#define RIPPLE_TX_CRON_H_INCLUDED
#include <ripple/app/tx/impl/Transactor.h>
#include <ripple/basics/Log.h>
#include <ripple/core/Config.h>
#include <ripple/protocol/Indexes.h>
namespace ripple {
class Cron : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Custom};
explicit Cron(ApplyContext& ctx) : Transactor(ctx)
{
}
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static TxConsequences
makeTxConsequences(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const&);
TER
doApply() override;
void
preCompute() override;
};
} // namespace ripple
#endif

View File

@@ -491,6 +491,7 @@ LedgerEntryTypesMatch::visitEntry(
case ltNFTOKEN_PAGE:
case ltNFTOKEN_OFFER:
case ltURI_TOKEN:
case ltCRON:
case ltIMPORT_VLSEQ:
case ltUNL_REPORT:
break;

View File

@@ -0,0 +1,305 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/app/tx/impl/SetCron.h>
#include <ripple/basics/Log.h>
#include <ripple/ledger/View.h>
#include <ripple/protocol/Feature.h>
#include <ripple/protocol/Indexes.h>
#include <ripple/protocol/TxFlags.h>
#include <ripple/protocol/st.h>
namespace ripple {
TxConsequences
SetCron::makeTxConsequences(PreflightContext const& ctx)
{
return TxConsequences{ctx.tx, TxConsequences::normal};
}
NotTEC
SetCron::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featureCron))
return temDISABLED;
if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;
auto& tx = ctx.tx;
auto& j = ctx.j;
if (tx.getFlags() & tfCronSetMask)
{
JLOG(j.warn()) << "SetCron: Invalid flags set.";
return temINVALID_FLAG;
}
// DelaySeconds (D), RepeatCount (R), StartTime (S)
// DRS - Set Cron with Delay and Repeat and StartTime
// DR- - Invalid(StartTime is required)
// D-S - Invalid (both DelaySeconds and RepeatCount are required)
// -RS - Invalid (both DelaySeconds and RepeatCount are required)
// --S - Onetime cron with StartTime only
// -- - Clear any existing cron (succeeds even if there isn't one) / with
// tfCronUnset flag set
bool const hasDelay = tx.isFieldPresent(sfDelaySeconds);
bool const hasRepeat = tx.isFieldPresent(sfRepeatCount);
bool const hasStartTime = tx.isFieldPresent(sfStartTime);
if (tx.isFlag(tfCronUnset))
{
// delete operation
if (hasDelay || hasRepeat || hasStartTime)
{
JLOG(j.debug()) << "SetCron: tfCronUnset flag cannot be used with "
"DelaySeconds, RepeatCount or StartTime.";
return temMALFORMED;
}
}
else
{
// create operation
if (!hasStartTime)
{
JLOG(j.debug())
<< "SetCron: StartTime is required. Use StartTime=0 for "
"immediate execution, or specify a future timestamp.";
return temMALFORMED;
}
if ((!hasDelay && hasRepeat) || (hasDelay && !hasRepeat))
{
JLOG(j.debug())
<< "SetCron: DelaySeconds and RepeatCount must both be present "
"for recurring crons, or both absent for one-off crons.";
return temMALFORMED;
}
// check delay is not too high
if (hasDelay)
{
auto delay = tx.getFieldU32(sfDelaySeconds);
if (delay > 31536000UL /* 365 days in seconds */)
{
JLOG(j.debug())
<< "SetCron: DelaySeconds was too high. (max 365 "
"days in seconds).";
return temMALFORMED;
}
}
// check repeat is not too high
if (hasRepeat)
{
auto recur = tx.getFieldU32(sfRepeatCount);
if (recur == 0)
{
JLOG(j.debug())
<< "SetCron: RepeatCount must be greater than 0."
"For one-time execution, omit DelaySeconds and "
"RepeatCount.";
return temMALFORMED;
}
if (recur > 256)
{
JLOG(j.debug())
<< "SetCron: RepeatCount too high. Limit is 256. Issue "
"new SetCron to increase.";
return temMALFORMED;
}
}
}
return preflight2(ctx);
}
TER
SetCron::preclaim(PreclaimContext const& ctx)
{
if (ctx.tx.isFieldPresent(sfStartTime) &&
ctx.tx.getFieldU32(sfStartTime) != 0)
{
// StartTime 0 means the cron will execute immediately
auto const startTime = ctx.tx.getFieldU32(sfStartTime);
auto const parentCloseTime =
ctx.view.parentCloseTime().time_since_epoch().count();
if (startTime < parentCloseTime)
{
JLOG(ctx.j.debug()) << "SetCron: StartTime must be in the future "
"(or 0 for immediate execution)";
return tecEXPIRED;
}
if (startTime > ctx.view.parentCloseTime().time_since_epoch().count() +
365 * 24 * 60 * 60)
{
JLOG(ctx.j.debug()) << "SetCron: StartTime is too far in the "
"future (max 365 days).";
return tecEXPIRED;
}
}
return tesSUCCESS;
}
TER
SetCron::doApply()
{
auto& view = ctx_.view();
auto const& tx = ctx_.tx;
bool const isDelete = tx.isFlag(tfCronUnset);
// delay can be zero, in which case the cron will usually execute next
// ledger.
uint32_t delay{0};
uint32_t recur{0};
uint32_t startTime{0};
if (!isDelete)
{
if (tx.isFieldPresent(sfDelaySeconds))
delay = tx.getFieldU32(sfDelaySeconds);
if (tx.isFieldPresent(sfRepeatCount))
recur = tx.getFieldU32(sfRepeatCount);
if (tx.isFieldPresent(sfStartTime))
{
startTime = tx.getFieldU32(sfStartTime);
if (startTime == 0)
startTime = view.parentCloseTime().time_since_epoch().count();
}
}
AccountID const& id = tx.getAccountID(sfAccount);
auto sle = view.peek(keylet::account(id));
if (!sle)
return tefINTERNAL;
// in all cases whatsoever, this transaction will delete an existing
// old cron object and return the owner reserve to the owner.
if (sle->isFieldPresent(sfCron))
{
Keylet klOld{ltCRON, sle->getFieldH256(sfCron)};
auto sleCron = view.peek(klOld);
if (!sleCron)
{
JLOG(j_.warn()) << "SetCron: Cron object didn't exist.";
return tefBAD_LEDGER;
}
if (safe_cast<LedgerEntryType>(
sleCron->getFieldU16(sfLedgerEntryType)) != ltCRON)
{
JLOG(j_.warn()) << "SetCron: sfCron pointed to non-cron object!!";
return tefBAD_LEDGER;
}
if (!view.dirRemove(
keylet::ownerDir(id), (*sleCron)[sfOwnerNode], klOld, false))
{
JLOG(j_.warn()) << "SetCron: Ownerdir bad. " << id;
return tefBAD_LEDGER;
}
view.erase(sleCron);
adjustOwnerCount(view, sle, -1, j_);
sle->makeFieldAbsent(sfCron);
}
// if the operation is a delete (no delay or recur specified then stop
// here.)
if (isDelete)
{
view.update(sle);
return tesSUCCESS;
}
// execution to here means we're creating a new Cron object and adding it to
// the user's owner dir
Keylet klCron = keylet::cron(startTime, id);
std::shared_ptr<SLE> sleCron = std::make_shared<SLE>(klCron);
STAmount const reserve{
view.fees().accountReserve(sle->getFieldU32(sfOwnerCount) + 1)};
STAmount const afterFee =
mPriorBalance - ctx_.tx.getFieldAmount(sfFee).xrp();
if (afterFee > mPriorBalance || afterFee < reserve)
return tecINSUFFICIENT_RESERVE;
// add to owner dir
auto const page =
view.dirInsert(keylet::ownerDir(id), klCron, describeOwnerDir(id));
if (!page)
return tecDIR_FULL;
sleCron->setFieldU64(sfOwnerNode, *page);
adjustOwnerCount(view, sle, 1, j_);
// set the fields
sleCron->setFieldU32(sfStartTime, startTime);
sleCron->setFieldU32(sfDelaySeconds, delay);
sleCron->setFieldU32(sfRepeatCount, recur);
sleCron->setAccountID(sfOwner, id);
sle->setFieldH256(sfCron, klCron.key);
view.update(sle);
view.insert(sleCron);
return tesSUCCESS;
}
XRPAmount
SetCron::calculateBaseFee(ReadView const& view, STTx const& tx)
{
auto const baseFee = Transactor::calculateBaseFee(view, tx);
if (tx.isFlag(tfCronUnset))
// delete cron
return baseFee;
auto const repeatCount =
tx.isFieldPresent(sfRepeatCount) ? tx.getFieldU32(sfRepeatCount) : 0;
// factor a cost based on the total number of txns expected
// for RepeatCount of 0 we have this txn (SetCron) and the
// single Cron txn (2). For a RepeatCount of 1 we have this txn,
// the first time the cron executes, and the second time (3).
uint32_t const additionalExpectedExecutions = 1 + repeatCount;
auto const additionalFee = baseFee * additionalExpectedExecutions;
if (baseFee + additionalFee < baseFee)
return baseFee;
return baseFee + additionalFee;
}
} // namespace ripple

View File

@@ -0,0 +1,56 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_TX_SETCRON_H_INCLUDED
#define RIPPLE_TX_SETCRON_H_INCLUDED
#include <ripple/app/tx/impl/Transactor.h>
#include <ripple/basics/Log.h>
#include <ripple/protocol/Indexes.h>
namespace ripple {
class SetCron : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Custom};
explicit SetCron(ApplyContext& ctx) : Transactor(ctx)
{
}
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static TxConsequences
makeTxConsequences(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const&);
TER
doApply() override;
};
} // namespace ripple
#endif

View File

@@ -28,6 +28,7 @@
#include <ripple/app/tx/impl/CreateCheck.h>
#include <ripple/app/tx/impl/CreateOffer.h>
#include <ripple/app/tx/impl/CreateTicket.h>
#include <ripple/app/tx/impl/Cron.h>
#include <ripple/app/tx/impl/DeleteAccount.h>
#include <ripple/app/tx/impl/DepositPreauth.h>
#include <ripple/app/tx/impl/Escrow.h>
@@ -43,6 +44,7 @@
#include <ripple/app/tx/impl/Payment.h>
#include <ripple/app/tx/impl/Remit.h>
#include <ripple/app/tx/impl/SetAccount.h>
#include <ripple/app/tx/impl/SetCron.h>
#include <ripple/app/tx/impl/SetHook.h>
#include <ripple/app/tx/impl/SetRegularKey.h>
#include <ripple/app/tx/impl/SetRemarks.h>
@@ -181,6 +183,10 @@ invoke_preflight(PreflightContext const& ctx)
case ttURITOKEN_CREATE_SELL_OFFER:
case ttURITOKEN_CANCEL_SELL_OFFER:
return invoke_preflight_helper<URIToken>(ctx);
case ttCRON_SET:
return invoke_preflight_helper<SetCron>(ctx);
case ttCRON:
return invoke_preflight_helper<Cron>(ctx);
default:
assert(false);
return {temUNKNOWN, TxConsequences{temUNKNOWN}};
@@ -306,6 +312,10 @@ invoke_preclaim(PreclaimContext const& ctx)
case ttURITOKEN_CREATE_SELL_OFFER:
case ttURITOKEN_CANCEL_SELL_OFFER:
return invoke_preclaim<URIToken>(ctx);
case ttCRON_SET:
return invoke_preclaim<SetCron>(ctx);
case ttCRON:
return invoke_preclaim<Cron>(ctx);
default:
assert(false);
return temUNKNOWN;
@@ -393,6 +403,10 @@ invoke_calculateBaseFee(ReadView const& view, STTx const& tx)
case ttURITOKEN_CREATE_SELL_OFFER:
case ttURITOKEN_CANCEL_SELL_OFFER:
return URIToken::calculateBaseFee(view, tx);
case ttCRON_SET:
return SetCron::calculateBaseFee(view, tx);
case ttCRON:
return Cron::calculateBaseFee(view, tx);
default:
return XRPAmount{0};
}
@@ -586,6 +600,14 @@ invoke_apply(ApplyContext& ctx)
URIToken p(ctx);
return p();
}
case ttCRON_SET: {
SetCron p(ctx);
return p();
}
case ttCRON: {
Cron p(ctx);
return p();
}
default:
assert(false);
return {temUNKNOWN, false};

View File

@@ -74,7 +74,7 @@ namespace detail {
// Feature.cpp. Because it's only used to reserve storage, and determine how
// large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than
// the actual number of amendments. A LogicError on startup will verify this.
static constexpr std::size_t numFeatures = 86;
static constexpr std::size_t numFeatures = 87;
/** Amendments that this server supports and the default voting behavior.
Whether they are enabled depends on the Rules defined in the validated
@@ -373,6 +373,7 @@ extern uint256 const fixProvisionalDoubleThreading;
extern uint256 const featureClawback;
extern uint256 const featureDeepFreeze;
extern uint256 const featureIOUIssuerWeakTSH;
extern uint256 const featureCron;
extern uint256 const fixInvalidTxFlags;
} // namespace ripple

View File

@@ -297,6 +297,9 @@ import_vlseq(PublicKey const& key) noexcept;
Keylet
uritoken(AccountID const& issuer, Blob const& uri);
Keylet
cron(uint32_t timestamp, AccountID const& id);
} // namespace keylet
// Everything below is deprecated and should be removed in favor of keylets:

View File

@@ -58,6 +58,12 @@ enum LedgerEntryType : std::uint16_t
*/
ltACCOUNT_ROOT = 0x0061,
/** A ledger object representing a scheduled cron execution on an account.
\sa keylet::cron
*/
ltCRON = 0x0041,
/** A ledger object which contains a list of object identifiers.
\sa keylet::page, keylet::quality, keylet::book, keylet::next and

View File

@@ -410,6 +410,9 @@ extern SF_UINT32 const sfRewardLgrLast;
extern SF_UINT32 const sfFirstNFTokenSequence;
extern SF_UINT32 const sfImportSequence;
extern SF_UINT32 const sfXahauActivationLgrSeq;
extern SF_UINT32 const sfDelaySeconds;
extern SF_UINT32 const sfRepeatCount;
extern SF_UINT32 const sfStartTime;
// 64-bit integers (common)
extern SF_UINT64 const sfIndexNext;
@@ -486,6 +489,7 @@ extern SF_UINT256 const sfURITokenID;
extern SF_UINT256 const sfGovernanceFlags;
extern SF_UINT256 const sfGovernanceMarks;
extern SF_UINT256 const sfEmittedTxnID;
extern SF_UINT256 const sfCron;
// currency amount (common)
extern SF_AMOUNT const sfAmount;

View File

@@ -200,6 +200,12 @@ constexpr std::uint32_t const tfImmutable = 1;
// Clawback flags:
constexpr std::uint32_t const tfClawbackMask = ~tfUniversal;
// CronSet Flags:
enum CronSetFlags : uint32_t {
tfCronUnset = 0x00000001,
};
constexpr std::uint32_t const tfCronSetMask = ~(tfUniversal | tfCronUnset);
// clang-format on
} // namespace ripple

View File

@@ -149,6 +149,12 @@ enum TxType : std::uint16_t
ttURITOKEN_CREATE_SELL_OFFER = 48,
ttURITOKEN_CANCEL_SELL_OFFER = 49,
/* A pseudo-txn alarm signal for invoking a hook, emitted by validators after alarm set conditions are met */
ttCRON = 92,
/* Sechedule an alarm for later */
ttCRON_SET = 93,
/* A note attaching transactor that allows the owner or issuer (on a object by object basis) to attach remarks */
ttREMARKS_SET = 94,

View File

@@ -479,6 +479,7 @@ REGISTER_FEATURE(Clawback, Supported::yes, VoteBehavior::De
REGISTER_FIX (fixProvisionalDoubleThreading, Supported::yes, VoteBehavior::DefaultYes);
REGISTER_FEATURE(DeepFreeze, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FEATURE(IOUIssuerWeakTSH, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FEATURE(Cron, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FIX (fixInvalidTxFlags, Supported::yes, VoteBehavior::DefaultYes);
// The following amendments are obsolete, but must remain supported

View File

@@ -72,6 +72,7 @@ enum class LedgerNameSpace : std::uint16_t {
URI_TOKEN = 'U',
IMPORT_VLSEQ = 'I',
UNL_REPORT = 'R',
CRON = 'A',
// No longer used or supported. Left here to reserve the space
// to avoid accidental reuse.
@@ -443,6 +444,51 @@ uritoken(AccountID const& issuer, Blob const& uri)
LedgerNameSpace::URI_TOKEN, issuer, Slice{uri.data(), uri.size()})};
}
// Constructs an ordered CRON keylet (32 bytes):
// [8-byte namespace][4-byte timestamp (big-endian, seconds)][20-byte
// AccountID]
//
// Properties
// - Namespacing: first 8 bytes are the most-significant bytes of
// indexHash(LedgerNameSpace::CRON).
// - Uniqueness (per ts,acct): exactly ONE cron per (timestamp, AccountID).
// Insert is upsert (last-write-wins).
// This is fine because we only ever allow one cron per account at a time—if
// the same (ts,acct) is written, its simply an update to that single entry
// (idempotent; no duplicate leaves).
// - Iteration order: chronological by timestamp (BE), then by raw AccountID
// bytes.
// NOTE: raw AccountID ordering may bias priority; consider hashing AccountID
// for uniform per-timestamp spread.
// - Expected accidental prefix collisions (foreign objects sharing the 8-byte
// namespace): n / 2^64,
// assuming uniform high-64-bit distribution of other objects.
// Examples: 100M → ~5.4e-12, 1B → ~5.4e-11, 10B → ~5.4e-10, 100B → ~5.4e-9
// (negligible).
Keylet
cron(uint32_t timestamp, AccountID const& id)
{
static const uint256 ns = indexHash(LedgerNameSpace::CRON);
uint8_t h[32];
// first 8 bytes are the namespacing
std::memcpy(h, ns.data(), 8);
// next 4 bytes are the timestamp in BE
h[8] = static_cast<uint8_t>((timestamp >> 24) & 0xFFU);
h[9] = static_cast<uint8_t>((timestamp >> 16) & 0xFFU);
h[10] = static_cast<uint8_t>((timestamp >> 8) & 0xFFU);
h[11] = static_cast<uint8_t>((timestamp >> 0) & 0xFFU);
const uint256 accHash = indexHash(LedgerNameSpace::CRON, timestamp, id);
// final 20 bytes are account ID
std::memcpy(h + 12, accHash.cdata(), 20);
return {ltCRON, uint256::fromVoid(h)};
}
} // namespace keylet
} // namespace ripple

View File

@@ -68,6 +68,7 @@ LedgerFormats::LedgerFormats()
{sfGovernanceMarks, soeOPTIONAL},
{sfAccountIndex, soeOPTIONAL},
{sfTouchCount, soeOPTIONAL},
{sfCron, soeOPTIONAL},
},
commonFields);
@@ -366,6 +367,17 @@ LedgerFormats::LedgerFormats()
},
commonFields);
add(jss::Cron,
ltCRON,
{
{sfOwner, soeREQUIRED},
{sfStartTime, soeREQUIRED},
{sfDelaySeconds, soeREQUIRED},
{sfRepeatCount, soeREQUIRED},
{sfOwnerNode, soeREQUIRED},
},
commonFields);
// clang-format on
}

View File

@@ -157,6 +157,9 @@ CONSTRUCT_TYPED_SFIELD(sfLockCount, "LockCount", UINT32,
CONSTRUCT_TYPED_SFIELD(sfFirstNFTokenSequence, "FirstNFTokenSequence", UINT32, 50);
CONSTRUCT_TYPED_SFIELD(sfStartTime, "StartTime", UINT32, 93);
CONSTRUCT_TYPED_SFIELD(sfRepeatCount, "RepeatCount", UINT32, 94);
CONSTRUCT_TYPED_SFIELD(sfDelaySeconds, "DelaySeconds", UINT32, 95);
CONSTRUCT_TYPED_SFIELD(sfXahauActivationLgrSeq, "XahauActivationLgrSeq",UINT32, 96);
CONSTRUCT_TYPED_SFIELD(sfImportSequence, "ImportSequence", UINT32, 97);
CONSTRUCT_TYPED_SFIELD(sfRewardTime, "RewardTime", UINT32, 98);
@@ -239,6 +242,7 @@ CONSTRUCT_TYPED_SFIELD(sfGovernanceFlags, "GovernanceFlags", UINT256,
CONSTRUCT_TYPED_SFIELD(sfGovernanceMarks, "GovernanceMarks", UINT256, 98);
CONSTRUCT_TYPED_SFIELD(sfEmittedTxnID, "EmittedTxnID", UINT256, 97);
CONSTRUCT_TYPED_SFIELD(sfHookCanEmit, "HookCanEmit", UINT256, 96);
CONSTRUCT_TYPED_SFIELD(sfCron, "Cron", UINT256, 95);
// currency amount (common)
CONSTRUCT_TYPED_SFIELD(sfAmount, "Amount", AMOUNT, 1);

View File

@@ -615,7 +615,7 @@ isPseudoTx(STObject const& tx)
auto tt = safe_cast<TxType>(*t);
return tt == ttAMENDMENT || tt == ttFEE || tt == ttUNL_MODIFY ||
tt == ttEMIT_FAILURE || tt == ttUNL_REPORT;
tt == ttEMIT_FAILURE || tt == ttUNL_REPORT || tt == ttCRON;
}
} // namespace ripple

View File

@@ -472,6 +472,23 @@ TxFormats::TxFormats()
{sfTicketSequence, soeOPTIONAL},
},
commonFields);
add(jss::Cron,
ttCRON,
{
{sfOwner, soeREQUIRED},
{sfLedgerSequence, soeREQUIRED},
},
commonFields);
add(jss::CronSet,
ttCRON_SET,
{
{sfDelaySeconds, soeOPTIONAL},
{sfRepeatCount, soeOPTIONAL},
{sfStartTime, soeOPTIONAL},
},
commonFields);
}
TxFormats const&

View File

@@ -51,14 +51,16 @@ JSS(Amendments); // ledger type.
JSS(Amount); // in: TransactionSign; field.
JSS(Authorize); // field
JSS(Blob);
JSS(Check); // ledger type.
JSS(CheckCancel); // transaction type.
JSS(CheckCash); // transaction type.
JSS(CheckCreate); // transaction type.
JSS(ClaimReward); // transaction type.
JSS(Clawback); // transaction type.
JSS(ClearFlag); // field.
JSS(CreateCode); // field.
JSS(Check); // ledger type.
JSS(CheckCancel); // transaction type.
JSS(CheckCash); // transaction type.
JSS(CheckCreate); // transaction type.
JSS(ClaimReward); // transaction type.
JSS(Clawback); // transaction type.
JSS(ClearFlag); // field.
JSS(CreateCode); // field.
JSS(Cron);
JSS(CronSet);
JSS(DeliverMin); // in: TransactionSign
JSS(DepositPreauth); // transaction and ledger type.
JSS(Destination); // in: TransactionSign; field.

View File

@@ -423,6 +423,7 @@ private:
addFlagsToJson<NFTokenMintFlags>(ret, "NFTokenMint");
addFlagsToJson<NFTokenCreateOfferFlags>(ret, "NFTokenCreateOffer");
addFlagsToJson<ClaimRewardFlags>(ret, "ClaimReward");
addFlagsToJson<CronSetFlags>(ret, "CronSet");
struct FlagData
{
std::string name;

496
src/test/app/Cron_test.cpp Normal file
View File

@@ -0,0 +1,496 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/app/ledger/LedgerMaster.h>
#include <ripple/protocol/Feature.h>
#include <ripple/protocol/jss.h>
#include <test/jtx.h>
namespace ripple {
namespace test {
struct Cron_test : public beast::unit_test::suite
{
void
testEnabled(FeatureBitset features)
{
testcase("enabled");
using namespace jtx;
using namespace std::literals::chrono_literals;
// setup env
auto const alice = Account("alice");
auto const issuer = Account("issuer");
for (bool const withCron : {false, true})
{
auto const amend = withCron ? features : features - featureCron;
Env env{*this, amend};
env.fund(XRP(1000), alice, issuer);
env.close();
auto const expectResult =
withCron ? ter(tesSUCCESS) : ter(temDISABLED);
// CLAIM
env(cron::set(alice),
cron::startTime(0),
cron::repeat(100),
cron::delay(100),
fee(XRP(1)),
expectResult);
env.close();
}
}
void
testFee(FeatureBitset features)
{
testcase("fee");
using namespace jtx;
using namespace std::literals::chrono_literals;
auto const alice = Account("alice");
Env env{*this, features | featureCron};
auto const baseFee = env.current()->fees().base;
env.fund(XRP(1000), alice);
env.close();
// create
auto expected = baseFee * 2 + baseFee * 256;
env(cron::set(alice),
cron::startTime(0),
cron::delay(356 * 24 * 60 * 60),
cron::repeat(256),
fee(expected - 1),
ter(telINSUF_FEE_P));
env.close();
env(cron::set(alice),
cron::startTime(0),
cron::delay(356 * 24 * 60 * 60),
cron::repeat(256),
fee(expected),
ter(tesSUCCESS));
env.close();
// delete
expected = baseFee;
env(cron::set(alice),
txflags(tfCronUnset),
fee(expected - 1),
ter(telINSUF_FEE_P));
env.close();
env(cron::set(alice),
txflags(tfCronUnset),
fee(expected),
ter(tesSUCCESS));
env.close();
}
void
testInvalidPreflight(FeatureBitset features)
{
testcase("invalid preflight");
using namespace test::jtx;
using namespace std::literals;
auto const alice = Account("alice");
test::jtx::Env env{
*this, network::makeNetworkConfig(21337), features | featureCron};
env.fund(XRP(1000), alice);
env.close();
//----------------------------------------------------------------------
// preflight
// temINVALID_FLAG
{
env(cron::set(alice), txflags(tfClose), ter(temINVALID_FLAG));
env(cron::set(alice),
txflags(tfUniversalMask),
ter(temINVALID_FLAG));
}
// temMALFORMED
{
// Invalid DelaySeconds and RepeatCount and StartTime are not
// specified
env(cron::set(alice), ter(temMALFORMED));
// Invalid DelaySeconds and RepeatCount combination with StartTime
env(cron::set(alice),
cron::startTime(100),
cron::delay(356 * 24 * 60 * 60),
ter(temMALFORMED));
env(cron::set(alice),
cron::startTime(100),
cron::repeat(256),
ter(temMALFORMED));
// Invalid DelaySeconds
env(cron::set(alice),
cron::startTime(100),
cron::delay(365 * 24 * 60 * 60 + 1),
cron::repeat(256),
ter(temMALFORMED));
// Invalid RepeatCount
env(cron::set(alice),
cron::startTime(100),
cron::delay(365 * 24 * 60 * 60),
cron::repeat(257),
ter(temMALFORMED));
// Invalid with tfCronUnset flag
env(cron::set(alice),
cron::delay(365 * 24 * 60 * 60),
txflags(tfCronUnset),
ter(temMALFORMED));
env(cron::set(alice),
cron::repeat(100),
txflags(tfCronUnset),
ter(temMALFORMED));
env(cron::set(alice),
cron::startTime(100),
txflags(tfCronUnset),
ter(temMALFORMED));
}
}
void
testInvalidPreclaim(FeatureBitset features)
{
testcase("invalid preclaim");
using namespace test::jtx;
using namespace std::literals;
auto const alice = Account("alice");
Env env{*this, features | featureCron};
env.fund(XRP(1000), alice);
env.close();
// Past StartTime
env(cron::set(alice),
cron::startTime(
env.timeKeeper().now().time_since_epoch().count() - 1),
fee(XRP(1)),
ter(tecEXPIRED));
env.close();
// Too far Future StartTime
env(cron::set(alice),
cron::startTime(
env.timeKeeper().now().time_since_epoch().count() +
365 * 24 * 60 * 60 + 1),
fee(XRP(1)),
ter(tecEXPIRED));
env.close();
}
void
testDoApply(FeatureBitset features)
{
testcase("doApply");
using namespace jtx;
using namespace std::literals::chrono_literals;
auto const alice = Account("alice");
Env env{*this, features | featureCron};
env.fund(XRP(1000), alice);
env.close();
auto const aliceOwnerCount = ownerCount(env, alice);
// create cron
auto parentCloseTime =
env.current()->parentCloseTime().time_since_epoch().count();
env(cron::set(alice),
cron::startTime(parentCloseTime + 356 * 24 * 60 * 60),
cron::delay(356 * 24 * 60 * 60),
cron::repeat(256),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
// increment owner count
BEAST_EXPECT(ownerCount(env, alice) == aliceOwnerCount + 1);
auto const accSle = env.le(keylet::account(alice.id()));
BEAST_EXPECT(accSle);
BEAST_EXPECT(accSle->isFieldPresent(sfCron));
auto const cronKey = keylet::child(accSle->getFieldH256(sfCron));
auto const cronSle = env.le(cronKey);
BEAST_EXPECT(cronSle);
BEAST_EXPECT(
cronSle->getFieldU32(sfDelaySeconds) == 356 * 24 * 60 * 60);
BEAST_EXPECT(cronSle->getFieldU32(sfRepeatCount) == 256);
BEAST_EXPECT(
cronSle->getFieldU32(sfStartTime) ==
parentCloseTime + 356 * 24 * 60 * 60);
// update cron
parentCloseTime =
env.current()->parentCloseTime().time_since_epoch().count();
env(cron::set(alice),
cron::startTime(0),
cron::delay(100),
cron::repeat(10),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
// owner count does not change
BEAST_EXPECT(ownerCount(env, alice) == aliceOwnerCount + 1);
auto const accSle2 = env.le(keylet::account(alice.id()));
BEAST_EXPECT(accSle2);
BEAST_EXPECT(accSle2->isFieldPresent(sfCron));
// old cron sle is deleted
BEAST_EXPECT(!env.le(cronKey));
auto const cronKey2 = keylet::child(accSle2->getFieldH256(sfCron));
auto const cronSle2 = env.le(cronKey2);
BEAST_EXPECT(cronSle2);
BEAST_EXPECT(cronSle2->getFieldU32(sfDelaySeconds) == 100);
BEAST_EXPECT(cronSle2->getFieldU32(sfRepeatCount) == 10);
BEAST_EXPECT(cronSle2->getFieldU32(sfStartTime) == parentCloseTime);
// delete cron
env(cron::set(alice),
fee(XRP(1)),
txflags(tfCronUnset),
ter(tesSUCCESS));
env.close();
// owner count decremented
BEAST_EXPECT(ownerCount(env, alice) == aliceOwnerCount);
auto const accSle3 = env.le(keylet::account(alice.id()));
BEAST_EXPECT(accSle3);
BEAST_EXPECT(!accSle3->isFieldPresent(sfCron));
// old cron sle is deleted
BEAST_EXPECT(!env.le(cronKey2));
// delete cron without object will succeed
env(cron::set(alice),
fee(XRP(1)),
txflags(tfCronUnset),
ter(tesSUCCESS));
env.close();
}
void
testCronExecution(FeatureBitset features)
{
testcase("cron execution");
using namespace jtx;
using namespace std::literals::chrono_literals;
auto const alice = Account("alice");
{
// test ttCron execution and repeatCount
Env env{*this, features | featureCron};
env.fund(XRP(1000), alice);
env.close();
auto baseTime = env.timeKeeper().now().time_since_epoch().count();
auto repeatCount = 10;
env(cron::set(alice),
cron::startTime(baseTime + 100),
cron::delay(100),
cron::repeat(repeatCount),
fee(XRP(1)));
env.close(10s);
auto lastCronKeylet =
keylet::child(env.le(alice)->getFieldH256(sfCron));
while (repeatCount >= 0)
{
// close ledger until 100 seconds has passed
while (env.timeKeeper().now().time_since_epoch().count() -
baseTime <
100)
{
env.close(10s);
auto txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 0);
}
// close after 100 seconds passed
env.close(10s);
auto txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 1);
for (auto it = txns.begin(); it != txns.end(); ++it)
{
auto const& tx = *it->first;
// check pseudo txn format
BEAST_EXPECT(tx.getTxnType() == ttCRON);
BEAST_EXPECT(tx.getAccountID(sfAccount) == AccountID());
BEAST_EXPECT(tx.getAccountID(sfOwner) == alice.id());
BEAST_EXPECT(
tx.getFieldU32(sfLedgerSequence) ==
env.closed()->info().seq);
BEAST_EXPECT(tx.getFieldAmount(sfFee) == XRP(0));
BEAST_EXPECT(tx.getFieldVL(sfSigningPubKey).size() == 0);
// check old Cron object is deleted
BEAST_EXPECT(!env.le(lastCronKeylet));
if (repeatCount > 0)
{
// check new Cron object
auto const cronKeylet =
keylet::child(env.le(alice)->getFieldH256(sfCron));
auto const cronSle = env.le(cronKeylet);
BEAST_EXPECT(cronSle);
BEAST_EXPECT(
cronSle->getFieldU32(sfDelaySeconds) == 100);
BEAST_EXPECT(
cronSle->getFieldU32(sfRepeatCount) ==
--repeatCount);
BEAST_EXPECT(
cronSle->getAccountID(sfOwner) == alice.id());
// set new base time
baseTime = baseTime + 100;
lastCronKeylet = cronKeylet;
}
else
{
// after all executions, the cron object should be
// deleted
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfCron));
BEAST_EXPECT(!env.le(lastCronKeylet));
--repeatCount; // decrement for break double loop
}
}
}
}
{
// test ttCron limit in a ledger
Env env{*this, features | featureCron};
std::vector<Account> accounts;
accounts.reserve(300);
for (int i = 0; i < 300; ++i)
{
auto const& account = accounts.emplace_back(
Account("account_" + std::to_string(i)));
accounts.emplace_back(account);
env.fund(XRP(10000), account);
}
env.close();
for (auto const& account : accounts)
{
env(cron::set(account), cron::startTime(0), fee(XRP(1)));
}
env.close();
// proceed ledger
env.close();
{
auto const txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 128);
for (auto it = txns.begin(); it != txns.end(); ++it)
{
auto const& tx = *it->first;
BEAST_EXPECT(tx.getTxnType() == ttCRON);
}
}
// proceed ledger
env.close();
{
auto const txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 128);
for (auto it = txns.begin(); it != txns.end(); ++it)
{
auto const& tx = *it->first;
BEAST_EXPECT(tx.getTxnType() == ttCRON);
}
}
// proceed ledger
env.close();
{
auto const txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 44);
for (auto it = txns.begin(); it != txns.end(); ++it)
{
auto const& tx = *it->first;
BEAST_EXPECT(tx.getTxnType() == ttCRON);
}
}
// proceed ledger
env.close();
{
auto const txns = env.closed()->txs;
auto size = std::distance(txns.begin(), txns.end());
BEAST_EXPECT(size == 0);
}
}
}
void
testWithFeats(FeatureBitset features)
{
testEnabled(features);
testFee(features);
testInvalidPreflight(features);
testInvalidPreclaim(features);
testDoApply(features);
testCronExecution(features);
}
public:
void
run() override
{
using namespace test::jtx;
auto const sa = supported_amendments();
testWithFeats(sa);
}
};
BEAST_DEFINE_TESTSUITE(Cron, app, ripple);
} // namespace test
} // namespace ripple

View File

@@ -22,6 +22,7 @@
#include <ripple/app/misc/HashRouter.h>
#include <ripple/app/misc/TxQ.h>
#include <ripple/app/tx/apply.h>
#include <ripple/basics/StringUtilities.h>
#include <ripple/protocol/Feature.h>
#include <ripple/protocol/PayChan.h>
#include <ripple/protocol/jss.h>
@@ -748,10 +749,22 @@ private:
jtx::Env& env,
int const& expected,
uint64_t const& lineno)
{
auto const hashStr =
env.tx()->getJson(JsonOptions::none)[jss::hash].asString();
uint256 const txHash = uint256::fromVoid(strUnHex(hashStr)->data());
testTSHStrongWeak(env, txHash, expected, lineno);
}
void
testTSHStrongWeak(
jtx::Env& env,
uint256 const& txHash,
int const& expected,
uint64_t const& lineno)
{
Json::Value params;
params[jss::transaction] =
env.tx()->getJson(JsonOptions::none)[jss::hash];
params[jss::transaction] = strHex(txHash);
auto const jrr = env.rpc("json", "tx", to_string(params));
auto const meta = jrr[jss::result][jss::meta];
validateTSHStrongWeak(meta, expected, lineno);
@@ -6251,6 +6264,107 @@ private:
}
}
// CronSet
// | otxn | tsh | cset |
// | A | A | S |
void
testCronSetTSH(FeatureBitset features)
{
testcase("cron set tsh");
using namespace test::jtx;
using namespace std::literals;
// otxn: account
// tsh account
// w/s: strong
for (bool const testStrong : {true, false})
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const account = Account("alice");
env.fund(XRP(1000), account);
env.close();
if (!testStrong)
addWeakTSH(env, account);
// set tsh hook
setTSHHook(env, account, testStrong);
// cron set
env(cron::set(account),
cron::startTime(0),
cron::delay(100),
cron::repeat(1),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
// verify tsh hook triggered
testTSHStrongWeak(env, tshSTRONG, __LINE__);
}
}
// | otxn | tsh | cron |
// | - | O | W |
void
testCronTSH(FeatureBitset features)
{
testcase("cron tsh");
using namespace test::jtx;
using namespace std::literals;
// otxn: -
// tsh owner
// w/s: weak
for (bool const testStrong : {true, false})
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const account = Account("alice");
env.fund(XRP(1000), account);
env.close();
auto const baseTime =
env.current()->parentCloseTime().time_since_epoch().count();
// cron set
env(cron::set(account),
cron::startTime(baseTime + 100),
cron::delay(100),
cron::repeat(1),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
if (!testStrong)
addWeakTSH(env, account);
// set tsh hook
setTSHHook(env, account, testStrong);
// proceed ledger
env.close(100s);
// close ledger
env.close();
// verify tsh hook triggered
auto const expected = testStrong ? tshNONE : tshWEAK;
auto const txs = env.closed()->txs;
BEAST_EXPECT(std::distance(txs.begin(), txs.end()) == 1);
auto const tx = txs.begin()->first;
BEAST_EXPECT(tx->getTxnType() == ttCRON);
testTSHStrongWeak(env, tx->getTransactionID(), expected, __LINE__);
}
}
void
testEmissionOrdering(FeatureBitset features)
{
@@ -6398,6 +6512,8 @@ private:
testURITokenCancelSellOfferTSH(features);
testURITokenCreateSellOfferTSH(features);
testRemitTSH(features);
testCronSetTSH(features);
testCronTSH(features);
}
void

View File

@@ -357,6 +357,32 @@ class UNLReport_test : public beast::unit_test::suite
BEAST_EXPECT(isImportVL(env, ivlKeys[0]) == true);
BEAST_EXPECT(isImportVL(env, ivlKeys[1]) == false);
BEAST_EXPECT(isActiveValidator(env, vlKeys[0]) == true);
// now test unrecognised keys that are already present in the ledger
// object (flap fix)
l = std::make_shared<Ledger>(
*l, env.app().timeKeeper().closeTime());
// insert a ttUNL_REPORT pseudo into the open ledger
env.app().openLedger().modify(
[&](OpenView& view, beast::Journal j) -> bool {
STTx tx = createUNLRTx(l->seq(), ivlKeys[1], vlKeys[0]);
uint256 txID = tx.getTransactionID();
auto s = std::make_shared<ripple::Serializer>();
tx.add(*s);
env.app().getHashRouter().setFlags(txID, SF_PRIVATE2);
view.rawTxInsert(txID, std::move(s), nullptr);
return true;
});
BEAST_EXPECT(hasUNLReport(env) == true);
// close the ledger
env.close();
BEAST_EXPECT(isImportVL(env, ivlKeys[0]) == true);
BEAST_EXPECT(isImportVL(env, ivlKeys[1]) == false);
BEAST_EXPECT(isActiveValidator(env, vlKeys[0]) == true);
}
}
@@ -1324,4 +1350,4 @@ createUNLRTx(
}
} // namespace test
} // namespace ripple
} // namespace ripple

View File

@@ -33,6 +33,7 @@
#include <test/jtx/amount.h>
#include <test/jtx/balance.h>
#include <test/jtx/check.h>
#include <test/jtx/cron.h>
#include <test/jtx/delivermin.h>
#include <test/jtx/deposit.h>
#include <test/jtx/escrow.h>

89
src/test/jtx/cron.h Normal file
View File

@@ -0,0 +1,89 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_TEST_JTX_CRON_H_INCLUDED
#define RIPPLE_TEST_JTX_CRON_H_INCLUDED
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
namespace ripple {
namespace test {
namespace jtx {
/** Cron operations. */
namespace cron {
/** Set a cron. */
Json::Value
set(jtx::Account const& account);
/** Sets the optional StartTime on a JTx. */
class startTime
{
private:
uint32_t startTime_;
public:
explicit startTime(uint32_t startTime) : startTime_(startTime)
{
}
void
operator()(Env&, JTx& jtx) const;
};
/** Sets the optional DelaySeconds on a JTx. */
class delay
{
private:
uint32_t delay_;
public:
explicit delay(uint32_t delay) : delay_(delay)
{
}
void
operator()(Env&, JTx& jtx) const;
};
/** Sets the optional RepeatCount on a JTx. */
class repeat
{
private:
uint32_t repeat_;
public:
explicit repeat(uint32_t repeat) : repeat_(repeat)
{
}
void
operator()(Env&, JTx& jtx) const;
};
} // namespace cron
} // namespace jtx
} // namespace test
} // namespace ripple
#endif // RIPPLE_TEST_JTX_CRON_H_INCLUDED

View File

@@ -0,0 +1,62 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/protocol/jss.h>
#include <test/jtx/cron.h>
namespace ripple {
namespace test {
namespace jtx {
namespace cron {
// Set a cron.
Json::Value
set(jtx::Account const& account)
{
using namespace jtx;
Json::Value jv;
jv[jss::TransactionType] = jss::CronSet;
jv[jss::Account] = account.human();
return jv;
}
void
startTime::operator()(Env& env, JTx& jt) const
{
jt.jv[sfStartTime.jsonName] = startTime_;
}
void
delay::operator()(Env& env, JTx& jt) const
{
jt.jv[sfDelaySeconds.jsonName] = delay_;
}
void
repeat::operator()(Env& env, JTx& jt) const
{
jt.jv[sfRepeatCount.jsonName] = repeat_;
}
} // namespace cron
} // namespace jtx
} // namespace test
} // namespace ripple