EvenFlow: The Problem With Waiting For A Swap

EvenFlow: The Problem With Waiting For A Swap

EvenFlow was the first of the six projects in our look at the UHI9 hookathon, and the one we singled out as the clearest case of something a Uniswap hook simply can’t do on its own. Here is a closer look at how it gets around that. The full project, contracts and tests included, is open here.

Essentially it is a Uniswap v4 hook that tries to make a liquidity provider's income less lumpy. When trading turns against the people who supply the pool's capital (LPs, for short), EvenFlow charges a little extra and sets that money aside in a reserve. When the market calms down again, it drips the reserve back to the LPs who stuck around, so their income ends up smoother without ending up smaller.

There is one catch, and it is the whole story. The drip is meant to happen "when the market goes quiet". But almost everything inside a pool is set in motion by a trade: someone swaps, and code runs. If the market goes genuinely quiet, with no swaps at all, there is nothing to set the drip in motion. The reserve sits there stranded, at exactly the moment it is supposed to be paid out.

Give the pool a clock

The fix is to give the pool a sense of time that does not depend on anyone trading. That is what a reactive contract provides. A reactive contract is a small program that watches for events and acts on its own when it sees one it cares about, and it can watch events on other chains too.

Reactive Network happens to emit a built-in periodic signal, called a CRON event, on a fixed cadence. Anyone can listen for it. EvenFlow's controller (a reactive contract living on Reactive's Lasna testnet) listens for two things:

  • the pool's own "the mood just changed" signal, so it can react the instant a swap tips the pool into a quiet spell, and
  • the network's CRON event, so idle pools that emit no events at all still get checked on a schedule.

The second one is the interesting half. It means the pool no longer has to wait for a swap to realize it has gone quiet. The network's clock realizes it on the pool's behalf.

In code, those two subscriptions are the base of the whole thing:

// Watch the hook's own "regime changed" event over on the origin chain...
service.subscribe(originChainId, shieldHook, RISK_REGIME_CHANGED_TOPIC0, ...);

// ...and listen for the network's periodic CRON event.
service.subscribe(block.chainid, cronSystem, cronTopic0, ...);

Two lines. One says "tell me when the pool's mood changes". The other says "and tap me on the shoulder every so often, no matter what". Everything else follows from those.

What happens when the clock ticks

When either signal arrives, the contract's `react()` function runs. Its job is small: decide whether this is a drip-worthy moment, and if so, ask for a drip on the other chain.

function react(LogRecord calldata log) external vmOnly {
    if (/* the pool just went quiet */) {
        _requestDrip(pool, REASON_REGIME_QUIET);
    } else if (/* the CRON event ticked */) {
        _requestDrip(targetPool, REASON_CRON);   // sweep the idle pool
    }
}

A reactive contract can’t reach across to another chain by itself, though. What it can do is ask:

```solidity
// Ask Reactive Network to deliver a call to the executor on the other chain.
emit Callback(destinationChainId, executor, CALLBACK_GAS_LIMIT, payload);
```

Emitting that `Callback` event is the request. Reactive Network's Signer picks it up and delivers it as a real transaction on the destination chain (here, Base), where a small contract called the executor is waiting. The full hop looks like this:

Who is allowed to press the button?

This is the part worth slowing down for. The executor's one useful power is to tell the hook "release the reserve now". You would not want just anyone able to trigger that. So before it does anything, the executor checks two things, and both have to pass:

// 1. The message must arrive through the chain's official callback proxy.
if (!senders[msg.sender]) revert UntrustedProxy(msg.sender);

// 2. It must carry the identity of the controller we locked in earlier.
if (sender != controllerRvmId) revert UnauthorizedReactive(...);

The first check confirms the message came in through Reactive Network's official delivery channel, and not from some random address that fancied a go. The second is a kind of return address that can’t be forged: when the controller was set up, the identity of the account behind it was recorded once, and from then on the executor only accepts messages stamped with that same identity. Locked in once, verified on every single delivery.

It can ask, but it can’t force

Even after both checks pass, the executor does not touch any funds itself. It just forwards the request to the hook:

shield.triggerQuietDrip(_poolKeys[poolId]);

The hook then re-checks everything from scratch: is the pool actually quiet, has the cooldown elapsed, is there anything in the reserve to pay out? If any answer is no, nothing happens and no harm is done. A callback that turns up at the wrong moment is simply a harmless no-op. The clock can tick as often as it likes; the money only moves when the pool itself agrees it should.


About Reactive Network

Reactive Network is an EVM automation layer built around reactive contracts, event-driven smart contracts for cross-chain, on-chain automation. It runs on CometBFT consensus, providing instant finality and roughly 1-second block times while maintaining full EVM compatibility.

Reactive contracts subscribe to event logs across EVM chains and execute Solidity logic automatically when matching events occur, deciding autonomously when to send cross-chain callback transactions. This model supports conditional cross-chain state changes and continuous cross-chain workflows.

Website | Blog | X | Telegram | Discord | Docs

Build once — react everywhere!