TrancheShield: Managing A Pool's Risk From Another Chain

TrancheShield: Managing A Pool's Risk From Another Chain

TrancheShield was the third of the six projects in our look at the UHI9 hookathon, and it takes yet another angle on the same limit: a Uniswap hook only runs when someone trades, but a pool's risk does not sit still between trades. Here is how it deals with that. The full project is open here if you want to see how the pieces fit together.

TrancheShield is a Uniswap v4 hook that protects the senior side of a pool against impermanent loss, the gap that opens up for liquidity providers when the price moves and they end up worse off than if they had simply held the two tokens. That protection is partial, capped at a coverage level, and paid for out of a reserve. To keep the whole thing solvent, the pool has a few settings it can turn: the swap fee, how much of the loss it currently promises to cover, and whether new money is allowed into the senior side at all.

The catch is that the right values for those settings depend on what is happening in and around the pool, and most of that has nothing to do with any single trade. Volatility climbs. The reserve gets thin. Holders start trying to pull their money out. A hook can’t keep up with any of this on its own, because it only wakes up inside a swap. It has no way to keep a running measure of volatility across many swaps, no way to notice the reserve draining or a wave of withdrawals building, and no way to calm back down during a quiet stretch when, by definition, nothing is calling it.

TrancheShield hands all of that to a reactive contract on Reactive Lasna testnet. It watches the pool from the outside, keeps its own running state, and adjusts those settings as conditions change. It is, in effect, a risk manager for the pool that never needs anyone to trade in order to do its job.

What it watches

The reactive contract subscribes to three kinds of event coming off Unichain Sepolia, plus one optional signal from Reactive Network itself:

// On Unichain Sepolia: every swap, every reserve-ratio update, every Senior withdrawal.
service.subscribe(UNICHAIN_SEPOLIA_CHAIN_ID, hook,    swapTopic0,     ...);
service.subscribe(UNICHAIN_SEPOLIA_CHAIN_ID, reserve, reserveTopic0,  ...);
service.subscribe(UNICHAIN_SEPOLIA_CHAIN_ID, hook,    withdrawTopic0, ...);

// Optional: a periodic tick on Reactive Network, used to let things settle down.
if (cronTopic0 != 0)
    service.subscribe(REACTIVE_CHAIN_ID, SERVICE_ADDR, cronTopic0, ...);

Each of the three tells it something different. Swaps say how sharply the price is moving and in which direction. Reserve-ratio updates say how healthy the backing is. Withdrawal events say how many senior holders are trying to leave. When any of them arrives, a single entry point sorts it by type and updates the pool it belongs to:

function react(LogRecord calldata log) external vmOnly {
    bytes32 poolId = bytes32(log.topic_1);
    if      (log.topic_0 == swapTopic0)     _onSwap(poolId, log);
    else if (log.topic_0 == reserveTopic0)  _onReserveRatio(poolId, log);
    else if (log.topic_0 == withdrawTopic0) _onSeniorWithdrawal(poolId, log);
    else if (log.topic_0 == cronTopic0)     _onCron(poolId);
}

Everything is tracked per pool. Each pool gets its own volatility history, its own risk level, its own counters. A volatility spike in one pool can’t pull another pool along with it, and a brand-new pool always starts calm.

Keeping a running read on volatility

Here is something a hook genuinely can’t do well: remember. The reactive contract holds state, so with every swap it folds the new price move into a running estimate of the pool's volatility. It does this without storing the full history. It keeps only a few running totals (a count, a sum, and a sum of squares) and derives a standard-deviation-style score from them, using a well-known online method for exactly this, Welford's algorithm. The cost of updating stays the same whether the pool has seen ten swaps or ten million.

That volatility score maps onto four risk levels, and each level sets the swap fee:

if      (score >= volCrisisThreshold) { mode = CRISIS; mult = 25_000; } // 2.50x fee
else if (score >= volHighThreshold)   { mode = HIGH;   mult = 17_500; } // 1.75x
else if (score >= volMediumThreshold) { mode = MEDIUM; mult = 12_500; } // 1.25x
else                                  { mode = LOW;    mult = 10_000; } // 1.00x

