Skip to main content

Custom Instruction

The Custom Instruction guide provides a general overview of how Flare smart account Custom Instructions work. In this guide, we will showcase an example script, which uses the TypeScript Viem library to register a Custom Instruction, send it on XRPL, and look for the CustomInstructionExecuted event on the Flare network.

The custom instruction we will be sending will execute three different calls on the Flare network. Each will interact with a different contract, and they will get progressively more complicated.

info

The code in this guide is set up for the Coston2 testnet. Despite that, we will refer to the network as Flare and its currency as FLR instead of Coston2 and C2FLR.

A prerequisite for the custom instruction to work is that it is properly funded. As we will see, the custom instruction that we will be sending transfers a total of 2 FLR to other accounts. The State Lookup guide explains how we can acquire its address. Then, we can use the Flare faucet to fund our account.

The full code showcased in this guide is available on GitHub.

Contracts

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 custom instruction will be to call the passCheckpoint function of this contract, deploy at the address 0xEE6D54382aA623f4D16e856193f5f8384E487002.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Checkpoint {
mapping(address => uint256) public numberOfPasses;

function passCheckpoint() public payable {
++numberOfPasses[msg.sender];
}
}

The second contract is a PiggyBank, and is a degree more useful. It allows a user to deposit FLR into it, and withdraw it all at once. Our second call will be to deposit 1 FLR into the PiggyBank contract at the address 0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract PiggyBank {
mapping(address => uint256) public balances;

function deposit() public payable {
balances[msg.sender] += msg.value;
}

function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0);
delete balances[msg.sender];
(bool success, ) = payable(msg.sender).call{ value: amount }("");
require(success);
}
}

The third and last contract is a NoticeBoard. It allows users to pinNotice. The value they send in this transaction will determine how long the notice stays up; as the contract is set up, 1 FLR gets you 30 days, with fractions allowed. The last call our personal account will perform is to pin a notice with the message Hello World! to the NoticeBoard at the address 0x59D57652BF4F6d97a6e555800b3920Bd775661Dc for 30 days (which means we need to attach a value of 1 FLR).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

struct Notice {
uint256 expirationTimestamp;
string message;
}

contract NoticeBoard {
uint256 public constant THIRTY_DAYS = 2592000;

mapping(address => Notice) public notices;
mapping(address => bool) public existingClients;
address[] public clients;

function pinNotice(string memory message) public payable {
require(msg.value > 0);

uint256 duration = THIRTY_DAYS * (msg.value / 1 ether);
uint256 expirationTimestamp = block.timestamp + duration;

notices[msg.sender] = Notice(expirationTimestamp, message);
if (!existingClients[msg.sender]) {
clients.push(msg.sender);
existingClients[msg.sender] = true;
}
}

function getNotices() public view returns (Notice[] memory) {
Notice[] memory _notices = new Notice[](clients.length);
for (uint256 i = 0; i < clients.length; ++i) {
Notice memory notice = notices[clients[i]];
if (notice.expirationTimestamp > block.timestamp) {
_notices[i] = notice;
}
}
return _notices;
}
}

The contract also performs additional checks to ensure that each client is only added to the array of all the clients once, and that it returns only the notices that have not yet expired.

Parameters

Besides the contract addresses and their arguments, the only other parameter we need to specify is a walletId. This is a one-byte value, assigned by Flare on a case-by-case basis. It is intended for wallet identification by the operator. We will set it to 0.

const walletId = 0;

const checkpointAddress = "0xEE6D54382aA623f4D16e856193f5f8384E487002";
const piggyBankAddress = "0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42";
const noticeBoardAddress = "0x59D57652BF4F6d97a6e555800b3920Bd775661Dc";

const depositAmount = 1 * 10 ** 18;
const pinNoticeAmount = 1 * 10 ** 18;
const pinNoticeMessage = "Hello World!";

As described in the general Custom Instruction guide, each custom instruction call is a Solidity struct containing the following fields:

  • targetContract: the address of the contract to call
  • value: the amount of FLR to send
  • data: the function calldata (function selector and parameter encoding)
note

Behind the scenes, each custom instruction call translates to sending a payment transaction to the targetContract address of value FLR with attached data.

We define the CustomInstruction type:

