Skip to content

Commit 180356f

Browse files
authored
Merge pull request #283 from valory-xyz/recover_funds_lost_agent_eoa
feat and test: scripts and tests to recover funds after agent EOA access is lost
2 parents e77bf81 + 2c415b2 commit 180356f

54 files changed

Lines changed: 2780 additions & 602 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ Key terminology (see docs/definitions.md for details):
2525

2626
When reading code, documentation, or contract names, use the smart contract terminology. When discussing the user-facing application, use the marketplace terminology.
2727

28+
## Setup
29+
30+
The project uses git submodules (`lib/forge-std`, `lib/solmate`). Clone with `git clone --recursive` or run `git submodule update --init --recursive` after cloning.
31+
32+
```bash
33+
yarn install
34+
```
35+
36+
Confirmed to work with Yarn 1.22.19, npm/npx 10.1.0, Node v18.17.0.
37+
2838
## Common Development Commands
2939

3040
### Build and Compile
@@ -46,8 +56,7 @@ yarn test
4656
# Run specific test file
4757
npx hardhat test test/ServiceRegistry.js
4858

49-
# Run with gas reporter
50-
yarn test # (gas reporter enabled by default in config)
59+
# Gas reporter is enabled by default in hardhat config
5160
```
5261

5362
#### Forge Tests (Solidity)
@@ -60,6 +69,7 @@ forge test --match-contract PolySafeCreator -vvv
6069

6170
# Run fork tests (requires FORK_NODE_URL env var)
6271
forge test -f $FORK_NODE_URL --match-contract IdentityRegistry -vvv
72+
forge test -f $FORK_NODE_URL --match-contract StakePolySafe -vvv
6373
```
6474

6575
### Coverage
@@ -92,6 +102,12 @@ scribble contracts/scribble/ServiceRegistryAnnotated.sol --disarm
92102
./scripts/scribble.sh scribble/ServiceRegistryAnnotated.sol
93103
```
94104

105+
### Docker (Local Development)
106+
```bash
107+
docker build . -t valory/autonolas-registries
108+
docker run -it -d -p 8545:8545 --name chain valory/autonolas-registries
109+
```
110+
95111
### Deployment and Network Operations
96112
```bash
97113
# Run mainnet snapshot (requires ALCHEMY_API_KEY)
@@ -208,17 +224,36 @@ The protocol is deployed across multiple chains. See `docs/configuration.json` f
208224
- Reentrancy protection uses a simple `_locked` variable pattern
209225
- Maximum unit IDs are bounded by `uint32` (~4.2 billion)
210226

227+
### Recovery Module & Fund Recovery
228+
229+
When an Agent EOA private key is lost, the `RecoveryModule` contract allows the service owner (Master Safe) to regain sole ownership of the Agent Safe. The full recovery script is at `scripts/recover_funds_lost_agent_eoa.py` with detailed documentation in `docs/recover_funds_lost_agent_eoa.md`.
230+
231+
**Recovery flow**: unstake (if staked) -> terminate -> unbond -> `recoveryModule.recoverAccess(serviceId)` -> sweep funds via MultiSend.
232+
233+
Key contracts involved:
234+
- `contracts/multisigs/RecoveryModule.sol`: `recoverAccess(serviceId)` removes all Agent Safe owners and makes the service owner the sole owner
235+
- `contracts/multisigs/SafeMultisigWithRecoveryModule.sol`: Creates Safe proxies with the RecoveryModule pre-enabled
236+
- `contracts/staking/StakingBase.sol`: `unstake(serviceId)` returns service NFT to the owner. `getServiceInfo(serviceId)` exposes the actual service owner when staked.
237+
238+
The recovery script uses Safe v=1 (sender-approved) signatures where Master Safe is both msg.sender and the sole owner. Forge tests covering all recovery scenarios are in `test/RecoverFunds.t.sol`.
239+
211240
## Key Files and Locations
212241

213242
- **Contracts:** `contracts/`
214243
- **Tests:** `test/` (JavaScript with Hardhat), `test/*.t.sol` (Solidity with Forge)
215244
- **Deployment scripts:** `scripts/deployment/`
245+
- **Recovery script:** `scripts/recover_funds_lost_agent_eoa.py` (see `docs/recover_funds_lost_agent_eoa.md`)
216246
- **ABIs:** `abis/` (organized by Solidity version)
217247
- **Documentation:** `docs/`
248+
- **Contract addresses per chain:** `docs/configuration.json`
218249
- **Solana integration:** `integrations/solana/`
219250

220251
## Development Notes
221252

