|
| 1 | +# Recover Funds When Agent EOA Is Lost |
| 2 | + |
| 3 | +This document describes the design and usage of `scripts/recover_funds_lost_agent_eoa.py`, a script that recovers funds from an Agent Safe (service multisig) when the Agent EOA private key has been lost. |
| 4 | + |
| 5 | +## Background |
| 6 | + |
| 7 | +In the Olas protocol, a service is deployed as a Gnosis Safe multisig (the "Agent Safe") owned by agent instance EOAs. When the Agent EOA key is lost, the service owner (Master Safe) can regain control of the Agent Safe through the RecoveryModule, and then sweep all remaining funds back to the Master Safe. |
| 8 | + |
| 9 | +### Key Actors |
| 10 | + |
| 11 | +| Actor | Description | |
| 12 | +|-------|-------------| |
| 13 | +| **Master EOA** | The externally owned account that signs transactions. Owns the Master Safe. | |
| 14 | +| **Master Safe** | A Gnosis Safe v1.3.0 with threshold=1. The service owner (holds the service NFT). Has at least 2 owners: Master EOA + backup address. | |
| 15 | +| **Agent EOA** | The agent instance EOA whose private key is **lost**. Was an owner of the Agent Safe. | |
| 16 | +| **Agent Safe** | The service multisig (Gnosis Safe v1.3.0) created when the service was deployed. Holds the funds to recover. | |
| 17 | +| **Staking Proxy** | (Optional) A staking contract that may hold the service NFT if the service is currently staked. | |
| 18 | + |
| 19 | +### Recovery Flow |
| 20 | + |
| 21 | +The script handles the full lifecycle automatically: |
| 22 | + |
| 23 | +``` |
| 24 | +1. [If staked] Master EOA -> Master Safe -> stakingProxy.unstake(serviceId) |
| 25 | +2. [If Deployed] Master EOA -> Master Safe -> serviceManager.terminate(serviceId) |
| 26 | +3. [If TerminatedBonded] Master EOA -> Master Safe -> serviceManager.unbond(serviceId) |
| 27 | +4. [If Agent Safe not owned by Master Safe] Master EOA -> Master Safe -> recoveryModule.recoverAccess(serviceId) |
| 28 | +5. Master EOA -> Master Safe -> Agent Safe.execTransaction(multiSend: transfer all tokens to Master Safe) |
| 29 | +``` |
| 30 | + |
| 31 | +Each step is skipped if the service is already past that state. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## Prerequisites |
| 36 | + |
| 37 | +### Python Dependencies |
| 38 | + |
| 39 | +```bash |
| 40 | +pip install web3 eth_account |
| 41 | +``` |
| 42 | + |
| 43 | +### Information Needed |
| 44 | + |
| 45 | +- **Service ID**: The on-chain service ID to recover |
| 46 | +- **Chain ID**: The chain where the service is deployed |
| 47 | +- **RPC URL**: An RPC endpoint for the target chain |
| 48 | +- **Master EOA key**: One of: private key file, `PRIVATE_KEY` env var, or `--impersonate` for fork testing |
| 49 | + |
| 50 | +The script auto-detects the Master Safe address from the service registry. If the service is staked, it detects the staking contract and resolves the actual owner from `stakingProxy.getServiceInfo(serviceId).owner`. |
| 51 | + |
| 52 | +### Supported Chains |
| 53 | + |
| 54 | +The script loads contract addresses from `docs/configuration.json`. Token lists for recovery are fetched at runtime from the [olas-operate-app token config](https://github.com/valory-xyz/olas-operate-app/blob/main/frontend/config/tokens.ts). |
| 55 | + |
| 56 | +| Chain | Chain ID | |
| 57 | +|-------|----------| |
| 58 | +| Ethereum | 1 | |
| 59 | +| Polygon | 137 | |
| 60 | +| Gnosis | 100 | |
| 61 | +| Base | 8453 | |
| 62 | +| Optimism | 10 | |
| 63 | +| Arbitrum | 42161 | |
| 64 | +| Celo | 42220 | |
| 65 | +| Mode | 34443 | |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## Usage |
| 70 | + |
| 71 | +### Production Mode (with private key) |
| 72 | + |
| 73 | +```bash |
| 74 | +# Using a private key file |
| 75 | +python scripts/recover_funds_lost_agent_eoa.py \ |
| 76 | + --service-id 42 \ |
| 77 | + --chain-id 100 \ |
| 78 | + --rpc-url https://rpc.gnosischain.com \ |
| 79 | + --private-key-file /path/to/master_eoa.key |
| 80 | + |
| 81 | +# Using PRIVATE_KEY environment variable |
| 82 | +export PRIVATE_KEY=0xabcdef... |
| 83 | +export RPC_URL=https://rpc.gnosischain.com |
| 84 | +python scripts/recover_funds_lost_agent_eoa.py \ |
| 85 | + --service-id 42 \ |
| 86 | + --chain-id 100 |
| 87 | +``` |
| 88 | + |
| 89 | +The private key file should contain the raw hex private key (with or without `0x` prefix). |
| 90 | + |
| 91 | +### Fork Testing Mode (Tenderly / Anvil) |
| 92 | + |
| 93 | +For testing without a private key, use `--impersonate` on a fork RPC. This sends unsigned transactions that the fork accepts from any address. |
| 94 | + |
| 95 | +```bash |
| 96 | +# Create a Tenderly fork or Anvil fork of the target chain, then: |
| 97 | +python scripts/recover_funds_lost_agent_eoa.py \ |
| 98 | + --service-id 109 \ |
| 99 | + --chain-id 137 \ |
| 100 | + --rpc-url https://virtual.polygon.eu.rpc.tenderly.co/<fork-id> \ |
| 101 | + --impersonate 0xMasterEOAAddress |
| 102 | +``` |
| 103 | + |
| 104 | +### CLI Arguments |
| 105 | + |
| 106 | +| Argument | Required | Description | |
| 107 | +|----------|----------|-------------| |
| 108 | +| `--service-id` | Yes | Service ID to recover | |
| 109 | +| `--chain-id` | Yes | Chain ID where the service lives | |
| 110 | +| `--rpc-url` | No | RPC endpoint (or set `RPC_URL` env var) | |
| 111 | +| `--private-key-file` | No | Path to file containing the Master EOA private key | |
| 112 | +| `--ledger` | No | Use Ledger hardware wallet (not yet implemented) | |
| 113 | +| `--derivation-path` | No | Ledger derivation path (default: `m/44'/60'/0'/0/0`) | |
| 114 | +| `--impersonate` | No | EOA address to impersonate on fork RPCs (no signing needed) | |
| 115 | + |
| 116 | +### Example Output |
| 117 | + |
| 118 | +``` |
| 119 | +Connected to chain 137 |
| 120 | +Master EOA (impersonated): 0x7945E6D6665B569eaE7E8465915bBf49205C7708 |
| 121 | +MultiSend address: 0x40A2aCCbd92BCA938b02010E17A5b8929b49130D |
| 122 | +
|
| 123 | +--- Checking service --- |
| 124 | +Service 109: |
| 125 | + NFT holder: 0xcE6192e447560A17eEB2CEc64726000bE4446179 |
| 126 | + Multisig (Agent Safe): 0x0FFAD2468e9Cc611761a8052791E56756Db6F137 |
| 127 | + State: PreRegistration (1) |
| 128 | + Service owner (Master Safe): 0xcE6192e447560A17eEB2CEc64726000bE4446179 |
| 129 | +
|
| 130 | +--- Validating Master Safe --- |
| 131 | +Master Safe owners: ['0x24786ba2BF5A6dB021518fFA669eb0c63c225513', '0x7945E6D6665B569eaE7E8465915bBf49205C7708'] |
| 132 | +Master Safe threshold: 1 |
| 133 | +
|
| 134 | +Agent Safe owners: ['0xcE6192e447560A17eEB2CEc64726000bE4446179'] |
| 135 | +Agent Safe sole owner is already Master Safe. Skipping recovery. |
| 136 | +
|
| 137 | +--- Recovering funds --- |
| 138 | +Found 3 ERC20 token(s) for chain 137: |
| 139 | + OLAS: 27.46 (27463850837138520060 wei) |
| 140 | + USDC: 1320.41 (1320413562 wei) |
| 141 | + USDC.e: 1012.16 (1012155666 wei) |
| 142 | + Native token: 2.0 (2000000000000000000 wei) |
| 143 | +
|
| 144 | +Building multicall with 4 transfer(s)... |
| 145 | +Executing fund recovery transaction... |
| 146 | + Transaction sent: 0xe335...22df9 |
| 147 | +
|
| 148 | +--- Recovery Summary --- |
| 149 | +Recovered from Agent Safe (0x0FFA...F137) to Master Safe (0xcE61...6179): |
| 150 | + OLAS: 27.46 |
| 151 | + USDC: 1320.41 |
| 152 | + USDC.e: 1012.16 |
| 153 | + Native: 2.0 |
| 154 | +
|
| 155 | +All ERC20 funds recovered successfully. |
| 156 | +``` |
| 157 | + |
| 158 | +--- |
| 159 | + |
| 160 | +## How It Works |
| 161 | + |
| 162 | +### Step-by-Step |
| 163 | + |
| 164 | +1. **Service discovery**: Queries `serviceRegistry.ownerOf(serviceId)` to find the NFT holder. If the holder is a contract, tries `getServiceInfo(serviceId)` on it to detect staking. The actual service owner (Master Safe) is resolved either directly or from the staking contract's `ServiceInfo.owner` field. |
| 165 | + |
| 166 | +2. **Master Safe validation**: Verifies the Master EOA is an owner of the Master Safe and the threshold is 1 (required for single-signer execution). |
| 167 | + |
| 168 | +3. **Operator discovery**: Before any state changes, queries `serviceRegistry.getAgentInstances(serviceId)` and `mapAgentInstanceOperators()` to find all operators. This is needed for the unbond step. |
| 169 | + |
| 170 | +4. **Unstake** (if staked): Master Safe calls `stakingProxy.unstake(serviceId)`, which transfers the service NFT back to Master Safe. The service remains in Deployed state. |
| 171 | + |
| 172 | +5. **Terminate** (if Deployed/Active/FinishedRegistration): Master Safe calls `serviceManager.terminate(serviceId)`, moving the service to TerminatedBonded (if agents were registered) or PreRegistration. |
| 173 | + |
| 174 | +6. **Unbond** (if TerminatedBonded): For each operator, calls `serviceManager.unbond(serviceId)`. In the typical olas-operate-app flow, Master Safe is the operator. If an operator is not Master Safe and we're in impersonate mode, the script impersonates the operator directly. In production mode with a non-Master-Safe operator, the script errors out. |
| 175 | + |
| 176 | +7. **Recovery**: With the service in PreRegistration state, Master Safe calls `recoveryModule.recoverAccess(serviceId)`. The RecoveryModule removes all Agent Safe owners and sets Master Safe as the sole owner (threshold=1). |
| 177 | + |
| 178 | +8. **Fund transfer**: Builds a MultiSend multicall that Agent Safe executes via delegatecall, transferring all ERC20 tokens (fetched from the olas-operate-app token config) and native tokens to Master Safe. The Master Safe uses a v=1 (sender-approved) Safe signature since it is the sole owner and msg.sender. |
| 179 | + |
| 180 | +### Safe Signature Types Used |
| 181 | + |
| 182 | +- **ECDSA (v=27/28)**: Master EOA signs the Master Safe transaction hash. Used for all Master Safe `execTransaction` calls in production mode. |
| 183 | +- **Sender-approved (v=1)**: Used for Agent Safe's `execTransaction` where Master Safe is both the sole owner and `msg.sender`. Format: `r = masterSafe address (32 bytes) | s = 0 (32 bytes) | v = 1 (1 byte)`. |
| 184 | +- **Impersonate mode**: Uses v=1 sender-approved signatures on both Master Safe and Agent Safe, combined with unsigned `eth_sendTransaction` calls that Tenderly/Anvil forks accept. |
| 185 | + |
| 186 | +### Contract Dependencies |
| 187 | + |
| 188 | +All addresses are loaded from `docs/configuration.json` at runtime: |
| 189 | +- `ServiceRegistry` (chain 1) or `ServiceRegistryL2` (all L2 chains) |
| 190 | +- `RecoveryModule` |
| 191 | +- `ServiceManager` / `ServiceManagerProxy` |
| 192 | +- `MultiSend` (read from `recoveryModule.multiSend()` immutable) |
| 193 | + |
| 194 | +### Limitations |
| 195 | + |
| 196 | +- Master Safe threshold must be 1. For threshold > 1, additional owner signatures would be needed. |
| 197 | +- If the operator is not Master Safe, production mode cannot unbond (need operator's key). Fork testing mode can impersonate. |
| 198 | +- Ledger hardware wallet signing is not yet implemented. |
| 199 | +- Only recovers tokens listed in the olas-operate-app token config for the given chain. Other tokens in the Agent Safe are not recovered. |
| 200 | + |
| 201 | +--- |
| 202 | + |
| 203 | +## Testing |
| 204 | + |
| 205 | +### Forge Tests |
| 206 | + |
| 207 | +The Solidity test `test/RecoverFunds.t.sol` covers: |
| 208 | + |
| 209 | +```bash |
| 210 | +forge test --match-contract RecoverFunds -vvv |
| 211 | +``` |
| 212 | + |
| 213 | +| Test | Description | |
| 214 | +|------|-------------| |
| 215 | +| `test_recoverAccess_and_transferFunds` | Full flow: recover ownership + transfer ERC20s + native | |
| 216 | +| `test_skipRecovery_whenAlreadyOwner` | Skip recovery when Agent Safe already owned by Master Safe | |
| 217 | +| `test_recoverNativeTokenOnly` | Recover only native tokens | |
| 218 | +| `test_recoverAccess_multipleAgentInstances` | Recovery with 2 agent instances | |
| 219 | +| `test_recoverFromStakedService` | Full flow from staked state: unstake -> terminate -> unbond -> recover -> transfer | |
| 220 | + |
| 221 | +### Fork Testing with Tenderly |
| 222 | + |
| 223 | +1. Go to [Tenderly](https://dashboard.tenderly.co/) and create a Virtual TestNet fork of the target chain |
| 224 | +2. Copy the RPC URL |
| 225 | +3. Run the script with `--impersonate`: |
| 226 | + |
| 227 | +```bash |
| 228 | +python scripts/recover_funds_lost_agent_eoa.py \ |
| 229 | + --service-id <ID> \ |
| 230 | + --chain-id <CHAIN> \ |
| 231 | + --rpc-url <TENDERLY_FORK_RPC> \ |
| 232 | + --impersonate <MASTER_EOA_ADDRESS> |
| 233 | +``` |
| 234 | + |
| 235 | +This lets you verify the full recovery flow without spending real funds or needing the Master EOA private key. |
0 commit comments