---
title: "Composability"
description: "Flash loans through the Flash singleton, and cross-pool routing"
audience: tech
type: explanation
status: live
lang: en
updated: "2026-09-02"
publish: true
---
# Composability

Patterns past a single swap or deposit. Single-pool calls are in [Basic Operations](/docs/5-1-1-basic-operations).

---

## 1. Flash loans

ERC-3156-**style**, with two deliberate departures from the canonical standard:

1. The borrower callback is **`postFlashLoan`**, not `onFlashLoan`, and returns `keccak256("ERC3156FlashBorrower.postFlashLoan")`.
2. `Flash` is a **pool-keyed singleton**, one per chain, not a per-pool `IERC3156FlashLender`. Every entry point takes `address pool` first.

```solidity
interface IFlash {
    function flashLoan(
        address pool,
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);

    function maxFlashLoan(address pool, address token) external view returns (uint256);
    function flashFee(address pool, address token, uint256 amount) external view returns (uint256);
}

interface IERC3156FlashBorrower {
    function postFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32);
}
```

### 1.1. Repayment

**Repay by transferring to the pool.** `Flash` checks the pool's balance after the callback; it never calls `transferFrom` on the receiver, so approving the `Flash` singleton does nothing. The transaction reverts unless the pool's balance has grown by at least the fee.

```solidity
contract MyFlashReceiver is IERC3156FlashBorrower {
    IFlash  immutable flash;
    address immutable pool;

    constructor(IFlash flash_, address pool_) {
        flash = flash_;
        pool  = pool_;
    }

    function execute(address token, uint256 amount) external {
        flash.flashLoan(pool, this, token, amount, abi.encode(msg.sender));
    }

    function postFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        require(msg.sender == address(flash), "untrusted caller"); // the singleton, not the pool
        require(initiator == address(this),   "unexpected initiator");

        // … arbitrage, liquidation, collateral swap …

        require(IERC20(token).balanceOf(address(this)) >= amount + fee, "short");
        IERC20(token).transfer(pool, amount + fee);                 // NOT approve(flash)
        return keccak256("ERC3156FlashBorrower.postFlashLoan");
    }
}
```

Check `msg.sender` against the `Flash` singleton **and** `initiator` against yourself. The first stops anyone invoking your callback directly; the second stops a third party initiating a loan that routes into your receiver.

### 1.2. Sizing and fees