export type CustomInstruction = {
targetContract: Address;
value: bigint;
data: `0x${string}`;
};

The targetContract and value do not need additional processing, but how can we generate the calldata? For that, we will use the encodeFunctionData function from the Viem library. It calculates the encoding for a function from its contract's ABI, the function name, and the arguments used. With that, we can prepare an array of calls, which is our custom instruction for this example.

const customInstructions = [
{
targetContract: checkpointAddress,
value: BigInt(0),
data: encodeFunctionData({
abi: checkpointAbi,
functionName: "passCheckpoint",
args: [],
}),
},
{
targetContract: piggyBankAddress,
value: BigInt(depositAmount),
data: encodeFunctionData({
abi: piggyBankAbi,
functionName: "deposit",
args: [],
}),
},
{
targetContract: noticeBoardAddress,
value: BigInt(pinNoticeAmount),
data: encodeFunctionData({
abi: noticeBoardAbi,
functionName: "pinNotice",
args: [pinNoticeMessage],
}),
},
] as CustomInstruction[];

Registering an instruction

Next, we need to register the custom instruction with the MasterAccountController contract. In order to read from the Flare chain, we first need to create a Viem public client.

import { createPublicClient, http } from "viem";
import { flareTestnet } from "viem/chains";

export const publicClient = createPublicClient({
chain: flareTestnet,
transport: http(),
});

We also need the address of the MasterAccountController contract. Since the Flare smart accounts are still in development, we cannot query the FlareContractRegistry for the MasterAccountController address. Instead, we need to hardcode it.

export const MASTER_ACCOUNT_CONTROLLER_ADDRESS =
"0x32F662C63c1E24bB59B908249962F00B61C6638f";

To register a custom instruction, we call the registerCustomInstruction function on the MasterAccountController contract and provide the custom instruction array as the argument. As recommended by the Viem documentation, we first use the simulateContract function to prepare the request. Then we call the writeContract Viem function, and actually register the instruction.

If the instruction has already been registered, a CustomInstructionAlreadyRegistered event is emitted. Otherwise, the instruction is registered, and a CustomInstructionRegistered event is emitted instead.

export async function registerCustomInstruction(
instructions: CustomInstruction[],
): Promise<`0x${string}`> {
const { request } = await publicClient.simulateContract({
account: account,
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: abi,
functionName: "registerCustomInstruction",
args: [instructions],
});
console.log("request:", request, "\n");

const registerCustomInstructionTransaction =
await walletClient.writeContract(request);
console.log(
"Register custom instruction transaction:",
registerCustomInstructionTransaction,
"\n",
);

return registerCustomInstructionTransaction;
}

Encoding an instruction

Before the custom instruction can be sent on the XRPL, it must be properly encoded. We call the encodeCustomInstruction read function of the MasterAccountController contract with the instruction as parameter. It returns a 32-byte callHash, which is not yet a proper instruction encoding. We need to replace the first two bytes with the following values:

  • 1st byte: custom instruction command ID 0xff
  • 2nd byte: wallet identifier walletId described above
async function encodeCustomInstruction(
instructions: CustomInstruction[],
walletId: number,
) {
const encodedInstruction = (await publicClient.readContract({
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: abi,
functionName: "encodeCustomInstruction",
args: [instructions],
})) as `0x${string}`;
// NOTE:(Nik) We cut off the `0x` prefix and the first 2 bytes to get the length down to 30 bytes
return ("0xff" +
toHex(walletId, { size: 1 }).slice(2) +
encodedInstruction.slice(6)) as `0x${string}`;
}

Sending an instruction

With that, we can send our custom instruction. We make an XRPL Payment transaction to one of the operator's XRPL addresses (the State Lookup guide explains how we can obtain these). The encoded instruction is attached as a memo of the transaction, with the preceding 0x removed.

async function sendCustomInstruction({
encodedInstruction,
xrplClient,
xrplWallet,
}: {
encodedInstruction: `0x${string}`;
xrplClient: Client;
xrplWallet: Wallet;
}) {
const operatorXrplAddress = (await getOperatorXrplAddresses())[0] as string;

const instructionFee = await getInstructionFee(encodedInstruction);
console.log("Instruction fee:", instructionFee, "\n");

const customInstructionTransaction = await sendXrplPayment({
destination: operatorXrplAddress,
amount: instructionFee,
memos: [{ Memo: { MemoData: encodedInstruction.slice(2) } }],
wallet: xrplWallet,
client: xrplClient,
});

return customInstructionTransaction;
}

