Security Model for Depositors / Point Sellers

As a point seller, how do I know that my principal assets, the funds I deposit into my Rumpel wallet, are safe? This page provides the answer.

Security components

Rumpel Wallet is built on top of Safe. Each user wallet is simply a standard Safe with a special Rumpel Module and Rumpel Guard added on. Therefore, our approach to security is largely inherited from Safe, and to understand the scope of an admin’s power, you need only look at these two main added components.

Rumpel Guard – restricts Rumpel user actions to specific calls added onto the allowlist

Rumpel Module – enables the admin to make calls on a user's behalf, unless the calls are on the blocklist

Why is an admin involved at all?

Rumpel Point Tokens are claims on future reward tokens, so when the reward tokens are released, the admin must be able to sweep them from user wallets into the Point Token Vault for pToken redemption. If a user were to withdraw the rewards first, pTokens would go unbacked.

However, there are many assets that we can be confident will never be reward tokens, and therefore, we can permanently enable many actions for users, and disable them for admins. Our contracts are designed with this in mind.

How our security works

The module blocklist and the guard allowlist block and allow calls. Calls are specific function selectors at specific addresses such as USDC.transfer or RSUSDE.deposit. While users are the only owners of their 1-of-1 Safe, the Guard allowlist blocks all calls by default, preventing users from making any malicious contract calls. Therefore, only “allowed” calls can be executed by the user.

When a call is added to the module blocklist, an admin can no longer make it on behalf of the user’s Safe. This list is one-way/immutable, meaning once a call is added, it cannot be removed.

🔒 During deployment, enableModule and disableModule are added to the blocklist (tx1, tx2), so admins are prevented from changing the module itself to get around the blocklist.

When a call is added to the guard allowlist, users are able to make that call via their Safe. An admin can add and remove these allowed calls, except if they’re set to PERMANENTLY_ON, in which case an admin can no longer remove them.

To put it simply, in the case of assets:

  1. Blocklist: If an asset is on the blocklist, the admin cannot withdraw it from users

  2. Allowlist: If an asset is on the allowlist, users can withdraw the asset

  3. Permanent Allowlist: When active for an asset, an admin cannot remove it from the allowlist

Example transaction

As defined above, there are three possible actions an admin can take:

  • add a call to the guard allowlist as ON

  • and add a call to the allowlist as PERMANENTLY_ON

  • add a call to the module blocklist

Here is a recent batch transaction where each of the three state transitions takes place. Let’s examine it by going through the logs.

  1. Add a call to the guard allowlist as ON This first log shows a call added to the guard allowlist with a list state of 1, corresponding to ON. The target is WBTC and the function selector is transfer(address,uint256), so this action adds WBTC.transfer to the allowlist – but an admin has the ability to remove it in the future.

  1. Add a call to the guard allowlist as PERMANENTLY_ON This next one shows a call added to the guard allowlist with a list state of 2, corresponding to PERMANENTLY_ON. The target is RE7LRT and the function selector is transfer(address,uint256), so this action adds RE7LRT.transfer to the allowlist – and an admin has no way of removing it.

  1. Add a call to the module blocklist This last one shows a call added to the module blocklist. The target is RE7LRT and the function selector is approve(address,uint256), so the action adds RE7LRT.approve to the blocklist. Per the list’s design, an admin cannot remove it, so an admin will never be able to make that call on behalf of a user.

As a result of these 3 changes:

  • Users can transfer WBTC from their Safe

  • Users can transfer RE7LRT from their Safe, and an admin can never disallow it

  • An admin can never give another address approvals on the RE7LRT in a user’s Safe

Audits

The Rumpel Wallet contracts have been audited several times to verify that they behave as outlined above.

We also maintain an ongoing bug bounty program through Sherlock.

Verification

The blocklist & allowlist can easily be verified using events. Here is a Python script that does so:

import os
from dotenv import load_dotenv
from web3 import Web3

# Load environment variables from .env file
load_dotenv()

# Connect to an Ethereum node
w3 = Web3(
    Web3.HTTPProvider(
        f"<https://eth-mainnet.g.alchemy.com/v2/>{os.environ.get('ALCHEMY_API_KEY', '')}"
    )
)

# Contract addresses
RUMPEL_GUARD_ADDRESS = "0x9000FeF2846A5253fD2C6ed5241De0fddb404302"
RUMPEL_MODULE_ADDRESS = "0x28c3498B4956f4aD8d4549ACA8F66260975D361a"
POINT_TOKENIZATION_VAULT_ADDRESS = "0xe47F9Dbbfe98d6930562017ee212C1A1Ae45ba61"

# ABIs
RUMPEL_GUARD_ABI = """[
    {"anonymous":false,"inputs":[{"indexed":true,"name":"target","type":"address"},{"indexed":true,"name":"functionSelector","type":"bytes4"},{"indexed":false,"name":"allowListState","type":"uint8"}],"name":"SetCallAllowed","type":"event"}
]"""

RUMPEL_MODULE_ABI = """[
    {"anonymous":false,"inputs":[{"indexed":true,"name":"target","type":"address"},{"indexed":true,"name":"functionSelector","type":"bytes4"}],"name":"SetModuleCallBlocked","type":"event"}
]"""

# Create contract instances
rumpel_guard = w3.eth.contract(address=RUMPEL_GUARD_ADDRESS, abi=RUMPEL_GUARD_ABI)
rumpel_module = w3.eth.contract(address=RUMPEL_MODULE_ADDRESS, abi=RUMPEL_MODULE_ABI)

