Search projects, hackathons, sponsors and tech

    ← All projects

    Freeboard Finance

    For Aave borrowers: a basket that shifts into your debt asset as health factor falls, and pays you.

    Demo

    About

    Freeboard Finance is a no-cliff liquidation guard for Aave v3 borrowers: a basket beside the loan that shifts into the debt asset as the health factor falls, and pays the borrower a spread for every step. THE PROBLEM Protecting a leveraged position today is binary. Nothing happens until a threshold is crossed, then collateral is dumped all at once, at the worst moment, into the thinnest market. Stop-losses, keeper bots and liquidation guards all share that cliff. THE INSIGHT A rebalance and a deleverage are the same operation at different urgency: sell some of X for Y according to a rule. An index rebalances toward target weights; a guard moves toward solvency. So Freeboard makes the target weights a function of the health factor. With ETH, BTC and USDC beside a USDC loan, the curve the borrower signs reads: HF 2.00 → 50/30/20, HF 1.60 → 40/24/36, HF 1.30 → 30/16/54, HF 1.15 → 20/10/70. As the health factor falls, the target slides toward the asset she owes. Trades that move the basket toward its target are priced attractively; trades that move it away are priced expensively. Arbitrageurs do the rebalancing, a little at a time, and pay the borrower a spread to do it. Deleveraging starts early, intensifies continuously, and is paid for the whole way down. By the time the loan is in danger she already holds the USDC to repay it. HOW THE HEALTH FACTOR GETS INTO THE PRICE The basket is a 1inch Aqua position. The strategy executes on the production AquaSwapVMRouter (0x111111338c…, swap-vm v1.0.2, matched to the tag by bytecode) through the router's own _extruction instruction, opcode 0x20, calling FreeboardExtruction, an immutable view contract. Inside the pricing path, on every quote and every swap, it staticcalls Aave v3's getUserAccountData for the maker, decodes the curve from the program bytes, interpolates the target at that health factor, reads the other basket legs from Aqua, and prices the fill by how much closer or farther from target the basket ends up. No router source is modified, no redeploy, nothing off-chain in the loop. Because Aave's read is deterministic within a block, quote and swap always agree; swap-vm's own invariant suite runs against the Freeboard program on the deployed router with every check on. WHY A PUBLIC CURVE CANNOT BE FRONT-RUN Front-running a rule needs a moment worth waiting for, and a continuous curve has none: the basket sells a little at every health factor, so there is no cliff. The health factor cannot be pushed; it moves with Aave's oracle and the borrower's own debt, neither of which is flash-loanable. Front-running another taker's fill hurts that taker, not the borrower. No fill ever beats the oracle, so there is no price below fair to drain at. And a public curve means more takers compete for the same fill, which is what protects the borrower. THE PER-FILL CAP One fill can move at most maxShiftBps of the basket's value (500 bps in the demo), checked after pricing on the amounts that actually settle. A wrong health factor, a bug, or a greedy taker cannot restructure the basket in one fill; a fill past the cap reverts with FreeboardFillExceedsMaxShift(shift, maxShift). If Aave cannot compute the health factor at all, the fill reverts rather than pricing from a number Aave would not stand behind; Aave cannot liquidate in that state either, so refusing costs the borrower nothing. LEDGER: THE HUMAN APPROVES THE CURVE The curve and the cap are bytes inside the SwapVM program, and the program is what Aqua.ship() commits to, indexed by its hash. So the transaction the borrower signs on her Ledger is the approval of her risk policy, not just a key. She signed it on a Nano X through the official @ledgerhq/wallet-cli, and it is on Ethereum mainnet: tx 0xc13c55e1…70c4, against FreeboardExtruction at 0x8804F353…0f41. A test decodes that transaction and asserts its calldata is byte-for-byte the program every fork test prices; flip one byte of the curve and the deployed router refuses it on quote and on swap. The device screen is a blind-signed contract call today; readable rows need an ERC-7730 descriptor, which is not done, and the README says so. THE AI AGENT: BOUNDED BY THE CHAIN, NOT BY A PROMPT The taker is an LLM agent (Claude, on the Strands Agents TypeScript SDK) with exactly three tools: read the position, quote a trade, fill it. No rule in code picks the pair, the size or the moment; the model does. Its RPC URL, taker key and API key are decrypted from Ledger's Key Ring into memory at start-up, never on disk or in the environment. In the judged run the operator told it to deleverage Alice "all of it, in ONE fill." It sent the whole 5,855 USDC gap; the router refused it, FreeboardFillExceedsMaxShift(945, 500). It then filled 3,100 USDC → 0.058883 WBTC, equal to its quote to the wei, toward target, and ended its report by explaining that the cap comes from the curve Alice signed on her Ledger, which no operator, prompt or agent can raise. That is Ledger's threshold model for agents, set once and enforced on-chain: autonomous under the cap, a new device signature above it. WHAT IT DOES FOR THE BORROWER, MEASURED Same wallet, same Aave position, same oracle path from HF 2.00 to 1.10, same taker, the same $80,000 through the deployed Aqua and router, with one instruction of difference: swap-vm's stock constant-product swap or Freeboard's curve. The stock basket buys the falling asset and pays its takers -$395.83 to do it; it reaches the bottom worth $53,703, holding 17,888 USDC. Freeboard earns +$24.47 in spread, arrives worth $56,328, holding 39,435 USDC of the debt asset, $5,128 ahead of holding the shipped basket untouched. When she repays with what the basket handed her, her health factor is 1.37 instead of 1.21. Every fill is real ERC-20 movement on a mainnet fork at block 25,900,000, against the deployed contracts' own bytecode. WHAT IT NEVER DOES Freeboard never touches the Aave position. No supply, borrow or repay; the collateral and the debt stay exactly where they are. It only changes what the basket beside the loan is made of, so that as her margin thins she is converted, and paid, into the asset she would need to repay. Repaying stays her action. The demo page draws the signed curve decoded from the program bytes, slides the cursor as the health factor falls, and counts the spread up fill by fill; it replays the committed run by default and reads a live anvil walking the same path when one is present. Every claim above names the test that pins it, in the README.

    How it's made

    CONTRACTS Solidity 0.8.30, Foundry. One contract of ours matters: FreeboardExtruction, an immutable view contract that the production AquaSwapVMRouter calls through its own _extruction instruction (opcode 0x20). Three small libraries beside it: Curve (breakpoints → interpolated target weights, clamped at both ends, Aave's no-debt sentinel clamped to the top), BasketDistance (how far a basket is from target, before and after a proposed fill), FreeboardArgs (the wire encoding of the curve and the cap inside the program bytes). extruction() is declared view so one function satisfies both IStaticExtruction and IExtruction and cannot write state; that is what makes quote and swap see the same health factor in the same block. Everything is tested on an Ethereum mainnet fork at block 25,900,000 with real Aave v3 positions and real ERC-20 transfers on both sides of every fill. There are no mocks in the fork tests; Freeboard has 140 Solidity test functions across 21 files, plus swap-vm's invariant suite and the agent's 4 checks; the only mock in the repo is a reverting Aave pool used to prove the fail-safe. swap-vm's own invariant suite (CoreInvariants at v1.0.2) is imported from the pinned dependency and run against the Freeboard program on the deployed router, every check on. It found something: a fill split in two across a target crossing pays the taker under 0.9 bps more than the same fill in one piece. We bounded it, proved the bound with a 2,000-run fuzz, and documented it rather than hiding it behind skipAdditivity as swap-vm's own fee strategies do. 1INCH AQUA AND SWAPVM Freeboard runs on the deployed contracts, not a fork or a redeploy: AquaSwapVMRouter 0x111111338c… (swap-vm v1.0.2) and Aqua 0x1111113CCf… (aqua v1.0.0). Aqua is what lets the basket stay in the borrower's wallet and still be tradeable: ship() moves no tokens, the allowance is consumed only when a taker fills. SwapVM is what lets the price depend on the borrower's risk instead of a fixed formula: the program is stock opcodes plus one _extruction, last in the byte stream, with no fee opcode. Before building on either we matched the deployed runtime bytecode against clean builds of the tagged source (CBOR metadata hash and immutable-masked runtime for the router; a source-exact IPFS metadata match for Aqua once the deployer's two extra remappings were added). That mattered: swap-vm's main branch is 130 commits past what is deployed. Neither package is on npm, so package.json pins both as git refs by tag, and remappings.txt routes every @1inch/aqua import to the top-level v1.0.0 even though swap-vm declares aqua 0.1.0 underneath it (safe: between those tags only AquaRouter.sol changed). The program bytes are one source of truth. ProgramBuilder produces them; the same bytes are the ship() calldata the Ledger signed on mainnet, the strategy every fork test prices, and what the web page decodes to draw the curve. Aqua indexes strategies by keccak256 of the program, so one byte of drift surfaces as a cryptic "insufficient balance"; the ship → execute round trip was the first test written. LEDGER The borrower approves the curve by signing Aqua.ship() on a Nano X through the official @ledgerhq/wallet-cli 2.1.0. The CLI has no sign-only mode and no EIP-712 path, and it broadcasts through Ledger's own explorer rather than an RPC, so the device-signed ship() had to land on real mainnet. We made that safe by design: FreeboardExtruction is deployed on mainnet for it (runtime asserted equal to the local build), the position has zero allowance to Aqua and no Aave debt at that address, so it prices at the curve's top row and nothing on mainnet can be filled. The fills stay on the fork. A test decodes the mainnet transaction and asserts its calldata is byte-for-byte the ProgramBuilder output. The agent's secrets live in the Ledger Key Ring: one file, agent/keeper.env.ring, encrypted under keys derived from the device, decrypted into process memory at start-up. The ring's password comes from the macOS keychain by command substitution, so no plaintext secret and no password ever sits in a file, the environment, or the log of a process an LLM is driving. THE AI AGENT Claude (claude-sonnet-5) through the Strands Agents TypeScript SDK on Node 22, with the Anthropic API key passed as a model option rather than an environment variable. Exactly three tools, each a thin viem wrapper: readPosition (health factor from the Pool, curve targets, the three Aqua legs, distance), quote (the deployed router), fill (a swap on anvil from the taker key). The judge asserts outcomes, never a transcript: every fill settled toward target and equal to its quote, and the router refused at least one oversized fill. THE WEB PAGE Vite 8, React 19, viem 2.56.3, TypeScript, set in Geist. No chart library: the curve is hand-drawn SVG from the program bytes. It replays the committed run (results/price-path.json, kept current by a test) by default and flips to live, with no refresh, when it finds the run's lens contract deployed on a local anvil. Hosted on Vercel. HACKY, AND WORTH KNOWING - The deployed router's instruction table declares 35 entries and dispatches 34, so every opcode is its literal position minus one. We found this by reading the deployed bytecode, and it is the reason _extruction is 0x20. - To crash the market on the fork we do not mock prices. We take Aave's ASSET_LISTING_ADMIN_ROLE through the ACL manager's admin, install our own price source per collateral via AaveOracle.setAssetSources at the scaled price, and let Aave compute the health factor itself. Freeboard's extruction and Aave's liquidationCall then read the same oracle. - The live walk on anvil is the same PricePathEngine as the committed run with three primitives overridden (_as, _deal, _warpTo) to broadcast transactions instead of using cheatcodes, so nothing about the path is restated, and the script then checks the node against the artifact line by line. - TakerTraits is not an ABI struct: a packed layout with a 20-byte slice table and flag bytes. Passing empty bytes as "no options" reverts. We build them with swap-vm's own TakerTraitsLib from tests. - Aqua.ship() succeeds with zero allowance and the position looks healthy by every read, yet is never fillable. End-to-end tests gate on allowance, not on Aqua's balance view. - The paired control replays every stock fill against swap-vm's own XYCSwap.sol to the wei, so "Freeboard vs the stock swap" is measured, not argued. - wallet-cli send --data is not in Ledger's published skill; we found it in send.ts and confirmed with --help before signing. Three PRs to ledger-live document what we learned.