Skip to main content

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 {
        // Trigger on any storage change to catch ownership modifications
        registerStorageChangeTrigger(this.assertionOwnerChange.selector);
        registerStorageChangeTrigger(this.assertionEmergencyAdminChange.selector);
        registerStorageChangeTrigger(this.assertionPoolAdminChange.selector);
    }

    // Check if the owner has changed
    function assertionOwnerChange() external view {
        ph.forkPreTx();
        address prevOwner = lendingPoolAddressesProvider.owner();
        ph.forkPostTx();
        address newOwner = lendingPoolAddressesProvider.owner();
        require(prevOwner == newOwner, "Owner has changed");
    }

    // Check if the emergency admin has changed
    function assertionEmergencyAdminChange() external view {
        ph.forkPreTx();
        address prevEmergencyAdmin = lendingPoolAddressesProvider.getEmergencyAdmin();
        ph.forkPostTx();
        address newEmergencyAdmin = lendingPoolAddressesProvider.getEmergencyAdmin();
        require(prevEmergencyAdmin == newEmergencyAdmin, "Emergency admin has changed");
    }

    // Check if the pool admin has changed
    function assertionPoolAdminChange() external view {
        ph.forkPreTx();
        address prevPoolAdmin = lendingPoolAddressesProvider.getPoolAdmin();
        ph.forkPostTx();
        address newPoolAdmin = lendingPoolAddressesProvider.getPoolAdmin();
        require(prevPoolAdmin == newPoolAdmin, "Pool admin has changed");
    }
}