本页目前只有英文版。

FlapVaultEx

Overview

FlapVaultEx is an asynchronous, intent-based settlement layer that lets a vault swap any token for any other token without knowing anything about DEX routing. The vault submits an intent — "sell this much of inputToken, I want outputToken" — and Flap's off-chain executor sources the best route across DEX aggregators and settles it back on-chain.

It bridges on-chain custody with off-chain routing:

  • The contract custodies the input and records a PENDING request.
  • The off-chain executor finds the best route and calls fulfillSwap with the route calldata.
  • The contract measures the real output by balance delta, enforces an on-chain minimum, takes the fee, and delivers the result straight back to the vault via a callback — atomically.

There are no signatures and no routing logic in the vault. Routes are bound by on-chain whitelists, output is verified by balance delta, and the executor can only settle — never redirect — the funds.

Deployed addresses:

NetworkFlapVaultEx (proxy)Max Callback Gas
BNB Chain Mainnet0x000048Df69f3d2af22Df6DC2d3B01FB592b90000200,000

Primary use cases:

  • Converting tax/fee revenue held in an arbitrary token into a stablecoin or a target dividend asset
  • Consolidating multiple fee tokens into one
  • Rebalancing a vault's holdings without building or auditing DEX routing

Fee-on-transfer (FOT) and rebasing tokens are not supported. The contract verifies exact balance deltas on both sides, so a fee-on-transfer token makes requestSwap / settlement revert, and a rebasing balance (which can change between request and settlement) is rejected too. The protocol can also block (denylist) unsupported tokens, in which case any request using them reverts.

How it works

sequenceDiagram
    participant V as Vault
    participant A as FlapVaultEx
    participant E as Executor (off-chain)
    participant R as DEX Aggregator

    V->>A: requestSwap(inputToken, outputToken, inputAmount)
    Note over A: custody input, status = PENDING
    A-->>E: emit FlapVaultExRequested(requestId, ...)
    E->>A: getRequest(requestId)
    E->>R: quote best route
    R-->>E: target, allowanceTarget, data, minBuyAmount
    E->>A: fulfillSwap(requestId, target, allowanceTarget, minOutputAmount, data)
    A->>R: target.call(data) (exact approval, reset after)
    R-->>A: outputToken
    A->>A: require output >= minOutputAmount, take fee
    A->>V: onFlapVaultExSettled(requestId, FULFILLED, outputToken, netOutput)
    Note over A: status = FULFILLED

Step by step:

  1. The vault calls requestSwap() with {inputToken, outputToken, inputAmount}, transferring the input ERC-20 into FlapVaultEx via approval.
  2. FlapVaultEx records the request as PENDING and emits FlapVaultExRequested.
  3. The off-chain executor indexes the event, quotes across DEX aggregators, and picks the best route.
  4. The executor calls fulfillSwap() with the winning route (target, allowanceTarget, data) and a minOutputAmount floor.
  5. FlapVaultEx approves exactly the input amount, performs the router call, resets the approval, and measures the received output by balance delta.
  6. If the output is at least minOutputAmount, it takes the fee and delivers the net output to the vault, then calls onFlapVaultExSettled(...). If anything fails, the whole call reverts.

Request lifecycle

StatusValueMeaning
NONE0No such request.
PENDING1Input custodied; awaiting settlement or a refund.
FULFILLED2Swapped; output delivered to the vault.
REFUNDED3Input returned to the vault (fee taken from the input side).
FAILED4A failed batch item whose automatic refund could not be delivered; retryable via refundSwap.

Requests never expire — there is no deadline. A backlogged order can always be settled later, so a vault is never punished because gas was high or a route was temporarily unavailable.


Fees

FlapVaultEx charges a small protocol fee, currently 0.5% (50 bps). The fee exists primarily to discourage abuse — creating requests only to immediately refund them for free, or spamming requests the executor has to process.

How the fee is taken:

  • It is charged on the quote-token side: if the input is a configured quote token (e.g. USDT/USDC/WBNB) and the output is not, it's taken from the input; if the output is the quote, from the output; if both or neither, it falls back to the admin-configured side.
  • The rate is snapshotted at requestSwap. Settlement always uses the snapshotted rate, so a fee change never affects an in-flight request.
  • A refund also takes the snapshotted feeBps, always from the input side (a refund has no output) — so a request → refund round trip is not free.

