Check OFT Transfer Limits
Overview
FXRP OFT transfers are capped by per-endpoint sliding-window rate limits on the OFT contracts themselves.
These are not the FAssets minting rate limits on AssetManager.
The same view API lives on:
FAssetOFTAdapteron Flare and Coston2 (lock/release)FXRPOFTon other chains (mint/burn)
See FXRP OFT Adapter for how those contracts relate, and OFT deployments for addresses.
A send records outgoing capacity on the source contract for the destination LayerZero endpoint ID (EID).
Credit records incoming capacity on the destination contract for the source EID.
The two directions do not refill each other.
A transfer that does not fit remaining capacity reverts with RateLimitExceeded.
That is a custom error, not an event: the call fails, and no log is emitted.
The practical getters are getAmountCanBeSent and getAmountCanBeReceived.
They apply the default fallback and linear decay, so they are what you should call before send().
This example queries the Coston2 adapter 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639 for Sepolia EID 40161.
On mainnet, call the Flare adapter or the destination FXRPOFT instead.
Query Functions
All six functions are view.
Anyone can call them; no wallet is required.
| Goal | Function | What it returns |
|---|---|---|
| Per-eid outgoing config | outgoingRateLimit(uint32 _dstEid) | Raw RateLimit storage. window == 0 means "use default", not unlimited |
| Per-eid incoming config | incomingRateLimit(uint32 _srcEid) | Raw RateLimit storage |
| Default outgoing | defaultOutgoingRateLimit() | Fallback when the per-eid outgoing window is 0 |
| Default incoming | defaultIncomingRateLimit() | Fallback when the per-eid incoming window is 0 |
| Remaining send capacity | getAmountCanBeSent(uint32 _dstEid) | (consumed after decay, amountCanBeSent) |
| Remaining receive capacity | getAmountCanBeReceived(uint32 _srcEid) | (consumed after decay, amountCanBeReceived) |
struct RateLimit {
uint192 consumedTransferCapacity;
uint64 lastUpdated;
uint192 limit;
uint64 window;
}
struct DefaultRateLimit {
uint192 limit;
uint64 window;
}
function outgoingRateLimit(uint32 _dstEid) external view returns (RateLimit memory);
function incomingRateLimit(uint32 _srcEid) external view returns (RateLimit memory);
function defaultOutgoingRateLimit() external view returns (DefaultRateLimit memory);
function defaultIncomingRateLimit() external view returns (DefaultRateLimit memory);
function getAmountCanBeSent(uint32 _dstEid)
public view returns (uint256 currentConsumedTransferCapacity, uint256 amountCanBeSent);
function getAmountCanBeReceived(uint32 _srcEid)
public view returns (uint256 currentConsumedTransferCapacity, uint256 amountCanBeReceived);
limit and remaining capacity are in token base units.
FXRP uses 6 decimals, so 1_000_000 is 1 FXRP.
window is the duration in seconds.
Capacity refills with linear decay at limit / window per second:
decay = limit * timeSinceLastUpdate / window
consumed = max(0, consumedTransferCapacity - decay)
remaining = max(0, limit - consumed)
quoteOFT also surfaces remaining send capacity as oftLimit.maxAmountLD = min(amountCanBeSent, totalSupply).
Which Contract to Call
Call the OFT on the chain that enforces capacity.
| Question | Call on | Function |
|---|---|---|
| How much can I send from Coston2 to Sepolia? | Coston2 FAssetOFTAdapter | getAmountCanBeSent(40161) |
| How much can Coston2 still receive from Sepolia? | Coston2 FAssetOFTAdapter | getAmountCanBeReceived(40161) |
| How much can I send from Sepolia to Coston2? | Sepolia FXRPOFT | getAmountCanBeSent(40294) |
| How much can Sepolia still receive from Coston2? | Sepolia FXRPOFT | getAmountCanBeReceived(40294) |
A full preflight for Coston2 → Sepolia reads both legs: outgoing on the adapter and incoming on Sepolia FXRPOFT.
The script below reads one contract (the Coston2 adapter) so you can see every getter in one place.
Coston2 EID is 40294 (EndpointId.FLARE_V2_TESTNET).
Sepolia EID is 40161 (EndpointId.SEPOLIA_V2_TESTNET).
Mainnet Flare is 30295.
Fallback Rules
A per-eid entry is configured only when its window > 0.
Per-eid window | Default window | Effective behavior |
|---|---|---|
> 0 | anything | Use the per-eid limit / window |
0 | > 0 | Fall back to the default |
0 | 0 | Limiting disabled: getAmountCanBe* returns (0, type(uint256).max) |
> 0, limit = 0 | — | Blocked: remaining capacity is 0 |
outgoingRateLimit / incomingRateLimit return stored limit / window as-is.
If window == 0, that means "use default", not "unlimited".
Use getAmountCanBeSent / getAmountCanBeReceived for effective remaining capacity.
Rate-limit configuration (setOutgoingRateLimits, setDefaultOutgoingRateLimit, and the incoming equivalents) is owner/governance only.
Transfer Limits Script
/**
* Read FXRP OFT transfer rate limits on the Coston2 OFT Adapter.
*
* Usage (from a project with viem and @layerzerolabs/lz-definitions):
* npx tsx getOftTransferLimits.ts
*
* No wallet or private key is required.
*/
import {
createPublicClient,
formatUnits,
http,
maxUint256,
type Address,
} from "viem";
import { flareTestnet } from "viem/chains";
import { EndpointId } from "@layerzerolabs/lz-definitions";
const COSTON2_OFT_ADAPTER =
"0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639" as Address;
const DST_EID = EndpointId.SEPOLIA_V2_TESTNET;
const FXRP_DECIMALS = 6;
const transferLimitingAbi = [
{
type: "function",
name: "outgoingRateLimit",
stateMutability: "view",
inputs: [{ name: "_dstEid", type: "uint32" }],
outputs: [
{
type: "tuple",
components: [
{ name: "consumedTransferCapacity", type: "uint192" },
{ name: "lastUpdated", type: "uint64" },
{ name: "limit", type: "uint192" },
{ name: "window", type: "uint64" },
],
},
],
},
{
type: "function",
name: "incomingRateLimit",
stateMutability: "view",
inputs: [{ name: "_srcEid", type: "uint32" }],
outputs: [
{
type: "tuple",
components: [
{ name: "consumedTransferCapacity", type: "uint192" },
{ name: "lastUpdated", type: "uint64" },
{ name: "limit", type: "uint192" },
{ name: "window", type: "uint64" },
],
},
],
},
{
type: "function",
name: "defaultOutgoingRateLimit",
stateMutability: "view",
inputs: [],
outputs: [
{
type: "tuple",
components: [
{ name: "limit", type: "uint192" },
{ name: "window", type: "uint64" },
],
},
],
},
{
type: "function",
name: "defaultIncomingRateLimit",
stateMutability: "view",
inputs: [],
outputs: [
{
type: "tuple",
components: [
{ name: "limit", type: "uint192" },
{ name: "window", type: "uint64" },
],
},
],
},
{
type: "function",
name: "getAmountCanBeSent",
stateMutability: "view",
inputs: [{ name: "_dstEid", type: "uint32" }],
outputs: [
{ name: "currentConsumedTransferCapacity", type: "uint256" },
{ name: "amountCanBeSent", type: "uint256" },
],
},
{
type: "function",
name: "getAmountCanBeReceived",
stateMutability: "view",
inputs: [{ name: "_srcEid", type: "uint32" }],
outputs: [
{ name: "currentConsumedTransferCapacity", type: "uint256" },
{ name: "amountCanBeReceived", type: "uint256" },
],
},
] as const;
type RateLimit = {
consumedTransferCapacity: bigint;
lastUpdated: bigint;
limit: bigint;
window: bigint;
};
type DefaultRateLimit = {
limit: bigint;
window: bigint;
};
const publicClient = createPublicClient({
chain: flareTestnet,
transport: http("https://coston2-api.flare.network/ext/C/rpc"),
});
function formatFxrp(amount: bigint): string {
if (amount === maxUint256) {
return "unlimited";
}
return `${amount.toString()} (${formatUnits(amount, FXRP_DECIMALS)} FXRP)`;
}
function formatWindow(
windowSeconds: bigint,
zeroMeans: "use default" | "limiting disabled",
): string {
if (windowSeconds === 0n) {
return `0s (${zeroMeans})`;
}
return `${windowSeconds.toString()}s`;
}
function printRateLimit(label: string, rl: RateLimit) {
console.log(`${label}:`);
console.log(` consumed: ${formatFxrp(rl.consumedTransferCapacity)}`);
console.log(` lastUpdated: ${rl.lastUpdated.toString()}`);
console.log(` limit: ${formatFxrp(rl.limit)}`);
console.log(` window: ${formatWindow(rl.window, "use default")}`);
}
function printDefault(label: string, rl: DefaultRateLimit) {
console.log(`${label}:`);
console.log(` limit: ${formatFxrp(rl.limit)}`);
console.log(` window: ${formatWindow(rl.window, "limiting disabled")}`);
}
async function main() {
console.log("=== FXRP OFT Transfer Limits ===\n");
console.log(`OFT Adapter: ${COSTON2_OFT_ADAPTER}`);
console.log(`Network: Coston2`);
console.log(`Remote EID: ${DST_EID} (Sepolia)\n`);
const [outgoing, incoming, defaultOutgoing, defaultIncoming, sent, received] =
await Promise.all([
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "outgoingRateLimit",
args: [DST_EID],
}),
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "incomingRateLimit",
args: [DST_EID],
}),
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "defaultOutgoingRateLimit",
}),
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "defaultIncomingRateLimit",
}),
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "getAmountCanBeSent",
args: [DST_EID],
}),
publicClient.readContract({
address: COSTON2_OFT_ADAPTER,
abi: transferLimitingAbi,
functionName: "getAmountCanBeReceived",
args: [DST_EID],
}),
]);
printRateLimit(`outgoingRateLimit(${DST_EID})`, outgoing);
printDefault("defaultOutgoingRateLimit()", defaultOutgoing);
printRateLimit(`incomingRateLimit(${DST_EID})`, incoming);
printDefault("defaultIncomingRateLimit()", defaultIncoming);
const [consumedOut, amountCanBeSent] = sent;
const [consumedIn, amountCanBeReceived] = received;
console.log(`\ngetAmountCanBeSent(${DST_EID}):`);
console.log(` consumed (after decay): ${formatFxrp(consumedOut)}`);
console.log(` remaining: ${formatFxrp(amountCanBeSent)}`);
if (outgoing.window === 0n) {
console.log(
" note: per-eid window is 0, so the default outgoing limit applies",
);
}
console.log(`\ngetAmountCanBeReceived(${DST_EID}):`);
console.log(` consumed (after decay): ${formatFxrp(consumedIn)}`);
console.log(` remaining: ${formatFxrp(amountCanBeReceived)}`);
if (incoming.window === 0n) {
console.log(
" note: per-eid window is 0, so the default incoming limit applies",
);
}
}
void main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
How to Run
npx tsx getOftTransferLimits.ts
Change DST_EID to another LayerZero V2 testnet EID, or point COSTON2_OFT_ADAPTER and the client at Flare mainnet to query production.
Expected Output
Values change as transfers consume capacity and as that consumption decays. In the snapshot below, Sepolia has no per-eid window, so the defaults apply. After decay, remaining outgoing capacity is the default outgoing limit (1,000,000 FXRP over 3600s) and remaining incoming capacity is the default incoming limit (1,500,000 FXRP over 3600s):
Expected Output
=== FXRP OFT Transfer Limits ===
OFT Adapter: 0xCd3d2127935Ae82Af54Fc31cCD9D3440dbF46639
Network: Coston2
Remote EID: 40161 (Sepolia)
outgoingRateLimit(40161):
consumed: 10000000 (10 FXRP)
lastUpdated: 1790081729
limit: 0 (0 FXRP)
window: 0s (use default)
defaultOutgoingRateLimit():
limit: 1000000000000 (1000000 FXRP)
window: 3600s
incomingRateLimit(40161):
consumed: 1000000 (1 FXRP)
lastUpdated: 1790081154
limit: 0 (0 FXRP)
window: 0s (use default)
defaultIncomingRateLimit():
limit: 1500000000000 (1500000 FXRP)
window: 3600s
getAmountCanBeSent(40161):
consumed (after decay): 0 (0 FXRP)
remaining: 1000000000000 (1000000 FXRP)
note: per-eid window is 0, so the default outgoing limit applies
getAmountCanBeReceived(40161):
consumed (after decay): 0 (0 FXRP)
remaining: 1500000000000 (1500000 FXRP)
note: per-eid window is 0, so the default incoming limit applies
Raw outgoingRateLimit can still show leftover consumedTransferCapacity while getAmountCanBeSent reports 0 consumed after decay.
That is expected: the raw getter does not apply decay or the default fallback.
Code Breakdown
- Client.
A Viem
publicClientonflareTestnetis enough. The script never creates a wallet client. - Target.
COSTON2_OFT_ADAPTERis the documented Coston2 adapter.DST_EIDis Sepolia (EndpointId.SEPOLIA_V2_TESTNET,40161), matching Bridge FXRP to Ethereum. - ABI.
Only the six
TransferLimitingview functions are inlined. They are not in@flarenetwork/flare-periphery-contracts. - Raw vs effective.
outgoingRateLimit/incomingRateLimitplus the defaults show the configured windows.getAmountCanBeSent/getAmountCanBeReceivedapply the fallback and decay. - Units.
Amounts print as base units and as FXRP via
formatUnits(..., 6).type(uint256).maxremaining prints asunlimited.
RateLimitExceeded
RateLimitExceeded is a Solidity custom error, not an event.
When remaining capacity is too small, _record reverts and the transfer does not happen.
There is no delay, no retry timestamp, and no RateLimitExceeded log to watch.
error RateLimitExceeded();
The error has no arguments.
Remaining capacity is not in the revert data — call getAmountCanBeSent or getAmountCanBeReceived before send().
| Where it reverts | Cause |
|---|---|
Source send | Outgoing remaining capacity for _dstEid is below the amount being sent |
Destination credit (lzReceive) | Incoming remaining capacity for _srcEid is below the amount being credited |
Wallets and explorers show a failed transaction with this custom error. Unlike minting rate limits, which delay execution, an OFT transfer that hits the cap is rejected outright.
Important Notes
- Call the contract on the chain you care about.
Outgoing capacity for Flare → Base is on the Flare adapter; incoming capacity for that same transfer is on Base
FXRPOFT. - Raw per-eid reads are not remaining capacity.
window == 0means "use default". Always prefergetAmountCanBeSent/getAmountCanBeReceivedfor preflight. - Overflow reverts.
sendand credit revert withRateLimitExceededwhen the amount does not fit. - Paused sends also revert.
Check
paused()on the adapter if a send fails for a reason other than the rate limit. See FXRP OFT Adapter.
- Read the adapter surface in FXRP OFT Adapter.
- Bridge from Coston2 to Sepolia in Bridge FXRP to Ethereum.
- Bridge and auto-redeem from Hyperliquid in FAsset Auto-Redemption.