Composability
Patterns past a single swap or deposit. Single-pool calls are in Basic Operations.
1. Flash loans
ERC-3156-style, with two deliberate departures from the canonical standard:
- The borrower callback is
postFlashLoan, notonFlashLoan, and returnskeccak256("ERC3156FlashBorrower.postFlashLoan"). Flashis a pool-keyed singleton, one per chain, not a per-poolIERC3156FlashLender. Every entry point takesaddress poolfirst.
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.
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 minusminLiquidity, where on a hooked leg. It returns0when the token is the native sentinel, when theFlashsingleton itself is paused (a guardian freeze onFlash, not on the pool), when the leg lacksFLASH_ENABLED_BIT, when any halt bit is set on the leg, or when liquid reserves do not exceedminLiquidity. See Basic Operations §2.flashFee(pool, token, amount)→amount × flashFeePbps / 1_000_000. PBPS, not BPS:flashFeePbps = 100is 0.01% per loan, not annualized. It is a governable per-poolFeeParamsfield.- 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.
1.3. SDK
There is no flash wrapper; call the singleton through the ABI.
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.
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; 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
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 prints both numbers.
2.6. Examples
Tested, runnable, all three surfaces:
- Solidity, forked against a live deployment:
foundry/test/02_RouterSwap.t.sol - TypeScript with the SDK:
typescript/sdk/02-router-swap.ts - TypeScript, plain JSON-RPC, no SDK:
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; its role as an incentive lever is in Incentivization §4.
4. References
- ERC-3156: Flash Loans: the standard this departs from, as described in §1
- Integration examples: every call on this page, runnable and tested
- Flash: the contract itself