Custom Instruction
Flare Smart Accounts let an XRPL user execute arbitrary contract calls on Flare through an XRPL Payment transaction.
Each personal account exposes an EIP-4337 style executeUserOp entry point that the MasterAccountController invokes when it processes a custom instruction memo.
The custom instruction (memo opcode 0xFE) is the recommended way to drive those calls.
The XRPL memo is a fixed 42 bytes that commits to a PackedUserOperation by carrying only its keccak256 hash, and an off-chain executor delivers the actual user operation bytes to the FAssets AssetManager on Flare.
This keeps the XRPL footprint constant regardless of how complex the call batch is.
For the simpler single-actor variant that ships the entire PackedUserOperation inline in the XRPL memo, see the Memo Field Custom Instruction; the comparison guide covers when to pick which.
XRPL transactions targeting smart accounts must not use a destination tag. A destination tag forces FAssets direct minting to credit the tag-holder, which would let an unrelated party front-run the user operation.
User Operation Payload
A custom instruction has two layers: the outer EIP-4337 PackedUserOperation that the XRPL memo commits to, and the inner executeUserOp(Call[]) that the personal account runs once the controller dispatches it.
Only three fields from the PackedUserOperation struct are required for Flare Smart Accounts:
sendermust equal the address of the personal account derived from the XRPL sender. UsegetPersonalAccountto look it up - the address is deterministic, so you can fetch it before the account is even deployed.noncemust equal the personal account's current nonce returned bygetNonce. The nonce auto-increments on every successful execution to prevent replay.callDatais the data that the controller invokes on the personal account. In practice, this isabi.encodeCall(IPersonalAccount.executeUserOp, (calls))- anything else either reverts or is rejected by the personal account'sonlyControllermodifier.
The remaining fields are not validated on-chain and can be left empty.
Authorization comes from the XRPL signature on the Payment XRPL payment transaction itself: only the XRPL key for sender's xrplOwner can deliver the memo.
If the personal account has pinned an executor via getExecutor, only that executor is permitted to relay the mint.
executeUserOp and the Call struct
The personal account's executeUserOp(Call[]) runs each entry in the Call[] in order, forwarding it with its supplied value and data:
struct Call {
address target;
uint256 value;
bytes data;
}
function executeUserOp(Call[] calldata _calls) external payable;
Each call is dispatched with the personal account as msg.sender.
If any call reverts, the whole user operation reverts with CallFailed - partial execution is not possible.
The executeUserOp function is payable, so the user operation can forward native tokens (e.g. FLR) alongside the calls.
To fund the personal account, send FLR to the address using the Flare faucet.
Building callData in TypeScript
You can build the callData in TypeScript using the encodeFunctionData function from the viem library:
import { encodeFunctionData } from "viem";
const calls = [
{
target: counterAddress,
value: 0n,
data: encodeFunctionData({
abi: counterAbi,
functionName: "increment",
args: [],
}),
},
];
const callData = encodeFunctionData({
abi: personalAccountAbi,
functionName: "executeUserOp",
args: [calls],
});
The encoded callData becomes the callData field of the PackedUserOperation that the XRPL memo commits to.
Memo Layout
The custom instruction memo is a constant 42 bytes:
| Bytes | Field | Meaning |
|---|---|---|
0 | instructionId | 0xFE - custom instruction |
1 | walletId | One-byte wallet identifier assigned by Flare; 0 if not registered |
2-9 | executorFeeUBA | Executor fee in the FAsset's smallest unit, big-endian uint64 |
10-41 | userOpHash | keccak256(abi.encode(userOp)) - the 32-byte commitment |
The memo length is independent of the call batch: a single small call and a 50-call batch both fit in the same 42 bytes, because the user-operation bytes the executor delivers off-chain never touch the XRPL ledger.
This is the main practical advantage over the Memo Field Custom Instruction, whose memo carries the entire ABI-encoded PackedUserOperation and is therefore subject to the XRPL's 1024-byte memo cap.
The off-chain delivery also makes the call payload private on XRPL.
Only the 32-byte commitment is published; the inner target, value, and data of each call only become visible when the executor submits the user operation to Flare.
Three-step Protocol
The 0xFE flow runs three steps that map onto two independent actors.
A demo script can run all three from the same process, but the on-chain checks are designed around the two-actor split.
The executor bridges the XRPL payment to Flare with a proof from the Flare Data Connector (FDC), the same attestation system used by the proof-based flow:
Step 1: User Side
The user constructs the PackedUserOperation as described in User operation payload, computes keccak256 over the ABI encoding, and packs the 42-byte memo from Memo layout.
The user sends an XRPL Payment to the FAssets direct minting address with this memo, and delivers the full PackedUserOperation bytes to the executor off-chain (e.g. over an authenticated HTTP API).
Step 2: Executor Side
The executor takes the XRPL transaction hash, requests an XRPPayment attestation from the Flare Data Connector, and calls executeDirectMintingWithData on AssetManagerFXRP (see the FAssets direct minting page):
function executeDirectMintingWithData(
IXRPPayment.Proof calldata _payment,
bytes calldata _data
) external payable;
_paymentis the FDC proof of the XRPLPayment._datais the ABI-encodedPackedUserOperationthat was delivered off-chain.msg.valuemust equal the sum ofcall.valueacross the user operation. TheAssetManagerFXRPforwards this value to theMasterAccountControllerfunctionhandleMintedFAssets, which forwards it to the personal account'sexecuteUserOpfunction so that the inner calls can attach the native value.
The executeDirectMintingWithData function is only valid for smart-account targets - calling it for a non-smart-account direct mint reverts.
Step 3: Confirmation
The MasterAccountController verifies on-chain that keccak256(_data) == userOpHash from the memo.
If it matches, it decodes _data as a PackedUserOperation, validates sender and nonce, executes executeUserOp on the personal account, and emits UserOperationExecuted - all inside the executor's transaction.
This is the key difference from the proof-based dispatch: there is no separate cross-chain wait, because the executor's call already executed the user operation by the time it returns.
Hash Mismatch
If the bytes the executor submits do not hash to the commitment in the memo, handleMintedFAssets reverts with CustomInstructionHashMismatch(expected, actual).
Because executeDirectMintingWithData is fully atomic, the entire Flare transaction reverts — no FXRP is minted.
The underlying XRP remains at the Core Vault until a successful direct mint finalizes it (see Failure Handling and Recovery after a failed mint).
Call Value Accounting
Whatever native value the executor attaches to executeDirectMintingWithData is forwarded all the way to executeUserOp (AssetManagerFXRP -> MasterAccountController.handleMintedFAssets -> PersonalAccount.executeUserOp).
The executor must therefore compute the total native value to attach as the sum of call.value across the user operation it received off-chain.
The user-side helper in the TypeScript guide returns this value alongside the XRPL transaction hash, so the executor does not have to recompute it from scratch.
Replay Protection
Two replay-protection layers gate every custom instruction:
- The user operation's
noncemust equal the personal account's current memo-instruction nonce; the nonce auto-increments on every successful execution. - The XRPL transaction ID is recorded in the controller and cannot be reused for a second mint.
The on-chain hash check additionally pins the executor's _data to the exact bytes the user signed via XRPL, so the executor cannot substitute a different payload after the fact.
Failure Handling
When executeDirectMintingWithData reverts, the entire Flare transaction rolls back atomically:
- No FXRP is minted on Flare.
- No user operation runs — there is no
UserOperationExecutedevent. - The XRPL payment is not reversed — the underlying XRP remains at the Core Vault until a successful direct mint finalizes it. It is not automatically refunded to the user's XRPL wallet.
This failure path should be rare.
The intended UX is mint plus user operation in one atomic Flare transaction — for example, minting FXRP and withdrawing it to an EOA in a single executeDirectMintingWithData call.
Common revert reasons
Any validation or execution failure inside handleMintedFAssets reverts the whole call:
- If
senderdoes not match the personal account, the call reverts withInvalidSender. - If
nonceis not the expected value, it reverts withInvalidNonce. - If the memo length is not exactly
42bytes,handleMintedFAssetsreverts withInvalidMemoData; an unrecognized instruction byte reverts withInvalidInstructionId. - If
keccak256(_data)does not match the hash in the memo, the call reverts withCustomInstructionHashMismatch(expected, actual). - If the executor's
msg.valueis less than the sum ofcall.valueacross the inner calls, the inner call reverts withCallFailedand the whole transaction reverts. - If the personal account has pinned an executor via
getExecutorand the caller ofexecuteDirectMintingWithDatais not that executor, the call reverts withWrongExecutor. - If any inner call reverts, the personal account surfaces it as
CallFailedand the entire transaction reverts.
Recovery after a failed mint
If executeDirectMintingWithData reverts and the XRPL payment is still unminted, the user can recover FXRP without executing the original custom instruction:
- Send an XRPL
Paymentwith memo opcode0xE0(Skip memo) targeting the stuck payment's transaction ID. This emitsIgnoreMemoSeton the personal account. - The executor re-submits
executeDirectMintingWithDatawith the original FDCXRPPaymentproof. The skip flag causes the controller to mint FXRP to the personal account without dispatching the original user operation. - The user can then move the FXRP through standard FAssets instructions or submit a new user operation with the current
getNoncevalue.
For a step-by-step TypeScript implementation, see the Recover Stuck Mint Transaction guide.
Related recovery opcodes:
0xE1(Fast-forward nonce) — advance the memo-instruction nonce when it is stuck after a partial or abandoned flow. For a step-by-step TypeScript implementation, see the Fast-Forward Nonce guide.0xE2(Replace executor fee) — set a replacement executor fee for a stuck XRPL payment.
Avoiding duplicate-nonce failures
A common cause of reverts is submitting two XRPL payments in short succession, each embedding a different PackedUserOperation but both using the same getNonce value — for example, two withdraw attempts built before either mint finalizes.
Only one payment can consume a given nonce.
Whichever mint executes first succeeds and increments the nonce; the other reverts with InvalidNonce, leaving its XRP at the Core Vault until recovered.
To avoid this:
- Read
getNonceonce per XRPL payment and do not reuse it across concurrent flows. - Wait for the first mint to finalize — or confirm that it reverted — before building another payment with a new user operation.
- Executors: if the
AssetManageremitsDirectMintingDelayed, wait untilexecutionAllowedAtand re-callexecuteDirectMintingWithData. Do not treat a delayed mint as a hard failure and prompt the user to send a duplicate XRPL payment with the same nonce.
Next steps
- Walk through a Viem implementation in the Custom Instruction TypeScript guide.
- See the simpler single-actor variant in the Memo Field Custom Instruction.
- Compare the two flows in the Custom Instruction Comparison.
- Dig into
IMasterAccountControllerin the reference.