Contracts

HelicoVault in one page. What it checks, its three doors, and its known limits.

HelicoVault enforces a user's committed mandate on an agent that re-centres their Uniswap v4 position. The user keeps the position NFT and approves the vault to act on it. The vault holds nothing across a transaction, and asserts at the end of every re-centre that it kept none of what passed through.

What a rogue agent can do

Re-range a position whose owner committed a mandate, into a band of the committed width, containing the current price, closer to it than before, no more often than the cooldown allows, before the mandate expires. The new NFT and every token go to the owner; those are the only destinations the contract writes. No path pays an agent.

The checks

CheckRejection
owner committed a mandate and still owns the positionMandateInactive, NotPositionOwner
position is in the committed poolPoolNotPermitted
ticks ordered and aligned to spacingTicksNotOrdered, TicksNotSpaced
range exactly rangeWidthTicks wideRangeWidthMismatch
range contains the current tickRangeOffMarket
closer by at least minImprovementBpsNotEnoughImprovement
cooldown elapsedCooldownNotElapsed
liquidity within maxLiquidityLiquidityTooLarge
delivered liquidity at least minRetainedBps of what was withdrawnLiquidityNotRetained
not expired, not revokedMandateExpired, MandateInactive
caller holds AGENT_ROLE, or one signed for itAccessControlUnauthorizedAccount, SignerLacksAgentRole

Afterwards: the position asked for is the one that exists, PositionNotDelivered, RangeNotDelivered. The range rules are one function, _checkRange.

Three doors

DoorCallerAuthority
recenteran AGENT_ROLE holderthe role and the mandate
recenterWithSignatureanyone, relayingan EIP-712 signature by an AGENT_ROLE holder, single use, bound to the mandate hash
onReportthe Chainlink KeystoneForwardermsg.sender == forwarder; the DON's signatures were verified by the forwarder

The door decides who may propose, never what they may propose.

How a re-centre runs

recenter takes numbers, not calldata. Inside its own unlock callback it burns the old position, swaps the excess side through the position's own pool with a price limit at the edge of the committed range, then mints the new range to the owner. The tick is re-read after the swap, amountIn is capped at what the burn returned, and the mint's maxima are what the vault holds. Re-centring changes the token id, so accounts are keyed by owner; one address, one position at a time; setMandate on a second position reverts with MandateAlreadyActive.

Roles and upgrades

AGENT_ROLE proposes, GUARDIAN_ROLE pauses actions but not exits, UPGRADER_ROLE schedules upgrades. Upgrades wait two days, commit the implementation's codehash, and expire seven days after becoming ready. revoke is never gated.

Known limits

  • The agent picks the withdrawal's slippage bounds; a dishonest one can pick weak ones.
  • DEFAULT_ADMIN_ROLE can grant itself AGENT_ROLE in one transaction; hold it in a multisig.
  • maxLiquidity caps the whole position.
  • Stray native sent to the vault is stuck, by design.
  • The mocks do not model the price curve; swap claims are asserted on a fork or not at all.

Run

cd contracts
forge install --no-git --shallow foundry-rs/forge-std@v1.16.2 \
  OpenZeppelin/openzeppelin-contracts@v5.7.0 OpenZeppelin/openzeppelin-contracts-upgradeable@v5.7.0
forge build && forge test
ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc forge test

CI also runs scripts/check-no-payable.py (multicall stays non-payable) and scripts/check-storage-layout.py (the upgradeable layout stays at 9 slots). The full reasoning is in contracts/README.md.

On this page