- `maxFlashLoan(pool, token)` → liquid reserves minus `minLiquidity`, where $R_{\mathrm{liq}} = R - R_{\mathrm{inv}}$ on a hooked leg. It returns `0` when the token is the native sentinel, when the **`Flash` singleton itself** is paused (a guardian freeze on `Flash`, not on the pool), when the leg lacks `FLASH_ENABLED_BIT`, when any halt bit is set on the leg, or when liquid reserves do not exceed `minLiquidity`. See [Basic Operations §2](/docs/5-1-1-basic-operations#2-check-the-flags-first).
- `flashFee(pool, token, amount)` → `amount × flashFeePbps / 1_000_000`. PBPS, not BPS: `flashFeePbps = 100` is 0.01% **per loan**, not annualized. It is a governable per-pool `FeeParams` field.
- The fee splits by `protoSharePct`: the protocol slice accrues to protocol fees, the remainder is credited to pool reserves and so to LPs.
- The EIP-7528 native sentinel (`0xEeee…`) is not loanable. Borrow wrapped native directly.

On a hooked leg, `flashPrepare` recalls `amount + minLiquidity` from the venue before sending, so a flash loan can pull against invested capital, at the cost of a venue round trip. See [Hooks §3](/docs/5-1-3-hooks#3-dispatch-and-gas).

### 1.3. SDK

There is no flash wrapper; call the singleton through the ABI.

```typescript
import { FLASH_ABI } from '@btr-protocol/sdk/abis';
import { Contract } from '@btr-protocol/sdk/eth';

const flash = new Contract({ address: flashAddress, abi: FLASH_ABI, provider, account });

const fee = await flash.read<bigint>('flashFee', [pool, token, amount]);
const max = await flash.read<bigint>('maxFlashLoan', [pool, token]);
// receiver = your deployed postFlashLoan-variant contract
const txHash = await flash.write('flashLoan', [pool, receiver, token, amount, data]);
```

---

## 2. Routing through the Router

A `Router` singleton executes an off-chain chosen path across several pools in one transaction. It is deployed alongside the rest of the fleet; find its address on [Contract Addresses](/docs/2-1-contract-addresses).

**It does not find routes.** Route selection is a global search over every pool's curve, coverage and marks, and running that on chain would cost more gas than the spread it saves. You bring the path; the Router executes it, atomically, and enforces what the caller was promised. Building the path is [Quotes & Routing](/docs/5-2-2-quotes-routing); `POST /v1/route` produces one.

**Why it exists.** Without it a multi-hop is N approvals, N signatures and N transactions, and a revert on leg 2 leaves the user holding an intermediate asset they never asked for. Aggregators do not need it (they call `Pool.swap` from their own router), so its whole purpose is BTR's own execution quality: one approval per input, one signature, all or nothing.

### 2.1. Call surface

```solidity
struct Hop   { address pool; address tokenOut; }
struct Part  { address tokenIn; uint256 amountIn; Hop[] hops; }
struct Floor { address token; uint256 minOut; }

function swap(
    Part[] calldata parts,
    Floor[] calldata floors,
    address recipient,
    uint256 deadline
) external returns (uint256[] memory received);
```

A `Hop`'s input token is implicit: the part's `tokenIn` for the first hop, the previous hop's `tokenOut` after that. `received[i]` is the amount of `floors[i].token` forwarded to `recipient`.

### 2.2. Floors are end to end, never per hop

`minOut` is checked ONCE per output token, on the balance the Router actually received, after every part has run. There is no per-hop floor and that is deliberate: a per-hop floor makes each intermediate leg spend the previous leg's FLOOR rather than its expected output, so tolerances compound into each other and a two-hop route rejects itself on a market that has not moved. The user was promised an amount of the token they asked for, and that is the only number that has to hold.

Balances are snapshotted before execution, so the floor measures what this call delivered rather than dust an earlier caller left behind, and it stays honest for a fee-on-transfer token: what counts is what arrived, not what a pool said it sent.

### 2.3. Parts are independent, not chained

`parts` is a list of independent paths. One call can spend two different inputs, split one input across two routes, or both. Parts run in order but do NOT feed each other.

Floors are keyed by OUTPUT TOKEN across the whole call, not per part. A split whose halves are floored separately is over-constrained: it would refuse a fill that is fine in aggregate because one leg came in light while the other more than covered it.

### 2.4. Rules the encoder cannot check for you

| Condition | Revert |
|---|---|
| A part's terminal token has no floor entry | `UnclaimedOutput(token)` |
| A token appears twice in `floors` | `DuplicateFloor(token)` |
| A hop names a pool the factory did not mint | `UnknownPool(pool)` |
| `recipient` is zero or the Router itself | `BadRecipient()` |
| A floor is not met after every part has run | `BelowFloor(token, received, minOut)` |
| An empty `parts`, or a part with no hops | `NoParts()` / `EmptyPath()` |

`UnclaimedOutput` exists because proceeds nobody claimed would sit in the Router with no one entitled to them: a silent loss that otherwise looks like a successful swap. `UnknownPool` exists because the Router grants an allowance to whatever address it is handed, so the factory registry is what makes a caller-supplied pool address safe to approve at all.

### 2.5. Cost

Routing a SINGLE hop through the Router costs more gas than calling the pool directly: the extra pull, the approval and the balance snapshots are real. It earns its keep on two or more hops and on splits, where the alternative is several signatures and an unhedged position between them. Measured, not asserted: [`02_RouterSwap.t.sol`](https://github.com/btr-protocol/examples/blob/main/foundry/test/02_RouterSwap.t.sol) prints both numbers.

### 2.6. Examples

Tested, runnable, all three surfaces:

- Solidity, forked against a live deployment: [`foundry/test/02_RouterSwap.t.sol`](https://github.com/btr-protocol/examples/blob/main/foundry/test/02_RouterSwap.t.sol)
- TypeScript with the SDK: [`typescript/sdk/02-router-swap.ts`](https://github.com/btr-protocol/examples/blob/main/typescript/sdk/02-router-swap.ts)
- TypeScript, plain JSON-RPC, no SDK: [`typescript/rpc/02-router-swap.ts`](https://github.com/btr-protocol/examples/blob/main/typescript/rpc/02-router-swap.ts)

### 2.7. Multicall3 still cannot do this

`Pool.swap` pulls `tokenIn` with `transferFrom(msg.sender)`, and under Multicall3 that `msg.sender` is the multicall contract, not the user. The Router works because it pulls the input itself and holds the intermediates for the length of one call. A generic batcher cannot substitute.

---

## 3. Protocol-owned liquidity

POL is an ordinary `deposit` from a treasury address: shares mint on the leg's ERC-20 receipt, fees compound through the liquidity index, and the position can be unwound like any other. Call shapes are in [Basic Operations §4](/docs/5-1-1-basic-operations#4-deposit--withdraw); its role as an incentive lever is in [Incentivization §4](/docs/5-3-3-incentivization#4-protocol-owned-liquidity).

---

## 4. References

- ERC-3156: [Flash Loans](https://eips.ethereum.org/EIPS/eip-3156): the standard this departs from, as described in §1
- [Integration examples](https://github.com/btr-protocol/examples): every call on this page, runnable and tested
- [Flash](/docs/1-2-4-flash): the contract itself
