Search projects, hackathons, sponsors and tech

    ← All projects

    Ratchet Range

    A one-way liquidity band with partly permanent price impact.

    Demo

    About

    A custom curve opcode (0x52) that gives an AMM position permanent, one-directional price impact. It prices each fill exactly like 1inch's audited XYCConcentrateSwap over a band [sqrtLo, sqrtHi], then — after pricing — translates the entire band against the taker by grip times the fill's own sqrt-price move, so the markup never decays and the position never re-offers a zone it has traded through.

    How it's made

    Stack. Solidity 0.8.30, Foundry end to end (forge for build/test/deploy/verify, anvil for the fork demo, cast for independent balance checks). 1inch SwapVM and Aqua come in as git submodules; OpenZeppelin 5.4.0 and @1inch/solidity-utils 6.9.10 via npm, because upstream remaps those into a node_modules/ that isn't vendored in the submodule. Compiler settings match upstream's exactly — via_ir, optimizer_runs = 700. What it is. RatchetRange is one custom SwapVM instruction at the free curve-bank slot 0x52. It prices a fill exactly like the audited XYCConcentrateSwap over a band [sqrtLo, sqrtHi], then — after the fill is priced — translates the whole band against the taker by grip × the fill's own sqrt-price move. The markup never decays, so the quote escalates with cumulative volume and slicing an order is strictly worse than sending it whole. Why 1inch's stack made this small. AquaOpcodes._runOpcode is internal virtual, so the whole extension is zero upstream diff: claim 0x52, super everything else, and every stock Aqua instruction keeps working. RatchetSwapVMRouter is AquaSwapVMRouter with one different opcode set. Because RatchetRange is a leaf (never calls ctx.runLoop()), it composes under stock wrappers — FeeFlatIn, Deadline, Salt, InvalidateBit — with no new plumbing. And Aqua's balance model does the inventory work for free: one maker wallet, one approve per token, balances keyed per (maker, app, strategyHash, token), so several positions account separately while the tokens never leave the wallet and the router never holds a maker approval at all. The hacky part worth mentioning: a self-staticcall to reuse audited math. The obvious build is to transcribe XYCConcentrateSwap.exec and swap in bounds read from storage — upstream parses its bounds out of bytes calldata, and a band held in storage has no calldata. That transcription would have been the single largest correctness risk in the design. Instead: XYCConcentrateSwap.exec only touches ctx.query.{tokenIn, tokenOut, isExactIn} and ctx.swap — never ctx.vm or ctx.fee — so the instruction builds a scratch Context and calls the audited function itself, through a self-STATICCALL that turns a memory value into real calldata: solidity ctx.swap = IRatchetLeafPricer(address(this)).priceLeaf( ctx.query, ctx.swap, abi.encodePacked(sqrtPriceMin, sqrtPriceMax) ); So grip = 0 reduces to XYCConcentrateSwap as a property of the call graph, not something we fuzz for — reverts and overflow corners included. Measured cost: 1,224 gas. The price is that any contract dispatching 0x52 must also inherit the RatchetLeafPricer mixin, which is nearly free since dispatching 0x52 already requires a custom opcode set. One sharp edge found the hard way: the uint128 band fields must be widened to uint256 before packing, because XYCConcentrateSwap.parse reads at(0).asU256()/at(32).asU256() and a 32-byte blob silently misparses both bounds. Other deliberate choices. State is one slot per orderHash under its own ERC-7201 namespace (ratchet.storage., not 1inch.storage., so an extension slot can never collide with an upstream one). T Testing. 39 tests, 5,000 fuzz runs under a deep profile. A CurveHarness hand-builds a Context (including a deliberately unreachable dispatch function pointer) to hit the curve directly with no Aqua, no signatures, no transfers; separate suites run the real router + real Aqua end to end. The A/B against a plain XYCConcentrateSwap control — identical bounds, inventory and fills — is asserted to the digit. The demo. script/fork-demo.sh pins a mainnet block, spins up anvil, funds a maker with canonical USDC/WETH, ships the position and reads balances back with cast on both sides, independently of anything the script claims. Two notable tricks: USDC is funded by impersonating the token's own masterMinter and calling configureMinter/mint rather than leaning on a whale balance that quietly rots at a pinned block; and the pin is validated by probing a historical state read, re-pinning rather than dying when a public full node has dropped the state. Deployment gotcha, solved. forge verify-contract submits only a contract's import closure, but a default forge build compiles src + test + script as one solc unit — and under via_ir the size of the unit changes codegen. 105 sources vs 71, 66 bytes apart, so Etherscan can't reproduce a default-profile deployment. Fix: a [profile.deploy] with skip = ["test/**", "script/**"] that makes the build unit equal the verification closure. (For anything already deployed the other way, DEPLOY.md documents capturing foundry's standard-json by wrapping solc and POSTing it manually.) Version pinning is load-bearing. Built against swap-vm origin/main and aqua v1.0.0. On v1.0.2 opcodes dispatch through a fixed function(...)[47] pointer array with no virtual hook, there's no Opcode enum or StorageSlots, and the concentrated curve is a balance-inflating wrapper rather than a pricing leaf — all three are required here, so the design simply doesn't exist on that version.