flare-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited flare-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill has two halves you should read together:
are non-negotiable here. These are the defaults. Deviating from any of them without a documented reason should fail review.
audit / audit-contractskills don't catch because they're not chain-aware.
Philosophy: assume hostile. Every external surface — function inputs, token calls, oracle reads, signatures, calldata, RPCs — is adversarial. Defense in depth always. The audit reports we've seen on Flare contracts are clear: most real exploits come from token edge cases, signature replay, missing access control on admin functions, and FTSO/oracle manipulation. None of those are exotic — all are preventable with the patterns below.
ALWAYS use `Ownable2Step` from OpenZeppelin, never `Ownable`.
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
contract MyContract is Ownable2Step {
constructor(address initialOwner) Ownable(initialOwner) { }
}Why: a single-step ownership transfer is a critical vulnerability if the target address is wrong (typo'd, hardware-wallet path mismatch, contract that can't accept the role). Ownable2Step requires the new owner to call acceptOwnership(), eliminating the silent-handoff failure mode. The cost is two transactions instead of one — non-issue.
For Flare deployments, the deploy → handoff dance is:
transferOwnership(<Ledger or multisig address>) — this stages thependingOwner.
acceptOwnership() to finalize.After step 3, the deployer EOA has zero privileged access. Verify by reading owner() and pendingOwner() (must be 0x0 post-handoff).
For multi-role systems, prefer AccessControl from OpenZeppelin with explicit role-grants. Never invent your own role mapping.
Use `ReentrancyGuardTransient` (post-EIP-1153, ~2k gas per call) over the storage-slot `ReentrancyGuard` whenever your `pragma >=0.8.24`.
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
contract MyContract is ReentrancyGuardTransient {
function externalEntry() external nonReentrant {
// ...
}
}Critical: `nonReentrant` MUST come BEFORE all other modifiers in a function's modifier list. This ensures the guard is set before any other modifier could make an external call. Modifier order is not commutative — it executes left-to-right.
// good — guard sets first
function withdraw(uint256 amount) external nonReentrant onlyOwner whenNotPaused {
// BAD — onlyOwner could (in some patterns) hit external code first
function withdraw(uint256 amount) external onlyOwner nonReentrant whenNotPaused {Cross-function reentrancy is the harder case. If two functions touch the SAME state variable, both must be nonReentrant. Read-only reentrancy (a view function returning stale state during a callback) bites view-based price oracles — mitigate by validating the read against an independent source.
Always use `SafeERC20`, never raw `transfer` / `transferFrom`. Many tokens on Flare (USDT0, eUSDT, similar Tether forks) don't return bool — a raw call either reverts your transaction at compile-time-bool-decode-fail or silently proceeds with a stale-state assumption. SafeERC20 handles both.
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
using SafeERC20 for IERC20;
IERC20(token).safeTransfer(to, amount);
IERC20(token).safeTransferFrom(from, to, amount);
IERC20(token).forceApprove(spender, amount); // NEVER plain approve()Use `forceApprove`, not `approve`. Some tokens (USDT-family, KNC) reject a non-zero → non-zero approval transition; forceApprove resets to 0 first when needed.
For fee-on-transfer tokens (FLX, FLRFROG on Flare), safeTransferFrom does NOT validate the recipient's balance change matches the requested amount. The recipient receives strictly less. Patterns for handling FoT:
transferred amount.
flare-network for thepattern when integrating with V2 routers via Permit2).
and add a require check that compares balance delta to amount.
Always: validate inputs → update state → make external calls. Never reverse this order.
function withdraw(uint256 amount) external nonReentrant {
// CHECKS
if (amount == 0) revert MyContract__ZeroAmount();
if (balances[msg.sender] < amount) revert MyContract__InsufficientBalance();
// EFFECTS — update state BEFORE the external call
balances[msg.sender] -= amount;
emit Withdrawn(msg.sender, amount);
// INTERACTIONS — last
IERC20(token).safeTransfer(msg.sender, amount);
}The reentrancy guard is a backstop. CEI is the primary defense. If you find yourself writing state changes AFTER an external call, stop and refactor.
Custom errors with contract-name prefixes:
error MyContract__ZeroAmount();
error MyContract__InsufficientBalance(uint256 have, uint256 want);
function f() external {
if (x == 0) revert MyContract__ZeroAmount();
}Why prefix with contract name + `__`: error selector collisions across contracts confuse decoder libraries when an error bubbles through a multi-hop revert. Prefixing makes it unambiguous. The __ is convention to distinguish from CamelCase variable names.
Custom errors are cheaper at deploy time (no string in bytecode) and at runtime (4-byte selector + ABI-encoded args vs. variable-length string). They also let auditors and frontend code decode the EXACT failure with rich data.
contract MyContract {
uint256 public constant FEE_BPS = 10_000; // compile-time, free to read
address public immutable WRAPPED_NATIVE; // constructor-set, ~3 gas to read
constructor(address wrappedNative) {
if (wrappedNative == address(0)) revert MyContract__ZeroAddress();
WRAPPED_NATIVE = wrappedNative;
}
}SLOAD is 2,100 gas; immutable is essentially free (loaded from bytecode). For a value read 10× per transaction, that's a 21,000-gas savings — meaningful.
Apply immutable to: external contract addresses (oracles, wrappers, registries), deploy-time-known constants (deployer, wrapped-native, fee receivers if fixed).
external over public for functions only called from outside (calldataargs are cheaper than memory).
internal over private for testability (allow inheriting test contracts tooverride).
public only when both internal and external callers exist.visibility but internal is cheap to type and removes ambiguity.
function f() external nonReentrant onlyOwner whenNotPaused {
// ^1 ^2 ^3
}nonReentrant — must run first to lock the guard before any other modifiercould make an external call.
onlyOwner, onlyRole).whenNotPaused, whenInitialized).Modifiers should NEVER make external calls (except the reentrancy guard's transient storage write). External calls in modifiers are unauditable and break the CEI invariant.
Never iterate over an unbounded array of user-controlled length. The block gas limit on Flare-family chains is ~15M; an unbounded loop hits it eventually and bricks the function.
// BAD — user can grow `pending` indefinitely until withdrawAll() reverts
mapping(address => uint256[]) pending;
function withdrawAll() external {
uint256[] storage list = pending[msg.sender];
for (uint256 i; i < list.length; ++i) { // ← unbounded
// ...
}
}
// GOOD — pull pattern, batch size enforced
function withdrawSome(uint256 maxCount) external {
uint256 n = pending[msg.sender].length;
uint256 stop = maxCount < n ? maxCount : n;
for (uint256 i; i < stop; ++i) {
// ...
}
}Constants for hard caps:
uint256 public constant MAX_BATCH_SIZE = 50;Never transfer ETH to user-supplied addresses inside a loop. A single contract recipient that reverts on receive() (or address.transfer's 2300-gas gate) bricks your loop.
// BAD — one bad recipient kills the whole batch
for (uint256 i; i < recipients.length; ++i) {
recipients[i].transfer(amounts[i]);
}
// GOOD — credit balances, let each user pull
for (uint256 i; i < recipients.length; ++i) {
pendingWithdraw[recipients[i]] += amounts[i];
}
function claim() external nonReentrant {
uint256 amt = pendingWithdraw[msg.sender];
if (amt == 0) revert MyContract__NothingToClaim();
pendingWithdraw[msg.sender] = 0;
payable(msg.sender).transfer(amt);
}For tokens, the same principle applies but you can wrap individual transfers in try/catch and redirect failures to a fallback recipient (the "treasury redirect" pattern when blacklisted tokens block a treasury address).
tx.origin for authenticationtx.origin is the EOA at the start of the call chain. Using it for auth lets any contract the user has approved (or accidentally interacts with) trick the contract into authorizing as the user. Use msg.sender always.
block.timestamp, block.number, blockhash(N) are all miner-influenceable. Use Flare's secure random source (flare-general documents this). Never use any block value to gate financial outcomes.
block.timestamp only for long intervalsMiners can shift block.timestamp by a few seconds. Don't use it for sub-minute deadlines. For DEX deadlines, ~10 minutes is the standard floor.
selfdestruct and delegatecallselfdestruct is being deprecated; in EIP-6780 it only works in the same txas deploy. Don't rely on it.
delegatecall to a user-controlled target = full takeover of your contract.Even to a "trusted" target it's a major audit-flag pattern. Justify case by case with a documented threat model.
abi.encode over abi.encodePacked for hashingabi.encodePacked with two dynamic-size inputs (string, bytes, uint256[]) is hash-collision-vulnerable: ("ab","c") and ("a","bc") hash to the same value. For hashing dynamic data, use abi.encode (length-prefixed).
address(0) for any address parameter that shouldn't be zero (revert with anamed error).
// BAD — returns false on failure for ERC-20s without revert
token.transfer(to, amount);
// GOOD — SafeERC20 reverts on false-return
IERC20(token).safeTransfer(to, amount);
// For low-level calls — always check ok
(bool ok, bytes memory data) = target.call(data);
if (!ok) revert MyContract__CallFailed(target, data);For any transaction whose value depends on observable state (price, deadline, nonce), apply at least one of:
block.timestamp > deadline.meaningful value.
replays across contracts are impossible.
Every contract that holds value or controls user funds should have an owner-callable pause:
bool public paused;
modifier whenNotPaused() {
if (paused) revert MyContract__Paused();
_;
}
function setPaused(bool paused_) external onlyOwner {
paused = paused_;
emit PausedSet(paused_);
}Pause should NEVER affect user-fund-recovery paths. If the contract holds user tokens and is paused, users must still be able to withdraw what they put in.
These are the things that bit real Flare deployments and are NOT covered by generic audit checklists. Apply on top of Part 1 for any contract you ship to Flare/Songbird/Coston2.
The canonical Uniswap Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) is deployed only on Flare (chain 14). NOT on Songbird, NOT on Coston2.
cast code 0x000000000022D473030F116dDEE9F6B43aC78BA3 --rpc-url <chain>
# Returns full bytecode on Flare; returns 0x on Songbird/Coston2If your contract takes a Permit2 address as an immutable, document this; if it hardcodes the canonical address, gate deployment on a chain-id check.
For Songbird/Coston2 canary-testing of Permit2-dependent contracts, your only options are: deploy your own Permit2 (~3000 LOC of Uniswap source — substantial), mock-only test, or skip canary and validate via Flare fork.
Multiple tokens on Flare apply a transfer fee — the recipient receives strictly less than the sender debited. Examples: FLX (0x22757fb83836e3F9F0F353126cACD3B1Dc82a387, ~3% FoT), FLRFROG, others.
Threats this creates:
transferFrom(user, this, X) and receives X * (1-r).safeTransferFrom(this, downstream, X) — revertswith TransferHelper::transferFrom because you don't have X anymore.
from what was quoted.
Mitigations:
balanceOf(this) change over a single transferFrom, then encode the downstream amount as that delta.
balanceOf(this) after the inputtransfer to determine the actual amount available, then use that for any downstream transfer.
received != amount on input,so the user gets a clean failure instead of weird behavior deeper in the call.
USDT0, USDC.e, eUSDT, exUSDT, and similar admin-controlled stablecoins can freeze any address (including your contract) at the token issuer's discretion.
Implications for your design:
state is stuck.
rest of the operation should NOT revert (use try/catch to fall through to a redirect).
That's a bigger threat than the blacklist itself. Instead document the risk and let users avoid the contract for blacklist-prone tokens.
Enosys's FtsoRewardRedistributorForNft (the contract you call to claim FTSO delegation rewards on V3 NFTs) is an upgradeable proxy. The implementation can change without warning. Implications:
not guaranteed in the contract code itself.
verification is point-in-time only.
brick your contract's main flow.
try IFtsoRewardRedistributorForNft(redistributor).claim(ids, recipient) {
// ok
} catch {
// redistributor reverted or mis-routed; continue without rewards
}For per-DEX rotation patterns and recovery via historical addresses, see enosys-dex-v3.
block.basefee on Songbird and Coston2 is often 1 wei or 2 wei. Any incentive-math gate that uses basefee * gasEstimate * coverageMultiple to require a minimum fee collapses to near-zero on those chains.
If your contract has a fee-incentive-coverage check, add a minBasefeeWei floor (typical: 25 gwei to match Flare's mainnet basefee, owner-tunable up to a hard cap of 1000 gwei).
uint256 effectiveBasefee = block.basefee > minBasefeeWei ? block.basefee : minBasefeeWei;
uint256 required = effectiveBasefee * gasEstimate * minCoverageMultiple;eth_getLogs 30-block capThe public RPC endpoints on Flare/Songbird/Coston2 cap historical log queries to 30 blocks per request. Don't build features that require scanning longer history off the public RPC.
Workarounds:
factory.getPool(t0, t1, fee)is cheap and scales O(pairs).
Multicall3 lives at 0xcA11bde05977b3631167028862bE2a173976CA11 on every Flare-family chain, but viem's defineChain does NOT auto-add it. If you call publicClient.multicall(...) against a custom-defined chain, it throws silently with a confusing error. Always register it explicitly:
defineChain({
id: 14,
name: 'Flare',
// ...
contracts: {
multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11', blockCreated: 3002461 },
},
})Coston2 emits an EIP-3855 warning when running Solidity 0.8.20+. It's cosmetic — deployments and execution work fine. Don't downgrade solc to 0.8.19 over this warning.
WFLR's transfer / transferFrom invoke an FTSO delegation-state hook (updateAtTokenTransfer). The hook is internal state-only on the WFLR contract itself — it does NOT call into the recipient. A recipient that reverts in receive()/fallback() cannot block delivery. Empirically verified 2026-05-08 with a Flare-fork test (WFLR.transfer(evil, 1 ether) to a contract whose receive() reverts succeeds).
The hook DOES revert with SafeMath: subtraction overflow when a SENDER has phantom balance (deal(WFLR, addr, X) writes the balance slot but skips the delegation state init). That's a test-setup pitfall, not a production DoS surface.
Test setup correction: use vm.deal(user, X) + vm.prank(user); WFLR.deposit{value: X}() instead of deal(WFLR, user, X). The latter writes the balance slot but skips the delegation state init.
Implication for audit findings: claims that protocol-fee safeTransfer of WFLR can be DoS'd by a malicious recipient are categorically false positives. Add a fork test to lock the answer down before re-litigation:
function test_WflrSafeTransfer_ToRevertingReceiver_Succeeds() external {
vm.createSelectFork("flare");
vm.deal(address(this), 10 ether);
IWNat(WFLR).deposit{value: 10 ether}();
address evil = address(new RevertingReceiver()); // receive() reverts
IERC20(WFLR).safeTransfer(evil, 1 ether); // succeeds
assertEq(IERC20(WFLR).balanceOf(evil), 1 ether);
}Sanity-check decimals at integration time. Common confusions:
| Token | Decimals |
|---|---|
| WFLR / WSGB / WC2FLR | 18 |
| USDT0 / USDC.e / FXRP / eUSDT | 6 |
| YAM-V2-style oddities | 24 |
Math involving cross-decimal tokens MUST normalize. The classic precision-loss bug is (a / b) * c — multiply BEFORE dividing.
Standard Flare-family ownership pattern: hardware wallet (Ledger) owner via Ownable2Step, optionally graduating to a multisig (Safe/Gnosis on Flare).
If owner() on a deployed contract is a deployer EOA, that's a one-key compromise away from total loss. Audit findings should flag this and recommend ownership handoff before mainnet.
The owner controls only what the contract's admin functions allow — review those individually:
Algebra V1.9+ pools (SparkDEX V4) have plugin-driven dynamic fees that adjust per-block. If your contract snapshots the fee at order-create time and uses it for incentive math at fill time, the actual fee may drift (typically modestly, but during volatility regime changes it can shift meaningfully).
Acceptable as approximation in spam-filter contexts. NOT acceptable when the fee determines a user's payout — read fresh in that case.
MintParams.deployer = address(0) only safe for default-deployer poolsAlgebra's NPM derives the destination pool via CREATE2(deployer, salt(t0, t1)). Passing deployer = address(0) uses the factory's default deployer. If a future DEX registers pools with a custom deployer, mints with deployer = address(0) silently route to the wrong pool.
Mitigation: pre-mint check that factory.computePoolAddress(t0, t1) matches the user's intended pool address. Revert if not.
communityFee cuts LP fees BEFORE distributionglobalState().communityFee is the protocol's cut taken off LP fees before distributing to LPs. SparkDEX V4 WFLR/USDT0 has communityFee = 250 (25%). Any contract that estimates "LP-fee share to executor" must apply this factor:
uint256 lpShareBps = 10_000 - globalState.communityFee * 10; // communityFee is 1/1000Contracts that take router calldata as a passthrough (sweeper-style designs) must enforce TWO layers:
isRouter[router])allowedSelector[router][selector])Both layers AND-gated. The selector check is critical — V3's SwapRouter has a multicall(bytes[]) that lets calldata chain arbitrary internal calls. Do NOT seed `multicall` for any router unless you've audited the implications.
Any time a contract swaps an asset whose price has an FTSO feed (FAsset → WFLR, WFLR → vault collateral, etc.), the slippage gate should be FTSO-anchored, not pool-quote-anchored. The pattern:
// 1. Read the FTSO-implied output for amountIn — the "fair" value.
uint256 ftsoImpliedOut = (amountIn * rateScaled1e36) / 1e36;
// 2. minOut floors at (1 - swapDownsideMaxBps). swapDownsideMaxBps is
// bounded (typically 50 = 0.5%); never make this caller-controlled.
uint256 minOut = (ftsoImpliedOut * (BPS_DENOM - swapDownsideMaxBps)) / BPS_DENOM;
// 3. Pass minOut to the router AND verify post-swap balance delta.
// The router enforces minOut against its quote; the balance delta
// catches FoT tokens that under-deliver while reporting full output.
uint256 wflrBefore = IERC20(WFLR).balanceOf(address(this));
router.exactInput(ExactInputParams({path: path, recipient: address(this), ...}));
uint256 amountOut = IERC20(WFLR).balanceOf(address(this)) - wflrBefore;
if (amountOut < minOut) revert SwapBelowMinOut(amountOut, minOut);What this defends against:
pay below FTSO-anchored expectation. Loss is bounded by swapDownsideMaxBps — typically 0.5% of swap volume. Below that, the swap reverts entirely and harvest is retried later.
FoT, the router-reported amountOut would lie. Balance-delta verification catches the actual delivered amount.
Bounded extraction is acceptable; complete DoS is not. 0.5% is the break-even point: attackers profit on average less than they spend on LP fees + gas. Tightening to 0.1% in low-liquidity regimes is fine; loosening beyond 1% is a finding.
Owner-only parameter setters (setSwapPath, setProtocolFeeBps, etc.) are trust-gated by access control, but if the owning multisig is publicly executable (Safe/Gnosis with visible queued transactions, on-chain governance with mempool-visible voting), an attacker can pre-position liquidity to extract slippage on the NEXT operation that uses the changed parameter.
Concrete attack: owner queues setSwapPath(FXRP, newPath) switching from a deep pool to a thinner one. Attacker sees the queued tx, drains the new pool just before execution, and the next harvest pays the slippage floor.
Bound: the same swapDownsideMaxBps that protects ordinary swaps. Attacker cannot force routing through a pool they fully control because the owner sends the literal path; front-running can't change tx contents.
Mitigations:
display "next harvest will route through new path" warnings.
Document, don't hide. List the exposed setters in CLAUDE.md alongside the bounded extraction estimate. Auditors will flag this otherwise.
Run this BEFORE every Flare mainnet broadcast. Don't ship without all green.
forge build no warnings, forge build --sizes showsall contracts under 24 KB runtime.
forge test (unit) + forge test --fork-url flare(fork) all green.
slither src/ --exclude-informational reports nohigh or medium findings.
transferOwnership to Ledger /multisig + acceptOwnership complete. Verify via owner() and pendingOwner() reads.
Ownable). Verify by reading the bytecodedispatch table for pendingOwner() / acceptOwnership() selectors.
that makes external calls or could be re-entered.
every function.
__.addresses match what you're deploying against.
verified deployed on the target chain.
explicitly reverts on FoT input.
tokens (USDT0, USDC.e, eUSDT), the design has no admin rescue path AND treasury fee paths use try/catch redirects.
audit (checklist) + audit-contract (adversarial)both completed; findings either fixed or accepted with documented reasoning.
both flare-explorer and flarescan (use the commands in flare-network).
Two skills to run, in order:
/audit src/MyContract.solChecklist-driven, 115+ items, SWC classification, weird-ERC20 catalogue. This is the systematic baseline review.
/audit-contract src/MyContract.solAdversarial multi-agent attack. Auto-selects 5–7 specialist attackers based on the contract's surface (signature handling → signatures agent; external calls → reentrancy agent; ERC-20 interactions → ERC20-edge-cases agent; multi-party flows → economic agent; etc.). Writes Foundry PoC tests for any CRITICAL or HIGH findings.
For Flare deployments specifically, after the two audit skills run, reread this skill's Part 2 to verify the Flare-specific overlays are addressed — especially Permit2, FoT, blacklistable tokens, and basefee floor. Those items won't be in the generic audit output.
For a Full Audit workflow that combines both audit skills with gas optimization and test extension, see the /Full Audit block at the top of the project's CLAUDE.md — it sequences audit → audit-contract → gas-optimize → test-foundry against a single target file and consolidates findings into one ranked report.
| Threat | Defense | Skill section |
|---|---|---|
| Re-entry | nonReentrant first + CEI | Part 1 |
| Owner-key compromise | Ownable2Step + Ledger/multisig + bounded admin powers | Part 1 |
| Token weirdness (no-return, FoT, blacklist) | SafeERC20 + balance-delta checks + try/catch on fee paths | Parts 1 + 2 |
| Signature replay | EIP-712 with chainId + nonce + spender binding | Part 1 |
| Front-running | Slippage + deadline + Permit2 | Part 1 |
| Block-value manipulation | Don't use for randomness or short timing | Part 1 |
| Permit2 chain gap | Verify availability + chain-id gate | Part 2 |
| Algebra dynamic fees | Read fresh OR document drift acceptance | Part 2 |
| FTSO redistributor changes | try/catch wrap + historical-allowlist recovery | Part 2 |
| Public RPC log limits | Use Multicall3 / Ankr / self-hosted | Part 2 |
| MEV / sandwich | Slippage + deadline + commit-reveal for high-value | Part 1 |
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.