|
| 1 | +--- |
| 2 | +sidebar_position: 1 |
| 3 | +slug: custom-instruction |
| 4 | +title: Custom Instruction |
| 5 | +authors: [nikerzetic] |
| 6 | +description: Performing custom function calls in the Flare Smart Accounts by committing to a PackedUserOperation hash on XRPL and delivering the bytes via an executor (0xFE). |
| 7 | +tags: [intermediate, ethereum, flare-smart-accounts] |
| 8 | +keywords: |
| 9 | + [ |
| 10 | + flare-fdc, |
| 11 | + ethereum, |
| 12 | + flare-smart-accounts, |
| 13 | + evm, |
| 14 | + flare-network, |
| 15 | + account-abstraction, |
| 16 | + ] |
| 17 | +--- |
| 18 | + |
| 19 | +import ThemedImage from "@theme/ThemedImage"; |
| 20 | +import useBaseUrl from "@docusaurus/useBaseUrl"; |
| 21 | + |
| 22 | +Flare Smart Accounts let an XRPL user execute arbitrary contract calls on Flare through an XRPL [`Payment`](https://xrpl.org/docs/references/protocol/transactions/types/payment) transaction. |
| 23 | +Each personal account exposes an [EIP-4337](https://eips.ethereum.org/EIPS/eip-4337) style `executeUserOp` entry point that the [`MasterAccountController`](/smart-accounts/reference/IMasterAccountController) invokes when it processes a custom instruction memo. |
| 24 | + |
| 25 | +The **custom instruction** (memo opcode `0xFE`) is the recommended way to drive those calls. |
| 26 | +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. |
| 27 | +This keeps the XRPL footprint constant regardless of how complex the call batch is. |
| 28 | + |
| 29 | +For the simpler single-actor variant that ships the entire `PackedUserOperation` inline in the XRPL memo, see the [Raw Custom Instruction](/smart-accounts/raw-custom-instruction); the [comparison guide](/smart-accounts/custom-instruction-comparison) covers when to pick which. |
| 30 | + |
| 31 | +:::warning No destination tags |
| 32 | +XRPL transactions targeting smart accounts must not use a destination tag. |
| 33 | +A destination tag forces [FAssets direct minting](/fassets/direct-minting) to credit the tag-holder, which would let an unrelated party front-run the user operation. |
| 34 | +::: |
| 35 | + |
| 36 | +## User operation payload |
| 37 | + |
| 38 | +A custom instruction has two layers: the outer EIP-4337 [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation) that the XRPL memo commits to, and the inner [`executeUserOp(Call[])`](/smart-accounts/reference/IPersonalAccount#executeuserop) that the personal account runs once the controller dispatches it. |
| 39 | + |
| 40 | +Only three fields from the [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation) struct are required for Flare Smart Accounts: |
| 41 | + |
| 42 | +- `sender` **must** equal the address of the personal account derived from the XRPL sender. |
| 43 | + Use [`getPersonalAccount`](/smart-accounts/reference/IMasterAccountController#getpersonalaccount) to look it up - the address is deterministic, so you can fetch it before the account is even deployed. |
| 44 | +- `nonce` **must** equal the personal account's current nonce returned by [`getNonce`](/smart-accounts/reference/IMasterAccountController#getnonce). |
| 45 | + The nonce auto-increments on every successful execution to prevent replay. |
| 46 | +- `callData` is the calldata that the controller invokes on the personal account. |
| 47 | + In practice, this is `abi.encodeCall(IPersonalAccount.executeUserOp, (calls))` - anything else either reverts or is rejected by the personal account's `onlyController` modifier. |
| 48 | + |
| 49 | +The remaining fields are not validated on-chain and can be left empty. |
| 50 | +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. |
| 51 | +If the personal account has pinned an executor via [`getExecutor`](/smart-accounts/reference/IMasterAccountController#getexecutor), only that executor is permitted to relay the mint. |
| 52 | + |
| 53 | +### `executeUserOp` and the `Call` struct |
| 54 | + |
| 55 | +The personal account's [`executeUserOp(Call[])`](/smart-accounts/reference/IPersonalAccount#executeuserop) runs each entry in the `Call[]` in order, forwarding it with its supplied `value` and `data`: |
| 56 | + |
| 57 | +```solidity |
| 58 | +struct Call { |
| 59 | + address target; |
| 60 | + uint256 value; |
| 61 | + bytes data; |
| 62 | +} |
| 63 | +
|
| 64 | +function executeUserOp(Call[] calldata _calls) external payable; |
| 65 | +``` |
| 66 | + |
| 67 | +Each call is dispatched with the personal account as `msg.sender`. |
| 68 | +If any call reverts, the whole user operation reverts with [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) - partial execution is not possible. |
| 69 | + |
| 70 | +The `executeUserOp` function is `payable`, so the user operation can forward native tokens (e.g. FLR) alongside the calls. |
| 71 | +To fund the personal account, send FLR to the address using the [Flare faucet](https://faucet.flare.network/). |
| 72 | + |
| 73 | +### Building `callData` in TypeScript |
| 74 | + |
| 75 | +You can build the `callData` in TypeScript using the [`encodeFunctionData`](https://viem.sh/docs/contract/encodeFunctionData#encodefunctiondata) function from the `viem` library: |
| 76 | + |
| 77 | +```typescript |
| 78 | +import { encodeFunctionData } from "viem"; |
| 79 | + |
| 80 | +const calls = [ |
| 81 | + { |
| 82 | + target: counterAddress, |
| 83 | + value: 0n, |
| 84 | + data: encodeFunctionData({ |
| 85 | + abi: counterAbi, |
| 86 | + functionName: "increment", |
| 87 | + args: [], |
| 88 | + }), |
| 89 | + }, |
| 90 | +]; |
| 91 | + |
| 92 | +const callData = encodeFunctionData({ |
| 93 | + abi: personalAccountAbi, |
| 94 | + functionName: "executeUserOp", |
| 95 | + args: [calls], |
| 96 | +}); |
| 97 | +``` |
| 98 | + |
| 99 | +The encoded `callData` becomes the `callData` field of the `PackedUserOperation` that the XRPL memo commits to. |
| 100 | + |
| 101 | +## Memo layout |
| 102 | + |
| 103 | +The custom instruction memo is a constant 42 bytes: |
| 104 | + |
| 105 | +| Bytes | Field | Meaning | |
| 106 | +| ------- | ---------------- | ------------------------------------------------------------------- | |
| 107 | +| `0` | `instructionId` | `0xFE` - custom instruction | |
| 108 | +| `1` | `walletId` | One-byte wallet identifier assigned by Flare; `0` if not registered | |
| 109 | +| `2-9` | `executorFeeUBA` | Executor fee in the FAsset's smallest unit, big-endian `uint64` | |
| 110 | +| `10-41` | `userOpHash` | `keccak256(abi.encode(userOp))` - the 32-byte commitment | |
| 111 | + |
| 112 | +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. |
| 113 | +This is the main practical advantage over the [Raw Custom Instruction](/smart-accounts/raw-custom-instruction), whose memo carries the entire ABI-encoded `PackedUserOperation` and is therefore subject to the XRPL's 1024-byte memo cap. |
| 114 | + |
| 115 | +The off-chain delivery also makes the call payload **private on XRPL**. |
| 116 | +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. |
| 117 | + |
| 118 | +## Three-step protocol |
| 119 | + |
| 120 | +The 0xFE flow runs three steps that map onto two independent actors in production. |
| 121 | +A demo script can run all three from the same process, but the on-chain checks are designed around the two-actor split. |
| 122 | +The executor bridges the XRPL payment to Flare with a proof from the [Flare Data Connector (FDC)](/fdc/overview), the same attestation system used by the proof-based flow: |
| 123 | + |
| 124 | +```mermaid |
| 125 | +sequenceDiagram |
| 126 | + participant User as User (XRPL) |
| 127 | + participant XRPL |
| 128 | + participant Executor |
| 129 | + participant AM as AssetManagerFXRP |
| 130 | + participant MasterAccountController |
| 131 | + participant PersonalAccount |
| 132 | +
|
| 133 | + User->>User: 1. encode UserOp |
| 134 | + User->>User: compute keccak256(userOp) |
| 135 | + User->>XRPL: 2. Payment to direct-minting<br/>address with 42-byte memo<br/>[0xFE][walletId][fee][hash] |
| 136 | + Note over Executor: receives userOp bytes<br/>out-of-band |
| 137 | +
|
| 138 | + Executor->>Executor: 3. fetch FDC XRPPayment proof |
| 139 | + Executor->>AM: 4. executeDirectMintingWithData(proof, data)<br/>{value: sum(call.value)} |
| 140 | + AM->>MasterAccountController: handleMintedFAssets(..., _memoData, _executor, _data) |
| 141 | + MasterAccountController->>MasterAccountController: keccak256(_data) == hash? |
| 142 | + MasterAccountController->>PersonalAccount: call{value}(executeUserOp(calls)) |
| 143 | + MasterAccountController-->>User: UserOperationExecuted event |
| 144 | +``` |
| 145 | + |
| 146 | +### Step 1: user side |
| 147 | + |
| 148 | +The user constructs the `PackedUserOperation` as described in [User operation payload](#user-operation-payload), computes `keccak256` over the ABI encoding, and packs the 42-byte memo from [Memo layout](#memo-layout). |
| 149 | + |
| 150 | +The user sends an XRPL `Payment` to the FAssets direct-minting address with this memo, and delivers the full `PackedUserOperation` bytes to the executor **out-of-band** (e.g. over an authenticated HTTP API). |
| 151 | +The bytes never appear on the XRPL ledger. |
| 152 | + |
| 153 | +### Step 2: executor side |
| 154 | + |
| 155 | +The executor takes the XRPL transaction hash, requests an [`IXRPPayment` attestation](/fdc/attestation-types/payment) from the [Flare Data Connector](/fdc/overview), and calls `executeDirectMintingWithData` on `AssetManagerFXRP` (see the [FAssets direct minting page](/fassets/direct-minting)): |
| 156 | + |
| 157 | +```solidity |
| 158 | +function executeDirectMintingWithData( |
| 159 | + IXRPPayment.Proof calldata _payment, |
| 160 | + bytes calldata _data |
| 161 | +) external payable; |
| 162 | +``` |
| 163 | + |
| 164 | +- `_payment` is the FDC proof of the XRPL `Payment`. |
| 165 | +- `_data` is the ABI-encoded `PackedUserOperation` that was delivered out-of-band. |
| 166 | +- `msg.value` **must equal the sum of `call.value` across the user operation**. |
| 167 | + `AssetManagerFXRP` forwards this value into `MasterAccountController.handleMintedFAssets`, which forwards it again into the personal account's `executeUserOp` so the inner calls can attach native value. |
| 168 | + |
| 169 | +The `executeDirectMintingWithData` function is **only valid for smart-account targets** - calling it for a non-smart-account direct mint reverts. |
| 170 | + |
| 171 | +### Step 3: confirmation |
| 172 | + |
| 173 | +The `MasterAccountController` verifies on-chain that `keccak256(_data) == userOpHash` from the memo. |
| 174 | +If it matches, it decodes `_data` as a `PackedUserOperation`, validates `sender` and `nonce`, executes `executeUserOp` on the personal account, and emits [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted) - **all inside the executor's transaction**. |
| 175 | +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. |
| 176 | + |
| 177 | +## Hash mismatch |
| 178 | + |
| 179 | +If the bytes the executor submits do not hash to the commitment in the memo, `handleMintedFAssets` reverts with `CustomInstructionHashMismatch(expected, actual)`. |
| 180 | +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 fresh user operation (see [Failure Handling](#failure-handling)). |
| 181 | + |
| 182 | +## Call value accounting |
| 183 | + |
| 184 | +Whatever native value the executor attaches to `executeDirectMintingWithData` is forwarded all the way to `executeUserOp` (`AssetManagerFXRP -> MasterAccountController.handleMintedFAssets -> PersonalAccount.executeUserOp`). |
| 185 | +The executor must therefore compute the total native value to attach as the sum of `call.value` across the user operation it received out-of-band. |
| 186 | +The user-side helper in the [TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts) returns this value alongside the XRPL transaction hash, so the executor does not have to recompute it from scratch. |
| 187 | + |
| 188 | +## Replay protection |
| 189 | + |
| 190 | +Two replay-protection layers gate every custom instruction: |
| 191 | + |
| 192 | +- The user operation's `nonce` must equal the personal account's current memo-instruction nonce; the nonce auto-increments on every successful execution. |
| 193 | +- The XRPL transaction ID is recorded in the controller and cannot be reused for a second mint. |
| 194 | + |
| 195 | +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. |
| 196 | + |
| 197 | +## Failure Handling |
| 198 | + |
| 199 | +The whole pipeline is atomic with respect to the user operation: |
| 200 | + |
| 201 | +- If `sender` does not match the personal account, the call reverts with [`InvalidSender`](/smart-accounts/reference/IMasterAccountController#invalidsender). |
| 202 | +- If `nonce` is not the expected value, it reverts with [`InvalidNonce`](/smart-accounts/reference/IMasterAccountController#invalidnonce). |
| 203 | +- If the memo length is not exactly `42` bytes, `handleMintedFAssets` reverts with [`InvalidMemoData`](/smart-accounts/reference/IMasterAccountController#invalidmemodata); an unrecognized instruction byte reverts with [`InvalidInstructionId`](/smart-accounts/reference/IMasterAccountController#invalidinstructionid). |
| 204 | +- If `keccak256(_data)` does not match the hash in the memo, the call reverts with `CustomInstructionHashMismatch(expected, actual)`. |
| 205 | +- If the executor's `msg.value` is less than the sum of `call.value` across the inner calls, the inner call reverts with [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the whole user operation reverts. |
| 206 | +- If the personal account has pinned an executor via [`getExecutor`](/smart-accounts/reference/IMasterAccountController#getexecutor) and the caller of `executeDirectMintingWithData` is not that executor, the call reverts with [`WrongExecutor`](/smart-accounts/reference/IMasterAccountController#wrongexecutor). |
| 207 | +- If any inner call reverts, the personal account surfaces it as [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the entire user operation reverts. |
| 208 | + |
| 209 | +Because the FAsset transfer happens before the memo is decoded, **the FXRP mint succeeds even if the user operation reverts** - see [`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted). |
| 210 | +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](/smart-accounts/fasset-instructions). |
| 211 | + |
| 212 | +## Next steps |
| 213 | + |
| 214 | +- Walk through a Viem implementation in the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts). |
| 215 | +- See the simpler single-actor variant in the [Raw Custom Instruction](/smart-accounts/raw-custom-instruction). |
| 216 | +- Compare the two flows in the [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison). |
| 217 | +- Dig into `IMasterAccountController` in the [reference](/smart-accounts/reference/IMasterAccountController). |
0 commit comments