diff --git a/docs/smart-accounts/1-overview.mdx b/docs/smart-accounts/1-overview.mdx index cfe21583..462173ee 100644 --- a/docs/smart-accounts/1-overview.mdx +++ b/docs/smart-accounts/1-overview.mdx @@ -33,7 +33,7 @@ Flare Smart Accounts support two complementary flows for turning an XRPL `Paymen ### Proof-based flow (payment reference) 1. The XRPL user sends a `Payment` transaction to the operator's XRPL address with a 32-byte instruction encoded as the **payment reference**. -2. The operator requests a [`Payment` attestation](/fdc/attestation-types/payment) from the [Flare Data Connector](/fdc/overview) and submits it to the `MasterAccountController`. +2. The operator requests a [`Payment` attestation](/fdc/attestation-types/payment) from the [Flare Data Connector (FDC)](/fdc/overview) and submits it to the `MasterAccountController`. 3. The XRPL user's `PersonalAccount` performs the action encoded in the reference. Executor
backend")] AssetManager["FAssets AssetManager
contract"] - MAC["MasterAccountController
contract"] - PA["PersonalAccount
contract"] + MasterAccountController["MasterAccountController
contract"] + PersonalAccount["PersonalAccount
contract"] end User -- "1 - Payment + memo" --> Agent Agent -. "2 - observe payment" .-> Executor Executor -- "3 - executeDirectMinting" --> AssetManager - AssetManager == "4 - mint FXRP +
mintedFAssets callback" ==> MAC - MAC -- "5 - forward FXRP +
dispatch memo instruction" --> PA - User -. "controls" .-> PA + AssetManager == "4 - mint FXRP +
handleMintedFAssets callback" ==> MasterAccountController + MasterAccountController -- "5 - forward FXRP +
dispatch memo instruction" --> PersonalAccount + User -. "controls" .-> PersonalAccount style Flare stroke-dasharray: 5 5 style XRPL stroke-dasharray: 5 5 @@ -83,7 +83,7 @@ graph TB

Direct-minting flow: XRPL `Payment` carrying a memo (1) → FAssets agent (2) → executor calls `AssetManager.executeDirectMinting` (3) → `AssetManager` mints - FXRP and calls back into `mintedFAssets` (4) → `MasterAccountController` + FXRP and calls back into `handleMintedFAssets` (4) → `MasterAccountController` forwards FXRP and dispatches the memo to the user's `PersonalAccount` (5).

@@ -106,7 +106,7 @@ The first nibble is the instruction type. This is either `FXRP`, `Firelight`, or `Upshift` (with corresponding type IDS `0`, `1`, and `2`). The second nibble is the instruction command; the available commands are different for each instruction type. -For the direct-minting flow, the memo carries a different layout — see the [Custom Instruction guide](/smart-accounts/custom-instruction) for the `PackedUserOperation` format and the `0xFF`/`0xE0`/`0xE1`/`0xE2`/`0xD0`/`0xD1` instruction codes. +For the direct-minting flow, the memo carries a different layout; the first byte selects one of the [memo opcodes](#memo-opcodes-direct-minting-flow) listed below.
Table of instruction IDs and corresponding actions. @@ -151,12 +151,28 @@ Instructions for interacting with an Upshift-type vault.
+### Memo opcodes (direct-minting flow) + +The XRPL memo for the direct-minting flow selects one of the following opcodes in its first byte: + +| Memo opcode | Action | Description | +| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0xFE` | Custom Instruction | Carry `keccak256(PackedUserOperation)` in the memo; the bytes are delivered off-chain by an executor. See [Custom Instruction](/smart-accounts/custom-instruction). | +| `0xFF` | Raw Custom Instruction | Carry the full ABI-encoded `PackedUserOperation` inline in the memo. See [Raw Custom Instruction](/smart-accounts/raw-custom-instruction). | +| `0xE0` | Skip memo | Mark a target XRPL transaction's memo to be skipped on its next direct mint. | +| `0xE1` | Fast-forward nonce | Advance the personal account's memo-instruction nonce. | +| `0xE2` | Replace executor fee | Set a replacement executor fee for a stuck XRPL transaction. | +| `0xD0` | Pin executor | Pin a specific executor address to the personal account. | +| `0xD1` | Unpin executor | Unpin the executor from the personal account. | + +The [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison) covers when to choose `0xFE` over `0xFF`. + ## Dispatch on Flare ### Proof-based flow The operator monitors incoming transactions to the specified XRPL address. -Upon receiving a payment, it requests a [`Payment` attestation](/fdc/attestation-types/payment) from the FDC and submits the proof together with the user's XRPL address to the appropriate facet on the `MasterAccountController`: +Upon receiving a payment, it requests a [`Payment` attestation](/fdc/attestation-types/payment) from the FDC and submits the proof together with the user's XRPL address to the appropriate function on the `MasterAccountController`: - [`reserveCollateral`](/smart-accounts/reference/IMasterAccountController#reservecollateral) — for command `00` of any instruction type. Takes the payment reference and XRPL transaction ID (no FDC proof needed at this stage, the user has only committed to mint). @@ -173,16 +189,17 @@ The contract then resolves the XRPL user's `PersonalAccount` from the address ma ### Direct-minting flow -When the user mints FXRP directly to their smart account via [FAssets direct minting](/fassets/direct-minting), the FAssets `AssetManager` calls back into [`mintedFAssets`](/smart-accounts/reference/IMasterAccountController#mintedfassets) on `MemoInstructionsFacet`. -The facet enforces that the caller is the `AssetManager`, resolves (or deploys) the user's `PersonalAccount`, pays an executor fee out of the minted FAssets, forwards the remainder to the personal account, and dispatches any memo instruction (`0xFF`, `0xE0`, `0xE1`, `0xE2`, `0xD0`, `0xD1`). +When the user mints FXRP directly to their smart account via [FAssets direct minting](/fassets/direct-minting), the FAssets `AssetManager` calls back into [`handleMintedFAssets`](/smart-accounts/reference/IMasterAccountController#handlemintedfassets) on the `MasterAccountController`. +It enforces that the caller is the `AssetManager`, resolves (or deploys) the user's `PersonalAccount`, pays an executor fee out of the minted FAssets, forwards the remainder to the personal account, and dispatches any [memo instruction](#memo-opcodes-direct-minting-flow). ## Actions on Flare The XRPL user's smart account performs the actions in the instructions. This can be any of the instructions listed above, reserving collateral for minting FXRP, transferring FXRP to another address, redeeming FXRP, depositing it into a vault ... -Furthermore, custom instructions (`0xFF`) can be executed — arbitrary function calls on Flare, encoded as an EIP-4337 `PackedUserOperation` carried in the XRPL memo and replayed on-chain by the personal account. +Furthermore, custom instructions can be executed - arbitrary function calls on Flare, encoded as an [EIP-4337](https://eips.ethereum.org/EIPS/eip-4337) `PackedUserOperation` and replayed on-chain by the personal account. +The user operation can be committed to as a 32-byte hash with the bytes delivered to Flare by an off-chain executor (opcode `0xFE`, see the [Custom Instruction guide](/smart-accounts/custom-instruction)), or carried in the XRPL memo in full (opcode `0xFF`, see the [Raw Custom Instruction guide](/smart-accounts/raw-custom-instruction)). Authorization comes from the XRPL `Payment` signature itself; the on-chain check only validates the `sender` and `nonce` fields of the `PackedUserOperation`. -See the [Custom Instruction guide](/smart-accounts/custom-instruction) for the details. +The [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison) covers when to pick each. ## Video Tutorials diff --git a/docs/smart-accounts/3-custom-instruction.mdx b/docs/smart-accounts/3-custom-instruction.mdx index d44a62b3..e7c84311 100644 --- a/docs/smart-accounts/3-custom-instruction.mdx +++ b/docs/smart-accounts/3-custom-instruction.mdx @@ -3,7 +3,7 @@ sidebar_position: 1 slug: custom-instruction title: Custom Instruction authors: [nikerzetic] -description: Performing custom function calls in the Flare Smart Accounts. +description: Performing custom function calls in Flare Smart Accounts via XRPL payments with an off-chain executor. tags: [intermediate, ethereum, flare-smart-accounts] keywords: [ @@ -19,29 +19,32 @@ keywords: import ThemedImage from "@theme/ThemedImage"; import useBaseUrl from "@docusaurus/useBaseUrl"; -Flare Smart Accounts let an XRPL user execute arbitrary contract calls on Flare trough the [`Payment`](https://xrpl.org/docs/references/protocol/transactions/types/payment) transaction on the XRPL blockchain. +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. 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. -Custom instructions carry the full call payload in the XRPL **memo** field. -The user signs a `PackedUserOperation`, ships it on XRPL, and the smart account replays it on the Flare blockchain. +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 [Raw Custom Instruction](/smart-accounts/raw-custom-instruction); the [comparison guide](/smart-accounts/custom-instruction-comparison) 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](/fassets/direct-minting) to credit the tag-holder, which would let an unrelated party front-run the user operation. ::: -## User operation payload +## User Operation Payload -The custom instruction payload has two layers: the outer EIP-4337 [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation) carried in the XRPL memo, and the inner [`executeUserOp(Call[])`](/smart-accounts/reference/IPersonalAccount#executeuserop) that the personal account runs once the controller dispatches it. +A custom instruction has two layers: the outer [EIP-4337](https://eips.ethereum.org/EIPS/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. Only three fields from the [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation) struct are required for Flare Smart Accounts: - `sender` **must** equal the address of the personal account derived from the XRPL sender. - 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. + 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. - `nonce` **must** equal the personal account's current nonce returned by [`getNonce`](/smart-accounts/reference/IMasterAccountController#getnonce). The nonce auto-increments on every successful execution to prevent replay. -- `callData` is the calldata that the controller invokes on the personal account. - In practice, this is `abi.encodeCall(IPersonalAccount.executeUserOp, (calls))` — anything else either reverts or is rejected by the personal account's `onlyController` modifier. +- `callData` is the data that the controller invokes on the personal account. + In practice, this is `abi.encodeCall(IPersonalAccount.executeUserOp, (calls))` - anything else either reverts or is rejected by the personal account's `onlyController` modifier. 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. @@ -62,7 +65,7 @@ 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`](/smart-accounts/reference/IPersonalAccount#callfailed) — partial execution is not possible. +If any call reverts, the whole user operation reverts with [`CallFailed`](/smart-accounts/reference/IPersonalAccount#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](https://faucet.flare.network/). @@ -93,12 +96,102 @@ const callData = encodeFunctionData({ }); ``` -The encoded `callData` becomes the `callData` field of the `PackedUserOperation` struct placed in the XRPL payment memo field. +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 [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. + +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)](/fdc/overview), the same attestation system used by the proof-based flow: + +```mermaid +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
address with 42-byte memo
[0xFE][walletId][fee][hash] + Note over Executor: receives userOp bytes
off-chain + + Executor->>Executor: 3. fetch FDC XRPPayment proof + Executor->>AM: 4. executeDirectMintingWithData(proof, data)
{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 +``` + +### Step 1: User Side + +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). + +The user sends an XRPL `Payment` to the FAssets [direct minting address](/fassets/reference/IAssetManager#directmintingpaymentaddress) 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](/fdc/attestation-types/xrp-payment) from the [Flare Data Connector](/fdc/overview), and calls `executeDirectMintingWithData` on `AssetManagerFXRP` (see the [FAssets direct minting page](/fassets/direct-minting)): + +```solidity +function executeDirectMintingWithData( + IXRPPayment.Proof calldata _payment, + bytes calldata _data +) external payable; +``` + +- `_payment` is the FDC proof of the XRPL `Payment`. +- `_data` is the ABI-encoded `PackedUserOperation` that was delivered off-chain. +- `msg.value` **must equal the sum of `call.value` across the user operation**. + The `AssetManagerFXRP` forwards this value to the `MasterAccountController` function `handleMintedFAssets`, which forwards it to the personal account's `executeUserOp` function 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`](/smart-accounts/reference/IMasterAccountController#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. -## Replay protection +## Hash Mismatch -Each personal account maintains a monotonically increasing nonce, accessible via [`getNonce`](/smart-accounts/reference/IMasterAccountController#getnonce). -A successful `executeUserOp` increments the nonce and emits [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted), so the same `PackedUserOperation` cannot be re-executed. +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](#failure-handling)). + +## 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](/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. + +## Replay Protection + +Two replay-protection layers gate every custom instruction: + +- The user operation's `nonce` must 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 @@ -106,8 +199,18 @@ The whole pipeline is atomic with respect to the user operation: - If `sender` does not match the personal account, the call reverts with [`InvalidSender`](/smart-accounts/reference/IMasterAccountController#invalidsender). - If `nonce` is not the expected value, it reverts with [`InvalidNonce`](/smart-accounts/reference/IMasterAccountController#invalidnonce). -- If the memo body has the wrong length for its instruction ID, it reverts with [`InvalidMemoData`](/smart-accounts/reference/IMasterAccountController#invalidmemodata); an unrecognized instruction byte reverts with [`InvalidInstructionId`](/smart-accounts/reference/IMasterAccountController#invalidinstructionid). +- 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). +- If `keccak256(_data)` does not match the hash in the memo, the call reverts with `CustomInstructionHashMismatch(expected, actual)`. +- 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. +- 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). - If any inner call reverts, the personal account surfaces it as [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the entire user operation reverts. -Because the FXRP transfer is performed before the memo is decoded, **the mint succeeds even if the user operation reverts** — see [`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted). -The freshly minted FXRP remains in the personal account, and the user can either re-submit a fixed user operation or transfer the FXRP to another address via standard [FAssets instructions](/smart-accounts/fasset-instructions). +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). +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). + +## Next steps + +- Walk through a Viem implementation in the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts). +- See the simpler single-actor variant in the [Raw Custom Instruction](/smart-accounts/raw-custom-instruction). +- Compare the two flows in the [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison). +- Dig into `IMasterAccountController` in the [reference](/smart-accounts/reference/IMasterAccountController). diff --git a/docs/smart-accounts/4-raw-custom-instruction.mdx b/docs/smart-accounts/4-raw-custom-instruction.mdx new file mode 100644 index 00000000..46a35350 --- /dev/null +++ b/docs/smart-accounts/4-raw-custom-instruction.mdx @@ -0,0 +1,76 @@ +--- +sidebar_position: 1 +slug: raw-custom-instruction +title: Raw Custom Instruction +authors: [nikerzetic] +description: Performing custom function calls in the Flare Smart Accounts by carrying the full PackedUserOperation in the XRPL memo. +tags: [intermediate, ethereum, flare-smart-accounts] +keywords: + [ + flare-fdc, + ethereum, + flare-smart-accounts, + evm, + flare-network, + account-abstraction, + ] +--- + +The **raw custom instruction** (memo opcode `0xFF`) is a single-actor variant of the [Custom Instruction](/smart-accounts/custom-instruction): instead of committing to a `keccak256(userOp)` hash and relying on an executor to deliver the bytes, the XRPL `Payment` memo carries the entire ABI-encoded `PackedUserOperation` inline immediately after a 10-byte header. +The user signs the XRPL `Payment`, ships the payload on XRPL, and the smart account replays the user operation on Flare with no further off-chain data. + +The recommended path is the [Custom Instruction](/smart-accounts/custom-instruction) - it lifts the XRPL memo size cap and is what the rest of the smart-accounts tooling defaults to. +Reach for the raw variant when you do not want to operate or coordinate with an executor and your call batch fits inside the XRPL memo cap. +The [comparison guide](/smart-accounts/custom-instruction-comparison) breaks down when each is appropriate. + +This page covers only what differs from the [Custom Instruction](/smart-accounts/custom-instruction). +For the [`PackedUserOperation` construction](/smart-accounts/custom-instruction#user-operation-payload) (`sender`, `nonce`, `callData`), the [`Call` struct](/smart-accounts/custom-instruction#executeuserop-and-the-call-struct), and how to [build `callData` in TypeScript](/smart-accounts/custom-instruction#building-calldata-in-typescript), refer to the [Custom Instruction page](/smart-accounts/custom-instruction). + +:::warning No destination tags +XRPL transactions targeting smart accounts must not use a destination tag. +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. +::: + +## Memo Layout + +The XRPL memo carries a 10-byte instruction header followed by the ABI-encoded `PackedUserOperation`: + +| Bytes | Field | Meaning | +| ----- | ---------------- | --------------------------------------------------------------------------- | +| `0` | `instructionId` | `0xFF` - raw 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+` | `userOpData` | `abi.encode(PackedUserOperation)` - the call payload the controller decodes | + +The total memo length is `10 + len(abi.encode(userOp))` bytes. +The XRPL caps each memo at `1024` bytes, so large or repetitive call batches must be split across multiple XRPL payments - each of which pays its own FAssets minting fee and executor fee - and a single call whose ABI-encoded calldata alone exceeds the budget cannot be expressed at all without deploying a shim contract on Flare to compress it. + +## Dispatch Flow + +A single XRPL payment drives the whole flow: the FAssets `AssetManager` mints FXRP into the smart account, calls `MasterAccountController.handleMintedFAssets`, and the controller decodes the `PackedUserOperation` directly from the memo bytes (no `_data` parameter, no hash check) before dispatching `executeUserOp` on the personal account. +There is no executor split: any indexer that observes the XRPL payment can submit the FDC proof through `executeDirectMinting(proof)`, and the `MasterAccountController` validates the same `sender`, `nonce`, and `callData` fields as for the [custom instruction](/smart-accounts/custom-instruction#user-operation-payload). + +## Replay Protection + +Each personal account maintains a monotonically increasing nonce, accessible via [`getNonce`](/smart-accounts/reference/IMasterAccountController#getnonce). +A successful `executeUserOp` increments the nonce and emits [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted), so the same `PackedUserOperation` cannot be re-executed. +The XRPL transaction ID is also recorded in the controller to prevent the same payment from being submitted twice. + +## Failure Handling + +The whole pipeline is atomic with respect to the user operation: + +- If `sender` does not match the personal account, the call reverts with [`InvalidSender`](/smart-accounts/reference/IMasterAccountController#invalidsender). +- If `nonce` is not the expected value, it reverts with [`InvalidNonce`](/smart-accounts/reference/IMasterAccountController#invalidnonce). +- If the memo body has the wrong length for its instruction ID, it reverts with [`InvalidMemoData`](/smart-accounts/reference/IMasterAccountController#invalidmemodata); an unrecognized instruction byte reverts with [`InvalidInstructionId`](/smart-accounts/reference/IMasterAccountController#invalidinstructionid). +- If any inner call reverts, the personal account surfaces it as [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the entire user operation reverts. + +Because the FXRP transfer is performed before the memo is decoded, **the mint succeeds even if the user operation reverts** - see [`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted). +The freshly minted FXRP remains in the personal account, and the user can either re-submit a fixed user operation or transfer the FXRP to another address via standard [FAssets instructions](/smart-accounts/fasset-instructions). + +## Next Steps + +- Walk through a Viem implementation in the [Raw Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/raw-custom-instruction-ts). +- Read the [Custom Instruction](/smart-accounts/custom-instruction) for the recommended hash-commitment variant. +- Compare the two flows in the [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison). +- Dig into `IMasterAccountController` in the [reference](/smart-accounts/reference/IMasterAccountController). diff --git a/docs/smart-accounts/5-custom-instruction-comparison.mdx b/docs/smart-accounts/5-custom-instruction-comparison.mdx new file mode 100644 index 00000000..25dec6a0 --- /dev/null +++ b/docs/smart-accounts/5-custom-instruction-comparison.mdx @@ -0,0 +1,93 @@ +--- +sidebar_position: 1 +slug: custom-instruction-comparison +title: Custom Instruction Comparison +authors: [nikerzetic] +description: Comparing the raw (0xFF) and hash-based (0xFE) custom instruction flows for Flare Smart Accounts. +tags: [intermediate, ethereum, flare-smart-accounts] +keywords: + [ + flare-fdc, + ethereum, + flare-smart-accounts, + evm, + flare-network, + account-abstraction, + ] +--- + +Flare Smart Accounts expose two custom instruction memo opcodes that ultimately execute the same `PackedUserOperation` in the personal account scope: + +- [**Custom Instruction**](/smart-accounts/custom-instruction) - opcode `0xFE`. + The XRPL memo carries only `keccak256(userOp)` in fixed 42 bytes; an off-chain executor delivers the ABI-encoded custom instruction (`userOp`) via `executeDirectMintingWithData`. +- [**Raw Custom Instruction**](/smart-accounts/raw-custom-instruction) - opcode `0xFF`. + The XRPL memo contains the ABI-encoded `PackedUserOperation` in full. + +Both flows are validated on-chain against the same `(sender, nonce)` rules and emit the same [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted) event. +The difference is purely in how the user-operation bytes travel to Flare, and which actor performs which step. + +The hash-based flow was added because the XRPL `Payment` memo is capped at `1024` bytes - small enough that non-trivial user operations either had to be split across multiple XRPL payments (each paying its own minting and executor fees) or routed through purpose-built shim contracts on Flare that compressed the call into something memo-sized. +The `0xFE` memo is a constant 42 bytes regardless of the user's operation size, removing the need for both workarounds. + +## Comparison + +| Dimension | [Custom Instruction](/smart-accounts/custom-instruction) (`0xFE`) | [Raw Custom Instruction](/smart-accounts/raw-custom-instruction) (`0xFF`) | +| ---------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| XRPL memo payload | 10-byte header + `keccak256(userOp)` (fixed 42 bytes) | 10-byte header + `abi.encode(PackedUserOperation)` | +| XRPL memo size | Constant 42 bytes regardless of batch size | Grows linearly with batch and argument sizes; capped at ~1024 bytes | +| Privacy of call payload | Only the hash is public; bytes travel off-chain to the executor | `target`, `value`, `data` are public on XRPL | +| Actors | Two: the XRPL user plus an off-chain executor who calls `executeDirectMintingWithData` | One: the XRPL user; an indexer relays the payment | +| Where the UserOp is decoded | The `MasterAccountController` decodes from `_data` after verifying `keccak256(_data) == hash` | The `MasterAccountController` decodes from `_memoData` in `handleMintedFAssets` | +| AssetManager entry point | `executeDirectMintingWithData(proof, data)` | `executeDirectMinting(proof)` | +| `msg.value` on the inner call | Whatever the executor attaches; forwarded to `executeUserOp` | Whatever the relayer attaches; forwarded to `executeUserOp` | +| Confirmation | Same event lives in the executor's transaction receipt | Watch for `UserOperationExecuted` after the `MasterAccountController` dispatches the memo | +| Replay protection | Same as raw - plus the on-chain hash check pins `_data` to the memo's commitment | Personal account nonce + `usedTransactionIds` | +| Failure modes specific to the flow | `CustomInstructionHashMismatch` if `keccak256(_data)` differs from the commitment | None beyond shared ones | + +**Use the custom instruction (`0xFE`) when** + +- The user operation's ABI encoding exceeds the XRPL memo cap (long `bytes` arguments, large call batches, or deeply nested structs). + Without it, you would either split the logical user operation into multiple XRPL payments (paying minting and executor fees on each) or deploy a Flare-side shim contract to compress the call - the custom instruction eliminates both. +- You want the call payload to remain off the public XRPL ledger. +- You already operate an executor that observes XRPL payments and submits Flare transactions - the custom instruction lets that executor batch deliveries or rate-limit submissions without splitting user operations into multiple XRPL payments. +- You need fee predictability: the XRPL memo is always 42 bytes, so the XRPL fee is constant regardless of how complex the user operation is. + +**Use the raw custom instruction (`0xFF`) when** + +- The call batch fits comfortably inside the XRPL ~1024-byte memo (most single-call user operations and small batches do). +- You do not need to hide the call payload from public XRPL observers. +- You do not want to operate or coordinate with an executor service. +- You want the simplest possible on-chain integration: a single FAssets transaction `executeDirectMinting(proof)` carries the whole thing. + +In short, the custom instruction trades a small amount of off-chain coordination (and one extra on-chain proof-binding check) for a fixed memo size and private payloads. +The raw custom instruction trades memo bandwidth for end-to-end simplicity. + +## Similarities + +The two flows share more than they differ: + +- The `PackedUserOperation` is built the same way in both - only `sender`, `nonce`, and `callData` are validated; the rest can be empty. +- The `Call[]` struct is identical. +- The personal account's `executeUserOp` runs identically: each call is dispatched in order with the personal account as `msg.sender`, and any inner revert surfaces as `CallFailed`. +- The XRPL `Payment` must be **untagged** in both flows; a destination tag forces the FAssets minting to credit the tag holder instead of the smart account. +- Both flows pay the FAssets minting fee and executor fee out of the XRPL `Payment` amount; the rest is minted as FXRP to the personal account. +- The mint succeeds even if the user operation reverts, because the FAsset transfer runs before the memo is decoded ([`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted)). + +## Choosing in TypeScript + +The example helpers in [`flare-viem-starter`](https://github.com/flare-foundation/flare-viem-starter) mirror the two flows directly: + +- Custom Instruction: [`sendHashInstruction`](/smart-accounts/guides/typescript-viem/custom-instruction-ts#step-1-send-the-hash-memo-on-xrpl) + [`executeDirectMintingWithData`](/smart-accounts/guides/typescript-viem/custom-instruction-ts#step-2-executor-submits-the-proof-and-the-bytes) + [`findUserOperationExecuted`](/smart-accounts/guides/typescript-viem/custom-instruction-ts#step-3-confirm-execution-from-the-receipt) - three calls, one per actor. +- Raw Custom Instruction: [`sendMemoFieldInstruction`](/smart-accounts/guides/typescript-viem/raw-custom-instruction-ts#sending-the-user-operation) - one call, waits for the event. + +A practical decision rule when integrating: + +1. Default to the custom instruction (`0xFE`). + It fits every call batch and keeps the call payload off the public XRPL ledger. +2. Reach for the raw custom instruction (`0xFF`) only when you do not want to operate or coordinate with an executor service, and you can guarantee `abi.encode(userOp)` stays well under ~900 bytes (leaving room for the 10-byte header and XRPL framing). + +## Reference + +- [`IMasterAccountController`](/smart-accounts/reference/IMasterAccountController) — memo dispatch, `UserOperationExecuted`, errors. +- [`IPersonalAccount`](/smart-accounts/reference/IPersonalAccount) — `executeUserOp`, `Call`, `CallFailed`. +- [FAssets direct minting](/fassets/direct-minting) — `executeDirectMinting`, `executeDirectMintingWithData`, `directMintingPaymentAddress`. diff --git a/docs/smart-accounts/4-reference.mdx b/docs/smart-accounts/6-reference.mdx similarity index 100% rename from docs/smart-accounts/4-reference.mdx rename to docs/smart-accounts/6-reference.mdx diff --git a/docs/smart-accounts/guides/other/01-custom-instruction-mocking.mdx b/docs/smart-accounts/guides/other/01-custom-instruction-mocking.mdx index 035e628f..d4d9a7c8 100644 --- a/docs/smart-accounts/guides/other/01-custom-instruction-mocking.mdx +++ b/docs/smart-accounts/guides/other/01-custom-instruction-mocking.mdx @@ -26,7 +26,7 @@ The Flare Smart Accounts allow XRPL users to make custom function calls on Flare In this guide, we will look at how the custom instructions can be developed using a mock version of the `MasterAccountController` contract. In a typical workflow, the user sends instructions as memo data on XRPL. -Those then have to be verified by the FDC on the Flare chain. +Those then have to be verified by the [Flare Data Connector (FDC)](/fdc/overview) on the Flare chain. That process requires waiting, which is less than ideal in a development environment. For that reason, a mock version of the `MasterAccountController` contract has been deployed. diff --git a/docs/smart-accounts/guides/typescript-viem/02-custom-instruction.mdx b/docs/smart-accounts/guides/typescript-viem/02-custom-instruction.mdx index 3683fa69..3e0a60fe 100644 --- a/docs/smart-accounts/guides/typescript-viem/02-custom-instruction.mdx +++ b/docs/smart-accounts/guides/typescript-viem/02-custom-instruction.mdx @@ -3,7 +3,7 @@ sidebar_position: 2 slug: custom-instruction-ts title: Custom Instruction authors: [nikerzetic] -description: Sending custom smart account instructions using Viem. +description: Sending custom smart account instructions via XRPL using Viem and an off-chain executor. tags: [quickstart, ethereum, flare-smart-accounts] keywords: [ @@ -20,22 +20,24 @@ unlisted: false import CodeBlock from "@theme/CodeBlock"; import CustomInstructionsScript from "!!raw-loader!/examples/developer-hub-javascript/smart-accounts/custom-instructions.ts"; -The [Custom Instruction overview](/smart-accounts/custom-instruction) explains how Flare smart accounts replay an [EIP-4337](https://eips.ethereum.org/EIPS/eip-4337) `PackedUserOperation` carried in an XRPL `Payment` memo. -This guide walks through a TypeScript script that builds the user operation with Viem library, sends a payment transaction with the user operation on XRPL, and waits for the [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted) event on Flare. - -The script executes three calls on three example smart contracts on the Flare blockchain. -Because the XRPL memo field is capped at roughly `1024` bytes, the calls are split into two user operations sent in sequence. +The [Custom Instruction overview](/smart-accounts/custom-instruction) introduces the `0xFE` memo opcode, which keeps the XRPL memo at a fixed 42 bytes by carrying only `keccak256(PackedUserOperation)` and delivers the user operation bytes to Flare through an off-chain executor. +This guide walks through a TypeScript script that drives all three steps of the protocol with Viem and the [Flare Data Connector (FDC)](/fdc/overview). A prerequisite for the user operation to be executed is that the personal account holds enough native tokens to cover the call values. -The script calls forward a total of `2` C2FLR (Coston2 Flare native tokens), so fund the personal account from the [Flare faucet](https://faucet.flare.network/coston2) before running it. +The script forwards a total of `2` C2FLR (Coston2 Flare native tokens), so fund the personal account from the [Flare faucet](https://faucet.flare.network/coston2) before running it. The [State Lookup guide](/smart-accounts/guides/typescript-viem/state-lookup-ts#personal-account-of-an-xrpl-address) shows how to derive the personal account address. +The XRPL wallet that signs the `Payment` also needs enough XRP to cover the payment amount (net mint plus fees, computed below). +Before sending, the script reads the wallet's XRP balance with the `getXrpBalance` helper and aborts if it is below the required amount. + The full code is available on [GitHub](https://github.com/flare-foundation/flare-viem-starter/blob/main/src/custom-instructions.ts). ## Contracts +The script executes three calls on three example smart contracts already deployed on Coston2. +None of them is useful on its own; together they exercise the three things a custom instruction can do (call with no value, call with native value, call with a string argument and native value). + The first contract is a `Checkpoint` that counts how many times each user has called its `passCheckpoint` function. -It is useless as anything other than an example. The first call of our user operation will be to call the `passCheckpoint` function of this contract, deployed at the address [`0xEE6D54382aA623f4D16e856193f5f8384E487002`](https://coston2-explorer.flare.network/address/0xEE6D54382aA623f4D16e856193f5f8384E487002?tab=contract). ```Solidity @@ -126,13 +128,35 @@ contract NoticeBoard { The contract also performs additional checks to ensure each client is only added to the array once, and that `getNotices` returns only notices that have not yet expired. -## Building the call array +## Two Actors, Three Steps + +The `0xFE` flow is a three-step procedure that maps to two independent actors on mainnet. +This script runs all three steps from a single process for demo purposes, but the on-chain checks are designed around the split: + +| Step | Actor | Action | +| ---- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | User | Encode the `PackedUserOperation` as `userOp`, commit `keccak256(userOp)` in the 42-byte XRPL memo, send the XRPL `Payment` and send the `userOp` data to the Executor. | +| 2 | Executor | Fetch an FDC `XRPPayment` proof and call `AssetManagerFXRP.executeDirectMintingWithData(proof, userOp)`. | +| 3 | Both | The `MasterAccountController` executes the user operation inside the executor's transaction and emits `UserOperationExecuted`. | + +On mainnet, the user delivers the `PackedUserOperation` bytes to the executor **off-chain** (e.g. over an authenticated HTTP API); they never go onto the XRPL ledger. + +:::info Executor role in this script +For demo purposes this script plays **both** roles itself: the same process encodes the user operation, sends the XRPL `Payment`, then fetches the FDC proof and submits `executeDirectMintingWithData`. +In production, Step 2 will be run by an executor service - initially a service operated by Flare - and the user will only hand it `(xrplTransactionHash, data, totalCallValue)` over an authenticated channel. +The on-chain checks (XRPL signature, `proofOwner` binding, `keccak256(data)` commitment) are what make the split safe: the executor cannot substitute different bytes or run the user operation against a payment that was not theirs to deliver. +::: + +## Building the Call Array Each entry in the user operation is a [`Call`](/smart-accounts/reference/IPersonalAccount#call) struct with three fields: -- `target` — the address of the contract to call; -- `value` — the amount of FLR to send; -- `data` — the function calldata (function selector and parameter encoding). +- `target` - the address of the Flare contract to invoke. + The personal account dispatches the call with itself as `msg.sender`, so this is the contract that ultimately runs the call. +- `value` - the amount of the native token (FLR on mainnet, C2FLR on Coston2) in wei to attach to the call. + The sum of `value` across all calls is what the executor must attach as `msg.value` on `executeDirectMintingWithData` in Step 2; that value flows `AssetManagerFXRP -> MasterAccountController -> PersonalAccount.executeUserOp` and is split back across the inner calls. +- `data` - the ABI-encoded calldata (4-byte function selector followed by ABI-encoded arguments) the personal account passes to `target`. + Use Viem's [`encodeFunctionData`](https://viem.sh/docs/contract/encodeFunctionData) to build it from the contract ABI, function name, and arguments. ```typescript export type Call = { @@ -142,13 +166,10 @@ export type Call = { }; ``` -We use Viem's [`encodeFunctionData`](https://viem.sh/docs/contract/encodeFunctionData) to build each `data` field from the contract ABI, function name, and arguments. - -XRPL caps the memo at roughly `1024` bytes. -The `pinNotice` call carries a string argument that, together with the other two calls, exceeds that limit, so the script splits the work into two user operations: +Because the `0xFE` memo is independent of batch size, the script issues a single batch for all three calls - the same three that overflow the XRPL memo cap in the [raw flow](/smart-accounts/guides/typescript-viem/raw-custom-instruction-ts): ```typescript -const checkpointAndDepositCalls: Call[] = [ +const calls: Call[] = [ { target: checkpointAddress, value: BigInt(0), @@ -167,9 +188,6 @@ const checkpointAndDepositCalls: Call[] = [ args: [], }), }, -]; - -const pinNoticeCalls: Call[] = [ { target: noticeBoardAddress, value: BigInt(pinNoticeAmount), @@ -199,19 +217,13 @@ export async function getNonce(personalAccount: Address): Promise { } ``` -## Encoding the user operation +## Encoding the Hash Memo -The XRPL memo carries a 10-byte header followed by an ABI-encoded [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation): - -- 1st byte: custom instruction command ID `0xff` -- 2nd byte: `walletId` — a one-byte wallet identifier assigned by Flare; we use `0` -- bytes 3-10: `executorFeeUBA` as an 8-byte big-endian unsigned integer - -Only three fields of the `PackedUserOperation` are validated on chain: `sender` must equal the personal account address, `nonce` must equal the current nonce, and `callData` must be the encoded `executeUserOp(calls)` call. -The rest can be left empty. +The XRPL memo is a fixed 42 bytes: 10-byte header followed by the 32-byte hash of the ABI-encoded `PackedUserOperation`. +The ABI-encoded user operation bytes are returned alongside the memo so they can be handed to the executor: ```typescript -export function encodeExecuteUserOpMemo({ +export function encodeHashInstructionMemo({ calls, walletId, executorFeeUBA, @@ -223,42 +235,20 @@ export function encodeExecuteUserOpMemo({ executorFeeUBA: bigint; sender: Address; nonce: bigint; -}): `0x${string}` { - const callData = encodeFunctionData({ - abi: coston2.iPersonalAccountAbi, - functionName: "executeUserOp", - args: [calls], - }); - - const encodedUserOp = encodeAbiParameters( - [PACKED_USER_OPERATION_TUPLE], - [ - { - sender, - nonce, - initCode: "0x", - callData, - accountGasLimits: ZERO_BYTES32, - preVerificationGas: 0n, - gasFees: ZERO_BYTES32, - paymasterAndData: "0x", - signature: "0x", - }, - ], - ); - - // 10-byte header: 0xFF | walletId (1B) | executorFee (8B, big-endian) - const header = concatHex([ - "0xff", - toHex(walletId, { size: 1 }), - toHex(executorFeeUBA, { size: 8 }), +}): { memoData: `0x${string}`; data: `0x${string}` } { + const data = encodePackedUserOpData({ calls, sender, nonce }); + const memoData = concatHex([ + buildInstructionHeader("0xfe", walletId, executorFeeUBA), + keccak256(data), ]); - - return concatHex([header, encodedUserOp]); + return { memoData, data }; } ``` -## Computing the XRPL payment amount +The `encodePackedUserOpData` helper ABI-encodes a `PackedUserOperation` whose only meaningful fields are `sender`, `nonce`, and `callData` - the others are not validated on-chain. +The `buildInstructionHeader` helper packs the opcode (`0xFE` for the custom instruction, `0xFF` for the raw variant), the one-byte `walletId`, and the 8-byte big-endian `executorFeeUBA` into the 10-byte instruction header. + +## Computing the XRPL Payment Amount A custom instruction rides on top of an FAssets direct minting payment, so the XRPL transfer must cover the net mint amount plus the minting fee and the executor fee, as read from the `AssetManagerFXRP` contract. For a memo-only transaction (no FXRP mint), the net amount is `0`, but the fees still apply. @@ -299,46 +289,95 @@ export async function computeDirectMintingPaymentAmountXrp({ } ``` -The first user operation in the script also mints `10` FXRP as a side effect, so its payment amount includes the net mint amount. -The second user operation carries only a memo, so its payment covers only fees. +The script also mints `10` FXRP as a side effect of the same payment, so the payment amount includes the net mint amount on top of the fees. -## Sending the user operation +## Checking the XRPL Wallet Balance -The XRPL `Payment` is sent to the FAssets **direct minting payment address** read from the `AssetManagerFXRP` contract — not to an operator wallet. -The encoded user operation is attached as a memo, with the `0x` prefix removed. +The signer of the XRPL `Payment` must hold at least `paymentAmountXrp` XRP, otherwise the payment will fail when submitted to the network. +The `getXrpBalance` helper issues an [`account_info`](https://xrpl.org/account_info.html) request against the validated ledger and returns the wallet's balance in XRP. +It reuses the caller's `Client` when one is passed in, and otherwise opens and closes its own connection: -:::warning No destination tags -XRPL payments targeting smart accounts must not use a destination tag. -A destination tag forces the FAssets direct minting flow to credit the tag holder, which could let an unrelated party front-run the user's operation. -::: +```typescript +export async function getXrpBalance( + xrplAddress: string, + client?: Client, +): Promise { + const ownsClient = client === undefined; + const xrplClient = client ?? new Client(process.env.XRPL_TESTNET_RPC_URL!); + if (!xrplClient.isConnected()) { + await xrplClient.connect(); + } + try { + const response = await xrplClient.request({ + command: "account_info", + account: xrplAddress, + ledger_index: "validated", + }); + return Number(dropsToXrp(response.result.account_data.Balance)); + } finally { + if (ownsClient) { + await xrplClient.disconnect(); + } + } +} +``` + +The `main` function calls it right after computing `paymentAmountXrp` and aborts before touching XRPL if the wallet is short. +Failing fast here avoids a half-committed flow where the XRPL submission errors out after the user has already paid gas on Flare for unrelated reads: + +```typescript +const xrpBalance = await getXrpBalance(xrplWallet.address, xrplClient); +if (xrpBalance < paymentAmountXrp) { + throw new Error( + `Insufficient XRP balance on ${xrplWallet.address}: have ${xrpBalance} XRP, need ${paymentAmountXrp} XRP`, + ); +} +``` + +This check covers only the payment amount; the XRPL network also requires the account to keep its base reserve, which is not subtracted here. +If the wallet's spendable balance is close to the reserve, the payment can still fail with `tecUNFUNDED_PAYMENT` even though this check passes. + +## Step 1: Send the hash memo on XRPL + +The user-side helper function computes the memo, sends the XRPL `Payment` to the FAssets direct-minting address, and returns everything the executor needs to drive Step 2: ```typescript -export async function sendMemoFieldInstruction({ - label, +export type HashInstructionUserSide = { + xrplTransactionHash: string; + /** ABI-encoded PackedUserOperation - bytes the executor delivers via _data. */ + data: `0x${string}`; + /** Sum of call.value across the UserOp; the executor must forward this as msg.value. */ + totalCallValue: bigint; + /** Nonce used in the UserOp; (personalAccount, nonce) identifies the UserOperationExecuted log. */ + nonce: bigint; +}; + +export async function sendHashInstruction({ calls, amountXrp, personalAccount, xrplClient, xrplWallet, }: { - label: string; calls: Call[]; amountXrp: number; personalAccount: Address; xrplClient: Client; xrplWallet: Wallet; -}) { - const nonce = await getNonce(personalAccount); +}): Promise { + const [nonce, coreVaultXrplAddress] = await Promise.all([ + getNonce(personalAccount), + getDirectMintingPaymentAddress(), + ]); - const memoData = encodeExecuteUserOpMemo({ + const { memoData, data } = encodeHashInstructionMemo({ calls, walletId: 0, executorFeeUBA: 0n, sender: personalAccount, nonce, }); - - const coreVaultXrplAddress = await getDirectMintingPaymentAddress(); + const totalCallValue = calls.reduce((acc, c) => acc + c.value, 0n); const transaction = await sendXrplPayment({ destination: coreVaultXrplAddress, @@ -348,65 +387,156 @@ export async function sendMemoFieldInstruction({ client: xrplClient, }); - const event = await waitForUserOperationExecuted({ personalAccount, nonce }); - return event; + return { + xrplTransactionHash: transaction.result.hash, + data, + totalCallValue, + nonce, + }; } ``` -The `xrplClient` and `xrplWallet` are the `Client` and `Wallet` classes from the `xrpl` library, initialized from the `.env` file: +The `data` field carries the full ABI-encoded `PackedUserOperation`. +This is what the executor must submit on Flare; the XRPL ledger only ever sees the 32-byte hash. + +:::warning No destination tags +XRPL payments targeting smart accounts must not use a destination tag. +A destination tag forces the FAssets direct minting flow to credit the tag holder, which could let an unrelated party front-run the user's operation. +::: + +## Step 2: Executor submits the proof and the bytes + +The executor takes the XRPL transaction hash, waits for the XRPL ledger to confirm it under the required number of validated ledgers (see [Finality](/fdc/attestation-types/xrp-payment#finality) on the `XRPPayment` attestation reference for the exact depth and reasoning), requests an FDC `XRPPayment` attestation, and submits `AssetManagerFXRP.executeDirectMintingWithData` with the proof and the user-operation bytes: ```typescript -const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!); -const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!); +export async function executeDirectMintingWithData({ + xrplTransactionHash, + data, + value, + xrplClient, +}: { + xrplTransactionHash: string; + data: `0x${string}`; + value: bigint; + xrplClient: Client; +}): Promise<{ hash: `0x${string}`; receipt: TransactionReceipt }> { + const transactionId = ( + xrplTransactionHash.startsWith("0x") + ? xrplTransactionHash + : `0x${xrplTransactionHash}` + ).toLowerCase() as `0x${string}`; + + // FDC XRPPayment rejects requests whose XRPL transaction isn't yet buried + // under XRPL_FDC_CONFIRMATIONS validated ledgers (3 on XRPL ~= 12 s). + await waitForXrplFinality({ + client: xrplClient, + transactionHash: xrplTransactionHash, + }); + + // proofOwner binds the proof to the executor's externally owned account so + // the AssetManager's verifyProofOwnership check accepts it on submission. + const { abiEncodedRequest } = await prepareXrpPaymentRequest({ + transactionId, + proofOwner: account.address, + verifierBaseUrl: process.env.VERIFIER_URL_TESTNET!, + apiKey: process.env.VERIFIER_API_KEY_TESTNET!, + }); + const roundId = await submitAttestationRequest(abiEncodedRequest); + const proof = await retrieveXrpPaymentProofWithRetry( + abiEncodedRequest, + roundId, + ); + + const assetManagerFxrpAddress = await getAssetManagerFXRPAddress(); + const hash = await walletClient.writeContract({ + account, + address: assetManagerFxrpAddress, + abi: iDirectMintingExtAbi, + functionName: "executeDirectMintingWithData", + args: [proof, data], + value, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + return { hash, receipt }; +} ``` -## Waiting for execution +Two details are critical here: + +- **`proofOwner`**: the FDC `XRPPayment` request binds the proof to the externally owned account that will eventually submit it. + `AssetManagerFXRP` enforces this through `TransactionAttestation.verifyProofOwnership`. + Pass the executor's address so the proof can only be used by that executor. +- **`value`**: `msg.value` on `executeDirectMintingWithData` is forwarded `AssetManager -> MasterAccountController.handleMintedFAssets -> PersonalAccount.executeUserOp`. + It must therefore equal the sum of `call.value` across the user operation - exactly the `totalCallValue` returned by [Step 1](#step-1-send-the-hash-memo-on-xrpl). -Once the operator bridges the instruction from XRPL to Flare, the personal account runs `executeUserOp` and the `MasterAccountController` emits [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted) event. -We watch for that event with Viem's [`watchContractEvent`](https://viem.sh/docs/contract/watchContractEvent#watchcontractevent) function, filtering on the personal account and the nonce we submitted: +## Step 3: Confirm execution from the receipt + +The `MasterAccountController` runs the user operation **inside** the executor's transaction, so `UserOperationExecuted` is already present in the receipt's logs by the time `executeDirectMintingWithData` returns. +There is no event watcher to start; the script just parses the receipt: ```typescript -export async function waitForUserOperationExecuted({ - personalAccount, - nonce, -}: { - personalAccount: Address; - nonce: bigint; -}): Promise { - const masterAccountControllerAddress = - await getMasterAccountControllerAddress(); - - return new Promise((resolve) => { - const unwatch = publicClient.watchContractEvent({ - address: masterAccountControllerAddress, - abi: iMemoInstructionsFacetAbi, - eventName: "UserOperationExecuted", - onLogs: (logs) => { - for (const log of logs) { - const typedLog = log as UserOperationExecutedEventType; - if ( - typedLog.args.personalAccount.toLowerCase() !== - personalAccount.toLowerCase() || - typedLog.args.nonce !== nonce - ) { - continue; - } - unwatch(); - resolve(typedLog); - return; - } - }, - }); +export function findUserOperationExecuted( + receipt: TransactionReceipt, + personalAccount: Address, + nonce: bigint, +): UserOperationExecutedEventType { + const logs = parseEventLogs({ + abi: iMemoInstructionsFacetAbi, + eventName: "UserOperationExecuted", + logs: receipt.logs, }); + for (const log of logs) { + const typedLog = log as unknown as UserOperationExecutedEventType; + if ( + typedLog.args.personalAccount.toLowerCase() === + personalAccount.toLowerCase() && + typedLog.args.nonce === nonce + ) { + return typedLog; + } + } + throw new Error( + `UserOperationExecuted not found on receipt ${receipt.transactionHash} ` + + `for (${personalAccount}, ${nonce}). The mint may have been delayed; ` + + "check for DirectMintingDelayed.", + ); } ``` -The bridging is handled by the [Flare Data Connector](/fdc/overview), which caps the round trip at 180 seconds. +The error path matters: if the `AssetManager` hits a rate limit or the minting is otherwise delayed (`DirectMintingDelayed`), the FAsset transfer is deferred and the user operation does not execute synchronously. +In that case the executor must wait for the delay to clear and re-call `executeDirectMintingWithData`. + +## Wiring it Together + +The `main` function chains the three steps: + +```typescript +const userSide = await sendHashInstruction({ + calls, + amountXrp: paymentAmountXrp, + personalAccount, + xrplClient, + xrplWallet, +}); + +const { receipt } = await executeDirectMintingWithData({ + xrplTransactionHash: userSide.xrplTransactionHash, + data: userSide.data, + value: userSide.totalCallValue, + xrplClient, +}); + +const event = findUserOperationExecuted( + receipt, + personalAccount, + userSide.nonce, +); +``` -If any inner call reverts, the whole user operation reverts with [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the nonce does not increment. -The FXRP transfer, however, is performed before the memo is decoded, so the mint succeeds even when the user operation reverts — see [`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted). +On mainnet, `sendHashInstruction` would run on the user's machine, the `(data, totalCallValue, xrplTransactionHash)` triple would be POST-ed to the executor service, and `executeDirectMintingWithData` would run there. +`findUserOperationExecuted` can run on either side once the executor publishes the resulting receipt. -## Full script +## Full Script The repository with the example is available on [GitHub](https://github.com/flare-foundation/flare-viem-starter). Helper functions live in the `src/utils` directory. @@ -420,11 +550,11 @@ Helper functions live in the `src/utils` directory. ```bash Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F -Payment amount (XRP, net mint + fees): 10.0011 +Payment amount (XRP, net mint + fees): 10.2 -Memo-only amount (XRP, fees only): 0.0011 +XRPL wallet XRP balance: 1000 -[checkpoint-and-deposit] calls: [ +[hash-instruction-batch] calls: [ { target: '0xEE6D54382aA623f4D16e856193f5f8384E487002', value: 0n, @@ -434,23 +564,7 @@ Memo-only amount (XRP, fees only): 0.0011 target: '0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42', value: 1000000000000000000n, data: '0xd0e30db0' - } -] - -[checkpoint-and-deposit] current nonce: 0n - -[checkpoint-and-deposit] XRPL transaction hash: 03165B82E4B7BB168AF7B217C9EC833896E968C796CA8BF8D2E66879ED311909 - -[checkpoint-and-deposit] UserOperationExecuted event: { - eventName: 'UserOperationExecuted', - args: { - personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F', - nonce: 0n }, - ... -} - -[pin-notice] calls: [ { target: '0x59D57652BF4F6d97a6e555800b3920Bd775661Dc', value: 1000000000000000000n, @@ -458,17 +572,60 @@ Memo-only amount (XRP, fees only): 0.0011 } ] -[pin-notice] current nonce: 1n +[hash-instruction-batch] current nonce: 58n + +[hash-instruction-batch] userOpHash: 0x3f2a3cc369ddaaf39a2837744875a32334ddd1bf067741dc766c905944f33c6d + +[hash-instruction-batch] _data (1216 bytes): 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f000000000000000000000000000000000000000000000000000000000000003a00000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000140... + +[hash-instruction-batch] total call.value (native value to attach on executor tx): 2000000000000000000n + +[hash-instruction-batch] XRPL transaction hash: FEAC1ABDB81809293E023DB9345715FA3A27949B23132AB9A2417D8F99A876E9 + +[hash-instruction-batch] Waiting for XRPL transaction to reach 3 confirmations +[hash-instruction-batch] XRPL finality reached: 3 confirmations (txLedger=17528566, validated=17528568) +[hash-instruction-batch] Preparing FDC XRPPayment attestation for txid 0xfeac1abdb81809293e023db9345715fa3a27949b23132ab9a2417d8f99a876e9 (proofOwner=0xF5488132432118596fa13800B68df4C0fF25131d) +Url: https://fdc-verifiers-testnet.flare.network/verifier/xrp/XRPPayment/prepareRequest + +Prepared request: + { + attestationType: '0x5852505061796d656e7400000000000000000000000000000000000000000000', + sourceId: '0x7465737458525000000000000000000000000000000000000000000000000000', + requestBody: { + transactionId: '0xfeac1abdb81809293e023db9345715fa3a27949b23132ab9a2417d8f99a876e9', + proofOwner: '0xF5488132432118596fa13800B68df4C0fF25131d' + } +} + +Response status is OK + +FDC attestation submitted. Round id: 1342615 +Waiting for FDC round to finalize... +Round finalized. -[pin-notice] XRPL transaction hash: 9F1A4D7E2B83C5C0A6F4E1D8B7C9A2E5F6D3B4C1E0F7A8B9C2D3E4F5A6B7C8D9 +[hash-instruction-batch] FDC proof obtained (votingRound=1342615) +[hash-instruction-batch] Calling executeDirectMintingWithData on 0xc1Ca88b937d0b528842F95d5731ffB586f4fbDFA (value=2000000000000000000) +[hash-instruction-batch] executeDirectMintingWithData tx: 0x80dbe245bf727959120104ba3e782b3b633a422f17d15ebc4a94c29b7bbd3d7d -[pin-notice] UserOperationExecuted event: { +UserOperationExecuted: { eventName: 'UserOperationExecuted', args: { personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F', - nonce: 1n + nonce: 58n }, - ... + address: '0x434936d47503353f06750db1a444dbdc5f0ad37c', + topics: [ + '0xf1fb8f9b365735a54cdafe3a27ffbad0a0cf1f35454f0c4c0c4dc68591d484fe', + '0x000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f' + ], + data: '0x000000000000000000000000000000000000000000000000000000000000003a', + blockNumber: 30741518n, + transactionHash: '0x80dbe245bf727959120104ba3e782b3b633a422f17d15ebc4a94c29b7bbd3d7d', + transactionIndex: 1, + blockHash: '0x9e15f4337d276a75306e5edf058fb7a8aa800006e54d70174e74adcd7c1d63ac', + logIndex: 12, + removed: false, + blockTimestamp: undefined } ``` @@ -477,6 +634,8 @@ Memo-only amount (XRP, fees only): 0.0011 To continue your Flare Smart Accounts development journey, you can: - Get the smart account state using the [State Lookup guide](/smart-accounts/guides/typescript-viem/state-lookup-ts). -- Explore the [Custom Instruction](/smart-accounts/custom-instruction) overview. +- Read the [Custom Instruction overview](/smart-accounts/custom-instruction) for the on-chain details. +- Explore the [Raw Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/raw-custom-instruction-ts). +- Compare the two flows in the [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison). - Dig into the `IMasterAccountController` [reference](/smart-accounts/reference/IMasterAccountController). ::: diff --git a/docs/smart-accounts/guides/typescript-viem/03-raw-custom-instruction.mdx b/docs/smart-accounts/guides/typescript-viem/03-raw-custom-instruction.mdx new file mode 100644 index 00000000..48d472c1 --- /dev/null +++ b/docs/smart-accounts/guides/typescript-viem/03-raw-custom-instruction.mdx @@ -0,0 +1,333 @@ +--- +sidebar_position: 3 +slug: raw-custom-instruction-ts +title: Raw Custom Instruction +authors: [nikerzetic] +description: Sending the full PackedUserOperation in an XRPL memo with Viem (0xFF flow). +tags: [intermediate, ethereum, flare-smart-accounts] +keywords: + [ + flare-fdc, + ethereum, + flare-smart-accounts, + evm, + flare-network, + account-abstraction, + ] +unlisted: false +--- + +import CodeBlock from "@theme/CodeBlock"; +import RawCustomInstructionsScript from "!!raw-loader!/examples/developer-hub-javascript/smart-accounts/custom-instructions-raw.ts"; + +The [Raw Custom Instruction overview](/smart-accounts/raw-custom-instruction) explains how Flare smart accounts replay an [EIP-4337](https://eips.ethereum.org/EIPS/eip-4337) `PackedUserOperation` carried in full in an XRPL `Payment` memo (opcode `0xFF`). +This guide walks through a TypeScript script that builds the user operation with the Viem library, sends a payment transaction with the user operation on XRPL, and waits for the [`UserOperationExecuted`](/smart-accounts/reference/IMasterAccountController#useroperationexecuted) event on Flare. + +:::info +This page covers only what differs from the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts). +For the [example contracts](/smart-accounts/guides/typescript-viem/custom-instruction-ts#contracts) (`Checkpoint`, `PiggyBank`, `NoticeBoard`), the [`Call` struct shape](/smart-accounts/guides/typescript-viem/custom-instruction-ts#building-the-call-array), the [personal-account/nonce lookup](/smart-accounts/guides/typescript-viem/custom-instruction-ts#personal-account-and-nonce), and the [XRPL payment amount calculation](/smart-accounts/guides/typescript-viem/custom-instruction-ts#computing-the-xrpl-payment-amount), refer to that guide. +::: + +The recommended path is the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts) - it lifts the XRPL memo cap, hides the call payload, and uses the executor flow the rest of the smart-accounts tooling defaults to. +Use the raw flow when you do not want to operate or coordinate with an executor and your call batch fits inside the XRPL memo cap. + +This guide hits that cap head-on: it executes the same three example calls as the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts), but the XRPL memo field is capped at `1024` bytes, and the three calls together exceed that budget when ABI-encoded. +The script therefore splits the work into two user operations sent in sequence, paying the FAssets minting fee and executor fee on each XRPL payment. +The same cap is also why some calls - those whose calldata alone overruns the memo budget - cannot be expressed in the `0xFF` flow without deploying a shim contract on Flare to compress them. + +A prerequisite for the user operation to be executed is that the personal account holds enough native tokens to cover the call values. +The script calls forward a total of `2` C2FLR (Coston2 Flare native tokens), so fund the personal account from the [Flare faucet](https://faucet.flare.network/coston2) before running it. + +The XRPL wallet must also cover the **sum** of both payments, `paymentAmountXrp + memoOnlyAmountXrp`, since the raw flow splits the batch into two XRPL `Payment` transactions. +The script reads the wallet's balance with the shared [`getXrpBalance`](/smart-accounts/guides/typescript-viem/custom-instruction-ts#checking-the-xrpl-wallet-balance) helper and aborts before sending either payment if the wallet is short, so neither half of the batch is submitted on partial funding: + +```typescript +const totalRequiredXrp = paymentAmountXrp + memoOnlyAmountXrp; +const xrpBalance = await getXrpBalance(xrplWallet.address, xrplClient); +if (xrpBalance < totalRequiredXrp) { + throw new Error( + `Insufficient XRP balance on ${xrplWallet.address}: have ${xrpBalance} XRP, need ${totalRequiredXrp} XRP (both payments)`, + ); +} +``` + +The full code is available on [GitHub](https://github.com/flare-foundation/flare-viem-starter/blob/main/src/custom-instructions-raw.ts). + +## Splitting the Call Array Across Two Payments + +XRPL caps the memo at `1024` bytes. +The `pinNotice` call carries a string argument that, together with the other two calls, exceeds that limit, so the script splits the work into two user operations: + +```typescript +const checkpointAndDepositCalls: Call[] = [ + { + target: checkpointAddress, + value: BigInt(0), + data: encodeFunctionData({ + abi: checkpointAbi, + functionName: "passCheckpoint", + args: [], + }), + }, + { + target: piggyBankAddress, + value: BigInt(depositAmount), + data: encodeFunctionData({ + abi: piggyBankAbi, + functionName: "deposit", + args: [], + }), + }, +]; + +const pinNoticeCalls: Call[] = [ + { + target: noticeBoardAddress, + value: BigInt(pinNoticeAmount), + data: encodeFunctionData({ + abi: noticeBoardAbi, + functionName: "pinNotice", + args: [pinNoticeMessage], + }), + }, +]; +``` + +The `Call` struct fields are the same as for the [custom instruction](/smart-accounts/guides/typescript-viem/custom-instruction-ts#building-the-call-array). +The split is the entire reason this guide exists: in the [custom instruction flow](/smart-accounts/guides/typescript-viem/custom-instruction-ts) the same three calls fit in a single 42-byte memo, regardless of batch size. + +## Encoding the User Operation + +The XRPL memo carries a 10-byte header followed by an ABI-encoded [`PackedUserOperation`](https://eips.ethereum.org/EIPS/eip-4337#useroperation): + +- 1st byte: custom instruction command ID `0xff` +- 2nd byte: `walletId` - a one-byte wallet identifier assigned by Flare; we use `0` +- bytes 3-10: `executorFeeUBA` as an 8-byte big-endian unsigned integer + +Only three fields of the `PackedUserOperation` are validated on chain: `sender` must equal the personal account address, `nonce` must equal the current nonce, and `callData` must be the encoded `executeUserOp(calls)` call. +The rest can be left empty. + +```typescript +export function encodeExecuteUserOpMemo({ + calls, + walletId, + executorFeeUBA, + sender, + nonce, +}: { + calls: Call[]; + walletId: number; + executorFeeUBA: bigint; + sender: Address; + nonce: bigint; +}): `0x${string}` { + const callData = encodeFunctionData({ + abi: coston2.iPersonalAccountAbi, + functionName: "executeUserOp", + args: [calls], + }); + + const encodedUserOp = encodeAbiParameters( + [PACKED_USER_OPERATION_TUPLE], + [ + { + sender, + nonce, + initCode: "0x", + callData, + accountGasLimits: ZERO_BYTES32, + preVerificationGas: 0n, + gasFees: ZERO_BYTES32, + paymasterAndData: "0x", + signature: "0x", + }, + ], + ); + + // 10-byte header: 0xFF | walletId (1B) | executorFee (8B, big-endian) + const header = concatHex([ + "0xff", + toHex(walletId, { size: 1 }), + toHex(executorFeeUBA, { size: 8 }), + ]); + + return concatHex([header, encodedUserOp]); +} +``` + +Unlike the custom instruction, which carries only `keccak256(userOp)` in the memo, this header is followed by the full ABI-encoded `PackedUserOperation` - hence the memo cap problem above. + +## Sending the User Operation + +The XRPL `Payment` is sent to the FAssets [direct minting payment address](/fassets/reference/IAssetManager#directmintingpaymentaddress) read from the `AssetManagerFXRP` contract - not to an operator wallet. +The encoded user operation is attached as a memo, with the `0x` prefix removed. + +:::warning No destination tags +XRPL payments targeting smart accounts must not use a destination tag. +A destination tag forces the FAssets direct minting flow to credit the tag holder, which could let an unrelated party front-run the user's operation. +::: + +```typescript +export async function sendMemoFieldInstruction({ + label, + calls, + amountXrp, + personalAccount, + xrplClient, + xrplWallet, +}: { + label: string; + calls: Call[]; + amountXrp: number; + personalAccount: Address; + xrplClient: Client; + xrplWallet: Wallet; +}) { + const nonce = await getNonce(personalAccount); + + const memoData = encodeExecuteUserOpMemo({ + calls, + walletId: 0, + executorFeeUBA: 0n, + sender: personalAccount, + nonce, + }); + + const coreVaultXrplAddress = await getDirectMintingPaymentAddress(); + + const transaction = await sendXrplPayment({ + destination: coreVaultXrplAddress, + amount: amountXrp, + memos: [{ Memo: { MemoData: memoData.slice(2) } }], + wallet: xrplWallet, + client: xrplClient, + }); + + const event = await waitForUserOperationExecuted({ personalAccount, nonce }); + return event; +} +``` + +There is no `(data, totalCallValue)` to hand off to an executor here: the entire user operation already lives in the memo, so the only handoff is the XRPL payment itself. +The `xrplClient` and `xrplWallet` are the `Client` and `Wallet` classes from the `xrpl` library, initialized from the `.env` file. + +## Waiting for Execution + +Because the raw flow has no executor receipt to parse, the script watches for the `UserOperationExecuted` event with Viem's [`watchContractEvent`](https://viem.sh/docs/contract/watchContractEvent#watchcontractevent), filtering on the personal account and the nonce we submitted: + +```typescript +export async function waitForUserOperationExecuted({ + personalAccount, + nonce, +}: { + personalAccount: Address; + nonce: bigint; +}): Promise { + const masterAccountControllerAddress = + await getMasterAccountControllerAddress(); + + return new Promise((resolve) => { + const unwatch = publicClient.watchContractEvent({ + address: masterAccountControllerAddress, + abi: iMemoInstructionsFacetAbi, + eventName: "UserOperationExecuted", + onLogs: (logs) => { + for (const log of logs) { + const typedLog = log as UserOperationExecutedEventType; + if ( + typedLog.args.personalAccount.toLowerCase() !== + personalAccount.toLowerCase() || + typedLog.args.nonce !== nonce + ) { + continue; + } + unwatch(); + resolve(typedLog); + return; + } + }, + }); + }); +} +``` + +The bridging is handled by the [Flare Data Connector](/fdc/overview), which caps the round trip at 180 seconds. + +If any inner call reverts, the whole user operation reverts with [`CallFailed`](/smart-accounts/reference/IPersonalAccount#callfailed) and the nonce does not increment. +The FXRP transfer, however, is performed before the memo is decoded, so the mint succeeds even when the user operation reverts - see [`DirectMintingExecuted`](/smart-accounts/reference/IMasterAccountController#directmintingexecuted). + +## Full Script + +The repository with the example is available on [GitHub](https://github.com/flare-foundation/flare-viem-starter). +Helper functions live in the `src/utils` directory. + + + {RawCustomInstructionsScript} + + +## Expected Output + +```bash +Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F + +Payment amount (XRP, net mint + fees): 10.0011 + +Memo-only amount (XRP, fees only): 0.0011 + +XRPL wallet XRP balance: 1000 + +[checkpoint-and-deposit] calls: [ + { + target: '0xEE6D54382aA623f4D16e856193f5f8384E487002', + value: 0n, + data: '0x80abd133' + }, + { + target: '0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42', + value: 1000000000000000000n, + data: '0xd0e30db0' + } +] + +[checkpoint-and-deposit] current nonce: 0n + +[checkpoint-and-deposit] XRPL transaction hash: 03165B82E4B7BB168AF7B217C9EC833896E968C796CA8BF8D2E66879ED311909 + +[checkpoint-and-deposit] UserOperationExecuted event: { + eventName: 'UserOperationExecuted', + args: { + personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F', + nonce: 0n + }, + ... +} + +[pin-notice] calls: [ + { + target: '0x59D57652BF4F6d97a6e555800b3920Bd775661Dc', + value: 1000000000000000000n, + data: '0x28d106b20000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c48656c6c6f20576f726c64210000000000000000000000000000000000000000' + } +] + +[pin-notice] current nonce: 1n + +[pin-notice] XRPL transaction hash: 9F1A4D7E2B83C5C0A6F4E1D8B7C9A2E5F6D3B4C1E0F7A8B9C2D3E4F5A6B7C8D9 + +[pin-notice] UserOperationExecuted event: { + eventName: 'UserOperationExecuted', + args: { + personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F', + nonce: 1n + }, + ... +} +``` + +:::tip What's next? + +- Use the recommended single-payment flow in the [Custom Instruction TypeScript guide](/smart-accounts/guides/typescript-viem/custom-instruction-ts). +- Compare the two flows in the [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison). +- Explore the [Raw Custom Instruction overview](/smart-accounts/raw-custom-instruction). +- Dig into the `IMasterAccountController` [reference](/smart-accounts/reference/IMasterAccountController). + ::: diff --git a/docs/smart-accounts/guides/typescript-viem/03-cross-chain-mint.mdx b/docs/smart-accounts/guides/typescript-viem/04-cross-chain-mint.mdx similarity index 99% rename from docs/smart-accounts/guides/typescript-viem/03-cross-chain-mint.mdx rename to docs/smart-accounts/guides/typescript-viem/04-cross-chain-mint.mdx index 01762c73..9e5a911d 100644 --- a/docs/smart-accounts/guides/typescript-viem/03-cross-chain-mint.mdx +++ b/docs/smart-accounts/guides/typescript-viem/04-cross-chain-mint.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 slug: cross-chain-mint-ts title: Cross-Chain Mint authors: [nikerzetic] diff --git a/docs/smart-accounts/guides/typescript-viem/04-cross-chain-redeem.mdx b/docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem.mdx similarity index 99% rename from docs/smart-accounts/guides/typescript-viem/04-cross-chain-redeem.mdx rename to docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem.mdx index 4155b663..f11eca15 100644 --- a/docs/smart-accounts/guides/typescript-viem/04-cross-chain-redeem.mdx +++ b/docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 slug: cross-chain-redeem-ts title: Cross-Chain Redeem authors: [nikerzetic] diff --git a/docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem-to-tag.mdx b/docs/smart-accounts/guides/typescript-viem/06-cross-chain-redeem-to-tag.mdx similarity index 99% rename from docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem-to-tag.mdx rename to docs/smart-accounts/guides/typescript-viem/06-cross-chain-redeem-to-tag.mdx index 4c73e537..cc023b34 100644 --- a/docs/smart-accounts/guides/typescript-viem/05-cross-chain-redeem-to-tag.mdx +++ b/docs/smart-accounts/guides/typescript-viem/06-cross-chain-redeem-to-tag.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 slug: cross-chain-redeem-to-tag-ts title: Cross-Chain Redeem to Tag authors: [nikerzetic] diff --git a/docs/smart-accounts/reference/IMasterAccountController.mdx b/docs/smart-accounts/reference/IMasterAccountController.mdx index 7a75bd86..a00793fc 100644 --- a/docs/smart-accounts/reference/IMasterAccountController.mdx +++ b/docs/smart-accounts/reference/IMasterAccountController.mdx @@ -243,22 +243,24 @@ function getPaymentProofValidityDurationSeconds() ## Memo Instructions -Memo instructions allow a [`PersonalAccount`](/smart-accounts/reference/IPersonalAccount) to execute [custom user operations](/smart-accounts/custom-instruction) embedded in the XRPL `Payment` memo when FAssets are direct-minted into the account. +Memo instructions allow a [`PersonalAccount`](/smart-accounts/reference/IPersonalAccount) to execute [custom user operations](/smart-accounts/custom-instruction-comparison) embedded in the XRPL `Payment` memo when FAssets are direct-minted into the account. -### `mintedFAssets` +### `handleMintedFAssets` Called by the FAssets `AssetManager` when FXRP is direct-minted into a personal account. Decodes the XRPL memo, distributes the minted FAssets between the personal account and the executor, and dispatches the custom instruction. -See [Custom Instruction](/smart-accounts/custom-instruction) for the end-to-end flow. +For the [`0xFE` custom instruction](/smart-accounts/custom-instruction), the `_data` parameter carries the ABI-encoded `PackedUserOperation` whose hash was committed to in the memo. +See [Custom Instruction Comparison](/smart-accounts/custom-instruction-comparison) for the end-to-end flow. ```solidity -function mintedFAssets( +function handleMintedFAssets( bytes32 _transactionId, string calldata _sourceAddress, uint256 _amount, uint256 _underlyingTimestamp, bytes calldata _memoData, - address payable _executor + address payable _executor, + bytes calldata _data ) external payable; ``` @@ -269,8 +271,10 @@ Parameters: - `_sourceAddress`: The XRPL source address that triggered the mint. - `_amount`: The total minted FAsset amount, in the FAsset's smallest unit (XRP drops for FXRP). - `_underlyingTimestamp`: The XRPL transaction timestamp. -- `_memoData`: The raw XRPL memo bytes carrying the [memo instruction](/smart-accounts/custom-instruction). +- `_memoData`: The raw XRPL memo bytes carrying the [memo instruction](/smart-accounts/custom-instruction-comparison). - `_executor`: The executor address; must equal the personal account's pinned executor when one is set. +- `_data`: Extra data not contained in the XRPL memo. + For the `0xFE` custom instruction this is the ABI-encoded `PackedUserOperation`; for all other instruction IDs it is ignored. The `AssetManager` is the only address allowed to call this function — any other caller reverts with [`OnlyAssetManager`](#onlyassetmanager). @@ -465,7 +469,7 @@ event Claimed( #### `UserOperationExecuted` -Emitted when a memo-encoded user operation (`0xFF`) executes with the personal account. +Emitted when a memo-dispatched user operation (`0xFF` raw custom instruction or `0xFE` custom instruction) executes against the personal account. ```solidity event UserOperationExecuted( @@ -587,7 +591,7 @@ Parameters: ### Memo Instructions -These errors are surfaced by [`mintedFAssets`](#mintedfassets) and the memo dispatch path. +These errors are surfaced by [`handleMintedFAssets`](#handlemintedfassets) and the memo dispatch path. #### `TransactionAlreadyExecuted` @@ -599,7 +603,7 @@ error TransactionAlreadyExecuted(); #### `OnlyAssetManager` -Thrown when [`mintedFAssets`](#mintedfassets) is called by any address other than the FAssets `AssetManager`. +Thrown when [`handleMintedFAssets`](#handlemintedfassets) is called by any address other than the FAssets `AssetManager`. ```solidity error OnlyAssetManager(); @@ -647,7 +651,7 @@ error InvalidMemoData(); #### `InvalidInstructionId` -Thrown when the first byte of the memo is not a recognized memo instruction ID (`0xFF`, `0xE0`, `0xE1`, `0xE2`, `0xD0`, `0xD1`). +Thrown when the first byte of the memo is not a recognized memo instruction ID (`0xFF`, `0xFE`, `0xE0`, `0xE1`, `0xE2`, `0xD0`, `0xD1`). ```solidity error InvalidInstructionId( @@ -659,6 +663,22 @@ Parameters: - `instructionId`: The unrecognized memo instruction ID. +#### `CustomInstructionHashMismatch` + +Thrown by the [`0xFE` custom instruction](/smart-accounts/custom-instruction) when `keccak256(_data)` does not match the 32-byte hash carried in the XRPL memo. + +```solidity +error CustomInstructionHashMismatch( + bytes32 expected, + bytes32 actual +); +``` + +Parameters: + +- `expected`: The hash committed to in the memo. +- `actual`: The hash of the supplied `_data`. + #### `InvalidSender` Thrown when the `sender` field of the embedded `PackedUserOperation` does not match the personal account derived from the XRPL source address. diff --git a/examples/developer-hub-javascript/smart-accounts/custom-instructions-raw.ts b/examples/developer-hub-javascript/smart-accounts/custom-instructions-raw.ts new file mode 100644 index 00000000..9505882c --- /dev/null +++ b/examples/developer-hub-javascript/smart-accounts/custom-instructions-raw.ts @@ -0,0 +1,110 @@ +import { encodeFunctionData } from "viem"; +import { Client, Wallet } from "xrpl"; +import { abi as checkpointAbi } from "./abis/Checkpoint"; +import { abi as piggyBankAbi } from "./abis/PiggyBank"; +import { abi as noticeBoardAbi } from "./abis/NoticeBoard"; +import { + getPersonalAccountAddress, + sendMemoFieldInstruction, + type Call, +} from "./utils/smart-accounts"; +import { computeDirectMintingPaymentAmountXrp } from "./utils/fassets"; +import { getXrpBalance } from "./utils/xrpl"; + +// NOTE:(Nik) For this example to work, you first need to faucet C2FLR to your personal account address. +async function main() { + // Net FXRP amount to mint in XRP. Minting + executor fees are fetched from + // AssetManagerFXRP and added on top to form the XRPL payment amount. + const fxrpMintAmount = 10; + + const checkpointAddress = "0xEE6D54382aA623f4D16e856193f5f8384E487002"; + const piggyBankAddress = "0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42"; + const noticeBoardAddress = "0x59D57652BF4F6d97a6e555800b3920Bd775661Dc"; + + const depositAmount = 1 * 10 ** 18; + const pinNoticeAmount = 1 * 10 ** 18; + const pinNoticeMessage = "Hello World!"; + + // XRPL caps each memo at ~1024 bytes. `pinNotice` has a string arg that pushes + // the 3-call version over the limit, so it goes in its own batch. + const checkpointAndDepositCalls: Call[] = [ + { + target: checkpointAddress, + value: BigInt(0), + data: encodeFunctionData({ + abi: checkpointAbi, + functionName: "passCheckpoint", + args: [], + }), + }, + { + target: piggyBankAddress, + value: BigInt(depositAmount), + data: encodeFunctionData({ + abi: piggyBankAbi, + functionName: "deposit", + args: [], + }), + }, + ]; + const pinNoticeCalls: Call[] = [ + { + target: noticeBoardAddress, + value: BigInt(pinNoticeAmount), + data: encodeFunctionData({ + abi: noticeBoardAbi, + functionName: "pinNotice", + args: [pinNoticeMessage], + }), + }, + ]; + + const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!); + const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!); + + const [personalAccount, paymentAmountXrp, memoOnlyAmountXrp] = + await Promise.all([ + getPersonalAccountAddress(xrplWallet.address), + computeDirectMintingPaymentAmountXrp({ + netMintAmountXrp: fxrpMintAmount, + }), + computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }), + ]); + console.log("Personal account address:", personalAccount, "\n"); + console.log("Payment amount (XRP, net mint + fees):", paymentAmountXrp, "\n"); + console.log("Memo-only amount (XRP, fees only):", memoOnlyAmountXrp, "\n"); + + const totalRequiredXrp = paymentAmountXrp + memoOnlyAmountXrp; + const xrpBalance = await getXrpBalance(xrplWallet.address, xrplClient); + console.log("XRPL wallet XRP balance:", xrpBalance, "\n"); + if (xrpBalance < totalRequiredXrp) { + throw new Error( + `Insufficient XRP balance on ${xrplWallet.address}: have ${xrpBalance} XRP, need ${totalRequiredXrp} XRP (both payments)`, + ); + } + + await sendMemoFieldInstruction({ + label: "checkpoint-and-deposit", + calls: checkpointAndDepositCalls, + amountXrp: paymentAmountXrp, + personalAccount, + xrplClient, + xrplWallet, + }); + + await sendMemoFieldInstruction({ + label: "pin-notice", + calls: pinNoticeCalls, + amountXrp: memoOnlyAmountXrp, + personalAccount, + xrplClient, + xrplWallet, + }); +} + +void main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/examples/developer-hub-javascript/smart-accounts/custom-instructions.ts b/examples/developer-hub-javascript/smart-accounts/custom-instructions.ts index bb9d3fb0..024c0494 100644 --- a/examples/developer-hub-javascript/smart-accounts/custom-instructions.ts +++ b/examples/developer-hub-javascript/smart-accounts/custom-instructions.ts @@ -4,13 +4,28 @@ import { abi as checkpointAbi } from "./abis/Checkpoint"; import { abi as piggyBankAbi } from "./abis/PiggyBank"; import { abi as noticeBoardAbi } from "./abis/NoticeBoard"; import { + executeDirectMintingWithData, + findUserOperationExecuted, getPersonalAccountAddress, - sendMemoFieldInstruction, + sendHashInstruction, type Call, } from "./utils/smart-accounts"; import { computeDirectMintingPaymentAmountXrp } from "./utils/fassets"; +import { getXrpBalance } from "./utils/xrpl"; // NOTE:(Nik) For this example to work, you first need to faucet C2FLR to your personal account address. +// +// The 0xFE flow is a three-step protocol. This script runs all three steps +// itself for end-to-end demo purposes, but in production they map to two +// independent actors: +// 1. USER SIDE - encode the UserOp, commit `keccak256(userOp)` in the +// 42-byte XRPL memo, send the XRPL Payment. +// 2. EXECUTOR SIDE - fetch an FDC Payment proof for the XRPL transaction +// and call AssetManagerFXRP.executeDirectMintingWithData +// with the proof and the full UserOp bytes. +// 3. CONFIRMATION - the MasterAccountController executes the UserOp inside the executor tx, +// so the receipt's logs already contain +// UserOperationExecuted; no separate watcher needed. async function main() { // Net FXRP amount to mint in XRP. Minting + executor fees are fetched from // AssetManagerFXRP and added on top to form the XRPL payment amount. @@ -24,9 +39,9 @@ async function main() { const pinNoticeAmount = 1 * 10 ** 18; const pinNoticeMessage = "Hello World!"; - // XRPL caps each memo at ~1024 bytes. `pinNotice` has a string arg that pushes - // the 3-call version over the limit, so it goes in its own batch. - const checkpointAndDepositCalls: Call[] = [ + // With 0xFE the XRPL memo is always 42 bytes regardless of call-batch size, + // so all three calls fit into a single payment. + const calls: Call[] = [ { target: checkpointAddress, value: BigInt(0), @@ -45,8 +60,6 @@ async function main() { args: [], }), }, - ]; - const pinNoticeCalls: Call[] = [ { target: noticeBoardAddress, value: BigInt(pinNoticeAmount), @@ -61,35 +74,54 @@ async function main() { const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!); const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!); - const [personalAccount, paymentAmountXrp, memoOnlyAmountXrp] = - await Promise.all([ - getPersonalAccountAddress(xrplWallet.address), - computeDirectMintingPaymentAmountXrp({ - netMintAmountXrp: fxrpMintAmount, - }), - computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: 0 }), - ]); + const [personalAccount, paymentAmountXrp] = await Promise.all([ + getPersonalAccountAddress(xrplWallet.address), + computeDirectMintingPaymentAmountXrp({ netMintAmountXrp: fxrpMintAmount }), + ]); console.log("Personal account address:", personalAccount, "\n"); console.log("Payment amount (XRP, net mint + fees):", paymentAmountXrp, "\n"); - console.log("Memo-only amount (XRP, fees only):", memoOnlyAmountXrp, "\n"); - await sendMemoFieldInstruction({ - label: "checkpoint-and-deposit", - calls: checkpointAndDepositCalls, + const xrpBalance = await getXrpBalance(xrplWallet.address, xrplClient); + console.log("XRPL wallet XRP balance:", xrpBalance, "\n"); + if (xrpBalance < paymentAmountXrp) { + throw new Error( + `Insufficient XRP balance on ${xrplWallet.address}: have ${xrpBalance} XRP, need ${paymentAmountXrp} XRP`, + ); + } + + // --- 1. USER SIDE ------------------------------------------------------- + // Send the XRPL Payment carrying the 32-byte UserOp hash in the memo. The + // full PackedUserOperation bytes (returned as `data`) never go onto XRPL. + const userSide = await sendHashInstruction({ + label: "hash-instruction-batch", + calls, amountXrp: paymentAmountXrp, personalAccount, xrplClient, xrplWallet, }); - await sendMemoFieldInstruction({ - label: "pin-notice", - calls: pinNoticeCalls, - amountXrp: memoOnlyAmountXrp, - personalAccount, + // --- 2. EXECUTOR SIDE --------------------------------------------------- + // Fetch the FDC Payment proof for the XRPL transaction and submit it to + // AssetManagerFXRP together with `data`. `totalCallValue` is forwarded as + // msg.value (AssetManager -> MasterAccountController.handleMintedFAssets -> PersonalAccount.call). + const { receipt } = await executeDirectMintingWithData({ + xrplTransactionHash: userSide.xrplTransactionHash, + data: userSide.data, + value: userSide.totalCallValue, xrplClient, - xrplWallet, + label: "hash-instruction-batch", }); + + // --- 3. CONFIRMATION ---------------------------------------------------- + // The MasterAccountController executes the UserOp inside the executor transaction, so the + // receipt's logs already contain UserOperationExecuted. + const event = findUserOperationExecuted( + receipt, + personalAccount, + userSide.nonce, + ); + console.log("UserOperationExecuted:", event, "\n"); } void main() diff --git a/sidebars.ts b/sidebars.ts index 9b8f3b78..69773bed 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -168,6 +168,8 @@ const sidebars: SidebarsConfig = { items: [ "smart-accounts/fasset-instructions", "smart-accounts/custom-instruction", + "smart-accounts/raw-custom-instruction", + "smart-accounts/custom-instruction-comparison", { type: "category", label: "Developer Guides",