Composability

Patterns past a single swap or deposit. Single-pool calls are in Basic Operations.


1. Flash loans

ERC-3156-style flash loans, with two departures from the canonical standard. Mechanics and pool-side accounting: Flash.

  1. The borrower callback is onFlashLoan, not postFlashLoan, and returns keccak256("ERC3156FlashBorrower.onFlashLoan").
  2. Flash is a pool-keyed singleton, one per chain, not a per-pool IERC3156FlashLender. Every entry point takes address pool first.
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 onFlashLoan( 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 OperationFailed unless the pool’s balance has grown by at least the fee, or if the callback returns anything but the magic value.

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 onFlashLoan( 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.onFlashLoan"); } }

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 Rliq=R-Rinv on a hooked leg. 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 (Flow Guards), or when liquid reserves do not exceed minLiquidity. flashFee and flashLoan do not return 0 in those cases: they revert FeatureDisabled(FLASH) (native, pause, flag clear) or FeatureDisabled(ASSET) (halt), and InvalidState for a pool the factory did not mint. See Basic Operations §2.
  • flashFee(pool, token, amount) → ceil(amount × flashFeePbps / 1_000_000) (mulDivUp, rounds toward the pool). PBPS, not BPS: flashFeePbps = 100 is 0.01% per loan, not annualized. A governable per-pool FeeParams field.
  • The fee splits by protoSharePct: the protocol slice is booked as pending treasury shares (protocol fee), the remainder raises the liquidity index for LPs. Both stay in the leg’s reserves.
  • The EIP-7528 native sentinel (0xEeee…) is not loanable. Borrow wrapped native directly.

On a hooked leg a flash loan draws on liquid reserves only: nothing is recalled from the venue, so maxFlashLoan is Rliq-minLiquidity. 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 onFlashLoan-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 optional per chain. Where deployed, its address is contracts.router in GET /v1/venues (deployed instances); where that key is absent, call Pool.swap_qe direct. Its ABI is ROUTER_ABI in @btr-protocol/sdk/abis (not served on /v1/abis).

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. The caller supplies the path; the Router executes it atomically and enforces the floors. Building the path is Quotes & Routing; POST /v1/route produces one.

Without it a multi-hop is N approvals, N signatures and N transactions, and a revert on leg 2 leaves the user holding the intermediate asset. Aggregators do not need it, they call Pool.swap_qe from their own router; the Router exists for 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 received, after every part has run. There is no per-hop floor: one would make each intermediate leg spend the previous leg’s FLOOR rather than its expected output, so tolerances compound and a two-hop route rejects itself on a market that has not moved. The only number that has to hold is the output amount of the requested token.

Balances are snapshotted before execution, so the floor measures what this call delivered rather than dust an earlier caller left behind, and it is correct for a fee-on-transfer token: the floor is checked on the amount that arrived, not on the amount a pool debited.

A direct Pool.swap_qe has no such snapshot: minAmountOut is checked on face, the amount the pool debited. On a token whose transfer fee is carved out of the recipient’s side, the caller measures its own balance or routes through the Router.

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

ConditionRevert
A part’s terminal token has no floor entryUnclaimedOutput(token)
A token appears twice in floorsDuplicateFloor(token)
A hop names a pool the factory did not mintUnknownPool(pool)
recipient is zero or the Router itselfBadRecipient()
A floor is not met after every part has runBelowFloor(token, received, minOut)
An empty parts, or a part with no hopsNoParts() / EmptyPath()
block.timestamp > deadlineDeadlineExpired()
Re-entryReentrancy()

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. It pays off on two or more hops and on splits, where the alternative is several signatures and an unhedged position between them. Measured: 02_RouterSwap.t.sol prints both numbers.

2.6. Examples

Tested, runnable, all three surfaces:

2.7. Multicall3 still cannot do this

Pool.swap_qe 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