The instruction fee is determined by the MasterAccountController contract from the decimal representation of the instruction ID. In this case, the decimal value of the instruction ID (ff) is 255. But we define a more general function that accepts any encoded instruction as input and extracts the necessary data from it.

export async function getInstructionFee(encodedInstruction: string) {
const instructionId = encodedInstruction.slice(0, 4);
const instructionIdDecimal = fromHex(
instructionId as `0x${string}`,
"bigint",
);

console.log("instructionIdDecimal:", instructionIdDecimal, "\n");

const requestFee = await publicClient.readContract({
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: coston2.iMasterAccountControllerAbi,
functionName: "getInstructionFee",
args: [instructionIdDecimal],
});
return dropsToXrp(Number(requestFee));
}

The sendCustomInstruction function requires two additional parameters, the xrplWallet and xrplClient. These are the Client and the Wallet classes from the xrpl library. We initialise them with values from the .env file.

const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);

Wait for instruction execution

Lastly, we need to wait for the operator to bridge the instruction from XRPL to Flare, and for the instruction to be executed. Once it does, our personal account executes the instruction, and the CustomInstructionExecuted event is emitted by the MasterAccountController contract. We watch for such events with the watchContractEvent Viem function.

We check each observed event, ensuring that:

  • the call hash of the event matches the one sent in the encoded instruction (here, only the last 30 bytes must match)
  • the personal account that executed the instruction is our personal account (lowercase to remove checksum validation)

When we find the correct event, we set the customInstructionExecutedEventFound value to true. If the value is false, we wait for 10 seconds, then check again. Once the value has been found, we stop observing the contract and return the event.

async function waitForCustomInstructionExecutedEvent({
encodedInstruction,
personalAccountAddress,
}: {
encodedInstruction: `0x${string}`;
personalAccountAddress: string;
}) {
let customInstructionExecutedEvent:
| CustomInstructionExecutedEventType
| undefined;
let customInstructionExecutedEventFound = false;

const unwatchCustomInstructionExecuted = publicClient.watchContractEvent({
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: iInstructionsFacetAbi,
eventName: "CustomInstructionExecuted",
onLogs: (logs) => {
for (const log of logs) {
customInstructionExecutedEvent =
log as CustomInstructionExecutedEventType;
if (
customInstructionExecutedEvent.args.callHash.slice(6) !==
encodedInstruction.slice(6) ||
customInstructionExecutedEvent.args.personalAccount.toLowerCase() !==
personalAccountAddress.toLowerCase()
) {
continue;
}
customInstructionExecutedEventFound = true;
break;
}
},
});

console.log("Waiting for CustomInstructionExecuted event...");
while (!customInstructionExecutedEventFound) {
await new Promise((resolve) => setTimeout(resolve, 10000));
}
unwatchCustomInstructionExecuted();

return customInstructionExecutedEvent;
}

The vehicle for bridging the instruction is the Flare Data Connector, which puts a maximum cap of 180 seconds on the process duration.

Full script

The repository with the above example is available on GitHub.

src/custom-instructions.ts
import { encodeFunctionData, toHex } from "viem";
import { abi as checkpointAbi } from "./abis/Checkpoint";
import { abi as piggyBankAbi } from "./abis/PiggyBank";
import { abi as noticeBoardAbi } from "./abis/NoticeBoard";
import { abi as iInstructionsFacetAbi } from "./abis/IInstructionsFacet";
import {
getInstructionFee,
getOperatorXrplAddresses,
getPersonalAccountAddress,
MASTER_ACCOUNT_CONTROLLER_ADDRESS,
registerCustomInstruction,
type CustomInstruction,
} from "./utils/smart-accounts";
import { publicClient } from "./utils/client";
import { sendXrplPayment } from "./utils/xrpl";
import { Client, Wallet } from "xrpl";
import type { CustomInstructionExecutedEventType } from "./utils/event-types";
import { abi } from "./abis/CustomInstructionsFacet";

