Create Custom Transactors
A transactor is code that processes a transaction and modifies the XRP Ledger. Creating custom transactors enables you to add new functionality to rippled
. This tutorial walks through coding transactors, but you'll have to go through the amendment process to add it to XRPL. See: Contribute Code to the XRP Ledger.
Transactors follow a basic order of operations:
- Access a view into a serialized type ledger entry (SLE).
- Update, erase, or insert values in the view.
- Apply the finalized changes from the view to the ledger.
This tutorial uses the existing CreateCheck
transactor as an example. You can view the source files here:
Header File
Create a header file in this format:
namespace ripple { class CreateCheck : public Transactor { public: static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; explicit CreateCheck(ApplyContext& ctx) : Transactor(ctx) { } static NotTEC preflight(PreflightContext const& ctx); static TER preclaim(PreclaimContext const& ctx); TER doApply() override; }; } // namespace ripple
Initializing the transactor with ApplyContext
gives it access to:
- The transaction that triggered the transactor.
- A view of the SLE.
- A journal to log errors.
CPP File
1. Add a preflight
function.
The preflight
function checks for errors in the transaction itself before accessing the ledger. It should reject invalid and incorrectly formed transactions.
PreflightContext
doesn't have a view of the ledger.Use bracket notation to retrieve fields from ledgers and transactions:
auto const curExpiration = (*sle*)[~sfExpiration]; (*sle)[sfBalance] = (*sle)[sfBalance] + reqDelta;
NoteThe~
symbol returns an optional type.You can view ledger and transaction schemas here:
rippled
summarizes transaction results with result codes. See: Transaction Results
CreateCheck::preflight(PreflightContext const& ctx) { // Check if this amendment functionality is enabled on the network. if (!ctx.rules.enabled(featureChecks)) return temDISABLED; NotTEC const ret{preflight1(ctx)}; if (!isTesSuccess(ret)) return ret; if (ctx.tx.getFlags() & tfUniversalMask) { // There are no flags (other than universal) for CreateCheck yet. JLOG(ctx.j.warn()) << "Malformed transaction: Invalid flags set."; return temINVALID_FLAG; } if (ctx.tx[sfAccount] == ctx.tx[sfDestination]) { // They wrote a check to themselves. JLOG(ctx.j.warn()) << "Malformed transaction: Check to self."; return temREDUNDANT; } { 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; } if (badCurrency() == sendMax.getCurrency()) { JLOG(ctx.j.warn()) << "Malformed transaction: Bad currency."; return temBAD_CURRENCY; } } if (auto const optExpiry = ctx.tx[~sfExpiration]) { if (*optExpiry == 0) { JLOG(ctx.j.warn()) << "Malformed transaction: bad expiration"; return temBAD_EXPIRATION; } } return preflight2(ctx); }
2. Add a preclaim
function.
The preclaim
function checks for errors that require viewing information on the current ledger.
- If this step returns a result code of
tesSUCCESS
or anytec
result, the transaction will be queued and broadcast to peers.
CreateCheck::preclaim(PreclaimContext const& ctx) { AccountID const dstId{ctx.tx[sfDestination]}; // Use the `keylet` function to get the key of the SLE. Views have either `read` or `peek` access. // `peek` access allows the developer to modify the SLE returned. auto const sleDst = ctx.view.read(keylet::account(dstId)); if (!sleDst) { JLOG(ctx.j.warn()) << "Destination account does not exist."; return tecNO_DST; } auto const flags = sleDst->getFlags(); // Check if the destination has disallowed incoming checks if (ctx.view.rules().enabled(featureDisallowIncoming) && (flags & lsfDisallowIncomingCheck)) return tecNO_PERMISSION; if ((flags & lsfRequireDestTag) && !ctx.tx.isFieldPresent(sfDestinationTag)) { // The tag is basically account-specific information we don't // understand, but we can require someone to fill it in. JLOG(ctx.j.warn()) << "Malformed transaction: DestinationTag required."; return tecDST_TAG_NEEDED; } { STAmount const sendMax{ctx.tx[sfSendMax]}; if (!sendMax.native()) { // The currency may not be globally frozen AccountID const& issuerId{sendMax.getIssuer()}; if (isGlobalFrozen(ctx.view, issuerId)) { JLOG(ctx.j.warn()) << "Creating a check for frozen asset"; return tecFROZEN; } // If this account has a trustline for the currency, that // trustline may not be frozen. // // Note that we DO allow create check for a currency that the // account does not yet have a trustline to. AccountID const srcId{ctx.tx.getAccountID(sfAccount)}; if (issuerId != srcId) { // Check if the issuer froze the line auto const sleTrust = ctx.view.read( keylet::line(srcId, issuerId, sendMax.getCurrency())); if (sleTrust && sleTrust->isFlag( (issuerId > srcId) ? lsfHighFreeze : lsfLowFreeze)) { JLOG(ctx.j.warn()) << "Creating a check for frozen trustline."; return tecFROZEN; } } if (issuerId != dstId) { // Check if dst froze the line. auto const sleTrust = ctx.view.read( keylet::line(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; } } } } if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) { JLOG(ctx.j.warn()) << "Creating a check that has already expired."; return tecEXPIRED; } return tesSUCCESS; }
3. Add a doApply()
function.
The doApply()
function has read/write access, enabling you to modify the ledger.
CreateCheck::doApply() { auto const sle = view().peek(keylet::account(account_)); if (!sle) return tefINTERNAL; // A check counts against the reserve of the issuing account, but we // check the starting balance because we want to allow dipping into the // reserve to pay fees. { STAmount const reserve{ view().fees().accountReserve(sle->getFieldU32(sfOwnerCount) + 1)}; if (mPriorBalance < reserve) return tecINSUFFICIENT_RESERVE; } // Note that we use the value from the sequence or ticket as the // Check sequence. For more explanation see comments in SeqProxy.h. std::uint32_t const seq = ctx_.tx.getSeqProxy().value(); Keylet const checkKeylet = keylet::check(account_, seq); auto sleCheck = std::make_shared<SLE>(checkKeylet); sleCheck->setAccountID(sfAccount, account_); 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); view().insert(sleCheck); auto viewJ = ctx_.app.journal("View"); // If it's not a self-send (and it shouldn't be), add Check to the // destination's owner directory. if (dstAccountId != account_) { 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); } { auto const page = view().dirInsert( keylet::ownerDir(account_), checkKeylet, describeOwnerDir(account_)); JLOG(j_.trace()) << "Adding Check to owner directory " << to_string(checkKeylet.key) << ": " << (page ? "success" : "failure"); if (!page) return tecDIR_FULL; sleCheck->setFieldU64(sfOwnerNode, *page); } // If we succeeded, the new entry counts against the creator's reserve. adjustOwnerCount(view(), sle, 1, viewJ); return tesSUCCESS; }
Additional Functions
You can add more helper functions to your custom transactor as necessary. There are a few special functions that are relevant in special cases.
calculateBaseFee
Most transactions inherit the default reference transaction cost. However, if your transactor needs to define a non-standard transaction cost, you can replace the transactor's calculateBaseFee
method with a custom one.
The following example shows how EscrowFinish
transactions charge an additional cost on conditional escrows based on the size of the fulfillment:
XRPAmount EscrowFinish::calculateBaseFee(ReadView const& view, STTx const& tx) { XRPAmount extraFee{0}; if (auto const fb = tx[~sfFulfillment]) { extraFee += view.fees().base * (32 + (fb->size() / 16)); } return Transactor::calculateBaseFee(view, tx) + extraFee; }
makeTxConsequences
rippled
uses a TxConsequences
class to describe the outcome to an account when applying a transaction. It tracks the fee, maximum possible XRP spent, and how many sequence numbers are consumed by the transaction. There are three types of consequences:
- Normal: The transactor doesn't affect transaction signing and only consumes an XRP fee. Transactions that spend XRP beyond the fee aren't considered normal.
- Blocker: The transactor affects transaction signing, preventing valid transactions from queueing behind it.
- Custom: The transactor needs to do additional work to determination consequences.
The makeTxConsequences
function enables you to create custom consequences for situations such as:
- Payments sending XRP.
- Tickets consuming more than one sequence number.
- Transactions that are normal or blockers, depending on flags or fields set.
TxConsequences
only affects the transaction queue. If a transaction is likely to claim a fee when applied to the ledger, it will be broadcast to peers. If it's not likely to claim a fee, or that can't be determined, it won't be broadcast.SetAccount::makeTxConsequences(PreflightContext const& ctx) { // The SetAccount may be a blocker, but only if it sets or clears // specific account flags. auto getTxConsequencesCategory = [](STTx const& tx) { if (std::uint32_t const uTxFlags = tx.getFlags(); uTxFlags & (tfRequireAuth | tfOptionalAuth)) return TxConsequences::blocker; if (auto const uSetFlag = tx[~sfSetFlag]; uSetFlag && (*uSetFlag == asfRequireAuth || *uSetFlag == asfDisableMaster || *uSetFlag == asfAccountTxnID)) return TxConsequences::blocker; if (auto const uClearFlag = tx[~sfClearFlag]; uClearFlag && (*uClearFlag == asfRequireAuth || *uClearFlag == asfDisableMaster || *uClearFlag == asfAccountTxnID)) return TxConsequences::blocker; return TxConsequences::normal; }; return TxConsequences{ctx.tx, getTxConsequencesCategory(ctx.tx)}; }
Next Steps
Re-compile the server with your new transactor and test it in stand-alone mode. If you coded the transactor behind an amendment, you can force-enable the feature using the config file.