You never compute the fee. getRequest().swapInputAmount (and the FlapVaultExRequested event) already expose the net input actually swapped, after any input-side fee — quote on that and you're done.


Callback gas limit

Callbacks run with a bounded gas budget — 200,000 on the current deployment — so settlement can never be griefed by an expensive callback. Keep onFlapVaultExSettled cheap; it should record the result, not do heavy work. Read the live value via maxCallbackGas().

The callback is atomic with settlement: if it reverts, the whole fulfillSwap reverts and the request stays PENDING.


How to integrate

Step 1 — Import the interfaces

import { IFlapVaultEx, IFlapVaultExConsumer } from "src/flap/IFlapVaultEx.sol";

Step 2 — Implement IFlapVaultExConsumer

Your vault must implement onFlapVaultExSettled. It is called for both outcomes, so branch on status:

  • status == 2 (FULFILLED): the output token was delivered, net of the fee.
  • status == 3 (REFUNDED): the input token was returned, net of the input-side fee.

In both cases the funds are already in your vault when the callback runs. Validate the caller, then do whatever your accounting needs.

requestSwap requires msg.sender to be a real contract (plain EOAs and EIP-7702 delegated accounts are rejected), precisely so this callback can be delivered.

Step 3 — Trigger a swap

Do not let the caller pick the tokens or amount. Fix the parameters at initialization (like the other vault examples in this repo) and expose a no-argument swap() that just swaps what the vault actually holds. Revenue (tax payouts, direct transfers) accumulates in the vault, so read the live balance — balanceOf(address(this)) — and only create a request once it is worth it.

Keep the returned requestId (or index it from FlapVaultExRequested) so you can correlate the callback.

Example vault

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

import { IFlapVaultEx, IFlapVaultExConsumer } from "src/flap/IFlapVaultEx.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";

/// @notice Example vault that swaps the revenue token it accumulates into a fixed output token
///         through FlapVaultEx. The tokens and the minimum swap size are fixed at construction and
///         `swap()` takes no arguments — it can only ever sell `inputToken` for `outputToken`,
///         and only up to the balance it actually holds.
contract MySwappingVault is IFlapVaultExConsumer {
    IFlapVaultEx public immutable flapVaultEx;

    /// @notice Revenue token accumulated by the vault (the token we sell).
    address public immutable inputToken;
    /// @notice Token we buy on every swap.
    address public immutable outputToken;
    /// @notice Don't bother swapping below this balance (dust / gas).
    uint256 public immutable minSwapAmount;

    uint8 private constant FULFILLED = 2;
    uint8 private constant REFUNDED  = 3;

    mapping(uint256 => bool) public isOurRequest;

    event SwapRequested(uint256 indexed requestId, uint256 amountIn);
    event SwapSettled(uint256 indexed requestId, uint8 status, address token, uint256 amount);

    error BadConfig();
    error NotFlapVaultEx();
    error UnknownRequest();
    error NothingToSwap();

    // Params are fixed here — never accepted from a caller on the swap path. (In a beacon-proxy
    // vault this would be an `initialize(...)` called once behind the proxy.)
    constructor(address flapVaultEx_, address inputToken_, address outputToken_, uint256 minSwapAmount_) {
        if (
            flapVaultEx_ == address(0) || inputToken_ == address(0) || outputToken_ == address(0)
                || inputToken_ == outputToken_
        ) revert BadConfig();

        flapVaultEx = IFlapVaultEx(flapVaultEx_);
        inputToken = inputToken_;
        outputToken = outputToken_;
        minSwapAmount = minSwapAmount_;
    }

    /// @notice Revenue (tax payouts, direct transfers) lands here.
    receive() external payable {}

    /// @notice Swap the revenue currently held by the vault. No arguments: sells `inputToken` for
    ///         `outputToken`, and only for the balance it actually has.
    function swap() external returns (uint256 requestId) {
        address tokenIn = inputToken;
        uint256 balance = IERC20(tokenIn).balanceOf(address(this));
        if (balance < minSwapAmount) revert NothingToSwap();

        IERC20(tokenIn).approve(address(flapVaultEx), balance);
        requestId = flapVaultEx.requestSwap(
            IFlapVaultEx.ExactInputParams({inputToken: tokenIn, outputToken: outputToken, inputAmount: balance})
        );
        IERC20(tokenIn).approve(address(flapVaultEx), 0);

        isOurRequest[requestId] = true;
        emit SwapRequested(requestId, balance);
    }

    /// @notice Called by FlapVaultEx after settlement — success OR refund.
    function onFlapVaultExSettled(uint256 requestId, uint8 status, address token, uint256 amount) external override {
        // SECURITY: only FlapVaultEx, and only for a request this vault actually created.
        if (msg.sender != address(flapVaultEx)) revert NotFlapVaultEx();
        if (!isOurRequest[requestId]) revert UnknownRequest();
        delete isOurRequest[requestId];

        if (status == FULFILLED) {
            // `token` is `outputToken`; `amount` is the net output delivered.
            _onFulfilled(requestId, token, amount);
        } else if (status == REFUNDED) {
            // `token` is `inputToken`; `amount` is the refund net of the input-side fee.
            _onRefunded(requestId, token, amount);
        } else {
            revert("unexpected status");
        }

        emit SwapSettled(requestId, status, token, amount);
    }

    function _onFulfilled(uint256 requestId, address outputToken_, uint256 netOutput) internal {
        // ... record proceeds, update NAV, distribute, etc. ...
    }

    function _onRefunded(uint256 requestId, address inputToken_, uint256 refunded) internal {
        // ... handle the failure path (e.g. keep the refunded input for a later retry) ...
    }
}

