diff --git a/contagion-trap/ContagionTrap.sol b/contagion-trap/ContagionTrap.sol new file mode 100644 index 0000000..3fdc222 --- /dev/null +++ b/contagion-trap/ContagionTrap.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "./interfaces/ITrap.sol"; + +interface IUniswapV2Pair { + function getReserves() external view returns ( + uint112 reserve0, + uint112 reserve1, + uint32 blockTimestampLast + ); +} + +/** + * ContagionTrap + * - data[0] is newest snapshot (Drosera convention) + * - shouldRespond returns (true, "") when we want to call pause() + * + * - compute naive liquidity = reserve0 + reserve1 + * - compute average of previous snapshots (exclude newest index 0) + * - require avgPrev > MIN_LIQUIDITY_FLOOR to avoid tiny pools + * - trigger when newest < THRESHOLD * avgPrev (threshold = 0.5) + */ +contract ContagionTrap is ITrap { + address public immutable NEIGHBOR_DEX_POOL; + address public immutable LENDING_PROTOCOL; + + struct PoolSnapshot { + uint256 liquidity; + uint256 blockNumber; + } + + uint256 public constant THRESH_NUM = 1; // (THRESH_NUM / THRESH_DEN) = 0.5 + uint256 public constant THRESH_DEN = 2; + uint256 public constant MIN_LIQUIDITY_FLOOR = 1_000_000; // adjust if your pool units differ + + constructor(address _neighborDex, address _lendingProtocol) { + NEIGHBOR_DEX_POOL = _neighborDex; + LENDING_PROTOCOL = _lendingProtocol; + } + + /// collect: called by operators to snapshot neighbor pool + function collect() external view override returns (bytes memory) { + (uint112 r0, uint112 r1, ) = IUniswapV2Pair(NEIGHBOR_DEX_POOL).getReserves(); + uint256 liq = uint256(r0) + uint256(r1); + return abi.encode(PoolSnapshot({ liquidity: liq, blockNumber: block.number })); + } + + /// shouldRespond: Drosera passes an array of snapshots (data[0] newest) + function shouldRespond(bytes[] calldata data) + external + pure + override + returns (bool shouldTrigger, bytes memory responseData) + { + // Need at least newest + one previous snapshot + if (data.length < 2) return (false, bytes("insufficient_history")); + + PoolSnapshot memory newest = abi.decode(data[0], (PoolSnapshot)); + + // compute average of previous snapshots (indices 1..data.length-1) + uint256 sum = 0; + uint256 count = 0; + for (uint256 i = 1; i < data.length; ++i) { + PoolSnapshot memory s = abi.decode(data[i], (PoolSnapshot)); + sum += s.liquidity; + unchecked { ++count; } + } + if (count == 0) return (false, bytes("no_baseline")); + + uint256 avgPrev = sum / count; + + // Avoid triggering on tiny pools or tiny baselines + if (avgPrev < MIN_LIQUIDITY_FLOOR) return (false, bytes("baseline_too_small")); + + // Trigger if newest < THRESH * avgPrev (THRESH = 0.5 here) + // Cross multiply to avoid precision issues + if (newest.liquidity * THRESH_DEN < avgPrev * THRESH_NUM) { + // pause() has no args — return empty bytes + return (true, bytes("")); + } + + return (false, bytes("")); + } +} diff --git a/contagion-trap/README.md b/contagion-trap/README.md new file mode 100644 index 0000000..ff243c0 --- /dev/null +++ b/contagion-trap/README.md @@ -0,0 +1,34 @@ +# ContagionTrap + +**Trap Name:** ContagionTrap +**Author:** Bindoskii (MUJI) +**License:** MIT + +## Description +ContagionTrap monitors liquidity levels of a neighboring DEX pool to detect sharp liquidity drains that may indicate contagion or instability. +It acts as an early warning mechanism that triggers a response when the newest liquidity snapshot drops below 50% of the recent average. + +## Trigger Logic +Collects reserve data (`reserve0 + reserve1`) from a UniswapV2 compatible pool. +Maintains a history of liquidity snapshots. +Compares the latest liquidity value to the average of previous snapshots. +- **Triggers** if: + `newest_liquidity * 2 < average_previous_liquidity` + +## Action on Trigger +When triggered, the trap emits a response intended for Drosera responders typically to **pause** the affected protocol or activate a containment mechanism. + +## Deployment Details +- **Network:** Hoodi Testnet (or simulated Foundry localnet) +- **Parameters:** + Replace `NEIGHBOR_DEX_POOL` with the target DEX pool address you want to monitor. +- **Setup:** + 1. Deploy the contract using Foundry or Drosera’s CLI. + 2. Register the trap on Drosera. + 3. Define response logic for containment. + +## Testing +You can simulate falling liquidity by adjusting mock pool reserves in your Foundry tests. + +## License +MIT diff --git a/contagion-trap/foundry.toml b/contagion-trap/foundry.toml new file mode 100644 index 0000000..c84a9ef --- /dev/null +++ b/contagion-trap/foundry.toml @@ -0,0 +1,8 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +remappings = [ + "contracts/=src/", +] +compiler_version = "0.8.19" diff --git a/contagion-trap/interfaces/ITrap.sol b/contagion-trap/interfaces/ITrap.sol new file mode 100644 index 0000000..cf82437 --- /dev/null +++ b/contagion-trap/interfaces/ITrap.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface ITrap { + /// @notice Collects state data to analyze later. + function collect() external view returns (bytes memory); + + /// @notice Decides whether a trap should respond, based on collected data. + /// @param data Collected snapshots (data[0] is newest). + /// @return shouldTrigger Whether the trap should trigger. + /// @return responseData Optional data for responder actions. + function shouldRespond(bytes[] calldata data) + external + pure + returns (bool shouldTrigger, bytes memory responseData); +} diff --git a/contagion-trap/remappings.txt b/contagion-trap/remappings.txt new file mode 100644 index 0000000..8555209 --- /dev/null +++ b/contagion-trap/remappings.txt @@ -0,0 +1,2 @@ +contracts/=src/ +forge-std/=lib/forge-std/src/ diff --git a/contagion-trap/src/ContagionTrap.sol b/contagion-trap/src/ContagionTrap.sol new file mode 100644 index 0000000..a767e28 --- /dev/null +++ b/contagion-trap/src/ContagionTrap.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ITrap} from "../interfaces/ITrap.sol"; + +interface IUniswapV2Pair { + function getReserves() external view returns ( + uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast + ); +} + +/** + * @title ContagionTrap + * @notice Monitors a neighbor DEX pool; triggers if newest liquidity < 50% of + * the average of previous snapshots in the window. + */ +contract ContagionTrap is ITrap { + address public constant NEIGHBOR_DEX_POOL = 0x0000000000000000000000000000000000000000; + + struct PoolSnapshot { + uint256 liquidity; + uint256 blockNumber; + } + + function collect() external view override returns (bytes memory) { + (uint112 r0, uint112 r1, ) = IUniswapV2Pair(NEIGHBOR_DEX_POOL).getReserves(); + uint256 liq = uint256(r0) + uint256(r1); + return abi.encode(PoolSnapshot({ liquidity: liq, blockNumber: block.number })); + } + + function shouldRespond(bytes[] calldata data) + external + pure + override + returns (bool shouldTrigger, bytes memory responseData) + { + if (data.length < 2) return (false, bytes("insufficient_history")); + + PoolSnapshot memory newest = abi.decode(data[0], (PoolSnapshot)); + + uint256 sum; + for (uint256 i = 1; i < data.length; i++) { + PoolSnapshot memory s = abi.decode(data[i], (PoolSnapshot)); + sum += s.liquidity; + } + + uint256 avgPrev = sum / (data.length - 1); + if (newest.liquidity * 2 < avgPrev) { + return (true, bytes("")); + } + + return (false, bytes("")); + } +} diff --git a/contagion-trap/test/ContagionTrap.t.sol b/contagion-trap/test/ContagionTrap.t.sol new file mode 100644 index 0000000..7d57a0d --- /dev/null +++ b/contagion-trap/test/ContagionTrap.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../src/ContagionTrap.sol"; + +contract ContagionTrapTest is Test { + ContagionTrap trap; + + function setUp() public { + trap = new ContagionTrap(); + } + + function testTriggerWhenLiquidityDrops() public { + // Simulate older high liquidity snapshots + bytes ; + data[0] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 100, blockNumber: 100 })); + data[1] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 400, blockNumber: 90 })); + data[2] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 500, blockNumber: 80 })); + + // Since newest (100) < 50% of avg (450), should trigger + (bool shouldTrigger, ) = trap.shouldRespond(data); + assertTrue(shouldTrigger, "Expected trigger when liquidity drops by >50%"); + } + + function testNoTriggerWhenStableLiquidity() public { + bytes ; + data[0] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 450, blockNumber: 100 })); + data[1] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 500, blockNumber: 90 })); + data[2] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 480, blockNumber: 80 })); + + (bool shouldTrigger, ) = trap.shouldRespond(data); + assertFalse(shouldTrigger, "Should not trigger when liquidity stable"); + } + + function testInsufficientHistory() public { + bytes ; + data[0] = abi.encode(ContagionTrap.PoolSnapshot({ liquidity: 500, blockNumber: 100 })); + + (bool shouldTrigger, bytes memory info) = trap.shouldRespond(data); + assertFalse(shouldTrigger, "Should not trigger with insufficient history"); + assertEq(string(info), "insufficient_history"); + } +}