The more sharply the price is moving, the more it costs to trade against the pool's liquidity, so the fee goes up to compensate the people providing it. If several swaps in a row push the price the same way, that is a sign of one-directional flow that is especially costly to serve, so the contract adds a surcharge on top, capped at a hard ceiling of 3.00x:

// 3+ swaps pushing the same direction in a row: add a surcharge.
if (consecutiveDirectional[poolId] >= 3) mult += 2_500; // +0.25x, never above 3.00x

Reserve health, and a wave of withdrawals

The other two signals are about solvency rather than price. When a reserve-ratio update comes in, the contract compares the reserve against what it owes the senior side and sets coverage accordingly: a healthy reserve keeps coverage near its cap, a thin reserve steps it down, and a critically low reserve drops it to a floor, pushes the pool into its highest risk level, and switches off new senior deposits.

The withdrawal signal is the one with a story in it. The contract keeps a small ring buffer of the timestamps of recent senior-withdrawal requests. Each new request goes in, and it counts how many landed in the last hour. If that count crosses a threshold, it reads the situation as a run on the pool and acts at once:

function _onSeniorWithdrawal(bytes32 poolId, LogRecord calldata log) internal {
    // record this request's time, then count how many happened in the last hour
    uint256 inWindow = _countWithdrawalsInWindow(poolId, timestamp);
    if (inWindow >= bankRunThreshold) {                    // 5 within an hour
        _callback(setRiskMode(poolId, CRISIS));
        _callback(updateCoverageRatio(poolId, 1_500));     // pull coverage down to 15%
        _callback(setSeniorDepositStatus(poolId, false));  // stop new deposits
    }
}

Notice what it does and does not do. It does not try to stop people leaving. It tightens the pool's stance so the holders who remain are not left relying on a promise the reserve can no longer keep, and it stops new deposits from entering a pool that is visibly under stress.

Letting a pool re-evaluate when it is quiet

The optional periodic tick fills a real gap. Risk levels rise during active trading, but if trading simply stops, a hook never runs again, and the pool could stay pinned in a high-fee, low-coverage stance long after the pressure has eased. The tick gives the reactive contract a scheduled chance to recompute a quiet pool's risk and, if the numbers no longer support a cautious stance, step it back down, without waiting for a trade that may never come.

Crossing the chain, within limits

When any of this changes a setting, the reactive contract does what the others in this series do: it emits a callback, and Reactive Network delivers it as a real transaction on Unichain Sepolia, to a receiver contract that applies the update to the pool.

The receiver is deliberately strict. Every value it accepts is clamped to a safe range before it touches the pool. The fee multiplier is held between 1.00x and 3.00x, coverage can never exceed 50%, and the risk level has to be one of the four valid values:

function updateFeeMultiplier(address rvmId, bytes32 poolId, uint256 newBps)
    external rvmIdOnly(rvmId)
{
    if (newBps < 10_000 || newBps > 30_000) revert FeeMultiplierOutOfBounds(newBps);
    hook.updateFeeMultiplier(PoolId.wrap(poolId), newBps);
}

This matters. The reactive contract is doing real work and pushing real changes, but it is not trusted with unbounded power over the pool. The worst it could do, even if something went badly wrong, is move a handful of settings within ranges fixed in advance, on one pool at a time. Coverage tops out at half by design, so the protection is always partial and the pool never promises more than it can plausibly back. The receiver also checks that each callback carries the identity of the exact reactive contract it expects, so a stray call from anywhere else goes nowhere.

There is a small engineering story worth sharing, because it is the kind of thing you only learn by running the system. The callback travels through two hops before it reaches the pool, the delivery proxy and then the receiver, and Ethereum passes on only part of the remaining gas at each hop. A gas budget that looked generous kept running out partway through, and the fix was to send far more than seemed necessary. It is a reminder that a cross-chain call is not one call but a short chain of them, each consuming part of what is left.


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!