Skip to main content

Gasless FXRP Payments

This guide explains how to set up gasless FXRP (FAsset) transfers on Flare. Users sign payment requests off-chain with EIP-712 typed data, and a relayer submits them on-chain and pays gas on their behalf.

Standards explained

EIP-3009 (Transfer with Authorization) extends ERC-20 with meta-transactions. The token holder signs an authorization off-chain, and a relayer executes the transfer on-chain and pays gas.

This guide uses a custom EIP-712 payment message and a forwarder contract instead of EIP-3009 on the token itself. The forwarder pulls FXRP from the user (after a one-time approval) and sends it to the provided recipient. The relayer pays gas on behalf of the user.

Architecture

The architecture of the gasless FXRP payments system is as follows:

Project setup

To follow this guide, create a new Hardhat project or use the Flare Hardhat Starter kit.

You will need to install the following dependencies:

npm install ethers viem express cors dotenv @openzeppelin/contracts \
@flarenetwork/flare-periphery-contracts

Add the following files to your project:

Gasless Payment Forwarder Contract

Save as contracts/GaslessPaymentForwarder.sol to create the forwarder smart contract that will be used to execute gasless payments.

View contracts/GaslessPaymentForwarder.sol
contracts/GaslessPaymentForwarder.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

// OpenZeppelin: SafeERC20, ECDSA, EIP712, Ownable, ReentrancyGuard
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

// Flare Contract Registry and FAsset interface
import {ContractRegistry} from "@flarenetwork/flare-periphery-contracts/flare/ContractRegistry.sol";
import {IAssetManager} from "@flarenetwork/flare-periphery-contracts/flare/IAssetManager.sol";
import {IFAsset} from "@flarenetwork/flare-periphery-contracts/flare/IFAsset.sol";

