| sidebar_position | 1 | ||||||
|---|---|---|---|---|---|---|---|
| slug | custom-instruction | ||||||
| title | Custom Instruction | ||||||
| authors |
|
||||||
| description | Performing custom function calls in Flare Smart Accounts via XRPL payments with an off-chain executor. | ||||||
| tags |
|
||||||
| keywords |
|
import ThemedImage from "@theme/ThemedImage"; import useBaseUrl from "@docusaurus/useBaseUrl";
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.
:::warning No destination tags 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. :::
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.
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.
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.
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.
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:
sequenceDiagram
participant User as User (XRPL)
participant XRPL
participant Executor
participant AM as AssetManagerFXRP
participant MasterAccountController
participant PersonalAccount
User->>User: 1. encode UserOp
User->>User: compute keccak256(userOp)
User->>XRPL: 2. Payment to direct-minting<br/>address with 42-byte memo<br/>[0xFE][walletId][fee][hash]
Note over Executor: receives userOp bytes<br/>off-chain
Executor->>Executor: 3. fetch FDC XRPPayment proof
Executor->>AM: 4. executeDirectMintingWithData(proof, data)<br/>{value: sum(call.value)}
AM->>MasterAccountController: handleMintedFAssets(..., _memoData, _executor, _data)
MasterAccountController->>MasterAccountController: keccak256(_data) == hash?
MasterAccountController->>PersonalAccount: call{value}(executeUserOp(calls))
MasterAccountController-->>User: UserOperationExecuted event
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).
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.
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.
If the bytes the executor submits do not hash to the commitment in the memo, handleMintedFAssets reverts with CustomInstructionHashMismatch(expected, actual).
The FAsset transfer is performed before the memo is decoded, however, so even on a mismatch the FXRP credited by direct minting remains in the personal account, and the user can recover by issuing a new user operation (see Failure Handling).
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.
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.
The whole pipeline is atomic with respect to the user operation:
- 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 user operation 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 user operation reverts.
Because the FAsset transfer happens before the memo is decoded, the FXRP mint succeeds even if the user operation reverts - see DirectMintingExecuted.
The minted FXRP remains in the personal account and the user can recover by either re-submitting a fixed user operation (with the next nonce) or moving the FXRP through standard FAssets instructions.
- 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.