> For the complete documentation index, see [llms.txt](https://docs.flap.sh/flap/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.flap.sh/flap/developers/preview/flap-vault-ex.md).

# 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:**

| Network           | FlapVaultEx (proxy)                          | Max Callback Gas |
| ----------------- | -------------------------------------------- | ---------------- |
| BNB Chain Mainnet | `0x000048Df69f3d2af22Df6DC2d3B01FB592b90000` | 200,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

{% hint style="warning" %}
**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.
{% endhint %}

### How it works

```mermaid
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

| Status      | Value | Meaning                                                                                        |
| ----------- | ----- | ---------------------------------------------------------------------------------------------- |
| `NONE`      | 0     | No such request.                                                                               |
| `PENDING`   | 1     | Input custodied; awaiting settlement or a refund.                                              |
| `FULFILLED` | 2     | Swapped; output delivered to the vault.                                                        |
| `REFUNDED`  | 3     | Input returned to the vault (fee taken from the input side).                                   |
| `FAILED`    | 4     | A failed batch item whose automatic refund could not be delivered; retryable via `refundSwap`. |

{% hint style="info" %}
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.
{% endhint %}

***

## 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.

{% hint style="success" %}
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.
{% endhint %}

***

## 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()`.

{% hint style="warning" %}
The callback is **atomic with settlement**: if it reverts, the whole `fulfillSwap` reverts and the request stays `PENDING`.
{% endhint %}

***

## How to integrate

### Step 1 — Import the interfaces

```solidity
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.

{% hint style="warning" %}
`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.
{% endhint %}

### 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

```solidity
// 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`:

```solidity
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:

```solidity
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);
}
```

{% hint style="warning" %}
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.
{% endhint %}

***

## Reference

### Full interface

```solidity
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;
}
```