Notes:

  • Tokens and the swap threshold are fixed at initialization; swap() takes no arguments and uses the live balanceOf(address(this)), so a caller can never steer the vault into selling the wrong asset or amount.
  • No-arg swap() is safe to leave callable by anyone (it only touches the vault's own revenue) — restrict it with an owner/operator check if your design needs to.
  • Approve FlapVaultEx for exactly the amount you swap (it pulls exactly that; fee-on-transfer inputs are rejected).
  • Branch on status — the same callback fires whether the swap settled or the input was refunded — and gate it on msg.sender == flapVaultEx plus your own request-id tracking.

Native (BNB/ETH) input and output

FlapVaultEx only swaps ERC-20 tokens — native is disabled. So if your vault wants to accept or pay out native BNB, wrap it into WBNB yourself (and unwrap on the way out):

  • Native input (sell native): wrap the vault's native balance into WBNB with deposit{value: amount}(), then request a swap from WBNB to your target token.
  • Native output (buy native): request a swap from your token to WBNB, then unwrap the WBNB you receive in the callback back into BNB with withdraw(amount).

Native input — sell native for an ERC-20

Vault receives native, wraps it, then requests WBNB → outputToken:

interface IWETH {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
    function balanceOf(address account) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
}

address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;

// ...inside MySwappingVault, with `outputToken` set to something other than WBNB...
receive() external payable {} // native revenue lands here

function swap() external returns (uint256 requestId) {
    uint256 balance = address(this).balance;
    if (balance < minSwapAmount) revert NothingToSwap();

    IWETH(WBNB).deposit{value: balance}();                  // native -> WBNB
    uint256 wrapped = IWETH(WBNB).balanceOf(address(this));

    IWETH(WBNB).approve(address(flapVaultEx), wrapped);
    requestId = flapVaultEx.requestSwap(
        IFlapVaultEx.ExactInputParams({
            inputToken: WBNB,          // wrapped input
            outputToken: outputToken,
            inputAmount: wrapped
        })
    );
    IWETH(WBNB).approve(address(flapVaultEx), 0);

    isOurRequest[requestId] = true;
    emit SwapRequested(requestId, wrapped);
}

On the refund path the input comes back as WBNB; call IWETH(WBNB).withdraw(amount) if the vault wants native again.

Native output — buy native with an ERC-20

Vault requests inputToken → WBNB, then unwraps the delivered amount in the callback:

function swap() external returns (uint256 requestId) {
    uint256 balance = IERC20(inputToken).balanceOf(address(this));
    if (balance < minSwapAmount) revert NothingToSwap();

    IERC20(inputToken).approve(address(flapVaultEx), balance);
    requestId = flapVaultEx.requestSwap(
        IFlapVaultEx.ExactInputParams({
            inputToken: inputToken,
            outputToken: WBNB,        // wrapped output -> we unwrap it
            inputAmount: balance
        })
    );
    IERC20(inputToken).approve(address(flapVaultEx), 0);

    isOurRequest[requestId] = true;
    emit SwapRequested(requestId, balance);
}

function onFlapVaultExSettled(uint256 requestId, uint8 status, address token, uint256 amount) external override {
    if (msg.sender != address(flapVaultEx)) revert NotFlapVaultEx();
    if (!isOurRequest[requestId]) revert UnknownRequest();
    delete isOurRequest[requestId];

    if (status == FULFILLED) {
        require(token == WBNB, "unexpected token");
        IWETH(WBNB).withdraw(amount);   // WBNB -> native, stays in the vault
    } else if (status == REFUNDED) {
        // `token` is `inputToken` (an ERC-20) — nothing to unwrap.
    } else {
        revert("unexpected status");
    }

    emit SwapSettled(requestId, status, token, amount);
}

Always unwrap the net amount reported by the callback — the fee may be taken on the wrapped-native output — and keep withdraw() within the 200,000 callback gas budget. On the refund path token is the ERC-20 input, so do not unwrap it.


Reference

Full interface

interface IFlapVaultEx {
    enum RequestStatus { NONE, PENDING, FULFILLED, REFUNDED, FAILED }

    struct ExactInputParams {
        address inputToken;   // ERC-20 being sold
        address outputToken;  // ERC-20 to buy
        uint256 inputAmount;
    }

    struct FulfillItem {
        uint256 requestId;
        address target;
        address allowanceTarget;
        uint256 minOutputAmount;
        bytes   data;
    }

    struct SwapRequestView {
        uint256 requestId;
        address consumer;
        address inputToken;
        address outputToken;
        uint256 inputAmount;
        uint256 swapInputAmount;  // net input actually swapped (quote on this)
        bool    feeInInput;
        uint64  feeBps;           // snapshotted at request time
        uint64  createdAt;        // informational; no expiry
        RequestStatus status;
    }

    // ── Events ──────────────────────────────────────────────────────────────
    event FlapVaultExRequested(uint256 indexed requestId, address indexed consumer,
        address inputToken, uint256 inputAmount, address outputToken, uint256 swapInputAmount,
        bool feeInInput, uint64 feeBps, uint64 createdAt);
    event FlapVaultExFulfilled(uint256 indexed requestId, address indexed consumer,
        address outputToken, uint256 netOutputAmount, uint256 feeAmount);
    event FlapVaultExRefunded(uint256 indexed requestId, address indexed consumer,
        address inputToken, uint256 refundAmount, uint256 feeAmount);
    event FlapVaultExUnspentInput(uint256 indexed requestId, address indexed consumer,
        address inputToken, uint256 swapInput, uint256 consumed, uint256 leftover);
    event FlapVaultExBatchItemFailed(uint256 indexed requestId, bytes reason);
    event FlapVaultExBatchItemRefunded(uint256 indexed requestId);
    event FlapVaultExBatchRefundFailed(uint256 indexed requestId, bytes reason);
    event FlapVaultExRequestFailed(uint256 indexed requestId, bytes reason);

    // ── Consumer ────────────────────────────────────────────────────────────
    function requestSwap(ExactInputParams calldata params) external payable returns (uint256 requestId);

    // ── Executor (FULFILLER_ROLE) ───────────────────────────────────────────
    function fulfillSwap(uint256 requestId, address target, address allowanceTarget,
        uint256 minOutputAmount, bytes calldata data) external returns (uint256 netOutputAmount);
    function batchFulfillSwap(FulfillItem[] calldata items) external returns (uint256 successCount);
    function refundSwap(uint256 requestId) external;

    // ── Views ───────────────────────────────────────────────────────────────
    function getRequest(uint256 requestId) external view returns (SwapRequestView memory);
    function getRequestsPaginated(uint256 offset, uint256 limit)
        external view returns (SwapRequestView[] memory requests, uint256 total);
    function getRequestsByRequesterPaginated(address requester, uint256 offset, uint256 limit)
        external view returns (SwapRequestView[] memory requests, uint256 total);
    function nextRequestId() external view returns (uint256);
    function feeConfig() external view returns (uint256 feeBps, address feeReceiver);
    function maxCallbackGas() external view returns (uint256);
    function isQuoteToken(address token) external view returns (bool);
    function isTokenBlocked(address token) external view returns (bool);
    function routerWhitelistEnabled() external view returns (bool);
}

interface IFlapVaultExConsumer {
    /// @param status  RequestStatus.FULFILLED (2) or RequestStatus.REFUNDED (3)
    /// @param token   ERC-20 delivered or returned
    /// @param amount  Amount delivered or returned (after fees on fulfillment)
    function onFlapVaultExSettled(uint256 requestId, uint8 status, address token, uint256 amount) external;
}