Overview
In October 2024, Radiant Capital was hacked. You can read more about the hack on Rekt.
In short, the attacker managed to gain control over 3 signers of the Radiant Capital multisig, which allowed the attacker to change ownership of the lending pools and ultimately drain the pools.
Had there been an assertion in place that checked that the ownership of the lending pools didn’t change, the hack would have been prevented.
Use Case
This use case is a good example of how to use assertions to detect ownership changes. A lot of DeFi protocols have the concept of owners and admins that can change the protocol’s behavior.
Usually these are controlled by a multisig, which is best practice, but it is not always enough. Especially if the multisig setup is not done in an optimal way.
The assertion shown below is easy to generalize and use in any protocol that wants to make sure that the ownership of critical contracts don’t change.
It would also be possible to define a whitelist of contracts that the ownership can be changed to. By default there is a cooldown period before an assertion can be paused or removed, so protocols need to plan ahead if they don’t have a whitelist defined.
Assertion
This assertions checks if the owner, emergency admin and pool admin of the lending pool have changed.
It’s a good example of how a simple assertion can be used to prevent disastrous hacks.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Assertion} from "../lib/credible-std/Assertion.sol";
interface ILendingPoolAddressesProvider {
function owner() external view returns (address);
function getEmergencyAdmin() external view returns (address);
function getPoolAdmin() external view returns (address);
}
// Radiant Lending Pool on Arbitrum that got hacked and drained
contract LendingPoolAddressesProviderAssertions is Assertion {
ILendingPoolAddressesProvider public lendingPoolAddressesProvider =
ILendingPoolAddressesProvider(0x091d52CacE1edc5527C99cDCFA6937C1635330E4); //arbitrum
function triggers() external view override {
registerCallTrigger(this.assertionOwnerChange.selector, lendingPoolAddressesProvider.owner.selector);
registerCallTrigger(this.assertionEmergencyAdminChange.selector, lendingPoolAddressesProvider.getEmergencyAdmin.selector);
registerCallTrigger(this.assertionPoolAdminChange.selector, lendingPoolAddressesProvider.getPoolAdmin.selector);
}
// Check if the owner has changed
// return true indicates a valid state -> owner is the same
// return false indicates an invalid state -> owner is different
function assertionOwnerChange() external returns (bool) {
ph.forkPreState();
address prevOwner = lendingPoolAddressesProvider.owner();
ph.forkPostState();
address newOwner = lendingPoolAddressesProvider.owner();
return prevOwner == newOwner;
}
// Check if the emergency admin has changed
// return true indicates a valid state -> emergency admin is the same
// return false indicates an invalid state -> emergency admin is different
function assertionEmergencyAdminChange() external returns (bool) {
ph.forkPreState();
address prevEmergencyAdmin = lendingPoolAddressesProvider.getEmergencyAdmin();
ph.forkPostState();
address newEmergencyAdmin = lendingPoolAddressesProvider.getEmergencyAdmin();
return prevEmergencyAdmin == newEmergencyAdmin;
}
// Check if the pool admin has changed
// return true indicates a valid state -> pool admin is the same
// return false indicates an invalid state -> pool admin is different
function assertionPoolAdminChange() external returns (bool) {
ph.forkPreState();
address prevPoolAdmin = lendingPoolAddressesProvider.getPoolAdmin();
ph.forkPostState();
address newPoolAdmin = lendingPoolAddressesProvider.getPoolAdmin();
return prevPoolAdmin == newPoolAdmin;
}
}