A code-level walkthrough of a real transactor (`CheckCreate`) across preflight, preclaim and doApply.
What you'll learn
≈45 min · Advanced · builds on TER result codes
Theory meets a real transactor here. In this module you'll follow CheckCreate line by line through preflight, preclaim and doApply, seeing genuine format checks, freeze and permission logic, and reserve handling in context. By the end you'll have a concrete template you could adapt to build a transaction type of your own.
In brief: creates a deferred payment (a Check) the recipient can cash later.
A CheckCreate transaction:
Transaction Fields:
| Field | Required | Description |
|---|---|---|
Account |
Yes | The sender creating the check |
Destination |
Yes | The recipient who can cash the check |
SendMax |
Yes | Maximum amount that can be cashed |
Expiration |
No | When the check expires |
DestinationTag |
No | Tag for the destination |
SourceTag |
No | Tag for the source |
InvoiceID |
No | Arbitrary reference ID |
include/xrpl/tx/transactors/check/CheckCreate.hsrc/libxrpl/tx/transactors/check/CheckCreate.cppIn brief: stateless checks: no self-send, a valid SendMax, a sane currency and expiration.
Preflight performs stateless validation on the transaction content.
Every check here reads only the transaction, never the ledger:
NotTEC
CheckCreate::preflight(PreflightContext const& ctx)
{
A check to yourself is redundant, you can just keep the money:
if (ctx.tx[sfAccount] == ctx.tx[sfDestination])
{
JLOG(ctx.j.warn()) << "Malformed transaction: Check to self.";
return temREDUNDANT;
}
Why temREDUNDANT? This is a permanent format error. The transaction can never be valid with these field values.
The amount must be positive and well-formed:
{
STAmount const sendMax{ctx.tx.getFieldAmount(sfSendMax)};
if (!isLegalNet(sendMax) || sendMax.signum() <= 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad sendMax amount: "
<< sendMax.getFullText();
return temBAD_AMOUNT;
}
isLegalNet() checks that:
signum() <= 0 ensures the amount is positive (not zero).
The currency code must be valid:
if (badCurrency() == sendMax.getCurrency())
{
JLOG(ctx.j.warn()) << "Malformed transaction: Bad currency.";
return temBAD_CURRENCY;
}
}
badCurrency() returns a special invalid currency code. This catches malformed currency specifications.
If an expiration is provided, it must not be zero:
if (auto const optExpiry = ctx.tx[~sfExpiration])
{
if (*optExpiry == 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad expiration";
return temBAD_EXPIRATION;
}
}
return tesSUCCESS;
}
Note: The ~ operator returns std::optional<T>, allowing us to check if the field is present.
In brief: ledger checks: destination exists, permissions and tags, freeze status, not expired.
Preclaim validates against the current ledger state.
TER
CheckCreate::preclaim(PreclaimContext const& ctx)
{
AccountID const dstId{ctx.tx[sfDestination]};
auto const sleDst = ctx.view.read(keylet::account(dstId));
if (!sleDst)
{
JLOG(ctx.j.warn()) << "Destination account does not exist.";
return tecNO_DST;
}
Why tecNO_DST? The destination doesn't exist now, but it could be created before this transaction is applied. However, the transaction will fail if applied without the destination.
Accounts can opt out of receiving certain objects:
auto const flags = sleDst->getFlags();
if (ctx.view.rules().enabled(featureDisallowIncoming) &&
(flags & lsfDisallowIncomingCheck))
return tecNO_PERMISSION;
This is amendment-gated (featureDisallowIncoming), so we only check if the amendment is enabled.
Pseudo-accounts (like AMM pools) cannot cash checks:
if (isPseudoAccount(sleDst))
return tecNO_PERMISSION;
Some accounts require a destination tag:
if ((flags & lsfRequireDestTag) && !ctx.tx.isFieldPresent(sfDestinationTag))
{
JLOG(ctx.j.warn()) << "Malformed transaction: DestinationTag required.";
return tecDST_TAG_NEEDED;
}
For non-XRP amounts, check that the asset isn't frozen:
{
STAmount const sendMax{ctx.tx[sfSendMax]};
if (!sendMax.native())
{
// Check global freeze
AccountID const& issuerId{sendMax.getIssuer()};
if (isGlobalFrozen(ctx.view, issuerId))
{
JLOG(ctx.j.warn()) << "Creating a check for frozen asset";
return tecFROZEN;
}
Global Freeze: The issuer has frozen all holdings of this currency.
// Check source trust line freeze
AccountID const srcId{ctx.tx.getAccountID(sfAccount)};
if (issuerId != srcId)
{
auto const sleTrust = ctx.view.read(
keylet::trustLine(srcId, issuerId, sendMax.getCurrency()));
if (sleTrust &&
sleTrust->isFlag(
(issuerId > srcId) ? lsfHighFreeze : lsfLowFreeze))
{
JLOG(ctx.j.warn())
<< "Creating a check for frozen trustline.";
return tecFROZEN;
}
}
Trust Line Freeze: The issuer has frozen the source's specific trust line.
The (issuerId > srcId) ? lsfHighFreeze: lsfLowFreeze pattern is due to how trust lines store flags, the "high" account's flags use different bits than the "low" account's flags.
// Check destination trust line freeze
if (issuerId != dstId)
{
auto const sleTrust = ctx.view.read(
keylet::trustLine(issuerId, dstId, sendMax.getCurrency()));
if (sleTrust &&
sleTrust->isFlag(
(dstId > issuerId) ? lsfHighFreeze : lsfLowFreeze))
{
JLOG(ctx.j.warn())
<< "Creating a check for destination frozen trustline.";
return tecFROZEN;
}
}
}
}
Don't create a check that's already expired:
if (hasExpired(ctx.view, ctx.tx[~sfExpiration]))
{
JLOG(ctx.j.warn()) << "Creating a check that has already expired.";
return tecEXPIRED;
}
return tesSUCCESS;
}
hasExpired() compares the expiration against the parent ledger's close time.
In brief: reserve check, then create the Check entry and wire it into both owner directories.
doApply modifies the ledger state.
Reserve first, then create the entry and update the directories:
TER
CheckCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL;
This should never fail, if we got this far, the account exists. tefINTERNAL indicates a bug.
{
STAmount const reserve{
view().fees().accountReserve(sle->getFieldU32(sfOwnerCount) + 1)};
if (preFeeBalance_ < reserve)
return tecINSUFFICIENT_RESERVE;
}
Calculate what the reserve will be with one more owned object. Use preFeeBalance_ (before fee) to allow dipping into reserve for fees.
std::uint32_t const seq = ctx_.tx.getSeqValue();
Keylet const checkKeylet = keylet::check(accountID_, seq);
auto sleCheck = std::make_shared<SLE>(checkKeylet);
The check's key is derived from the creator's account and the transaction sequence (or ticket number).
sleCheck->setAccountID(sfAccount, accountID_);
AccountID const dstAccountId = ctx_.tx[sfDestination];
sleCheck->setAccountID(sfDestination, dstAccountId);
sleCheck->setFieldU32(sfSequence, seq);
sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]);
if (auto const srcTag = ctx_.tx[~sfSourceTag])
sleCheck->setFieldU32(sfSourceTag, *srcTag);
if (auto const dstTag = ctx_.tx[~sfDestinationTag])
sleCheck->setFieldU32(sfDestinationTag, *dstTag);
if (auto const invoiceId = ctx_.tx[~sfInvoiceID])
sleCheck->setFieldH256(sfInvoiceID, *invoiceId);
if (auto const expiry = ctx_.tx[~sfExpiration])
sleCheck->setFieldU32(sfExpiration, *expiry);
The [~sfField] pattern returns std::optional, allowing conditional field setting.
view().insert(sleCheck);
This adds the new SLE to the view's modified set.
auto viewJ = ctx_.app.journal("View");
if (dstAccountId != accountID_)
{
auto const page = view().dirInsert(
keylet::ownerDir(dstAccountId),
checkKeylet,
describeOwnerDir(dstAccountId));
JLOG(j_.trace()) << "Adding Check to destination directory "
<< to_string(checkKeylet.key) << ": "
<< (page ? "success" : "failure");
if (!page)
return tecDIR_FULL;
sleCheck->setFieldU64(sfDestinationNode, *page);
}
The check is added to the destination's owner directory so they can find checks payable to them. The page number is stored in the Check for later removal.
{
auto const page = view().dirInsert(
keylet::ownerDir(accountID_),
checkKeylet,
describeOwnerDir(accountID_));
JLOG(j_.trace()) << "Adding Check to owner directory "
<< to_string(checkKeylet.key) << ": "
<< (page ? "success" : "failure");
if (!page)
return tecDIR_FULL;
sleCheck->setFieldU64(sfOwnerNode, *page);
}
The check is also added to the source's directory, they need to track checks they've created.
adjustOwnerCount(view(), sle, 1, viewJ);
return tesSUCCESS;
}
Increment the source's owner count. This affects their reserve requirement.
Key idea. doApply is the only phase that writes. Notice the order: check the reserve, create the entry, add it to the owner directories, then adjust the owner count. Miss a step and the ledger's accounting drifts.
One CheckCreate transaction transforms the ledger like this:
In brief: the reusable patterns you would copy to build a transactor of your own.
ctx.view.rules().enabled()[~sfField] patterntem* for format, tec* for stateUnderstanding CheckCreate helps with these related transactions:
| File | Description |
|---|---|
src/libxrpl/tx/transactors/check/CheckCreate.cpp |
CheckCreate implementation |
include/xrpl/tx/transactors/check/CheckCreate.h |
CheckCreate class definition |
src/libxrpl/tx/transactors/check/CheckCash.cpp |
CashCheck for comparison |
src/libxrpl/tx/transactors/check/CheckCancel.cpp |
CancelCheck for comparison |
include/xrpl/protocol/Indexes.h |
Keylet definitions including keylet::check |
This module traced a real transactor, CheckCreate, through preflight, preclaim, and doApply. You saw genuine format checks (self-send, SendMax, currency, expiration), ledger-state checks (destination existence, permissions, freeze status), and the doApply logic that verifies the reserve, creates the Check entry, wires it into the owner directories, and adjusts the owner count. It is a concrete template you could adapt to build a transaction type of your own.
To remember:
src/libxrpl/tx/transactors/check/CheckCreate.cpp (with CheckCash and CheckCancel beside it)temREDUNDANT), SendMax positive and legal, currency valid, expiration not zerotecNO_DST), lsfDisallowIncomingCheck / pseudo-account (tecNO_PERMISSION), destination tag required (tecDST_TAG_NEEDED), frozen (tecFROZEN), expired (tecEXPIRED)tecINSUFFICIENT_RESERVE), create the SLE at keylet::check(account, seq), dirInsert into BOTH owner directories, adjustOwnerCount(+1)Next up. That closes the tour of the machine. New phase: everything you just saw is held together by mathematics. Welcome to cryptography, starting with the security model.
Resources
Assignments
0 of 4 complete