Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions contagion-trap/ContagionTrap.sol
Original file line number Diff line number Diff line change
@@ -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(""));
}
}
34 changes: 34 additions & 0 deletions contagion-trap/README.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions contagion-trap/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
"contracts/=src/",
]
compiler_version = "0.8.19"
16 changes: 16 additions & 0 deletions contagion-trap/interfaces/ITrap.sol
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions contagion-trap/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
contracts/=src/
forge-std/=lib/forge-std/src/
54 changes: 54 additions & 0 deletions contagion-trap/src/ContagionTrap.sol
Original file line number Diff line number Diff line change
@@ -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(""));
}
}
44 changes: 44 additions & 0 deletions contagion-trap/test/ContagionTrap.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}