The Stylus contracts use dynamic registries — indexed mappings with soft-delete
removal — so new protocols and DEXes can be added on-chain without
redeployment. This guide covers both workflows.
Adding a Lending Protocol to LiquidationMonitor
The LiquidationMonitor contract maintains a registry of lending protocols
it can scan for at-risk accounts. Each protocol entry has an address, a
type constant, and an active flag.Supported Protocol Types
| Type Constant | Value | Protocol |
|---|
LENDING_TYPE_AAVE_V3 | 0 | Aave V3 |
LENDING_TYPE_COMPOUND_V3 | 1 | Compound V3 (reserved) |
Define the protocol interface
If the new protocol uses a different interface than Aave V3, add a
sol_interface! declaration in contracts/liquidation-monitor/src/lib.rs:sol_interface! {
interface ICompoundComet {
function borrowBalanceOf(address account)
external view returns (uint256);
function getAssetInfo(uint8 i)
external view returns (
uint8 offset,
address asset,
address priceFeed,
uint64 scale,
uint64 borrowCollateralFactor,
uint64 liquidateCollateralFactor,
uint64 liquidationFactor,
uint128 supplyCap
);
}
}
Add a type constant
Define a new constant for the protocol type:const LENDING_TYPE_COMPOUND_V3: u64 = 1;
The contract dispatches health factor queries based on this type
constant, so each protocol needs a unique value. Add dispatch logic in the health scanner
In the get_health_factor method, add a branch for the new type:if protocol_type == U256::from(LENDING_TYPE_COMPOUND_V3) {
let comet = ICompoundComet::new(pool_address);
let borrow_balance = comet.borrow_balance_of(
self.vm(), Call::new(), account
)?;
// Calculate health factor based on Compound V3 logic
// ...
}
Register the protocol on-chain
Call add_lending_protocol with the pool address and type constant:cast send LIQUIDATION_MONITOR_ADDRESS \
"add_lending_protocol(address,uint64)" \
COMPOUND_COMET_ADDRESS 1 \
--rpc-url https://arb1.arbitrum.io/rpc \
--private-key 0xOWNER_KEY
This emits a LendingProtocolAdded event and returns the registry
index. Write tests
Add tests using the TestVM and mock_static_call patterns:#[test]
fn test_compound_health_factor() {
let (vm, mut monitor) = setup();
vm.set_sender(OWNER);
// Register Compound V3
let idx = monitor
.add_lending_protocol(COMPOUND_ADDR, 1)
.unwrap();
// Mock the borrow_balance_of call
let calldata = /* build calldata */;
vm.mock_static_call(
COMPOUND_ADDR, calldata,
Ok(U256::from(1000).to_be_bytes_vec())
);
// Test health factor query
let hf = monitor.get_health_factor(ALICE).unwrap();
assert!(hf > U256::ZERO);
}
mock_static_call in stylus-test 0.10.0 always returns the LAST
registered mock’s data. Register losing mocks first, then the
winning mock last.
Adding a DEX to RouteOptimizer
The RouteOptimizer maintains a registry of DEX quoters it queries for
route comparison. Each DEX has an address, a type constant, and an
active flag. Maximum 20 DEXes per contract.Supported DEX Types
| Type Constant | Value | Protocol |
|---|
DEX_TYPE_UNIV3 | 0 | Uniswap V3 (concentrated liquidity) |
DEX_TYPE_AMM_V2 | 1 | AMM V2 (constant product: Camelot, SushiSwap) |
Define the DEX interface (if needed)
The contract already has interfaces for Uniswap V3 Quoter and AMM V2
routers. If your DEX uses a different quoting interface, add it:sol_interface! {
interface ICurvePool {
function get_dy(int128 i, int128 j, uint256 dx)
external view returns (uint256);
}
}
Add a type constant
const DEX_TYPE_CURVE: u64 = 2;
Add dispatch logic in find_best_route
In the find_best_route method, the contract iterates all active
DEXes and queries each for a quote. Add handling for the new type:if dex_type == U256::from(DEX_TYPE_CURVE) {
let pool = ICurvePool::new(dex_address);
let amount_out = pool.get_dy(
self.vm(), Call::new(),
/* i */ 0.into(),
/* j */ 1.into(),
amount_in
)?;
// Compare with current best
}
Register the DEX on-chain
cast send ROUTE_OPTIMIZER_ADDRESS \
"add_dex(address,uint64)" \
CURVE_POOL_ADDRESS 2 \
--rpc-url https://arb1.arbitrum.io/rpc \
--private-key 0xOWNER_KEY
Write tests
#[test]
fn test_curve_route() {
let (vm, mut optimizer) = setup();
vm.set_sender(OWNER);
let idx = optimizer
.add_dex(CURVE_ADDR, DEX_TYPE_CURVE)
.unwrap();
// Mock the get_dy call
let calldata = /* build calldata */;
vm.mock_static_call(
CURVE_ADDR, calldata,
Ok(U256::from(990_000).to_be_bytes_vec())
);
let (best_out, tokens, fees) = optimizer
.find_best_route(TOKEN_A, TOKEN_B, U256::from(1_000_000))
.unwrap();
assert!(best_out > U256::ZERO);
}
Soft-Delete Removal Pattern
Both contracts use soft-delete rather than array compaction. When you remove
a protocol or DEX, the active flag is set to false but the entry remains
in the mapping. The scanning functions skip inactive entries.
# Remove a lending protocol by index
cast send LIQUIDATION_MONITOR_ADDRESS \
"remove_lending_protocol(uint256)" 0 \
--rpc-url https://arb1.arbitrum.io/rpc \
--private-key 0xOWNER_KEY
# Remove a DEX by index
cast send ROUTE_OPTIMIZER_ADDRESS \
"remove_dex(uint256)" 0 \
--rpc-url https://arb1.arbitrum.io/rpc \
--private-key 0xOWNER_KEY
Removing a protocol emits a LendingProtocolRemoved or DexRemoved event.
Attempting to remove an already-inactive entry reverts.
Use get_lending_protocol(index) or get_dex(index) to check the current
state of a registry entry before removal.