# Token and Protocol addresses
TOKEN_ADDRESSES = {
    "0x82f5104b23FF2FA54C2345F821dAc9369e9E0B26": "RSUSDE",
    "0x7a4EffD87C2f3C55CA251080b1343b605f327E3a": "RSTETH",
    "0xe1B4d34E8754600962Cd944B535180Bd758E6c2e": "AGETH",
    "0x9D39A5DE30e57443BfF2A8307A4256c8797A3497": "SUSDE",
    "0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "USDE",
    "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0": "WSTETH",
    "0xBE3cA34D0E877A1Fc889BD5231D65477779AFf4e": "KUSDE",
    "0x2DABcea55a12d73191AeCe59F508b191Fb68AdaC": "KWEETH",
    "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee": "WEETH",
    "0x49446A0874197839D15395B908328a74ccc96Bc0": "MSTETH",
    "0x84631c0d0081FDe56DeB72F6DE77abBbF6A9f93a": "RE7LRT",
    "0x7F43fDe12A40dE708d908Fb3b9BFB8540d9Ce444": "RE7RWBTC",
    "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "WBTC",
    "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "stETH",
    "0x917ceE801a67f933F2e6b33fC0cD1ED2d5909D88": "weETHs",
    "0x0000000000000000000000000000000000000000": "ETH",
}

PROTOCOL_ADDRESSES = {
    "0x4095F064B8d3c3548A3bebfd0Bbfd04750E30077": "Morpho Bundler",
    "0xF047ab4c75cebf0eB9ed34Ae2c186f3611aEAfa6": "Zircuit Restaking Pool",
    "0xC329400492c6ff2438472D4651Ad17389fCb843a": "Symbiotic wstETH Collateral",
    "0x19d0D8e6294B7a04a2733FE433444704B791939A": "Symbiotic sUSDe Collateral",
    "0x54e44DbB92dBA848ACe27F44c0CB4268981eF1CC": "Karak Vault Supervisor",
    "0xAfa904152E04aBFf56701223118Be2832A4449E0": "Karak Delegation Supervisor",
    "0x8707f238936c12c309bfc2B9959C35828AcFc512": "Ethena LP Staking",
}

# Known function selectors
KNOWN_SELECTORS = {
    "23b872dd": "transferFrom",
    "a9059cbb": "transfer",
    "095ea7b3": "approve",
    "ac9650d8": "multicall",
    "f7654176": "depositFor",
    "2e1a7d4d": "withdraw",
    "b6b55f25": "deposit",
    "a0712d68": "mint",
    "db006a75": "redeem",
    "2e17de78": "unstake",
    "70ed1731": "cooldownAssets",
    "b88ba9c3": "cooldownShares",
    "a694fc3a": "stake",
    "5d36b190": "gimmieShares",
    "3f2e5fc3": "returnShares",
    "5bcee7d8": "depositAndGimmie",
    "92dca407": "startWithdraw",
    "86e9a1f7": "finishWithdraw",
    "441a3e70": "registerWithdrawal",
    "610b5925": "enableModule",
    "e009cfde": "disableModule",
}

def get_pretty_name(address):
    if address == "0x0000000000000000000000000000000000000000":
        return "User Safe"
    return (
        "Point Tokenization Vault"
        if address == POINT_TOKENIZATION_VAULT_ADDRESS
        else (
            TOKEN_ADDRESSES.get(address, f"{address} (Token)")
            if address in TOKEN_ADDRESSES
            else (
                PROTOCOL_ADDRESSES.get(address, f"{address} (Protocol)")
                if address in PROTOCOL_ADDRESSES
                else address
            )
        )
    )

def get_function_name(selector):
    return KNOWN_SELECTORS.get(selector, f"Unknown ({selector})")

def print_allow_list():
    print("RumpelGuard Allow List:")
    print("=======================")

    logs = rumpel_guard.events.SetCallAllowed().get_logs(from_block=0)
    allow_list = {}

    for log in logs:
        target = log.args.target
        selector = log.args.functionSelector.hex()
        state = log.args.allowListState

        if target not in allow_list:
            allow_list[target] = {}

        allow_list[target][selector] = state

    for target, selectors in allow_list.items():
        if len(selectors) == 1 and list(selectors.values())[0] == 0:
            continue

        print(f"\\n{get_pretty_name(target)}:")
        for selector, state in selectors.items():
            state_str = ["OFF", "ON", "PERMANENTLY_ON"][state]
            if state_str != "OFF":
                print(f"  {get_function_name(selector)}: {state_str}")

def print_block_list():
    print("\\nRumpelModule Block List:")
    print("========================")

    logs = rumpel_module.events.SetModuleCallBlocked().get_logs(from_block=0)
    block_list = {}

    for log in logs:
        target = log.args.target
        selector = log.args.functionSelector.hex()

        if target not in block_list:
            block_list[target] = set()

        block_list[target].add(selector)

    for target, selectors in block_list.items():
        print(f"\\n{get_pretty_name(target)}:")
        for selector in selectors:
            print(f"  {get_function_name(selector)}")

if __name__ == "__main__":
    with open("lists.txt", "w") as f:
        # Redirect stdout to the file
        import sys

        original_stdout = sys.stdout
        sys.stdout = f

        print_allow_list()
        print("\\n")  # Add more space between lists
        print_block_list()

        # Restore stdout
        sys.stdout = original_stdout

    print("Output has been saved to lists.txt")

Last updated