async function encodeCustomInstruction(
instructions: CustomInstruction[],
walletId: number,
) {
const encodedInstruction = (await publicClient.readContract({
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: abi,
functionName: "encodeCustomInstruction",
args: [instructions],
})) as `0x${string}`;
// NOTE:(Nik) We cut off the `0x` prefix and the first 2 bytes to get the length down to 30 bytes
return ("0xff" +
toHex(walletId, { size: 1 }).slice(2) +
encodedInstruction.slice(6)) as `0x${string}`;
}

async function sendCustomInstruction({
encodedInstruction,
xrplClient,
xrplWallet,
}: {
encodedInstruction: `0x${string}`;
xrplClient: Client;
xrplWallet: Wallet;
}) {
const operatorXrplAddress = (await getOperatorXrplAddresses())[0] as string;

const instructionFee = await getInstructionFee(encodedInstruction);
console.log("Instruction fee:", instructionFee, "\n");

const customInstructionTransaction = await sendXrplPayment({
destination: operatorXrplAddress,
amount: instructionFee,
memos: [{ Memo: { MemoData: encodedInstruction.slice(2) } }],
wallet: xrplWallet,
client: xrplClient,
});

return customInstructionTransaction;
}

async function waitForCustomInstructionExecutedEvent({
encodedInstruction,
personalAccountAddress,
}: {
encodedInstruction: `0x${string}`;
personalAccountAddress: string;
}) {
let customInstructionExecutedEvent:
| CustomInstructionExecutedEventType
| undefined;
let customInstructionExecutedEventFound = false;

const unwatchCustomInstructionExecuted = publicClient.watchContractEvent({
address: MASTER_ACCOUNT_CONTROLLER_ADDRESS,
abi: iInstructionsFacetAbi,
eventName: "CustomInstructionExecuted",
onLogs: (logs) => {
for (const log of logs) {
customInstructionExecutedEvent =
log as CustomInstructionExecutedEventType;
if (
customInstructionExecutedEvent.args.callHash.slice(6) !==
encodedInstruction.slice(6) ||
customInstructionExecutedEvent.args.personalAccount.toLowerCase() !==
personalAccountAddress.toLowerCase()
) {
continue;
}
customInstructionExecutedEventFound = true;
break;
}
},
});

console.log("Waiting for CustomInstructionExecuted event...");
while (!customInstructionExecutedEventFound) {
await new Promise((resolve) => setTimeout(resolve, 10000));
}
unwatchCustomInstructionExecuted();

return customInstructionExecutedEvent;
}

// NOTE:(Nik) For this example to work, you first need to faucet C2FLR to your personal account address.
async function main() {
const walletId = 0;

const checkpointAddress = "0xEE6D54382aA623f4D16e856193f5f8384E487002";
const piggyBankAddress = "0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42";
const noticeBoardAddress = "0x59D57652BF4F6d97a6e555800b3920Bd775661Dc";

const depositAmount = 1 * 10 ** 18;
const pinNoticeAmount = 1 * 10 ** 18;
const pinNoticeMessage = "Hello World!";

const customInstructions = [
{
targetContract: checkpointAddress,
value: BigInt(0),
data: encodeFunctionData({
abi: checkpointAbi,
functionName: "passCheckpoint",
args: [],
}),
},
{
targetContract: piggyBankAddress,
value: BigInt(depositAmount),
data: encodeFunctionData({
abi: piggyBankAbi,
functionName: "deposit",
args: [],
}),
},
{
targetContract: noticeBoardAddress,
value: BigInt(pinNoticeAmount),
data: encodeFunctionData({
abi: noticeBoardAbi,
functionName: "pinNotice",
args: [pinNoticeMessage],
}),
},
] as CustomInstruction[];
console.log("Custom instructions:", customInstructions, "\n");

const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(process.env.XRPL_SEED!);

const personalAccountAddress = await getPersonalAccountAddress(
xrplWallet.address,
);
console.log("Personal account address:", personalAccountAddress, "\n");

const customInstructionCallHash =
await registerCustomInstruction(customInstructions);
console.log("Custom instruction call hash:", customInstructionCallHash, "\n");
const encodedInstruction = await encodeCustomInstruction(
customInstructions,
walletId,
);
console.log("Encoded instructions:", encodedInstruction, "\n");

const customInstructionTransaction = await sendCustomInstruction({
encodedInstruction,
xrplClient,
xrplWallet,
});
console.log(
"Custom instruction transaction hash:",
customInstructionTransaction.result.hash,
"\n",
);

const customInstructionExecutedEvent =
await waitForCustomInstructionExecutedEvent({
encodedInstruction,
personalAccountAddress,
});
console.log(
"CustomInstructionExecuted event:",
customInstructionExecutedEvent,
"\n",
);
}