253+
- When using `web3.py` with Polygon (or other POA chains), inject `geth_poa_middleware` before making calls — Polygon blocks have >32 byte `extraData` which causes `ExtraDataLengthError` without it
254+
- When encoding calldata for Safe transactions (without sending), use `contract.functions.func(args)._encode_transaction_data()` instead of `build_transaction()["data"]` to avoid unnecessary gas estimation RPC calls that may revert (e.g., MultiSend enforces delegatecall-only)
255+
- Gnosis Safe v1.3.0 signature types: `v=27/28` for ECDSA, `v=0` for contract signature, `v=1` for sender-approved (msg.sender is owner, no actual signature needed — `r=address padded to 32 bytes, s=0, v=1`)
256+
- `StakingBase.stake` has two overloads (`stake(uint256)` and `stake(uint256,uint256)`), so `StakingBase.stake.selector` is ambiguous in Solidity. Use `bytes4(keccak256("stake(uint256)"))` instead. Same applies to other overloaded functions.
222257
- The `manager` role (not `owner`) typically calls `create()` functions on registries
223258
- Service ownership transfers happen during deployment (service owner gives up multisig ownership to agent instances)
224259
- When working with service registration, always verify the state machine constraints
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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.

scripts/deployment/deploy_01_component_registry.sh

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,18 @@ derivationPath=$(jq -r '.derivationPath' $globals)
1414
chainId=$(jq -r '.chainId' $globals)
1515
networkURL=$(jq -r '.networkURL' $globals)
1616

17-
# Getting L1 API key
18-
if [ $chainId == 1 ]; then
19-
API_KEY=$ALCHEMY_API_KEY_MAINNET
20-
if [ "$API_KEY" == "" ]; then
21-
echo "set ALCHEMY_API_KEY_MAINNET env variable"
22-
exit 0
17+
# Check for Alchemy keys
18+
if [[ "$networkURL" == *"alchemy.com"* ]]; then
19+
case $chainId in
20+
1) API_KEY=$ALCHEMY_API_KEY_MAINNET; keyName="ALCHEMY_API_KEY_MAINNET" ;;
21+
11155111) API_KEY=$ALCHEMY_API_KEY_SEPOLIA; keyName="ALCHEMY_API_KEY_SEPOLIA" ;;
22+
137) API_KEY=$ALCHEMY_API_KEY_MATIC; keyName="ALCHEMY_API_KEY_MATIC" ;;
23+
80002) API_KEY=$ALCHEMY_API_KEY_AMOY; keyName="ALCHEMY_API_KEY_AMOY" ;;
24+
esac
25+
if [ -n "$keyName" ] && [ "$API_KEY" == "" ]; then
26+
echo "set $keyName env variable"
27+
exit 0
2328
fi
24-
elif [ $chainId == 11155111 ]; then
25-
API_KEY=$ALCHEMY_API_KEY_SEPOLIA
26-
if [ "$API_KEY" == "" ]; then
27-
echo "set ALCHEMY_API_KEY_SEPOLIA env variable"
28-
exit 0
29-
fi
3029
fi
3130

3231
componentRegistryName=$(jq -r '.componentRegistryName' $globals)

scripts/deployment/deploy_02_agent_registry.sh

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,18 @@ derivationPath=$(jq -r '.derivationPath' $globals)
1414
chainId=$(jq -r '.chainId' $globals)
1515
networkURL=$(jq -r '.networkURL' $globals)
1616

17-
# Getting L1 API key
18-
if [ $chainId == 1 ]; then
19-
API_KEY=$ALCHEMY_API_KEY_MAINNET
20-
if [ "$API_KEY" == "" ]; then
21-
echo "set ALCHEMY_API_KEY_MAINNET env variable"
22-
exit 0
17+
# Check for Alchemy keys
18+
if [[ "$networkURL" == *"alchemy.com"* ]]; then
19+
case $chainId in
20+
1) API_KEY=$ALCHEMY_API_KEY_MAINNET; keyName="ALCHEMY_API_KEY_MAINNET" ;;
21+
11155111) API_KEY=$ALCHEMY_API_KEY_SEPOLIA; keyName="ALCHEMY_API_KEY_SEPOLIA" ;;
22+
137) API_KEY=$ALCHEMY_API_KEY_MATIC; keyName="ALCHEMY_API_KEY_MATIC" ;;
23+
80002) API_KEY=$ALCHEMY_API_KEY_AMOY; keyName="ALCHEMY_API_KEY_AMOY" ;;
24+
esac
25+
if [ -n "$keyName" ] && [ "$API_KEY" == "" ]; then
26+
echo "set $keyName env variable"
27+
exit 0
2328
fi
24-
elif [ $chainId == 11155111 ]; then
25-
API_KEY=$ALCHEMY_API_KEY_SEPOLIA
26-
if [ "$API_KEY" == "" ]; then
27-
echo "set ALCHEMY_API_KEY_SEPOLIA env variable"
28-
exit 0
29-
fi
3029
fi
3130

3231
agentRegistryName=$(jq -r '.agentRegistryName' $globals)

0 commit comments

Comments
 (0)