diff --git a/audits/README.md b/audits/README.md
index 9cb8b51..2bb9fa9 100644
--- a/audits/README.md
+++ b/audits/README.md
@@ -12,6 +12,7 @@ An `audit7` with a focus `main` branch [audit7](https://github.com/LemonTreeTech
An `audit8` with a focus `main` branch [audit8](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit8).
An `audit9` — full project re-audit + delta review [audit9](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit9).
An `audit10` — post-audit fix review of `post-audit` branch [audit10](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit10).
+An `audit11` — full post-audit re-review of all contracts (with runnable PoCs) [audit11](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit11).
### External audits
External audit reports are listed in their historical order:
diff --git a/audits/audit11/README.md b/audits/audit11/README.md
new file mode 100644
index 0000000..7d2fb67
--- /dev/null
+++ b/audits/audit11/README.md
@@ -0,0 +1,156 @@
+# Audit 11 — Full post-audit re-review
+
+Focus: `main` branch — complete manual re-review of the whole protocol at commit
+`019b21eea223ac74f93cfe5b124f88694d137983` ("Merge pull request #15 from post-audit").
+
+## Scope
+
+All in-scope contracts were read in full and reviewed by hand:
+
+- **L1:** `stOLAS`, `Depository`, `Treasury`, `Distributor`, `Lock`, `UnstakeRelayer`,
+ `bridging/DefaultDepositProcessorL1`, `bridging/GnosisDepositProcessorL1`,
+ `bridging/BaseDepositProcessorL1`.
+- **L2:** `StakingManager`, `ExternalStakingDistributor`, `StakingTokenLocked`, `ActivityModule`,
+ `Collector`, `MultisigGuard`, `ModuleActivityChecker`, `StakingHelper`,
+ `bridging/DefaultStakingProcessorL2`, `bridging/GnosisStakingProcessorL2`,
+ `bridging/BaseStakingProcessorL2`.
+- **Shared:** `Beacon`, `BeaconProxy`, `Implementation`, `Proxy`.
+
+`bridging/LzOracle.sol` (LayerZero-driven staking-model management) is present in the source tree
+but is **not part of the live deployment** (`doc/configuration.json` contains no `LzOracle`
+address, and the live cross-chain path uses the native Gnosis AMB / Base bridge). It was therefore
+treated as out of the live scope; its `Depository` hooks (`LzCreateAndActivateStakingModel`,
+`LzCloseStakingModel`) are dormant while no `lzOracle` is set.
+
+## Method
+
+1. Full by-hand review of every contract, entry point and fund-movement path.
+2. Regression check of every resolution from the prior internal audits (`audit1`–`audit10`) and the
+ external CODESPECT report, confirming each fix is present and correct in the reviewed commit.
+3. On-chain verification of the live L1 (Ethereum mainnet) deployment.
+4. Runnable proof-of-concept tests, on the project's own real-contract test harness (real Gnosis
+ Safe, real Autonolas registries), for the one exploit hypothesis that warranted a test and for
+ the one accounting observation — see [`poc/`](poc).
+
+## Verdict
+
+**PASS — no exploitable vulnerabilities found (0 Critical / 0 High / 0 Medium).**
+
+The codebase is mature. The one substantive item is a low-severity fee-model question (funds remain
+within the protocol); the remainder are informational / hardening notes. The core ERC-4626 vault is
+immune to donation, inflation and read-only-reentrancy manipulation because `totalAssets()` returns
+internal accounting (`totalReserves`), never `balanceOf(this)`.
+
+---
+
+## Findings
+
+### L-1 (Low) — External-staking (V1) rewards can be taxed twice
+
+`ExternalStakingDistributor._distributeRewards` already splits an external service's reward into
+collector / protocol / curating-agent shares (per the configured `protocolRewardFactor`) and sends
+the collector share to `Collector` under the `REWARD` operation. Later, `Collector.relayTokens(REWARD)`
+applies `protocolFactor` **again** to that same collector bucket before relaying the remainder to the
+L1 `Distributor`. Because internal-staking and external-staking rewards share the single
+`mapOperationReceiverBalances[REWARD]` bucket and `protocolFactor` is one global value, external
+rewards are subject to two protocol cuts.
+
+Quantified in [`poc/double_fee.js`](poc/double_fee.js) with `protocolFactor = 10%` and a reward `R`:
+
+| Recipient | Intended (ESD split) | Actual after `relayTokens` |
+|---|---|---|
+| stOLAS holders (via Distributor) | 80.0% of R | **72.0% of R** |
+| Protocol | 17.5% of R | **25.5% of R** |
+| Curating agent | 2.5% of R | 2.5% of R |
+
+Conservation holds — no funds leave the protocol (this is a protocol-treasury vs stOLAS-holder
+re-allocation, not a loss) — but the effective external-staking yield delivered to stOLAS holders is
+lower than the configured split implies.
+
+**Recommendation.** Confirm the intended fee model. If only a single protocol fee is intended on
+external rewards, either exempt the ESD-sourced collector bucket from `Collector.protocolFactor`, or
+set `protocolRewardFactor = 0` in the external staking configs so the protocol fee is applied once.
+
+### I-1 (Informational / hardening) — `MultisigGuard` does not constrain the service-multisig token balance
+
+`MultisigGuard.checkTransaction` is empty, and `checkAfterExecution` validates only that the
+external-staking-distributor and guard modules remain enabled and that the operator bond is not
+slashed — it does not check the service multisig's OLAS balance. In principle the service Safe owner
+(the `agentInstance` supplied at stake time) can therefore move any OLAS held by that Safe via a
+guard-checked `execTransaction`.
+
+This is **not currently exploitable for protocol funds**, and we verified so on the real contracts
+(see [`poc/guard_balance.js`](poc/guard_balance.js)):
+
+- The external staking proxies gate `claim` / `checkpointAndClaim` / `unstake` to the recorded
+ service owner, which is the `ExternalStakingDistributor` (it is the account that calls
+ `stake(serviceId)`). A direct call by any other account reverts. So a service reward reaches the
+ Safe **only inside the atomic `ExternalStakingDistributor.claim` → distribute call**, which
+ immediately splits it and leaves ~0 on the Safe.
+- No other protocol OLAS is held on the service Safe (the staking deposit lives in the service
+ registry token utility).
+
+In the PoC, an account that stakes an external V1 service and controls the Safe ends up with only the
+legitimate 2.5% curating-agent share; the protocol keeps ≥ 97.5%, and the owner's real `execTransaction`
+sweep after distribution moves ~0.
+
+**Hardening.** Consider having `MultisigGuard` reject an owner `execTransaction` that transfers the
+staking token out of the multisig, so the "no standing balance on the Safe" invariant does not rely on
+the claim-gating behaviour of externally-integrated staking proxies.
+
+---
+
+## Observations (Informational)
+
+- **Treasury withdrawal finalization is time-based.** `withdrawDelay` must be configured at or above
+ the worst-case L2→L1 unstake + bridge latency, otherwise `finalizeWithdrawRequests` reverts until
+ the bridged OLAS arrives at the Treasury (a liveness property, not a loss; withdrawal tickets are
+ minted for the full redemption value and the shortfall is unstaked from L2 on request).
+
+- **`Depository.deposit` is permissionless and accepts `stakeAmount == 0`.** Called with zero, it lets
+ any account move idle L1 reserve OLAS into owner-approved, Active L2 staking models. This is aligned
+ with reserve utilisation, but a griefer could keep L1 reserve low and force withdrawals onto the
+ slower L2-unstake path; the owner pause mitigates. Routing is limited to owner-approved Active models.
+
+- **`DefaultStakingProcessorL2.redeem`** emits `RequestExecuted(operation = STAKE)` for a failed-stake
+ request whose funds are redirected to the unstake reserve for return to L1. The event label reflects
+ the original request; the fund movement is correct. Off-chain indexers keying on the event operation
+ should account for this.
+
+- **`BaseStakingProcessorL2.relayToL1`** passes the literal string `"0x"` (two bytes) as the bridge
+ `extraData` rather than empty bytes; harmless for the OP-stack `withdrawTo`, but inconsistent with the
+ Gnosis path.
+
+- **`StakingTokenLocked.stake`** does not validate the staked service multisig's proxy hash. This is
+ safe because `stake` is restricted to `StakingManager`, which is the sole minter of those multisigs;
+ noted as a deviation from the base `StakingToken` pattern.
+
+---
+
+## Prior-audit regression check
+
+All resolutions from `audit1`–`audit10` and the external CODESPECT report were re-verified present and
+correct at the reviewed commit, including:
+
+- ERC-4626 vault accounting uses internal `totalReserves` (not `balanceOf`), giving donation /
+ inflation immunity; `deposit` and `redeem` round in the vault's favour.
+- `DefaultStakingProcessorL2` initialises a queued failed request with a non-default status
+ (`EXTERNAL_CALL_FAILED`) and redirects a failed deposit to the unstake reserve rather than
+ re-attempting the stake; the request queue is replay-safe.
+- Per-service curating-agent access (`mapServiceIdCuratingAgents[serviceId]`), the `create` vs `update`
+ service flag, and the Safe-setup signature are correct.
+- `Lock` has a withdraw path and its `increaseLock` pulls OLAS from `msg.sender` only (own-funds), so an
+ outsider cannot lock the contract's idle balance.
+- `Distributor` resets its dangling OLAS approval to the Lock on failure; the removed
+ `DefaultDepositProcessorL1.drain()` is confirmed absent.
+- `StakingTokenLocked.stake` is restricted to `StakingManager`; the manager can always reclaim staking
+ slots via `unstake`, so there is no permanent slot exhaustion.
+
+## On-chain verification (Ethereum mainnet)
+
+The live `stOLAS` vault is wired to the correct `treasury` / `depository` / `distributor` /
+`unstakeRelayer` proxy addresses — deployment was atomic and the unprotected `initialize` was not
+front-run. At the time of review the vault held ≈ 4.04M OLAS in reserves against ≈ 3.42M stOLAS supply
+(price-per-share ≈ 1.18), with internal accounting functioning as designed. The Depository owner is a
+single account (governance is expected to move it under a timelock, per the roadmap) and the contract
+is unpaused.
diff --git a/audits/audit11/poc/README.md b/audits/audit11/poc/README.md
new file mode 100644
index 0000000..f1be705
--- /dev/null
+++ b/audits/audit11/poc/README.md
@@ -0,0 +1,34 @@
+# Audit 11 — Proof-of-Concept tests
+
+These are self-contained Hardhat tests that run against the project's real contracts (real Gnosis
+Safe, real Autonolas registries, real `ExternalStakingDistributor` / `Collector` / `MultisigGuard` /
+`StakingTokenV1`). Each file inlines the full deployment in its `beforeEach`; no mocks beyond those the
+project's own test suite already uses.
+
+## Running
+
+From the repository root (LayerZero libs are not installed, so temporarily rename `LzOracle.sol` as the
+`test:hardhat` script does):
+
+```bash
+mv contracts/l1/bridging/LzOracle.sol contracts/l1/bridging/LzOracle._sol && \
+npx hardhat test audits/audit11/poc/guard_balance.js audits/audit11/poc/double_fee.js ; \
+mv contracts/l1/bridging/LzOracle._sol contracts/l1/bridging/LzOracle.sol
+```
+
+## Tests
+
+### `guard_balance.js` — supports finding **I-1**
+Demonstrates that a staker who controls an external-V1 service Safe **cannot capture protocol funds**,
+despite `MultisigGuard` not checking the multisig token balance:
+- the staking proxy gates `claim` / `checkpointAndClaim` / `unstake` to the recorded service owner
+ (the `ExternalStakingDistributor`) — a direct call by any other account reverts;
+- the real `ExternalStakingDistributor.claim` distributes the reward atomically (≈97.5% to the
+ Collector/protocol, ≈2.5% to the staker's legitimate curating-agent share) and leaves ~0 on the Safe;
+- the staker's real, guard-checked `execTransaction` sweep of the Safe therefore moves ~0.
+
+### `double_fee.js` — supports finding **L-1**
+Quantifies the double protocol fee on external-V1 rewards: with `Collector.protocolFactor = 10%`, an
+external reward that the `ExternalStakingDistributor` split intended to send 80% to stOLAS holders
+sends only 72% (the protocol take rises from 17.5% to 25.5%). Conservation holds — every wei stays
+inside the protocol — so this is a fee-model question, not a loss.
diff --git a/audits/audit11/poc/double_fee.js b/audits/audit11/poc/double_fee.js
new file mode 100644
index 0000000..41f9904
--- /dev/null
+++ b/audits/audit11/poc/double_fee.js
@@ -0,0 +1,488 @@
+/*global describe, beforeEach, it*/
+//
+// PoC (finding L-1): DOUBLE protocol fee on external V1 rewards (fee-model observation, not a theft)
+// ================================================================================================
+//
+// WHAT THIS QUANTIFIES:
+// The external-V1 reward R is split ONCE by ExternalStakingDistributor._distributeRewards:
+// collectorAmount = 80.0% * R -> Collector via topUpBalance(collectorAmount, REWARD)
+// protocolAmount = 17.5% * R -> Collector.protocolBalance via topUpProtocol(protocolAmount)
+// curatingAgent = 2.5% * R -> paid out directly to the curating agent
+// So after ESD.claim, the Collector's REWARD-operation balance = collectorAmount (80% * R), and the
+// protocol has already taken its 17.5% * R.
+//
+// Later, Collector.relayTokens(REWARD, ...) forwards that REWARD balance to L1 (to the Distributor,
+// which is where stOLAS-holder rewards go). But relayTokens applies protocolFactor A SECOND TIME to
+// the REWARD balance (Collector.sol lines 342-356):
+// protocolAmount2 = (rewardBalance * protocolFactor) / MAX_PROTOCOL_FACTOR
+// = protocolFactor * (80% * R)
+// relayed to L1 = (80% * R) - protocolAmount2
+//
+// Net effect with protocolFactor = 10% (1000):
+// total-to-protocol = 17.5%*R (ESD split) + 10%*(80%*R) = 17.5%*R + 8%*R = 25.5%*R
+// total-to-stOLAS(Distributor)= 90% * (80%*R) = 72%*R (instead of the 80%*R the ESD split intended)
+//
+// The external reward is thus taxed TWICE: once by the ESD split (protocolAmount) and once by the
+// Collector.protocolFactor on relay. The stOLAS-holder share drops from the intended 80% to 72%.
+//
+// NOTE FOR THE TEAM (this is a fee-MODEL question, not a bug/theft claim):
+// This is NOT a theft or an attacker path — every wei stays inside the protocol (it just moves the
+// extra 8%*R from the stOLAS-holder rewards bucket into Collector.protocolBalance, which the owner
+// controls via fundExternal). It MAY be entirely intended (protocolFactor is a deliberate,
+// owner-set fee, and it is legitimately applied to unstake/other operations too). The observation
+// is only that, FOR THE EXTERNAL-V1 REWARD PATH, protocolFactor stacks on top of the ESD's own
+// protocolAmount split, so the effective protocol take on external rewards is higher than either
+// number alone. Whether the double application is intended is a question for the team. This test
+// PASSES by asserting exactly the arithmetic it measured -- it is a documented measurement.
+//
+const { expect } = require("chai");
+const { ethers } = require("hardhat");
+const helpers = require("@nomicfoundation/hardhat-network-helpers");
+const safeContracts = require("@gnosis.pm/safe-contracts");
+
+describe("PoC (L-1): double protocol fee on external V1 rewards (ESD split + Collector.protocolFactor)", function () {
+ let serviceRegistry, serviceRegistryTokenUtility, serviceManager, olas, st, gnosisSafe, gnosisSafeL2,
+ gnosisSafeProxyFactory, safeModuleInitializer, fallbackHandler, multiSend, gnosisSafeMultisig,
+ gnosisSafeSameAddressMultisig, recoveryModule, safeMultisigWithRecoveryModule, externalActivityChecker,
+ stakingFactory, stakingVerifier, lock, distributor, unstakeRelayer, depository, treasury, collector,
+ beacon, activityModule, stakingManager, externalStakingDistributor, multisigGuard,
+ stakingTokenImplementation, externalStakingTokenImplementationV1, externalStakingTokenImplementationV2,
+ stakingTokenInstance, gnosisDepositProcessorL1, gnosisStakingProcessorL2, activityChecker, operatorWhitelist,
+ bridgeRelayer;
+ let signers, deployer, agent, bytecodeHash;
+
+ const AddressZero = ethers.constants.AddressZero;
+ const HashZero = ethers.constants.HashZero;
+ const oneDay = 86400;
+ const defaultHash = "0x" + "5".repeat(64);
+ const regDeposit = ethers.utils.parseEther("10000");
+ const serviceId = 1;
+ const agentId = 1;
+ const agentIds = [agentId];
+ const livenessPeriod = oneDay;
+ const initSupply = "5" + "0".repeat(26);
+ const livenessRatio = "1";
+ const externalLivenessRatio = "1" + "0".repeat(12);
+ const maxNumServices = 100;
+ const minStakingDeposit = regDeposit;
+ const fullStakeDeposit = regDeposit.mul(2);
+ const timeForEmissions = 30 * oneDay;
+ let serviceParams = {
+ metadataHash: defaultHash, maxNumServices, rewardsPerSecond: "5" + "0".repeat(14), minStakingDeposit,
+ minNumStakingPeriods: 0, maxNumInactivityPeriods: 0, numAgentInstances: 1, livenessPeriod, timeForEmissions,
+ agentIds, threshold: 0, configHash: HashZero, proxyHash: HashZero, serviceRegistry: AddressZero,
+ activityChecker: AddressZero, serviceRegistryTokenUtility: AddressZero, stakingToken: AddressZero,
+ stakingManager: AddressZero
+ };
+ const apyLimit = ethers.utils.parseEther("3");
+ const lockFactor = 100;
+ const chainId = 31337;
+ const gnosisChainId = 100;
+ const stakingSupply = fullStakeDeposit.mul(ethers.BigNumber.from(maxNumServices));
+ const bridgePayload = "0x";
+ const rewardOperation = "0x0b9821ae606ebc7c79bf3390bdd3dc93e1b4a7cda27aad60646e7b88ff55b001";
+ const unstakeOperation = "0x8ca9a95e41b5eece253c93f5b31eed1253aed6b145d8a6e14d913fdf8e732293";
+ const unstakeRetiredOperation = "0x9065ad15d9673159e4597c86084aff8052550cec93c5a6e44b3f1dba4c8731b3";
+
+ beforeEach(async function () {
+ signers = await ethers.getSigners();
+ deployer = signers[0];
+ agent = signers[0];
+
+ const ServiceRegistry = await ethers.getContractFactory("ServiceRegistryL2");
+ serviceRegistry = await ServiceRegistry.deploy("Service Registry L2", "SERVICE", "https://localhost/service/");
+ await serviceRegistry.deployed();
+ serviceParams.serviceRegistry = serviceRegistry.address;
+
+ const ServiceRegistryTokenUtility = await ethers.getContractFactory("ServiceRegistryTokenUtility");
+ serviceRegistryTokenUtility = await ServiceRegistryTokenUtility.deploy(serviceRegistry.address);
+ await serviceRegistryTokenUtility.deployed();
+ serviceParams.serviceRegistryTokenUtility = serviceRegistryTokenUtility.address;
+
+ const OperatorWhitelist = await ethers.getContractFactory("OperatorWhitelist");
+ operatorWhitelist = await OperatorWhitelist.deploy(serviceRegistry.address);
+ await operatorWhitelist.deployed();
+
+ const ServiceManager = await ethers.getContractFactory("ServiceManager");
+ serviceManager = await ServiceManager.deploy(serviceRegistry.address, serviceRegistryTokenUtility.address);
+ await serviceManager.deployed();
+ let proxyData = serviceManager.interface.encodeFunctionData("initialize", []);
+ const ServiceManagerProxy = await ethers.getContractFactory("ServiceManagerProxy");
+ const serviceManagerProxy = await ServiceManagerProxy.deploy(serviceManager.address, proxyData);
+ await serviceManagerProxy.deployed();
+ serviceManager = await ethers.getContractAt("ServiceManager", serviceManagerProxy.address);
+
+ const GnosisSafe = await ethers.getContractFactory("GnosisSafe");
+ gnosisSafe = await GnosisSafe.deploy();
+ await gnosisSafe.deployed();
+ const GnosisSafeL2 = await ethers.getContractFactory("GnosisSafeL2");
+ gnosisSafeL2 = await GnosisSafeL2.deploy();
+ await gnosisSafeL2.deployed();
+ const GnosisSafeProxyFactory = await ethers.getContractFactory("GnosisSafeProxyFactory");
+ gnosisSafeProxyFactory = await GnosisSafeProxyFactory.deploy();
+ await gnosisSafeProxyFactory.deployed();
+ const SafeToL2Setup = await ethers.getContractFactory("SafeToL2Setup");
+ safeModuleInitializer = await SafeToL2Setup.deploy();
+ await safeModuleInitializer.deployed();
+ const FallbackHandler = await ethers.getContractFactory("DefaultCallbackHandler");
+ fallbackHandler = await FallbackHandler.deploy();
+ await fallbackHandler.deployed();
+ const MultiSend = await ethers.getContractFactory("MultiSendCallOnly");
+ multiSend = await MultiSend.deploy();
+ await multiSend.deployed();
+ const GnosisSafeProxy = await ethers.getContractFactory("GnosisSafeProxy");
+ const gnosisSafeProxy = await GnosisSafeProxy.deploy(gnosisSafe.address);
+ await gnosisSafeProxy.deployed();
+ const bytecode = await ethers.provider.getCode(gnosisSafeProxy.address);
+ bytecodeHash = ethers.utils.keccak256(bytecode);
+ const GnosisSafeMultisig = await ethers.getContractFactory("GnosisSafeMultisig");
+ gnosisSafeMultisig = await GnosisSafeMultisig.deploy(gnosisSafe.address, gnosisSafeProxyFactory.address);
+ await gnosisSafeMultisig.deployed();
+ const GnosisSafeSameAddressMultisig = await ethers.getContractFactory("GnosisSafeSameAddressMultisig");
+ gnosisSafeSameAddressMultisig = await GnosisSafeSameAddressMultisig.deploy(bytecodeHash);
+ await gnosisSafeSameAddressMultisig.deployed();
+ const RecoveryModule = await ethers.getContractFactory("RecoveryModule");
+ recoveryModule = await RecoveryModule.deploy(multiSend.address, serviceRegistry.address);
+ await recoveryModule.deployed();
+ const SafeMultisigWithRecoveryModule = await ethers.getContractFactory("SafeMultisigWithRecoveryModule");
+ safeMultisigWithRecoveryModule = await SafeMultisigWithRecoveryModule.deploy(gnosisSafe.address,
+ gnosisSafeProxyFactory.address, recoveryModule.address);
+ await safeMultisigWithRecoveryModule.deployed();
+
+ const ERC20Token = await ethers.getContractFactory("ERC20Token");
+ olas = await ERC20Token.deploy();
+ await olas.deployed();
+ serviceParams.stakingToken = olas.address;
+ await olas.mint(deployer.address, initSupply);
+
+ const VE = await ethers.getContractFactory("MockVE");
+ const ve = await VE.deploy(olas.address);
+ await ve.deployed();
+ const SToken = await ethers.getContractFactory("stOLAS");
+ st = await SToken.deploy(olas.address);
+ await st.deployed();
+ const Lock = await ethers.getContractFactory("Lock");
+ lock = await Lock.deploy(olas.address, ve.address);
+ await lock.deployed();
+ const LockProxy = await ethers.getContractFactory("Proxy");
+ let initPayload = lock.interface.encodeFunctionData("initialize", []);
+ const lockProxy = await LockProxy.deploy(lock.address, initPayload);
+ await lockProxy.deployed();
+ lock = await ethers.getContractAt("Lock", lockProxy.address);
+ await olas.transfer(lock.address, ethers.utils.parseEther("1"));
+ await lock.setGovernorAndCreateFirstLock(deployer.address);
+
+ const Distributor = await ethers.getContractFactory("Distributor");
+ distributor = await Distributor.deploy(olas.address, st.address, lock.address);
+ await distributor.deployed();
+ const DistributorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = distributor.interface.encodeFunctionData("initialize", [lockFactor]);
+ const distributorProxy = await DistributorProxy.deploy(distributor.address, initPayload);
+ await distributorProxy.deployed();
+ distributor = await ethers.getContractAt("Distributor", distributorProxy.address);
+
+ const UnstakeRelayer = await ethers.getContractFactory("UnstakeRelayer");
+ unstakeRelayer = await UnstakeRelayer.deploy(olas.address, st.address);
+ await unstakeRelayer.deployed();
+ const UnstakeRelayerProxy = await ethers.getContractFactory("Proxy");
+ initPayload = unstakeRelayer.interface.encodeFunctionData("initialize", []);
+ const unstakeRelayerProxy = await UnstakeRelayerProxy.deploy(unstakeRelayer.address, initPayload);
+ await unstakeRelayerProxy.deployed();
+ unstakeRelayer = await ethers.getContractAt("UnstakeRelayer", unstakeRelayerProxy.address);
+
+ const Depository = await ethers.getContractFactory("Depository");
+ depository = await Depository.deploy(olas.address, st.address);
+ await depository.deployed();
+ const DepositoryProxy = await ethers.getContractFactory("Proxy");
+ initPayload = depository.interface.encodeFunctionData("initialize", []);
+ const depositoryProxy = await DepositoryProxy.deploy(depository.address, initPayload);
+ await depositoryProxy.deployed();
+ depository = await ethers.getContractAt("Depository", depositoryProxy.address);
+ await depository.changeProductType(2);
+
+ const Treasury = await ethers.getContractFactory("Treasury");
+ treasury = await Treasury.deploy(olas.address, st.address, depository.address);
+ await treasury.deployed();
+ const TreasuryProxy = await ethers.getContractFactory("Proxy");
+ initPayload = treasury.interface.encodeFunctionData("initialize", [0]);
+ const treasuryProxy = await TreasuryProxy.deploy(treasury.address, initPayload);
+ await treasuryProxy.deployed();
+ treasury = await ethers.getContractAt("Treasury", treasuryProxy.address);
+
+ await st.initialize(treasury.address, depository.address, distributor.address, unstakeRelayer.address);
+ await depository.changeTreasury(treasury.address);
+
+ const StakingVerifier = await ethers.getContractFactory("StakingVerifier");
+ stakingVerifier = await StakingVerifier.deploy(olas.address, serviceRegistry.address,
+ serviceRegistryTokenUtility.address, minStakingDeposit, timeForEmissions, maxNumServices, apyLimit);
+ await stakingVerifier.deployed();
+ const StakingFactory = await ethers.getContractFactory("StakingFactory");
+ stakingFactory = await StakingFactory.deploy(stakingVerifier.address);
+ await stakingFactory.deployed();
+
+ const Collector = await ethers.getContractFactory("Collector");
+ collector = await Collector.deploy(olas.address);
+ await collector.deployed();
+ const CollectorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = collector.interface.encodeFunctionData("initialize", []);
+ const collectorProxy = await CollectorProxy.deploy(collector.address, initPayload);
+ await collectorProxy.deployed();
+ collector = await ethers.getContractAt("Collector", collectorProxy.address);
+
+ const ActivityModule = await ethers.getContractFactory("ActivityModule");
+ activityModule = await ActivityModule.deploy(olas.address, collector.address, multiSend.address);
+ await activityModule.deployed();
+ const Beacon = await ethers.getContractFactory("Beacon");
+ beacon = await Beacon.deploy(activityModule.address);
+ await beacon.deployed();
+
+ const StakingManager = await ethers.getContractFactory("StakingManager");
+ stakingManager = await StakingManager.deploy(olas.address, serviceManager.address, stakingFactory.address,
+ safeModuleInitializer.address, gnosisSafeL2.address, beacon.address, collector.address, agentId, defaultHash);
+ await stakingManager.deployed();
+ const StakingManagerProxy = await ethers.getContractFactory("Proxy");
+ initPayload = stakingManager.interface.encodeFunctionData("initialize", [gnosisSafeMultisig.address,
+ gnosisSafeSameAddressMultisig.address, fallbackHandler.address]);
+ const stakingManagerProxy = await StakingManagerProxy.deploy(stakingManager.address, initPayload);
+ await stakingManagerProxy.deployed();
+ stakingManager = await ethers.getContractAt("StakingManager", stakingManagerProxy.address);
+ serviceParams.stakingManager = stakingManager.address;
+ await deployer.sendTransaction({to: stakingManager.address, value: ethers.utils.parseEther("1")});
+
+ const ExternalStakingDistributor = await ethers.getContractFactory("ExternalStakingDistributor");
+ externalStakingDistributor = await ExternalStakingDistributor.deploy(olas.address, serviceManager.address,
+ safeMultisigWithRecoveryModule.address, gnosisSafeSameAddressMultisig.address, fallbackHandler.address,
+ multiSend.address, collector.address);
+ await externalStakingDistributor.deployed();
+ const ExternalStakingDistributorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = externalStakingDistributor.interface.encodeFunctionData("initialize", []);
+ const externalStakingDistributorProxy = await ExternalStakingDistributorProxy.deploy(externalStakingDistributor.address, initPayload);
+ await externalStakingDistributorProxy.deployed();
+ externalStakingDistributor = await ethers.getContractAt("ExternalStakingDistributor", externalStakingDistributorProxy.address);
+ await deployer.sendTransaction({to: externalStakingDistributor.address, value: ethers.utils.parseEther("1")});
+
+ const MultisigGuard = await ethers.getContractFactory("MultisigGuard");
+ multisigGuard = await MultisigGuard.deploy(serviceRegistryTokenUtility.address, externalStakingDistributor.address);
+ await multisigGuard.deployed();
+ const multisigGuardProxy = await ExternalStakingDistributorProxy.deploy(multisigGuard.address,
+ multisigGuard.interface.encodeFunctionData("initialize", []));
+ await multisigGuardProxy.deployed();
+ multisigGuard = await ethers.getContractAt("MultisigGuard", multisigGuardProxy.address);
+
+ const BridgeRelayer = await ethers.getContractFactory("BridgeRelayer");
+ bridgeRelayer = await BridgeRelayer.deploy(olas.address);
+ await bridgeRelayer.deployed();
+ const GnosisDepositProcessorL1 = await ethers.getContractFactory("GnosisDepositProcessorL1");
+ gnosisDepositProcessorL1 = await GnosisDepositProcessorL1.deploy(olas.address, depository.address,
+ bridgeRelayer.address, bridgeRelayer.address);
+ await gnosisDepositProcessorL1.deployed();
+ const GnosisStakingProcessorL2 = await ethers.getContractFactory("GnosisStakingProcessorL2");
+ gnosisStakingProcessorL2 = await GnosisStakingProcessorL2.deploy(olas.address, stakingManager.address,
+ externalStakingDistributor.address, collector.address, bridgeRelayer.address, bridgeRelayer.address,
+ gnosisDepositProcessorL1.address, chainId);
+ await gnosisStakingProcessorL2.deployed();
+
+ await collector.changeStakingManager(stakingManager.address);
+ await collector.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await stakingManager.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await externalStakingDistributor.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await externalStakingDistributor.changeMultisigGuard(multisigGuard.address);
+ await gnosisDepositProcessorL1.setL2StakingProcessor(gnosisStakingProcessorL2.address);
+ await depository.setDepositProcessorChainIds([gnosisDepositProcessorL1.address], [gnosisChainId]);
+
+ const ActivityChecker = await ethers.getContractFactory("ModuleActivityChecker");
+ activityChecker = await ActivityChecker.deploy(livenessRatio);
+ await activityChecker.deployed();
+ serviceParams.activityChecker = activityChecker.address;
+
+ const StakingTokenLocked = await ethers.getContractFactory("StakingTokenLocked");
+ stakingTokenImplementation = await StakingTokenLocked.deploy();
+ await stakingTokenImplementation.deployed();
+ await stakingVerifier.setImplementationsStatuses([stakingTokenImplementation.address], [true], true);
+ initPayload = stakingTokenImplementation.interface.encodeFunctionData("initialize", [serviceParams]);
+ let tx = await stakingFactory.createStakingInstance(stakingTokenImplementation.address, initPayload);
+ let res = await tx.wait();
+ const stakingTokenAddress = "0x" + res.logs[0].topics[2].slice(26);
+ stakingTokenInstance = await ethers.getContractAt("StakingTokenLocked", stakingTokenAddress);
+
+ const ExternalActivityChecker = await ethers.getContractFactory("StakingActivityChecker");
+ externalActivityChecker = await ExternalActivityChecker.deploy(externalLivenessRatio);
+ await externalActivityChecker.deployed();
+ const StakingTokenV1 = await ethers.getContractFactory("StakingTokenV1");
+ externalStakingTokenImplementationV1 = await StakingTokenV1.deploy();
+ await externalStakingTokenImplementationV1.deployed();
+ const StakingTokenV2 = await ethers.getContractFactory("StakingToken");
+ externalStakingTokenImplementationV2 = await StakingTokenV2.deploy();
+ await externalStakingTokenImplementationV2.deployed();
+ await stakingVerifier.setImplementationsStatuses([externalStakingTokenImplementationV1.address,
+ externalStakingTokenImplementationV2.address], [true, true], true);
+
+ await serviceRegistry.changeManager(serviceManager.address);
+ await serviceRegistryTokenUtility.changeManager(serviceManager.address);
+ await serviceRegistry.changeMultisigPermission(gnosisSafeMultisig.address, true);
+ await serviceRegistry.changeMultisigPermission(gnosisSafeSameAddressMultisig.address, true);
+ await serviceRegistry.changeMultisigPermission(recoveryModule.address, true);
+
+ await olas.approve(stakingTokenAddress, stakingSupply);
+ await stakingTokenInstance.deposit(stakingSupply);
+ await depository.createAndActivateStakingModels([gnosisChainId], [stakingTokenAddress], [fullStakeDeposit],
+ [maxNumServices]);
+ await collector.setOperationReceivers([rewardOperation, unstakeOperation, unstakeRetiredOperation],
+ [distributor.address, treasury.address, unstakeRelayer.address]);
+ });
+
+ it("external reward is taxed twice: ESD split (17.5%) + Collector.protocolFactor (10% of the 80%) -> stOLAS share = 72% not 80%", async function () {
+ this.timeout(1600000);
+ const eth = (x) => ethers.utils.formatEther(x);
+ const olasAmount = minStakingDeposit.mul(8); // 80,000 OLAS of protocol funds into the ESD
+
+ console.log("\n============================================================");
+ console.log(" PoC (L-1): double protocol fee on external V1 rewards (fee-MODEL observation)");
+ console.log("============================================================");
+
+ // ---- Set Collector protocol factor = 10% (1000 / 10000) ----
+ await collector.changeProtocolFactor(1000);
+ const protocolFactor = await collector.protocolFactor();
+ console.log("[setup] Collector.protocolFactor:", protocolFactor.toString(), "( = 10.0% )");
+ expect(protocolFactor).to.equal(1000);
+
+ // ---- Fund the ExternalStakingDistributor with PROTOCOL OLAS ----
+ await olas.approve(depository.address, initSupply);
+ await depository.deposit(olasAmount, [], [], [], []);
+ await depository.setExternalStakingDistributorChainIds([gnosisChainId], [externalStakingDistributor.address]);
+ await depository.depositExternal([gnosisChainId], [olasAmount], [bridgePayload], [0]);
+
+ // ---- Deploy a V1 staking proxy (stakingGuard 0) and stake an external service ----
+ let externalServiceParams = {
+ metadataHash: defaultHash, maxNumServices: 3, rewardsPerSecond: "5" + "0".repeat(14),
+ minStakingDeposit: regDeposit, minNumStakingPeriods: 3, maxNumInactivityPeriods: 3, livenessPeriod: 10,
+ timeForEmissions: 100, numAgentInstances: 1, agentIds, threshold: 0, configHash: HashZero,
+ proxyHash: bytecodeHash, serviceRegistry: serviceRegistry.address, activityChecker: externalActivityChecker.address
+ };
+ const maxInactivity = externalServiceParams.maxNumInactivityPeriods * livenessPeriod + 1;
+ let initPayload = externalStakingTokenImplementationV1.interface.encodeFunctionData("initialize",
+ [externalServiceParams, serviceRegistryTokenUtility.address, olas.address]);
+ let tx = await stakingFactory.createStakingInstance(externalStakingTokenImplementationV1.address, initPayload);
+ let res = await tx.wait();
+ const proxyV1 = "0x" + res.logs[0].topics[2].slice(26);
+ const proxyV1Inst = await ethers.getContractAt("StakingTokenV1", proxyV1);
+
+ // 80% collector / 17.5% protocol / 2.5% curatingAgent
+ const cfgV1 = await externalStakingDistributor.wrapStakingConfig(AddressZero, 8000, 1750, 250, 0);
+ await externalStakingDistributor.setStakingProxyConfigs([proxyV1], [cfgV1]);
+ await olas.approve(proxyV1, stakingSupply);
+ await proxyV1Inst.deposit(stakingSupply);
+
+ // deployer stakes (owner path); msg.sender is recorded as curatingAgent
+ await externalStakingDistributor.stake(proxyV1, 0, 0, defaultHash, deployer.address);
+ const service = await serviceRegistry.getService(serviceId);
+ const multisig = service.multisig;
+ const multisigV1 = await ethers.getContractAt("GnosisSafe", multisig);
+
+ // ---- Accrue a REAL V1 reward R ----
+ let nonce = await multisigV1.nonce();
+ await safeContracts.executeTxWithSigners(multisigV1, {
+ to: multisig, value: 0, data: multisigV1.interface.encodeFunctionData("getThreshold", []),
+ operation: 0, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: AddressZero, refundReceiver: AddressZero, nonce
+ }, [deployer]);
+ await helpers.time.increase(maxInactivity);
+ await proxyV1Inst.checkpoint();
+ const reward = await proxyV1Inst.calculateStakingReward(serviceId); // = R
+ console.log("[reward] real accrued V1 reward R:", eth(reward), "OLAS");
+ expect(reward).to.be.gt(0);
+
+ // =====================================================================================
+ // STEP 1: run the REAL ESD.claim. This applies the FIRST fee (the ESD split):
+ // Collector REWARD-operation balance = collectorAmount = 80% * R
+ // Collector.protocolBalance += protocolAmount = 17.5% * R (fee #1)
+ // curatingAgent(deployer) += 2.5% * R
+ // =====================================================================================
+ const protocolBalBeforeClaim = await collector.protocolBalance();
+ await externalStakingDistributor.claim([proxyV1], [serviceId]);
+
+ // Collector REWARD-operation balance after the ESD split
+ const rewardRB = await collector.mapOperationReceiverBalances(rewardOperation);
+ const collectorRewardBalance = rewardRB.balance; // = collectorAmount = 80% * R
+ const protocolFromESD = (await collector.protocolBalance()).sub(protocolBalBeforeClaim); // = protocolAmount = 17.5% * R
+
+ // Intended split constants for reporting
+ const collectorAmount = reward.mul(8000).div(10000); // 80% R
+ const protocolAmountESD = reward.mul(1750).div(10000); // 17.5% R (fee #1)
+ console.log("\n--- STEP 1: after real ESD.claim (fee #1 = the ESD split) ---");
+ console.log("[esd] Collector REWARD-operation balance (collectorAmount = 80% R):", eth(collectorRewardBalance), "OLAS");
+ console.log("[esd] Collector.protocolBalance from ESD split (protocolAmount 17.5% R) [FEE #1]:", eth(protocolFromESD), "OLAS");
+ expect(collectorRewardBalance).to.equal(collectorAmount);
+ expect(protocolFromESD).to.equal(protocolAmountESD);
+
+ // =====================================================================================
+ // STEP 2: run the REAL Collector.relayTokens(REWARD). This applies the SECOND fee
+ // (protocolFactor on the REWARD balance) before relaying the remainder to L1:
+ // protocolAmount2 = protocolFactor * (80% * R) = 10% * 80% R = 8% R (fee #2)
+ // relayed to L1 (Distributor = stOLAS holders) = 90% * (80% R) = 72% R
+ // =====================================================================================
+ const distributorBefore = await olas.balanceOf(distributor.address); // L1 receiver for REWARD
+ const protocolBalBeforeRelay = await collector.protocolBalance();
+
+ await collector.relayTokens(rewardOperation, bridgePayload);
+
+ const relayedToDistributor = (await olas.balanceOf(distributor.address)).sub(distributorBefore); // to stOLAS holders
+ const protocolFromRelay = (await collector.protocolBalance()).sub(protocolBalBeforeRelay); // fee #2
+
+ // Expected arithmetic
+ const protocolAmount2 = collectorRewardBalance.mul(protocolFactor).div(10000); // 10% * 80% R = 8% R
+ const relayedExpected = collectorRewardBalance.sub(protocolAmount2); // 72% R
+
+ console.log("\n--- STEP 2: after real Collector.relayTokens(REWARD) (fee #2 = protocolFactor on the 80% R) ---");
+ console.log("[relay] protocolFactor take on the REWARD balance (10% * 80% R) [FEE #2]:", eth(protocolFromRelay), "OLAS");
+ console.log("[relay] relayed to L1 Distributor (stOLAS-holder rewards) = 90% * 80% R :", eth(relayedToDistributor), "OLAS");
+ expect(protocolFromRelay).to.equal(protocolAmount2);
+ expect(relayedToDistributor).to.equal(relayedExpected);
+
+ // =====================================================================================
+ // TALLY: total-to-protocol vs total-to-stOLAS-holders, expressed against R.
+ // =====================================================================================
+ const totalToProtocol = protocolFromESD.add(protocolFromRelay); // 17.5% R + 8% R = 25.5% R
+ const totalToStOLAS = relayedToDistributor; // 72% R
+ const intendedStOLAS = collectorAmount; // 80% R (what the ESD split earmarked for stOLAS)
+
+ // Basis points of R
+ const bps = (x) => x.mul(10000).div(reward).toString();
+
+ console.log("\n------------------------------------------------------------");
+ console.log(" TALLY (as a share of the external reward R =", eth(reward), "OLAS)");
+ console.log(" total-to-protocol = protocolAmount(ESD 17.5%R) + protocolFactor*collectorAmount(10%*80%R)");
+ console.log(" =", eth(protocolFromESD), "+", eth(protocolFromRelay), "=", eth(totalToProtocol),
+ "OLAS (", bps(totalToProtocol), "bps =", (Number(bps(totalToProtocol)) / 100).toFixed(2) + "% )");
+ console.log(" total-to-stOLAS = 90% * 80% R =", eth(totalToStOLAS),
+ "OLAS (", bps(totalToStOLAS), "bps =", (Number(bps(totalToStOLAS)) / 100).toFixed(2) + "% )");
+ console.log(" intended-to-stOLAS = 80% R (the ESD split) =", eth(intendedStOLAS),
+ "OLAS (", bps(intendedStOLAS), "bps = 80.00% )");
+ console.log("------------------------------------------------------------");
+ const stOLASPct = (Number(bps(totalToStOLAS)) / 100).toFixed(2);
+ console.log(" external reward is taxed twice (ESD split + Collector.protocolFactor); stOLAS-holder share = "
+ + stOLASPct + "% vs intended 80%");
+ console.log("------------------------------------------------------------");
+
+ // =====================================================================================
+ // NON-FAILING RECORD (this is a documented MEASUREMENT, not a bug-claim):
+ // assert exactly the arithmetic we measured so the test PASSES.
+ // =====================================================================================
+ // fee #1 = 17.5% R
+ expect(bps(protocolFromESD)).to.equal("1750");
+ // fee #2 = 10% of 80% R = 8% R
+ expect(bps(protocolFromRelay)).to.equal("800");
+ // total-to-protocol = 25.5% R
+ expect(bps(totalToProtocol)).to.equal("2550");
+ // total-to-stOLAS = 72% R (down from the intended 80% R)
+ expect(bps(totalToStOLAS)).to.equal("7200");
+ expect(bps(intendedStOLAS)).to.equal("8000");
+ // conservation: every wei stays inside the protocol (no leakage) --
+ // R = curator(2.5%) + protocol(25.5%) + stOLAS(72.0%)
+ const curator = reward.sub(collectorAmount).sub(protocolAmountESD); // 2.5% R
+ expect(totalToProtocol.add(totalToStOLAS).add(curator)).to.equal(reward);
+ console.log(" conservation check: curator 2.5% + protocol 25.5% + stOLAS 72.0% == R (no funds leave the protocol)");
+
+ console.log("\n MEASUREMENT RECORDED: fee-model question for the team -- protocolFactor stacks on the ESD's own");
+ console.log(" protocolAmount split for external-V1 rewards. This may be intended; it is NOT a theft.\n");
+ });
+});
diff --git a/audits/audit11/poc/guard_balance.js b/audits/audit11/poc/guard_balance.js
new file mode 100644
index 0000000..dc4a832
--- /dev/null
+++ b/audits/audit11/poc/guard_balance.js
@@ -0,0 +1,521 @@
+/*global describe, beforeEach, it*/
+//
+// PoC (finding I-1): external-V1 reward custody — a Safe-controlling staker cannot capture protocol funds
+// =========================================================================================
+//
+// BACKGROUND — the hypothesis under test:
+// Hypothesis: an account that stakes an external V1 service (permitted for
+// ANY caller when the proxy config's stakingGuard == address(0)) becomes the sole owner of the
+// service Safe and can therefore steal the V1 staking reward, because the MultisigGuard never
+// checks the multisig's OLAS balance.
+//
+// This does NOT hold, because of who controls the reward. The V1 staking proxy gates claim/unstake to
+// the *service owner recorded inside the proxy* (StakingTokenV1._claim, line 717:
+// `if (msg.sender != sInfo.owner) revert OwnerOnly(...)`), and that recorded owner is the ESD
+// itself (the ESD is the account that calls `IStaking(stakingProxy).stake(serviceId)` in
+// `_deployAndStake`, so `sInfo.owner = msg.sender = ESD`). Consequently the reward is only ever
+// moved onto the service Safe *inside* the atomic `ESD.claim` -> proxy.claim -> _withdraw(multisig)
+// -> ESD._distributeRewards() call. There is no standing, attacker-drainable reward sitting on the
+// Safe between transactions: the same atomic call that pays the reward onto the Safe immediately
+// splits it 80% collector / 17.5% protocol / 2.5% curating-agent and empties the Safe.
+//
+// The attacker owning the Safe can indeed sweep *whatever is on the Safe* — but after ESD.claim
+// that is ~0. The attacker's only real gain is the legitimate 2.5% curating-agent cut, which is
+// theirs by design because they staked the service (mapServiceIdCuratingAgents[serviceId] =
+// msg.sender = the staker). The protocol keeps >= 97.5%.
+//
+// WHAT THIS TEST PROVES DETERMINISTICALLY, ON THE REAL CONTRACTS (real StakingTokenV1, real ESD,
+// real Collector, real Gnosis Safe + real MultisigGuard) — NO mocks beyond what setUp already uses:
+// 1. The attacker CANNOT force the reward onto the Safe on their own: proxy.claim /
+// proxy.checkpointAndClaim / proxy.unstake all revert OwnerOnly for the attacker (only the ESD,
+// the recorded owner, may call them).
+// 2. The real, permissionless ESD.claim distributes the reward correctly:
+// Collector OLAS += ~80% + ~17.5% (collectorAmount via topUpBalance + protocolAmount via topUpProtocol)
+// Collector.protocolBalance += ~17.5% (the protocol's share)
+// curatingAgent(attacker) += ~2.5% (their legitimate staker cut)
+// The attacker does NOT receive more than that 2.5%.
+// 3. After ESD.claim there is ~0 OLAS on the Safe. The attacker's REAL guard-checked
+// execTransaction sweeping the Safe moves ~0 — there is nothing to steal.
+// 4. Conclusion: attacker total OLAS gain over the whole scenario <= 2.5% + dust; protocol
+// (Collector + protocolBalance) kept >= 97.5%. The "drain" is a no-op on protocol funds.
+//
+const { expect } = require("chai");
+const { ethers } = require("hardhat");
+const helpers = require("@nomicfoundation/hardhat-network-helpers");
+const safeContracts = require("@gnosis.pm/safe-contracts");
+
+describe("PoC (I-1): a Safe-controlling staker cannot capture protocol funds on external-V1", function () {
+ let serviceRegistry, serviceRegistryTokenUtility, serviceManager, olas, st, gnosisSafe, gnosisSafeL2,
+ gnosisSafeProxyFactory, safeModuleInitializer, fallbackHandler, multiSend, gnosisSafeMultisig,
+ gnosisSafeSameAddressMultisig, recoveryModule, safeMultisigWithRecoveryModule, externalActivityChecker,
+ stakingFactory, stakingVerifier, lock, distributor, unstakeRelayer, depository, treasury, collector,
+ beacon, activityModule, stakingManager, externalStakingDistributor, multisigGuard,
+ stakingTokenImplementation, externalStakingTokenImplementationV1, externalStakingTokenImplementationV2,
+ stakingTokenInstance, gnosisDepositProcessorL1, gnosisStakingProcessorL2, activityChecker, operatorWhitelist,
+ bridgeRelayer;
+ let signers, deployer, agent, bytecodeHash;
+
+ const AddressZero = ethers.constants.AddressZero;
+ const HashZero = ethers.constants.HashZero;
+ const oneDay = 86400;
+ const defaultHash = "0x" + "5".repeat(64);
+ const regDeposit = ethers.utils.parseEther("10000");
+ const serviceId = 1;
+ const agentId = 1;
+ const agentIds = [agentId];
+ const livenessPeriod = oneDay;
+ const initSupply = "5" + "0".repeat(26);
+ const livenessRatio = "1";
+ const externalLivenessRatio = "1" + "0".repeat(12);
+ const maxNumServices = 100;
+ const minStakingDeposit = regDeposit;
+ const fullStakeDeposit = regDeposit.mul(2);
+ const timeForEmissions = 30 * oneDay;
+ let serviceParams = {
+ metadataHash: defaultHash, maxNumServices, rewardsPerSecond: "5" + "0".repeat(14), minStakingDeposit,
+ minNumStakingPeriods: 0, maxNumInactivityPeriods: 0, numAgentInstances: 1, livenessPeriod, timeForEmissions,
+ agentIds, threshold: 0, configHash: HashZero, proxyHash: HashZero, serviceRegistry: AddressZero,
+ activityChecker: AddressZero, serviceRegistryTokenUtility: AddressZero, stakingToken: AddressZero,
+ stakingManager: AddressZero
+ };
+ const apyLimit = ethers.utils.parseEther("3");
+ const lockFactor = 100;
+ const chainId = 31337;
+ const gnosisChainId = 100;
+ const stakingSupply = fullStakeDeposit.mul(ethers.BigNumber.from(maxNumServices));
+ const bridgePayload = "0x";
+ const rewardOperation = "0x0b9821ae606ebc7c79bf3390bdd3dc93e1b4a7cda27aad60646e7b88ff55b001";
+ const unstakeOperation = "0x8ca9a95e41b5eece253c93f5b31eed1253aed6b145d8a6e14d913fdf8e732293";
+ const unstakeRetiredOperation = "0x9065ad15d9673159e4597c86084aff8052550cec93c5a6e44b3f1dba4c8731b3";
+
+ beforeEach(async function () {
+ signers = await ethers.getSigners();
+ deployer = signers[0];
+ agent = signers[0];
+
+ const ServiceRegistry = await ethers.getContractFactory("ServiceRegistryL2");
+ serviceRegistry = await ServiceRegistry.deploy("Service Registry L2", "SERVICE", "https://localhost/service/");
+ await serviceRegistry.deployed();
+ serviceParams.serviceRegistry = serviceRegistry.address;
+
+ const ServiceRegistryTokenUtility = await ethers.getContractFactory("ServiceRegistryTokenUtility");
+ serviceRegistryTokenUtility = await ServiceRegistryTokenUtility.deploy(serviceRegistry.address);
+ await serviceRegistryTokenUtility.deployed();
+ serviceParams.serviceRegistryTokenUtility = serviceRegistryTokenUtility.address;
+
+ const OperatorWhitelist = await ethers.getContractFactory("OperatorWhitelist");
+ operatorWhitelist = await OperatorWhitelist.deploy(serviceRegistry.address);
+ await operatorWhitelist.deployed();
+
+ const ServiceManager = await ethers.getContractFactory("ServiceManager");
+ serviceManager = await ServiceManager.deploy(serviceRegistry.address, serviceRegistryTokenUtility.address);
+ await serviceManager.deployed();
+ let proxyData = serviceManager.interface.encodeFunctionData("initialize", []);
+ const ServiceManagerProxy = await ethers.getContractFactory("ServiceManagerProxy");
+ const serviceManagerProxy = await ServiceManagerProxy.deploy(serviceManager.address, proxyData);
+ await serviceManagerProxy.deployed();
+ serviceManager = await ethers.getContractAt("ServiceManager", serviceManagerProxy.address);
+
+ const GnosisSafe = await ethers.getContractFactory("GnosisSafe");
+ gnosisSafe = await GnosisSafe.deploy();
+ await gnosisSafe.deployed();
+ const GnosisSafeL2 = await ethers.getContractFactory("GnosisSafeL2");
+ gnosisSafeL2 = await GnosisSafeL2.deploy();
+ await gnosisSafeL2.deployed();
+ const GnosisSafeProxyFactory = await ethers.getContractFactory("GnosisSafeProxyFactory");
+ gnosisSafeProxyFactory = await GnosisSafeProxyFactory.deploy();
+ await gnosisSafeProxyFactory.deployed();
+ const SafeToL2Setup = await ethers.getContractFactory("SafeToL2Setup");
+ safeModuleInitializer = await SafeToL2Setup.deploy();
+ await safeModuleInitializer.deployed();
+ const FallbackHandler = await ethers.getContractFactory("DefaultCallbackHandler");
+ fallbackHandler = await FallbackHandler.deploy();
+ await fallbackHandler.deployed();
+ const MultiSend = await ethers.getContractFactory("MultiSendCallOnly");
+ multiSend = await MultiSend.deploy();
+ await multiSend.deployed();
+ const GnosisSafeProxy = await ethers.getContractFactory("GnosisSafeProxy");
+ const gnosisSafeProxy = await GnosisSafeProxy.deploy(gnosisSafe.address);
+ await gnosisSafeProxy.deployed();
+ const bytecode = await ethers.provider.getCode(gnosisSafeProxy.address);
+ bytecodeHash = ethers.utils.keccak256(bytecode);
+ const GnosisSafeMultisig = await ethers.getContractFactory("GnosisSafeMultisig");
+ gnosisSafeMultisig = await GnosisSafeMultisig.deploy(gnosisSafe.address, gnosisSafeProxyFactory.address);
+ await gnosisSafeMultisig.deployed();
+ const GnosisSafeSameAddressMultisig = await ethers.getContractFactory("GnosisSafeSameAddressMultisig");
+ gnosisSafeSameAddressMultisig = await GnosisSafeSameAddressMultisig.deploy(bytecodeHash);
+ await gnosisSafeSameAddressMultisig.deployed();
+ const RecoveryModule = await ethers.getContractFactory("RecoveryModule");
+ recoveryModule = await RecoveryModule.deploy(multiSend.address, serviceRegistry.address);
+ await recoveryModule.deployed();
+ const SafeMultisigWithRecoveryModule = await ethers.getContractFactory("SafeMultisigWithRecoveryModule");
+ safeMultisigWithRecoveryModule = await SafeMultisigWithRecoveryModule.deploy(gnosisSafe.address,
+ gnosisSafeProxyFactory.address, recoveryModule.address);
+ await safeMultisigWithRecoveryModule.deployed();
+
+ const ERC20Token = await ethers.getContractFactory("ERC20Token");
+ olas = await ERC20Token.deploy();
+ await olas.deployed();
+ serviceParams.stakingToken = olas.address;
+ await olas.mint(deployer.address, initSupply);
+
+ const VE = await ethers.getContractFactory("MockVE");
+ const ve = await VE.deploy(olas.address);
+ await ve.deployed();
+ const SToken = await ethers.getContractFactory("stOLAS");
+ st = await SToken.deploy(olas.address);
+ await st.deployed();
+ const Lock = await ethers.getContractFactory("Lock");
+ lock = await Lock.deploy(olas.address, ve.address);
+ await lock.deployed();
+ const LockProxy = await ethers.getContractFactory("Proxy");
+ let initPayload = lock.interface.encodeFunctionData("initialize", []);
+ const lockProxy = await LockProxy.deploy(lock.address, initPayload);
+ await lockProxy.deployed();
+ lock = await ethers.getContractAt("Lock", lockProxy.address);
+ await olas.transfer(lock.address, ethers.utils.parseEther("1"));
+ await lock.setGovernorAndCreateFirstLock(deployer.address);
+
+ const Distributor = await ethers.getContractFactory("Distributor");
+ distributor = await Distributor.deploy(olas.address, st.address, lock.address);
+ await distributor.deployed();
+ const DistributorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = distributor.interface.encodeFunctionData("initialize", [lockFactor]);
+ const distributorProxy = await DistributorProxy.deploy(distributor.address, initPayload);
+ await distributorProxy.deployed();
+ distributor = await ethers.getContractAt("Distributor", distributorProxy.address);
+
+ const UnstakeRelayer = await ethers.getContractFactory("UnstakeRelayer");
+ unstakeRelayer = await UnstakeRelayer.deploy(olas.address, st.address);
+ await unstakeRelayer.deployed();
+ const UnstakeRelayerProxy = await ethers.getContractFactory("Proxy");
+ initPayload = unstakeRelayer.interface.encodeFunctionData("initialize", []);
+ const unstakeRelayerProxy = await UnstakeRelayerProxy.deploy(unstakeRelayer.address, initPayload);
+ await unstakeRelayerProxy.deployed();
+ unstakeRelayer = await ethers.getContractAt("UnstakeRelayer", unstakeRelayerProxy.address);
+
+ const Depository = await ethers.getContractFactory("Depository");
+ depository = await Depository.deploy(olas.address, st.address);
+ await depository.deployed();
+ const DepositoryProxy = await ethers.getContractFactory("Proxy");
+ initPayload = depository.interface.encodeFunctionData("initialize", []);
+ const depositoryProxy = await DepositoryProxy.deploy(depository.address, initPayload);
+ await depositoryProxy.deployed();
+ depository = await ethers.getContractAt("Depository", depositoryProxy.address);
+ await depository.changeProductType(2);
+
+ const Treasury = await ethers.getContractFactory("Treasury");
+ treasury = await Treasury.deploy(olas.address, st.address, depository.address);
+ await treasury.deployed();
+ const TreasuryProxy = await ethers.getContractFactory("Proxy");
+ initPayload = treasury.interface.encodeFunctionData("initialize", [0]);
+ const treasuryProxy = await TreasuryProxy.deploy(treasury.address, initPayload);
+ await treasuryProxy.deployed();
+ treasury = await ethers.getContractAt("Treasury", treasuryProxy.address);
+
+ await st.initialize(treasury.address, depository.address, distributor.address, unstakeRelayer.address);
+ await depository.changeTreasury(treasury.address);
+
+ const StakingVerifier = await ethers.getContractFactory("StakingVerifier");
+ stakingVerifier = await StakingVerifier.deploy(olas.address, serviceRegistry.address,
+ serviceRegistryTokenUtility.address, minStakingDeposit, timeForEmissions, maxNumServices, apyLimit);
+ await stakingVerifier.deployed();
+ const StakingFactory = await ethers.getContractFactory("StakingFactory");
+ stakingFactory = await StakingFactory.deploy(stakingVerifier.address);
+ await stakingFactory.deployed();
+
+ const Collector = await ethers.getContractFactory("Collector");
+ collector = await Collector.deploy(olas.address);
+ await collector.deployed();
+ const CollectorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = collector.interface.encodeFunctionData("initialize", []);
+ const collectorProxy = await CollectorProxy.deploy(collector.address, initPayload);
+ await collectorProxy.deployed();
+ collector = await ethers.getContractAt("Collector", collectorProxy.address);
+
+ const ActivityModule = await ethers.getContractFactory("ActivityModule");
+ activityModule = await ActivityModule.deploy(olas.address, collector.address, multiSend.address);
+ await activityModule.deployed();
+ const Beacon = await ethers.getContractFactory("Beacon");
+ beacon = await Beacon.deploy(activityModule.address);
+ await beacon.deployed();
+
+ const StakingManager = await ethers.getContractFactory("StakingManager");
+ stakingManager = await StakingManager.deploy(olas.address, serviceManager.address, stakingFactory.address,
+ safeModuleInitializer.address, gnosisSafeL2.address, beacon.address, collector.address, agentId, defaultHash);
+ await stakingManager.deployed();
+ const StakingManagerProxy = await ethers.getContractFactory("Proxy");
+ initPayload = stakingManager.interface.encodeFunctionData("initialize", [gnosisSafeMultisig.address,
+ gnosisSafeSameAddressMultisig.address, fallbackHandler.address]);
+ const stakingManagerProxy = await StakingManagerProxy.deploy(stakingManager.address, initPayload);
+ await stakingManagerProxy.deployed();
+ stakingManager = await ethers.getContractAt("StakingManager", stakingManagerProxy.address);
+ serviceParams.stakingManager = stakingManager.address;
+ await deployer.sendTransaction({to: stakingManager.address, value: ethers.utils.parseEther("1")});
+
+ const ExternalStakingDistributor = await ethers.getContractFactory("ExternalStakingDistributor");
+ externalStakingDistributor = await ExternalStakingDistributor.deploy(olas.address, serviceManager.address,
+ safeMultisigWithRecoveryModule.address, gnosisSafeSameAddressMultisig.address, fallbackHandler.address,
+ multiSend.address, collector.address);
+ await externalStakingDistributor.deployed();
+ const ExternalStakingDistributorProxy = await ethers.getContractFactory("Proxy");
+ initPayload = externalStakingDistributor.interface.encodeFunctionData("initialize", []);
+ const externalStakingDistributorProxy = await ExternalStakingDistributorProxy.deploy(externalStakingDistributor.address, initPayload);
+ await externalStakingDistributorProxy.deployed();
+ externalStakingDistributor = await ethers.getContractAt("ExternalStakingDistributor", externalStakingDistributorProxy.address);
+ await deployer.sendTransaction({to: externalStakingDistributor.address, value: ethers.utils.parseEther("1")});
+
+ const MultisigGuard = await ethers.getContractFactory("MultisigGuard");
+ multisigGuard = await MultisigGuard.deploy(serviceRegistryTokenUtility.address, externalStakingDistributor.address);
+ await multisigGuard.deployed();
+ const multisigGuardProxy = await ExternalStakingDistributorProxy.deploy(multisigGuard.address,
+ multisigGuard.interface.encodeFunctionData("initialize", []));
+ await multisigGuardProxy.deployed();
+ multisigGuard = await ethers.getContractAt("MultisigGuard", multisigGuardProxy.address);
+
+ const BridgeRelayer = await ethers.getContractFactory("BridgeRelayer");
+ bridgeRelayer = await BridgeRelayer.deploy(olas.address);
+ await bridgeRelayer.deployed();
+ const GnosisDepositProcessorL1 = await ethers.getContractFactory("GnosisDepositProcessorL1");
+ gnosisDepositProcessorL1 = await GnosisDepositProcessorL1.deploy(olas.address, depository.address,
+ bridgeRelayer.address, bridgeRelayer.address);
+ await gnosisDepositProcessorL1.deployed();
+ const GnosisStakingProcessorL2 = await ethers.getContractFactory("GnosisStakingProcessorL2");
+ gnosisStakingProcessorL2 = await GnosisStakingProcessorL2.deploy(olas.address, stakingManager.address,
+ externalStakingDistributor.address, collector.address, bridgeRelayer.address, bridgeRelayer.address,
+ gnosisDepositProcessorL1.address, chainId);
+ await gnosisStakingProcessorL2.deployed();
+
+ await collector.changeStakingManager(stakingManager.address);
+ await collector.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await stakingManager.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await externalStakingDistributor.changeStakingProcessorL2(gnosisStakingProcessorL2.address);
+ await externalStakingDistributor.changeMultisigGuard(multisigGuard.address);
+ await gnosisDepositProcessorL1.setL2StakingProcessor(gnosisStakingProcessorL2.address);
+ await depository.setDepositProcessorChainIds([gnosisDepositProcessorL1.address], [gnosisChainId]);
+
+ const ActivityChecker = await ethers.getContractFactory("ModuleActivityChecker");
+ activityChecker = await ActivityChecker.deploy(livenessRatio);
+ await activityChecker.deployed();
+ serviceParams.activityChecker = activityChecker.address;
+
+ const StakingTokenLocked = await ethers.getContractFactory("StakingTokenLocked");
+ stakingTokenImplementation = await StakingTokenLocked.deploy();
+ await stakingTokenImplementation.deployed();
+ await stakingVerifier.setImplementationsStatuses([stakingTokenImplementation.address], [true], true);
+ initPayload = stakingTokenImplementation.interface.encodeFunctionData("initialize", [serviceParams]);
+ let tx = await stakingFactory.createStakingInstance(stakingTokenImplementation.address, initPayload);
+ let res = await tx.wait();
+ const stakingTokenAddress = "0x" + res.logs[0].topics[2].slice(26);
+ stakingTokenInstance = await ethers.getContractAt("StakingTokenLocked", stakingTokenAddress);
+
+ const ExternalActivityChecker = await ethers.getContractFactory("StakingActivityChecker");
+ externalActivityChecker = await ExternalActivityChecker.deploy(externalLivenessRatio);
+ await externalActivityChecker.deployed();
+ const StakingTokenV1 = await ethers.getContractFactory("StakingTokenV1");
+ externalStakingTokenImplementationV1 = await StakingTokenV1.deploy();
+ await externalStakingTokenImplementationV1.deployed();
+ const StakingTokenV2 = await ethers.getContractFactory("StakingToken");
+ externalStakingTokenImplementationV2 = await StakingTokenV2.deploy();
+ await externalStakingTokenImplementationV2.deployed();
+ await stakingVerifier.setImplementationsStatuses([externalStakingTokenImplementationV1.address,
+ externalStakingTokenImplementationV2.address], [true, true], true);
+
+ await serviceRegistry.changeManager(serviceManager.address);
+ await serviceRegistryTokenUtility.changeManager(serviceManager.address);
+ await serviceRegistry.changeMultisigPermission(gnosisSafeMultisig.address, true);
+ await serviceRegistry.changeMultisigPermission(gnosisSafeSameAddressMultisig.address, true);
+ await serviceRegistry.changeMultisigPermission(recoveryModule.address, true);
+
+ await olas.approve(stakingTokenAddress, stakingSupply);
+ await stakingTokenInstance.deposit(stakingSupply);
+ await depository.createAndActivateStakingModels([gnosisChainId], [stakingTokenAddress], [fullStakeDeposit],
+ [maxNumServices]);
+ await collector.setOperationReceivers([rewardOperation, unstakeOperation, unstakeRetiredOperation],
+ [distributor.address, treasury.address, unstakeRelayer.address]);
+ });
+
+ it("attacker can only take the 2.5% curator cut; proxy claim/unstake are ESD-owner-gated; Safe holds ~0 to drain", async function () {
+ this.timeout(1600000);
+ const eth = (x) => ethers.utils.formatEther(x);
+ const olasAmount = minStakingDeposit.mul(8); // 80,000 OLAS of protocol funds into the ESD
+ const attacker = signers[1];
+
+ console.log("\n============================================================");
+ console.log(" PoC (I-1): a Safe-controlling staker cannot capture protocol funds on external-V1");
+ console.log("============================================================");
+
+ // ---- Fund the ExternalStakingDistributor with PROTOCOL OLAS (deposit -> depositExternal) ----
+ await olas.approve(depository.address, initSupply);
+ await depository.deposit(olasAmount, [], [], [], []);
+ await depository.setExternalStakingDistributorChainIds([gnosisChainId], [externalStakingDistributor.address]);
+ await depository.depositExternal([gnosisChainId], [olasAmount], [bridgePayload], [0]);
+ const esdFunded = await olas.balanceOf(externalStakingDistributor.address);
+ console.log("[setup] ESD protocol OLAS balance:", eth(esdFunded));
+ expect(esdFunded).to.equal(olasAmount);
+
+ // ---- Deploy a V1 staking proxy with stakingGuard == address(0) (permissionless staking) ----
+ let externalServiceParams = {
+ metadataHash: defaultHash, maxNumServices: 3, rewardsPerSecond: "5" + "0".repeat(14),
+ minStakingDeposit: regDeposit, minNumStakingPeriods: 3, maxNumInactivityPeriods: 3, livenessPeriod: 10,
+ timeForEmissions: 100, numAgentInstances: 1, agentIds, threshold: 0, configHash: HashZero,
+ proxyHash: bytecodeHash, serviceRegistry: serviceRegistry.address, activityChecker: externalActivityChecker.address
+ };
+ const maxInactivity = externalServiceParams.maxNumInactivityPeriods * livenessPeriod + 1;
+ let initPayload = externalStakingTokenImplementationV1.interface.encodeFunctionData("initialize",
+ [externalServiceParams, serviceRegistryTokenUtility.address, olas.address]);
+ let tx = await stakingFactory.createStakingInstance(externalStakingTokenImplementationV1.address, initPayload);
+ let res = await tx.wait();
+ const proxyV1 = "0x" + res.logs[0].topics[2].slice(26);
+ const proxyV1Inst = await ethers.getContractAt("StakingTokenV1", proxyV1);
+
+ // V1 config with stakingGuard = AddressZero, 80% collector / 17.5% protocol / 2.5% curatingAgent
+ const cfgV1 = await externalStakingDistributor.wrapStakingConfig(AddressZero, 8000, 1750, 250, 0);
+ await externalStakingDistributor.setStakingProxyConfigs([proxyV1], [cfgV1]);
+ const [cfgGuard] = await externalStakingDistributor.unwrapStakingConfig(cfgV1);
+ console.log("[setup] V1 proxy stakingGuard:", cfgGuard, "(address(0) => ANY caller may stake)");
+ expect(cfgGuard).to.equal(AddressZero);
+
+ // Fund the V1 proxy with rewards to distribute
+ await olas.approve(proxyV1, stakingSupply);
+ await proxyV1Inst.deposit(stakingSupply);
+
+ // ---- ATTACKER stakes the service with agentInstance = attacker (deposit comes from ESD protocol OLAS) ----
+ await externalStakingDistributor.connect(attacker).stake(proxyV1, 0, 0, defaultHash, attacker.address);
+
+ // The attacker is the curating agent recorded for this service (their legitimate 2.5% cut).
+ const curatingAgent = await externalStakingDistributor.mapServiceIdCuratingAgents(serviceId);
+ console.log("[setup] recorded curatingAgent for the service:", curatingAgent, "(== attacker; their 2.5% is legit)");
+ expect(curatingAgent).to.equal(attacker.address);
+
+ // ---- The service Safe is owned SOLELY by the attacker; ESD + guard are modules, guard set ----
+ const service = await serviceRegistry.getService(serviceId);
+ const multisig = service.multisig;
+ const multisigV1 = await ethers.getContractAt("GnosisSafe", multisig);
+ const owners = await multisigV1.getOwners();
+ const threshold = await multisigV1.getThreshold();
+ console.log("[safe] service multisig:", multisig);
+ console.log("[safe] owners:", owners, " threshold:", threshold.toString());
+ expect(owners[0]).to.equal(attacker.address);
+ expect(threshold).to.equal(1);
+
+ // ---- Confirm the proxy records the ESD (NOT the attacker) as sInfo.owner ----
+ const sInfo = await proxyV1Inst.mapServiceInfo(serviceId);
+ console.log("[proxy] StakingTokenV1 sInfo.owner:", sInfo.owner, "(== ESD, the claim/unstake authority)");
+ expect(sInfo.owner).to.equal(externalStakingDistributor.address);
+
+ // ---- Accrue a REAL V1 staking reward. Bump the multisig nonce (liveness) as owner, then checkpoint. ----
+ let nonce = await multisigV1.nonce();
+ await safeContracts.executeTxWithSigners(multisigV1, {
+ to: multisig, value: 0, data: multisigV1.interface.encodeFunctionData("getThreshold", []),
+ operation: 0, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: AddressZero, refundReceiver: AddressZero, nonce
+ }, [attacker]);
+ await helpers.time.increase(maxInactivity);
+ await proxyV1Inst.checkpoint();
+
+ const reward = await proxyV1Inst.calculateStakingReward(serviceId);
+ console.log("[reward] real accrued V1 reward for the service:", eth(reward), "OLAS");
+ expect(reward).to.be.gt(0);
+
+ // =====================================================================================
+ // ASSERTION 1: the attacker CANNOT force the reward onto the Safe on their own.
+ // proxy.claim / proxy.checkpointAndClaim / proxy.unstake are gated to sInfo.owner == ESD.
+ // =====================================================================================
+ console.log("\n--- ASSERTION 1: attacker cannot force the reward onto the Safe (OwnerOnly gates) ---");
+ await expect(proxyV1Inst.connect(attacker).claim(serviceId)).to.be.reverted;
+ console.log("[gate] proxyV1.claim(serviceId) as attacker : REVERTED (OwnerOnly)");
+ await expect(proxyV1Inst.connect(attacker).checkpointAndClaim(serviceId)).to.be.reverted;
+ console.log("[gate] proxyV1.checkpointAndClaim(serviceId) as attacker : REVERTED (OwnerOnly)");
+ await expect(proxyV1Inst.connect(attacker).unstake(serviceId)).to.be.reverted;
+ console.log("[gate] proxyV1.unstake(serviceId) as attacker : REVERTED (OwnerOnly)");
+
+ // Sanity: reward is still unpaid, Safe still holds 0 OLAS (nothing landed).
+ expect(await olas.balanceOf(multisig)).to.equal(0);
+ console.log("[gate] multisig OLAS after failed attacker claims :", eth(await olas.balanceOf(multisig)), "OLAS (reward never landed)");
+
+ // =====================================================================================
+ // ASSERTION 2: the real permissionless ESD.claim distributes correctly.
+ // Measure Collector OLAS gain, Collector.protocolBalance gain, and the attacker's (curator) gain.
+ // =====================================================================================
+ console.log("\n--- ASSERTION 2: real ESD.claim distributes 80% collector / 17.5% protocol / 2.5% curator ---");
+ const attackerBefore = await olas.balanceOf(attacker.address);
+ const collectorBefore = await olas.balanceOf(collector.address);
+ const protocolBalBefore = await collector.protocolBalance();
+
+ // Anyone (here: the attacker themselves) may call the permissionless ESD.claim.
+ await externalStakingDistributor.connect(attacker).claim([proxyV1], [serviceId]);
+
+ const attackerAfterClaim = await olas.balanceOf(attacker.address);
+ const collectorAfter = await olas.balanceOf(collector.address);
+ const protocolBalAfter = await collector.protocolBalance();
+
+ const collectorGain = collectorAfter.sub(collectorBefore); // collectorAmount + protocolAmount (both land on Collector)
+ const protocolGain = protocolBalAfter.sub(protocolBalBefore); // protocol's 17.5% (accounted in protocolBalance)
+ const curatorGain = attackerAfterClaim.sub(attackerBefore); // attacker == curatingAgent -> 2.5%
+
+ // Intended split of R
+ const intendedCollector = reward.mul(8000).div(10000); // 80%
+ const intendedProtocol = reward.mul(1750).div(10000); // 17.5%
+ const intendedCurator = reward.sub(intendedCollector).sub(intendedProtocol); // 2.5%
+ const intendedCollectorTotal = intendedCollector.add(intendedProtocol); // Collector OLAS receives 97.5%
+
+ console.log("[claim] Collector OLAS received (collector 80% + protocol 17.5%):", eth(collectorGain), "OLAS");
+ console.log("[claim] Collector.protocolBalance increased by (protocol 17.5%) :", eth(protocolGain), "OLAS");
+ console.log("[claim] curatingAgent(attacker) OLAS received (their 2.5% cut) :", eth(curatorGain), "OLAS");
+ console.log(" --- intended split of R =", eth(reward), "OLAS ---");
+ console.log(" collector 80.0% :", eth(intendedCollector), "OLAS");
+ console.log(" protocol 17.5% :", eth(intendedProtocol), "OLAS");
+ console.log(" curator 2.5% :", eth(intendedCurator), "OLAS");
+
+ // Collector OLAS balance rose by ~97.5% of R (collectorAmount + protocolAmount).
+ expect(collectorGain).to.equal(intendedCollectorTotal);
+ // protocolBalance rose by exactly the 17.5% protocol share.
+ expect(protocolGain).to.equal(intendedProtocol);
+ // The attacker (as curating agent) got ONLY the 2.5% cut -- not more.
+ expect(curatorGain).to.equal(intendedCurator);
+ expect(curatorGain.mul(10000).div(reward)).to.be.lte(250); // <= 2.5%
+
+ // =====================================================================================
+ // ASSERTION 3: after ESD.claim there is ~0 on the Safe -> the "drain" moves ~0.
+ // =====================================================================================
+ console.log("\n--- ASSERTION 3: no standing reward on the Safe; attacker's drain moves ~0 ---");
+ const multisigAfterClaim = await olas.balanceOf(multisig);
+ console.log("[drain] multisig OLAS after ESD.claim (nothing to steal):", eth(multisigAfterClaim), "OLAS");
+ expect(multisigAfterClaim).to.be.lte(ethers.BigNumber.from(10)); // dust <= a few wei
+
+ // The attacker, as sole Safe owner, runs a REAL guard-checked execTransaction sweeping the Safe.
+ const attackerBeforeDrain = await olas.balanceOf(attacker.address);
+ nonce = await multisigV1.nonce();
+ const drainResp = await safeContracts.executeTxWithSigners(multisigV1, {
+ to: olas.address, value: 0,
+ data: olas.interface.encodeFunctionData("transfer", [attacker.address, multisigAfterClaim]),
+ operation: 0, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: AddressZero, refundReceiver: AddressZero, nonce
+ }, [attacker]);
+ const drainRcpt = await drainResp.wait();
+ const drained = (await olas.balanceOf(attacker.address)).sub(attackerBeforeDrain);
+ console.log("[drain] attacker execTransaction status:", drainRcpt.status === 1 ? "SUCCESS (guard did NOT block)" : "FAILED");
+ console.log("[drain] attacker drained from Safe: ~0 (actual:", eth(drained), "OLAS)");
+ expect(drainRcpt.status).to.equal(1); // the guard genuinely permits the owner-sweep...
+ expect(drained).to.be.lte(ethers.BigNumber.from(10)); // ...but there is ~0 to sweep -> no theft
+
+ // =====================================================================================
+ // ASSERTION 4 (conclusion): attacker total gain <= 2.5% + dust; protocol kept >= 97.5%.
+ // =====================================================================================
+ console.log("\n--- ASSERTION 4: conclusion ---");
+ const attackerTotalGain = (await olas.balanceOf(attacker.address)).sub(attackerBefore);
+ const protocolKept = collectorGain; // 97.5% of R now held by the Collector (collector + protocol shares)
+ console.log("[concl] attacker TOTAL OLAS gain over the whole scenario:", eth(attackerTotalGain), "OLAS");
+ console.log("[concl] protocol (Collector) kept :", eth(protocolKept), "OLAS");
+ console.log("[concl] attacker share of R:", attackerTotalGain.mul(10000).div(reward).toString(),
+ "bps | protocol share of R:", protocolKept.mul(10000).div(reward).toString(), "bps");
+
+ // attacker total <= 2.5% + a few wei dust
+ expect(attackerTotalGain).to.be.lte(intendedCurator.add(ethers.BigNumber.from(10)));
+ // protocol kept >= 97.5%
+ expect(protocolKept).to.be.gte(intendedCollectorTotal);
+ expect(protocolKept.mul(10000).div(reward)).to.be.gte(9750); // >= 97.5%
+
+ console.log("\n DISPROOF PASSED: the attack does NOT steal protocol funds. Attacker gets only the legit 2.5%");
+ console.log(" curator cut; Collector/protocol keep >= 97.5%; the Safe holds ~0 after the atomic ESD.claim.\n");
+ });
+});