void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Expected output

Custom instructions: [
{
targetContract: '0xEE6D54382aA623f4D16e856193f5f8384E487002',
value: 0n,
data: '0x80abd133'
},
{
targetContract: '0x42Ccd4F0aB1C6Fa36BfA37C9e30c4DC4DD94dE42',
value: 1000000000000000000n,
data: '0xd0e30db0'
},
{
targetContract: '0x59D57652BF4F6d97a6e555800b3920Bd775661Dc',
value: 1000000000000000000n,
data: '0x28d106b20000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c48656c6c6f20576f726c64210000000000000000000000000000000000000000'
}
]

Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F

request: {
abi: [
{
inputs: [Array],
name: 'registerCustomInstruction',
outputs: [Array],
stateMutability: 'nonpayable',
type: 'function'
}
],
address: '0x434936d47503353f06750Db1A444DBDC5F0AD37c',
args: [ [ [Object], [Object], [Object] ] ],
dataSuffix: undefined,
functionName: 'registerCustomInstruction',
account: {
address: '0xF5488132432118596fa13800B68df4C0fF25131d',
nonceManager: undefined,
sign: [AsyncFunction: sign],
signAuthorization: [AsyncFunction: signAuthorization],
signMessage: [AsyncFunction: signMessage],
signTransaction: [AsyncFunction: signTransaction],
signTypedData: [AsyncFunction: signTypedData],
source: 'privateKey',
type: 'local',
publicKey: '0x04cd84974fb965b048aa4230fdf0a2735c951f28b076727e1067bf56577a59b1cbc41e8d03b33acc9105828f048c0cf940c78bba518b0da2765078ca2a42e056c3'
}
}

Register custom instruction transaction: 0x2f6500791d89ea7f8ff5f3ce8983d92d7aaf62e04207513e8e0b2a2bfdf09a1e

Custom instruction call hash: 0x2f6500791d89ea7f8ff5f3ce8983d92d7aaf62e04207513e8e0b2a2bfdf09a1e

Encoded instructions: 0xff00e54bf1b4e93c5306e08d919864ec111211c4fb36960261e8018da9959791

instructionIdDecimal: 255n

Instruction fee: 0.001

Custom instruction transaction hash: 03165B82E4B7BB168AF7B217C9EC833896E968C796CA8BF8D2E66879ED311909

Waiting for CustomInstructionExecuted event...
CustomInstructionExecuted event: {
eventName: 'CustomInstructionExecuted',
args: {
personalAccount: '0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F',
callHash: '0x0000e54bf1b4e93c5306e08d919864ec111211c4fb36960261e8018da9959791',
customInstruction: [ [Object], [Object], [Object] ]
},
address: '0x434936d47503353f06750db1a444dbdc5f0ad37c',
topics: [
'0x1c09418c54894f576841186935c5f666b3bedd66c29f3a03bcab4051fe2509f3',
'0x000000000000000000000000fd2f0eb6b9fa4fe5bb1f7b26fee3c647ed103d9f',
'0x0000e54bf1b4e93c5306e08d919864ec111211c4fb36960261e8018da9959791'
],
data: '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000ee6d54382aa623f4d16e856193f5f8384e48700200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000480abd1330000000000000000000000000000000000000000000000000000000000000000000000000000000042ccd4f0ab1c6fa36bfa37c9e30c4dc4dd94de420000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000059d57652bf4f6d97a6e555800b3920bd775661dc0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000006428d106b20000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c48656c6c6f20576f726c6421000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
blockNumber: 26663892n,
transactionHash: '0xe3ba1751d63a9b11ff3b3185254c28c447f1db6ff101708f6968ff935c28e3f3',
transactionIndex: 2,
blockHash: '0x69a3b948a56a5195fbe0ced46b157ca33390226a4503f59a3d8e59fe07538d23',
logIndex: 7,
removed: false,
blockTimestamp: undefined
}