/**
* @title GaslessPaymentForwarder
* @notice Enables gasless FXRP transfers using EIP-712 signed meta-transactions
* @dev Users sign payment requests off-chain, relayers submit them on-chain.
* FXRP from Flare Contract Registry: getAssetManagerFXRP() -> fAsset().
*
* Flow: (1) User approves this contract to spend FXRP once.
* (2) User signs PaymentRequest off-chain. (3) Relayer calls executePayment().
* (4) Contract verifies signature and executes transfer.
*/
contract GaslessPaymentForwarder is EIP712, Ownable, ReentrancyGuard {
// 1. Define the necessary libraries and contract variables
using SafeERC20 for IFAsset;
using ECDSA for bytes32;

mapping(address => uint256) public nonces; // replay protection per sender
mapping(address => bool) public authorizedRelayers; // relayer allowlist

// EIP-712 type hash for PaymentRequest
bytes32 public constant PAYMENT_REQUEST_TYPEHASH =
keccak256(
"PaymentRequest(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"
);

// 2. Contract events
event PaymentExecuted(
address indexed from,
address indexed to,
uint256 amount,
uint256 nonce
);
event RelayerAuthorized(address indexed relayer, bool authorized); // relayer allowlist changed

// 3. Custom errors
error InvalidSignature(); // signer != from
error ExpiredRequest(); // block.timestamp > deadline
error InvalidNonce(); // nonce mismatch (replay)
error UnauthorizedRelayer(); // caller not in allowlist
error InsufficientAllowance(); // user approval < amount
error ZeroAddress(); // zero address passed

// 4. Constructor
constructor() EIP712("GaslessPaymentForwarder", "1") Ownable(msg.sender) {}

// 5. Returns FXRP token from Flare Contract Registry
function fxrp() public view returns (IFAsset) {
IAssetManager assetManager = ContractRegistry.getAssetManagerFXRP();
return IFAsset(address(assetManager.fAsset())); // FXRP token from registry
}

// 6. Execute a gasless payment
function executePayment(
address from,
address to,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external nonReentrant {
if (block.timestamp > deadline) revert ExpiredRequest(); // validate deadline

uint256 currentNonce = nonces[from];

// 7. Hash the payment request
bytes32 structHash = keccak256(
abi.encode(
PAYMENT_REQUEST_TYPEHASH,
from,
to,
amount,
currentNonce,
deadline
)
);

// 8. Recover the signer from the hash
bytes32 hash = _hashTypedDataV4(structHash);
address signer = hash.recover(signature);

// 9. Check if the signer is the from address
if (signer != from) revert InvalidSignature();

nonces[from] = currentNonce + 1; // increment nonce (prevents replay)

IFAsset _fxrp = fxrp();

// 10. Check if the allowance is sufficient
if (_fxrp.allowance(from, address(this)) < amount) {
revert InsufficientAllowance();
}

// 11. Transfer the amount to the recipient
_fxrp.safeTransferFrom(from, to, amount);

emit PaymentExecuted(from, to, amount, currentNonce); // log success
}

// 12. Views for off-chain signing / validation
function getNonce(address account) external view returns (uint256) {
return nonces[account]; // current nonce for off-chain signing
}

function getDomainSeparator() external view returns (bytes32) {
return _domainSeparatorV4(); // EIP-712 domain separator
}

function getPaymentRequestHash(
address from,
address to,
uint256 amount,
uint256 nonce,
uint256 deadline
) external view returns (bytes32) {
bytes32 structHash = keccak256(
abi.encode(
PAYMENT_REQUEST_TYPEHASH,
from,
to,
amount,
nonce,
deadline
)
);
return _hashTypedDataV4(structHash); // full EIP-712 typed-data hash
}

// 13. Owner: relayer allowlist
function setRelayerAuthorization(
address relayer,
bool authorized
) external onlyOwner {
authorizedRelayers[relayer] = authorized; // update allowlist
emit RelayerAuthorized(relayer, authorized);
}
}

How the contract works

  1. Define libraries and state (nonces, authorizedRelayers).
  2. Define events (PaymentExecuted, RelayerAuthorized).
  3. Define errors.
  4. Implement a constructor that sets the EIP-712 domain and owner.
  5. Implement fxrp to return the FXRP token from the Flare Contract Registry.
  6. Define executePayment(from, to, amount, deadline, signature).
  7. Hash the request (type hash + from, to, amount, nonce, deadline) and get the EIP-712 digest.
  8. Recover the signer from the signature.
  9. Require signer to be the from address; increment nonce.
  10. Require sufficient allowance; transfer amount from sender to recipient.
  11. Define view functions: getNonce, getDomainSeparator, getPaymentRequestHash.
  12. Define setRelayerAuthorization for the relayer allowlist.

Run the following command to compile the contract and generate the typechain-types:

npx hardhat compile

It will generate the typechain-types directory with the contract interfaces and factories.

Utilities

Save as utils/payment.ts to create the utility functions that will be used to create and sign payment requests off-chain.

info

This utility uses the typechain-types generated by the previous command.

View utils/payment.ts
utils/payment.ts
/**
* FXRP Gasless Payment Utilities
*
* This module provides utilities for users to sign gasless payment requests
* using EIP-712 typed data signatures.
*
*/

// 1. Import the necessary libraries
import { ethers, Contract, type Wallet, type Provider } from "ethers";
import { erc20Abi, type TypedDataDomain, type TypedData } from "viem";
import { GaslessPaymentForwarder__factory } from "../typechain-types/factories/contracts/GaslessPaymentForwarder__factory";

// 2. Define the default deadline, EIP-712 domain and types

// Default deadline: 30 minutes from now
const DEFAULT_DEADLINE_SECONDS = 30 * 60;

// EIP-712 domain (viem TypedDataDomain format)
export const EIP712_DOMAIN: TypedDataDomain = {
name: "GaslessPaymentForwarder",
version: "1",
};

// EIP-712 types (viem TypedData format - compatible with ethers signTypedData)
export const PAYMENT_REQUEST_TYPES = {
PaymentRequest: [
{ name: "from", type: "address" as const },
{ name: "to", type: "address" as const },
{ name: "amount", type: "uint256" as const },
{ name: "nonce", type: "uint256" as const },
{ name: "deadline", type: "uint256" as const },
],
} satisfies TypedData;

// Type definitions
export interface SignPaymentParams {
forwarderAddress: string;
to: string;
amount: bigint;
nonce: bigint;
deadline: number;
chainId: bigint;
}

export interface PaymentRequest {
from: string;
to: string;
amount: string;
deadline: number;
signature: string;
meta: {
amountFormatted: string;
nonce: string;
chainId: string;
};
}

export interface ApprovalResult {
transactionHash: string;
blockNumber: number | null;
fxrpAddress: string;
approved: string;
}

export interface UserStatus {
fxrpAddress: string;
balance: string;
balanceFormatted: string;
allowance: string;
allowanceFormatted: string;
nonce: string;
needsApproval: boolean;
}

// 3. Parse amount from human-readable string using token decimals
export function parseAmount(amount: string | number, decimals: number): bigint {
return ethers.parseUnits(amount.toString(), decimals);
}

// 4. Format amount from raw units to human-readable string
export function formatAmount(drops: bigint | string, decimals: number): string {
return ethers.formatUnits(drops, decimals);
}

// 5. Get FXRP token decimals from the forwarder contract using the provider
export async function getTokenDecimals(
provider: Provider,
forwarderAddress: string,
): Promise<number> {
const forwarder = GaslessPaymentForwarder__factory.connect(
forwarderAddress,
provider,
);
const fxrpAddress: string = await forwarder.fxrp();
const fxrp = new Contract(
fxrpAddress,
erc20Abi as ethers.InterfaceAbi,
provider,
);
return (await fxrp.decimals()) as number;
}

// 6. Get the current nonce for a user from the forwarder contract
export async function getNonce(
provider: Provider,
forwarderAddress: string,
userAddress: string,
): Promise<bigint> {
const forwarder = GaslessPaymentForwarder__factory.connect(
forwarderAddress,
provider,
);
return await forwarder.getNonce(userAddress);
}

// 7. Sign a payment request using EIP-712
export async function signPaymentRequest(
wallet: Wallet,
params: SignPaymentParams,
): Promise<string> {
const { forwarderAddress, to, amount, nonce, deadline, chainId } = params;

// Build the EIP-712 domain
const domain = {
...EIP712_DOMAIN,
chainId: chainId,
verifyingContract: forwarderAddress,
};

// Build the message
const message = {
from: wallet.address,
to: to,
amount: amount,
nonce: nonce,
deadline: deadline,
};

// Sign the typed data
const signature = await wallet.signTypedData(
domain,
PAYMENT_REQUEST_TYPES,
message,
);

return signature;
}

// 8. Create a complete payment request ready for submission to a relayer
export async function createPaymentRequest(
wallet: Wallet,
forwarderAddress: string,
to: string,
amount: string | number,
deadlineSeconds: number = DEFAULT_DEADLINE_SECONDS,
): Promise<PaymentRequest> {
const provider = wallet.provider;
if (!provider) {
throw new Error("Wallet must be connected to a provider");
}

// Get chain ID and token decimals
const [network, decimals] = await Promise.all([
provider.getNetwork(),
getTokenDecimals(provider, forwarderAddress),
]);
const chainId = network.chainId;

// Get current nonce
const nonce = await getNonce(provider, forwarderAddress, wallet.address);

// Use chain block timestamp for deadline (avoids clock skew vs contract's block.timestamp)
const block = await provider.getBlock("latest");
const chainTime = block?.timestamp ?? Math.floor(Date.now() / 1000);
const deadline = chainTime + deadlineSeconds;

// Parse amount
const amountDrops = parseAmount(amount, decimals);

// Sign the request
const signature = await signPaymentRequest(wallet, {
forwarderAddress,
to,
amount: amountDrops,
nonce,
deadline,
chainId,
});

return {
from: wallet.address,
to: to,
amount: amountDrops.toString(),
deadline: deadline,
signature: signature,
// Metadata (not part of signature)
meta: {
amountFormatted: formatAmount(amountDrops, decimals) + " FXRP",
nonce: nonce.toString(),
chainId: chainId.toString(),
},
};
}

// 9. Approve the forwarder contract to spend FXRP (one-time per user)
export async function approveFXRP(
wallet: Wallet,
forwarderAddress: string,
amount: bigint = ethers.MaxUint256,
): Promise<ApprovalResult> {
const provider = wallet.provider;
if (!provider) {
throw new Error("Wallet must be connected to a provider");
}

// Get FXRP token address from forwarder
const forwarder = GaslessPaymentForwarder__factory.connect(
forwarderAddress,
provider,
);
const fxrpAddress: string = await forwarder.fxrp();

// Approve
const fxrp = new Contract(
fxrpAddress,
erc20Abi as ethers.InterfaceAbi,
wallet,
);
const tx = await fxrp.approve(forwarderAddress, amount);
const receipt = await tx.wait();

return {
transactionHash: tx.hash,
blockNumber: receipt?.blockNumber ?? null,
fxrpAddress: fxrpAddress,
approved: amount.toString(),
};
}

// 10. Check user's FXRP balance and allowance
export async function checkUserStatus(
provider: Provider,
forwarderAddress: string,
userAddress: string,
): Promise<UserStatus> {
const forwarder = GaslessPaymentForwarder__factory.connect(
forwarderAddress,
provider,
);
const fxrpAddress: string = await forwarder.fxrp();
const fxrp = new Contract(
fxrpAddress,
erc20Abi as ethers.InterfaceAbi,
provider,
);

const [balance, allowance, nonce, decimals] = await Promise.all([
fxrp.balanceOf(userAddress) as Promise<bigint>,
fxrp.allowance(userAddress, forwarderAddress) as Promise<bigint>,
forwarder.getNonce(userAddress) as Promise<bigint>,
fxrp.decimals() as Promise<number>,
]);

return {
fxrpAddress,
balance: balance.toString(),
balanceFormatted: formatAmount(balance, decimals) + " FXRP",
allowance: allowance.toString(),
allowanceFormatted: formatAmount(allowance, decimals) + " FXRP",
nonce: nonce.toString(),
needsApproval: allowance === 0n,
};
}

Code Breakdown

  1. Import the necessary libraries.
  2. Define constants and EIP-712 types (PaymentRequest: from, to, amount, nonce, deadline).
  3. Define types for sign params, payment request, approval result, and user status.
  4. Parse human-readable amount to raw bigint with decimals.
  5. Format the raw amount to a readable string with decimals.
  6. Get FXRP decimals from the forwarder and token contract.
  7. Get the current nonce for a user from the forwarder.
  8. Sign the payment message with EIP-712 and return the signature.
  9. Create a full payment request: fetch nonce, set deadline, sign, return payload for the relayer.
  10. Approve the forwarder to spend FXRP (one-time).
  11. Check user balance, allowance, nonce, and whether approval is needed.

Relayer Service

This is a relayer service that submits payment requests to the Flare blockchain and pays gas on behalf of the user.

Save as relayer/index.ts to start the relayer service.

View relayer/index.ts
relayer/index.ts
/**
* FXRP Gasless Payment Relayer Service
*
* This service accepts signed payment requests from users and submits them
* to the blockchain, paying gas fees on behalf of users.
*
* Usage:
* npx ts-node relayer/index.ts
*
* Environment variables required:
* RELAYER_PRIVATE_KEY - Private key of the relayer wallet
* FORWARDER_ADDRESS - Address of the deployed GaslessPaymentForwarder contract
* RPC_URL - Flare network RPC URL (optional, defaults to Coston2 testnet)
*/

// 1. Import the necessary libraries
import { ethers, Contract, Wallet, JsonRpcProvider } from "ethers";
import {
erc20Abi,
recoverTypedDataAddress,
type TypedDataDomain,
type TypedData,
} from "viem";
import express, { type Request, type Response } from "express";
import cors from "cors";
import "dotenv/config";
import type { GaslessPaymentForwarder } from "../typechain-types/contracts/GaslessPaymentForwarder";
import { GaslessPaymentForwarder__factory } from "../typechain-types/factories/contracts/GaslessPaymentForwarder__factory";

// 2. Define the network configurations

// EIP-712 domain and types (viem format, must match contract)
const EIP712_DOMAIN: TypedDataDomain = {
name: "GaslessPaymentForwarder",
version: "1",
};

const PAYMENT_REQUEST_TYPES = {
PaymentRequest: [
{ name: "from", type: "address" as const },
{ name: "to", type: "address" as const },
{ name: "amount", type: "uint256" as const },
{ name: "nonce", type: "uint256" as const },
{ name: "deadline", type: "uint256" as const },
],
} satisfies TypedData;

// Network configurations
const NETWORKS: Record<string, { rpc: string; chainId: number }> = {
flare: {
rpc: "https://flare-api.flare.network/ext/C/rpc",
chainId: 14,
},
coston2: {
rpc: "https://coston2-api.flare.network/ext/C/rpc",
chainId: 114,
},
songbird: {
rpc: "https://songbird-api.flare.network/ext/C/rpc",
chainId: 19,
},
};

// 3. Define the type definitions
export interface RelayerConfig {
relayerPrivateKey: string;
forwarderAddress: string;
rpcUrl?: string;
}

export interface PaymentRequest {
from: string;
to: string;
amount: string;
deadline: number;
signature: string;
}

export interface ExecuteResult {
success: boolean;
transactionHash: string;
blockNumber: number | null;
gasUsed: string;
}

// 4. Define the GaslessRelayer class
export class GaslessRelayer {
private config: RelayerConfig;
private provider: JsonRpcProvider;
private wallet: Wallet;
private forwarder: GaslessPaymentForwarder;

constructor(config: RelayerConfig) {
this.config = config;

// Setup provider and wallet
const rpcUrl = config.rpcUrl || NETWORKS.coston2.rpc;
this.provider = new JsonRpcProvider(rpcUrl);
this.wallet = new Wallet(config.relayerPrivateKey, this.provider);

// Setup contract (generated ABI from typechain-types)
this.forwarder = GaslessPaymentForwarder__factory.connect(
config.forwarderAddress,
this.wallet,
);

console.log(`Relayer initialized`);
console.log(` Relayer address: ${this.wallet.address}`);
console.log(` Forwarder contract: ${config.forwarderAddress}`);
}

// 5. Execute a single gasless payment using the forwarder contract
async executePayment(request: PaymentRequest): Promise<ExecuteResult> {
// Normalize and validate request format
const from = ethers.getAddress(request.from);
const to = ethers.getAddress(request.to);
const amount = BigInt(request.amount);
const deadline = Number(request.deadline);
const sig = request.signature;
if (typeof sig !== "string" || sig.length < 130) {
throw new Error("Invalid signature: must be a hex string");
}
const signature = sig.startsWith("0x") ? sig : "0x" + sig;

const normalizedRequest: PaymentRequest = {
from,
to,
amount: amount.toString(),
deadline,
signature,
};

// Verify EIP-712 signature off-chain (catches domain/nonce mismatches before submitting)
const chainId = (await this.provider.getNetwork()).chainId;
const nonce = await this.forwarder.getNonce(from);
const domain: TypedDataDomain = {
...EIP712_DOMAIN,
chainId: Number(chainId),
verifyingContract: ethers.getAddress(
this.config.forwarderAddress,
) as `0x${string}`,
};
const message = {
from,
to,
amount,
nonce,
deadline,
};
let recoveredAddress: string;
try {
recoveredAddress = await recoverTypedDataAddress({
domain,
types: PAYMENT_REQUEST_TYPES,
primaryType: "PaymentRequest",
message,
signature: signature as `0x${string}`,
});
} catch (e) {
throw new Error(
`Invalid signature format: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (recoveredAddress.toLowerCase() !== from.toLowerCase()) {
throw new Error(
`Signature invalid: recovered ${recoveredAddress} but expected ${from}. ` +
`Check chainId (expected ${chainId}), forwarder address, and nonce (expected ${nonce}).`,
);
}

// Validate the request
await this.validateRequest(normalizedRequest);

// Simulate first (fails fast, may yield better revert reason)
try {
await this.forwarder.executePayment.staticCall(
from,
to,
amount,
deadline,
signature,
);
} catch (simError) {
const err = simError as Error & { reason?: string; data?: string };
const msg =
err.reason || (err.data ? `revert data: ${err.data}` : err.message);
throw new Error(`Contract simulation failed: ${msg}`);
}

// Re-check nonce right before send (prevents race if another request executed first)
const nonceNow = await this.forwarder.getNonce(from);
if (nonceNow !== nonce) {
throw new Error(
`Nonce changed (was ${nonce}, now ${nonceNow}). ` +
`Payment may have been submitted by another request. Please create a new payment request.`,
);
}

// Estimate gas (staticCall uses block limit, so we must estimate for real tx)
let gasLimit: bigint;
try {
const estimated = await this.forwarder.executePayment.estimateGas(
from,
to,
amount,
deadline,
signature,
);
gasLimit = (estimated * 130n) / 100n; // 30% buffer
} catch (estError) {
const err = estError as Error;
throw new Error(
`Gas estimation failed (contract would revert): ${err.message}`,
);
}

// Execute the payment
const tx = await this.forwarder.executePayment(
from,
to,
amount,
deadline,
signature,
{ gasLimit },
);

// Wait for confirmation
const receipt = await tx.wait();

return {
success: true,
transactionHash: tx.hash,
blockNumber: receipt?.blockNumber ?? null,
gasUsed: receipt?.gasUsed?.toString() ?? "0",
};
}

// 6. Validate a payment request before submission
async validateRequest(request: PaymentRequest): Promise<void> {
const { from, amount, deadline } = request;

// Check deadline against chain time (not local clock - avoids skew)
const block = await this.provider.getBlock("latest");
const chainTime = block?.timestamp ?? Math.floor(Date.now() / 1000);
if (deadline <= chainTime) {
throw new Error(
`Payment request has expired (deadline: ${deadline}, chain: ${chainTime})`,
);
}

// Get FXRP token from forwarder
const fxrpAddress: string = await this.forwarder.fxrp();
const fxrp = new Contract(
fxrpAddress,
erc20Abi as ethers.InterfaceAbi,
this.provider,
);
const decimals = (await fxrp.decimals()) as number;

// Check sender's FXRP balance
const balance: bigint = await fxrp.balanceOf(from);
const totalRequired = BigInt(amount);
if (balance < totalRequired) {
throw new Error(
`Insufficient FXRP balance. Required: ${ethers.formatUnits(totalRequired, decimals)}, Available: ${ethers.formatUnits(balance, decimals)}`,
);
}

// Check allowance
const allowance: bigint = await fxrp.allowance(
from,
this.config.forwarderAddress,
);
if (allowance < totalRequired) {
throw new Error(
`Insufficient FXRP allowance. Required: ${ethers.formatUnits(totalRequired, decimals)}, Approved: ${ethers.formatUnits(allowance, decimals)}`,
);
}
}

// 7. Get the current nonce for an address
async getNonce(address: string): Promise<bigint> {
return await this.forwarder.getNonce(address);
}

// 8. Get the FXRP token decimals
async getTokenDecimals(): Promise<number> {
const fxrpAddress: string = await this.forwarder.fxrp();
const fxrp = new Contract(
fxrpAddress,
erc20Abi as ethers.InterfaceAbi,
this.provider,
);
return (await fxrp.decimals()) as number;
}

// 9. Check relayer's FLR balance for gas
async getRelayerBalance(): Promise<string> {
const balance = await this.provider.getBalance(this.wallet.address);
return ethers.formatEther(balance);
}
}

// 11. Express server for receiving payment requests
async function startServer(
relayer: GaslessRelayer,
port: number = 3000,
): Promise<void> {
const app = express();
app.use(cors());
app.use(express.json());

// Get nonce for an address
app.get(
"/nonce/:addr",
async (req: Request<{ addr: string }>, res: Response) => {
try {
const nonce = await relayer.getNonce(req.params.addr);
res.json({ nonce: nonce.toString() });
} catch (error) {
const err = error as Error;
res.status(400).json({ error: err.message });
}
},
);

// Execute payment
app.post("/execute", async (req: Request, res: Response) => {
try {
const result = await relayer.executePayment(req.body);
res.json(result);
} catch (error) {
const err = error as Error;
console.error("Payment execution failed:", err.message);
res.status(400).json({ error: err.message });
}
});

app.listen(port, () => {
console.log(`\nRelayer server running on http://localhost:${port}`);
console.log(`\nEndpoints:`);
console.log(` GET /nonce/:addr - Get nonce for address`);
console.log(` POST /execute - Execute single payment`);
});
}

// 12. Main entry point for the relayer server
async function main(): Promise<void> {
const relayerPrivateKey = process.env.RELAYER_PRIVATE_KEY;
const forwarderAddress = process.env.FORWARDER_ADDRESS;
const rpcUrl = process.env.RPC_URL;
const port = parseInt(process.env.PORT || "3000", 10);

if (!relayerPrivateKey) {
console.error("Error: RELAYER_PRIVATE_KEY environment variable required");
process.exit(1);
}

if (!forwarderAddress) {
console.error("Error: FORWARDER_ADDRESS environment variable required");
process.exit(1);
}

const relayer = new GaslessRelayer({
relayerPrivateKey,
forwarderAddress,
rpcUrl,
});

// Check relayer balance
const balance = await relayer.getRelayerBalance();
console.log(`Relayer FLR balance: ${balance} FLR`);

if (parseFloat(balance) < 0.1) {
console.warn(
"Warning: Low relayer balance. Please fund the relayer wallet.",
);
}

await startServer(relayer, port);
}

// 13. Run the server
main().catch(console.error);

Code Breakdown

  1. Import necessary libraries.
  2. Define EIP-712 domain and payment request types (from, to, amount, nonce, deadline); define NETWORKS (flare, coston2, songbird).
  3. Define RelayerConfig, PaymentRequest, and ExecuteResult types.
  4. Implement GaslessRelayer class: constructor sets provider, wallet, and forwarder.
  5. Execute payment: normalize and validate request, recover signer with EIP-712, validate request (balance, allowance, deadline), staticCall, recheck nonce, estimate gas, call executePayment, wait for receipt, return result.
  6. Validate request: check deadline against chain time, check sender balance and allowance.
  7. Get nonce for an address from the forwarder.
  8. Get FXRP token decimals from the forwarder and token contract.
  9. Get the relayer FLR balance for gas.
  10. Start Express server: GET /nonce/:addr, POST /execute.
  11. Main: read env (RELAYER_PRIVATE_KEY, FORWARDER_ADDRESS, RPC_URL, PORT), create relayer, log FLR balance, start server.
  12. Run main to start the relayer service.

Deploy Script

Save as scripts/deploy.ts to deploy the GaslessPaymentForwarder contract.

View scripts/deploy.ts
scripts/deploy.ts
/**
* Deploy the GaslessPaymentForwarder contract
*
* Usage: npm run deploy:coston2
*/

// 1. Import the necessary libraries
import hre from "hardhat";
import type { GaslessPaymentForwarder } from "../typechain-types/contracts/GaslessPaymentForwarder";

async function main(): Promise<string> {
const network = hre.network.name;
console.log(`\nDeploying GaslessPaymentForwarder to ${network}...`);

// 2. Get deployer account and balance
const [deployer] = await hre.ethers.getSigners();
console.log(`Deployer address: ${deployer.address}`);

const balance = await hre.ethers.provider.getBalance(deployer.address);
console.log(`Deployer balance: ${hre.ethers.formatEther(balance)} FLR`);

console.log(`\nFXRP address will be fetched from Flare Contract Registry`);
console.log(
` ContractRegistry.getAssetManagerFXRP() -> AssetManager.fAsset()`,
);

// 3. Deploy the GaslessPaymentForwarder contract (FXRP fetched from registry)
const GaslessPaymentForwarder = await hre.ethers.getContractFactory(
"GaslessPaymentForwarder",
);
const forwarder =
(await GaslessPaymentForwarder.deploy()) as unknown as GaslessPaymentForwarder;

await forwarder.waitForDeployment();
const forwarderAddress = await forwarder.getAddress();

// 4. Get FXRP token address from forwarder (for display)
const fxrpAddress = await forwarder.fxrp();

console.log(`\nGaslessPaymentForwarder deployed to: ${forwarderAddress}`);
console.log("\n--- Deployment Summary ---");
console.log(`Network: ${network}`);
console.log(`Contract: ${forwarderAddress}`);
console.log(`FXRP Token: ${fxrpAddress}`);
console.log(`Owner: ${deployer.address}`);

// 5. Verify contract on block explorer
console.log("\nVerifying contract...");
try {
await hre.run("verify:verify", {
address: forwarderAddress,
constructorArguments: [],
});
console.log("Contract verified successfully.");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("Already Verified") || msg.includes("already verified")) {
console.log("Contract already verified.");
} else {
console.warn("Verification failed:", msg);
console.log(
`\nTo verify manually: npx hardhat verify --network ${network} ${forwarderAddress}`,
);
}
}

return forwarderAddress;
}

// 6. Main entry point for the deployment script
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Code Breakdown

  1. Import Hardhat and TypeChain types.
  2. Get deployer signer and balance; log address and FLR balance.
  3. Deploy GaslessPaymentForwarder (no constructor args; FXRP from Flare Contract Registry); wait for deployment and get address.
  4. Get FXRP token address from the forwarder; log deployment summary (network, contract, FXRP, owner).
  5. Verify contract on block explorer (constructor arguments empty).
  6. Run main: exit 0 on success, exit 1 on error.

Example Flow

Use scripts/example-usage.ts to run the example flow.

View scripts/example-usage.ts
scripts/example-usage.ts
/**
* Example: How to use the FXRP Gasless Payment System
*
* This script demonstrates the complete flow:
* 1. Check user's FXRP balance and allowance
* 2. Approve the forwarder (if needed)
* 3. Create and sign a gasless payment request
* 4. Submit to the relayer
*
* Prerequisites:
* - Deploy the GaslessPaymentForwarder contract
* - Set the FORWARDER_ADDRESS in the environment variables
* - Start the relayer service
* - Have FXRP in your wallet
*
* Usage: npm run example
*/

// 1. Import the necessary libraries
import { Wallet, JsonRpcProvider } from "ethers";
import {
createPaymentRequest,
approveFXRP,
checkUserStatus,
} from "../utils/payment";
import "dotenv/config";

// 2. Configuration from environment
const RPC_URL = process.env.RPC_URL;
const RELAYER_URL = process.env.RELAYER_URL;
const FORWARDER_ADDRESS = process.env.FORWARDER_ADDRESS;
const USER_PRIVATE_KEY = process.env.USER_PRIVATE_KEY;

// Relayer /execute API response type
interface RelayerResponse {
success?: boolean;
transactionHash?: string;
blockNumber?: number;
gasUsed?: string;
error?: string;
}

async function main(): Promise<void> {
console.log("=== FXRP Gasless Payment Example ===\n");

// 3. Validate configuration
if (!FORWARDER_ADDRESS) {
console.error("Error: FORWARDER_ADDRESS not set in environment");
process.exit(1);
}
if (!USER_PRIVATE_KEY) {
console.error("Error: USER_PRIVATE_KEY not set in environment");
process.exit(1);
}

// 4. Setup provider and wallet
const provider = new JsonRpcProvider(RPC_URL);
const wallet = new Wallet(USER_PRIVATE_KEY, provider);

console.log(`User address: ${wallet.address}`);
console.log(`Forwarder: ${FORWARDER_ADDRESS}`);
console.log(`Relayer: ${RELAYER_URL}\n`);

// 5. Check user FXRP balance and allowance
console.log("Step 1: Checking FXRP balance and allowance...");
const status = await checkUserStatus(
provider,
FORWARDER_ADDRESS,
wallet.address,
);

console.log(` FXRP Token: ${status.fxrpAddress}`);
console.log(` Balance: ${status.balanceFormatted}`);
console.log(` Allowance: ${status.allowanceFormatted}`);
console.log(` Nonce: ${status.nonce}`);

// 6. Approve FXRP for forwarder (if needed)
if (status.needsApproval) {
console.log("\nStep 2: Approving FXRP for gasless payments...");
const approvalResult = await approveFXRP(wallet, FORWARDER_ADDRESS);
console.log(` Approved! TX: ${approvalResult.transactionHash}`);
} else {
console.log("\nStep 2: Already approved, skipping...");
}

// 7. Create and sign the payment request
const recipientAddress =
process.env.RECIPIENT_ADDRESS ||
"0x0000000000000000000000000000000000000001";
const amountFXRP = "0.1"; // 0.1 FXRP

console.log(`\nStep 3: Creating payment request...`);
console.log(` To: ${recipientAddress}`);
console.log(` Amount: ${amountFXRP} FXRP`);

const paymentRequest = await createPaymentRequest(
wallet,
FORWARDER_ADDRESS,
recipientAddress,
amountFXRP,
);

console.log(`\n Signed request created:`);
console.log(` From: ${paymentRequest.from}`);
console.log(` To: ${paymentRequest.to}`);
console.log(` Amount: ${paymentRequest.meta.amountFormatted}`);
console.log(
` Deadline: ${new Date(paymentRequest.deadline * 1000).toISOString()}`,
);
console.log(` Signature: ${paymentRequest.signature.slice(0, 20)}...`);

// 8. Submit the payment request to the relayer
console.log(`\nStep 4: Submitting to relayer...`);

try {
const response = await fetch(`${RELAYER_URL}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(paymentRequest),
});

const result = (await response.json()) as RelayerResponse;

if (result.success) {
console.log(` Payment executed successfully!`);
console.log(` Transaction: ${result.transactionHash}`);
console.log(` Block: ${result.blockNumber}`);
console.log(` Gas used: ${result.gasUsed}`);
} else {
console.log(` Payment failed: ${result.error}`);
}
} catch (error) {
const err = error as Error;
console.log(` Failed to reach relayer: ${err.message}`);
console.log(`\n Make sure the relayer is running at ${RELAYER_URL}`);

// Output the request for manual submission
console.log(`\n You can manually submit this request:`);
console.log(JSON.stringify(paymentRequest, null, 2));
}
}

// 9. Main entry point for the example usage script
main().catch(console.error);

Code Breakdown

  1. Import the necessary libraries.
  2. Read configuration from the environment like the RPC URL, relayer URL, forwarder address, and user private key.
  3. Validate the configuration to ensure that the forwarder address and user private key are set.
  4. Create a provider and a wallet.
  5. Check user FXRP balance and allowance.
  6. Approve the forwarder to spend FXRP.
  7. Create a payment request and sign it.
  8. Submit the payment request to the relayer.
  9. Call the main function to run the example flow.

Configuration

Create a .env file in your project root with the following variables:

VariableDescription
PRIVATE_KEYDeployer private key (for deployment)
RELAYER_PRIVATE_KEYRelayer wallet (pays gas)
USER_PRIVATE_KEYUser wallet private key for testing
FORWARDER_ADDRESSDeployed contract address (set after deploy)
RPC_URLFlare network RPC
RELAYER_URLRelayer HTTP URL (default: http://localhost:3000)

Deploy and run

Compile Contracts

Run the following command to compile the contracts:

npx hardhat compile

Deploy the Forwarder

Deploy the GaslessPaymentForwarder contract to Coston2 (testnet):

npx hardhat run scripts/deploy.ts --network coston2

Set FORWARDER_ADDRESS in .env to the deployed contract address.

Start the Relayer

Run the following command to start the relayer service.

The relayer is an Express server that submits payment requests on the Flare blockchain and pays gas on behalf of the user.

npx ts-node relayer/index.ts

Main relayer backend endpoints:

MethodPathDescription
POST/executeExecute single payment
GET/nonce/:addressGet nonce for address

Run the example Flow

Run the following command to run the example:

npx ts-node scripts/example-usage.ts

This script will execute the following steps:

  1. Checks the user's balance and allowance.
  2. Approves the forwarder to spend FXRP if needed.
  3. Creates a payment request.
  4. Submits the payment request to the relayer.
  5. Checks the payment status.

You should see the following output:

View example output
=== FXRP Gasless Payment Example ===

User address: 0x0d09ff7630588E05E2449aBD3dDD1D8d146bc5c2
Forwarder: 0xffc5F792e1Ca050B598577fFaFa30634A66174A5
Relayer: http://localhost:3000

Step 1: Checking FXRP balance and allowance...
FXRP Token: 0x0b6A3645c240605887a5532109323A3E12273dc7
Balance: 11773.287907 FXRP
Allowance: 0.0 FXRP
Nonce: 0

Step 2: Approving FXRP for gasless payments...
Approved! TX: 0x4aa5304c4e3e261d9d88ac1fe24a9d96faaa5786065a9253de463a5c33d0c368

Step 3: Creating payment request...
To: 0x5EEeaD99dFfeB32Fe96baad6554f6E009c8B348a
Amount: 0.1 FXRP

Signed request created:
From: 0x0d09ff7630588E05E2449aBD3dDD1D8d146bc5c2
To: 0x5EEeaD99dFfeB32Fe96baad6554f6E009c8B348a
Amount: 0.1 FXRP
Deadline: 2026-02-13T14:14:20.000Z
Signature: 0x0cc5532b3c67eb0ef4...

Step 4: Submitting to relayer...
Payment executed successfully!
Transaction: 0xd18b532b294656fbeadd4884b6baf5e45efabb3809787c1625f675cccff2b648
Block: 27196879
Gas used: 229589
Next steps