Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/config/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("Load configuration from JSON file", () => {
})
it("reads prod Polygon RPC URL", () => {
const rpcHttpUrl = config.polygon.rpcEndpoints[0].url
const expected = "https://polygon-rpc.com"
const expected = "https://polygon.drpc.org"
assert.equal(rpcHttpUrl, expected, `Expecting prod polygon RPC URL to equal ${expected}, got '${rpcHttpUrl}'`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -781,8 +781,9 @@ contract Operator is Initializable, ERC2771ContextUpgradeable, IERC677Receiver,
* 1: latestDelegationTimestamp[delegator] added in 2024-01-24 (ETH-717)
* 2: recompilation and redeployment (ETH-748)
* 3: slash self-delegation when getting kicked (ETH-754)
* 4: withdrawEarningsFromSponsorships only accepts staked Sponsorships and measures earnings from the DATA balance change (2026-10-09 Operator drain)
*/
function version() public pure returns (uint) {
return 3;
return 4;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,25 @@ contract StakeModule is IStakeModule, Operator {
emit OperatorValueUpdate(totalStakedIntoSponsorshipsWei - totalSlashedInSponsorshipsWei, token.balanceOf(address(this)));
}

/** @dev this is in stakeModule because it calls _splitEarnings */
/**
* Withdraw earnings from the given Sponsorships and split them (protocol fee, operator's cut, delegators' profit).
* Only Sponsorships this Operator is currently staked in are accepted, and the earnings are measured as the actual change
* in this contract's DATA balance, NOT as the number returned by `withdraw()`.
* @dev This function is callable by anyone (via withdrawEarningsFromSponsorships), so the input must NOT be trusted:
* @dev an attacker-controlled contract could return arbitrary "earnings" from withdraw() without sending any DATA,
* @dev and the fisherman reward + protocol fee of those fake earnings would then be paid out of this contract's own DATA balance.
* @dev this is in stakeModule because it calls _splitEarnings
*/
function _withdrawEarnings(Sponsorship[] memory sponsorshipAddresses) public returns (uint sumEarnings) {
uint balanceBeforeWei = token.balanceOf(address(this));
for (uint i; i < sponsorshipAddresses.length; i++) {
sumEarnings += sponsorshipAddresses[i].withdraw(); // this contract receives DATA tokens
Sponsorship sponsorship = sponsorshipAddresses[i];
if (indexOfSponsorships[sponsorship] == 0) {
revert NotMyStakedSponsorship();
}
sponsorship.withdraw(); // this contract receives DATA tokens
}
sumEarnings = token.balanceOf(address(this)) - balanceBeforeWei;
if (sumEarnings == 0) {
revert NoEarnings();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship {
bool failEmptyOnIncrease;
bool sendDataWithFailInGetInsolvencyTimestamp;
bool failInGetInsolvencyTimestamp;
bool reportFakePayoutOnWithdraw;
}

function localData() internal view returns(LocalStorage storage data) {
bytes32 storagePosition = keccak256(abi.encodePacked("sponsorship.storage.TestAllocationPolicy", address(this)));
assembly {data.slot := storagePosition} // solhint-disable-line no-inline-assembly
}

// solhint-disable-next-line code-complexity
function setParam(uint testCase) external {
if (testCase == 1) {
require(false, "test_setParam");
Expand All @@ -43,6 +45,8 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship {
localData().sendDataWithFailInGetInsolvencyTimestamp = true;
} else if (testCase == 10) {
localData().failInGetInsolvencyTimestamp = true;
} else if (testCase == 11) {
localData().reportFakePayoutOnWithdraw = true;
}
}

Expand Down Expand Up @@ -86,7 +90,15 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship {
}
}

function onWithdraw(address) external pure returns (uint payoutWei) {
/**
* Test case 11: report a payout that is larger than the Sponsorship's DATA balance.
* Sponsorship._withdraw then does `try token.transferAndCall(...) {} catch {}`, so the transfer fails silently
* and withdraw() returns a number even though NO DATA was actually sent to the Operator.
*/
function onWithdraw(address) external view returns (uint payoutWei) {
if (localData().reportFakePayoutOnWithdraw) {
return token.balanceOf(address(this)) + 1 ether;
}
return 0;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

/**
* Minimal "sponsorship" that was NOT deployed by the SponsorshipFactory and that the Operator is NOT staked in.
* Its withdraw() returns whatever number the caller has set, but never transfers any DATA tokens.
* Models the fake sponsorship used in the 2026-10-09 Operator drain: Operator._withdrawEarnings trusted the return value
* of withdraw() and paid out protocol fee + fisherman reward from the Operator's own DATA balance.
*/
contract TestFakeSponsorship {
uint public fakeEarningsWei;

function setFakeEarnings(uint fakeEarningsWei_) external {
fakeEarningsWei = fakeEarningsWei_;
}

function withdraw() external view returns (uint payoutWei) {
return fakeEarningsWei;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { advanceToTimestamp, getBlockTimestamp, VOTE_KICK, VOTE_START, log } fro
import { deployOperatorContract } from "./deployOperatorContract"

import { deploySponsorship } from "./deploySponsorshipContract"
import { IKickPolicy, IExchangeRatePolicy, Operator, Sponsorship, TestToken } from "../../../typechain"
import { IAllocationPolicy, IKickPolicy, IExchangeRatePolicy, Operator, Sponsorship, TestToken } from "../../../typechain"
import { setupSponsorships } from "./setupSponsorships"

import type { Wallet } from "ethers"
Expand Down Expand Up @@ -35,6 +35,7 @@ describe("Operator contract", (): void => {
let defaultOperator: Operator
let defaultSponsorship: Sponsorship
let testKickPolicy: IKickPolicy
let testAllocationPolicy: IAllocationPolicy
let testExchangeRatePolicy: IExchangeRatePolicy
let testExchangeRatePolicy2: IExchangeRatePolicy
let testExchangeRatePolicy3: IExchangeRatePolicy
Expand Down Expand Up @@ -82,7 +83,8 @@ describe("Operator contract", (): void => {
token = sharedContracts.token

testKickPolicy = await (await (await getContractFactory("TestKickPolicy", admin)).deploy()).deployed() as unknown as IKickPolicy
await (await sharedContracts.sponsorshipFactory.addTrustedPolicies([ testKickPolicy.address])).wait()
testAllocationPolicy = await (await getContractFactory("TestAllocationPolicy", admin)).deploy() as unknown as IAllocationPolicy
await (await sharedContracts.sponsorshipFactory.addTrustedPolicies([ testKickPolicy.address, testAllocationPolicy.address ])).wait()

testExchangeRatePolicy = await (await getContractFactory("TestExchangeRatePolicy", admin)).deploy() as IExchangeRatePolicy
testExchangeRatePolicy2 = await (await getContractFactory("TestExchangeRatePolicy2", admin)).deploy() as IExchangeRatePolicy
Expand Down Expand Up @@ -1198,6 +1200,89 @@ describe("Operator contract", (): void => {
expect(formatEther(await operator.balanceInData(delegator.address))).to.equal("15700.0")
})

// Regression test for the 2026-10-09 Operator drain: a fake "sponsorship" whose withdraw() returns a made-up number (and sends no DATA)
// was passed to withdrawEarningsFromSponsorships by an outsider. The fake earnings X = freeBalance / (protocolFee + fishermanReward)
// triggered the fisherman path, and 5% + 25% = 30% of X == the whole free DATA balance was paid out of the Operator's own DATA.
// Before the fix (StakeModule version 4) this test's transaction succeeded and the Operator was left with 0 DATA.
it("does NOT pay fisherman reward or protocol fee for a fake sponsorship given to withdrawEarningsFromSponsorships", async () => {
const attacker = (await getSigners())[9] as unknown as Wallet
await setTokens(operatorWallet, "1000")
await setTokens(delegator, "2000")
await setTokens(attacker, "0")
await setTokens(protocolFeeBeneficiary, "0")
const { operator } = await deployOperator(operatorWallet)
await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("1000"), "0x")).wait()
await (await token.connect(delegator).transferAndCall(operator.address, parseEther("2000"), "0x")).wait()
expect(await token.balanceOf(operator.address)).to.equal(parseEther("3000")) // free (unstaked) DATA
expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("1000")) // self-delegation
expect(await operator.balanceOf(delegator.address)).to.equal(parseEther("2000"))

// attacker deploys a contract that is NOT a Sponsorship: not from SponsorshipFactory, Operator is not staked in it, it never sends DATA
const fakeSponsorship = await (await getContractFactory("TestFakeSponsorship", attacker)).deploy()
await fakeSponsorship.deployed()
// choose X so that protocolFee (5%) + fishermanReward (25%) = 30% of X = 3000 DATA = everything the Operator holds
await (await fakeSponsorship.setFakeEarnings(parseEther("10000"))).wait()

await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([fakeSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship")
// also not when mixed in with a real sponsorship the operator is staked in
await setTokens(operatorWallet, "5000")
await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("5000"), "0x")).wait()
await (await operator.stake(defaultSponsorship.address, parseEther("5000"))).wait()
await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([defaultSponsorship.address, fakeSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship")
// and not through the triggerAnotherOperatorWithdraw fisherman path either
await expect(defaultOperator.connect(operatorWallet).triggerAnotherOperatorWithdraw(operator.address, [fakeSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship")

expect(await token.balanceOf(operator.address)).to.equal(parseEther("3000")) // 3000 + 5000 - 5000 staked, nothing drained
expect(await token.balanceOf(attacker.address)).to.equal(parseEther("0"))
expect(await token.balanceOf(protocolFeeBeneficiary.address)).to.equal(parseEther("0"))
expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("6000")) // self-delegation intact
expect(await operator.balanceInData(delegator.address)).to.equal(parseEther("2000"))
})

it("does NOT withdraw from a real Sponsorship that the Operator is not staked in", async function(): Promise<void> {
await setTokens(operatorWallet, "10000")
const { operator } = await deployOperator(operatorWallet)
const stakedSponsorship = await deploySponsorship(sharedContracts)
const notStakedSponsorship = await deploySponsorship(sharedContracts)
await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("10000"), "0x")).wait()
await (await operator.stake(stakedSponsorship.address, parseEther("5000"))).wait()

await expect(operator.withdrawEarningsFromSponsorships([notStakedSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship")
await expect(operator.withdrawEarningsFromSponsorships([stakedSponsorship.address, notStakedSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship")
})

// Second layer of defense: even a genuine Sponsorship that the Operator IS staked in must not be able to make the Operator
// book earnings that never arrived (Sponsorship._withdraw swallows transferAndCall failures but still returns the payout number)
it("counts withdrawn earnings from the DATA balance change, NOT from what Sponsorship.withdraw() returns", async function(): Promise<void> {
const attacker = (await getSigners())[9] as unknown as Wallet
await setTokens(operatorWallet, "15000")
await setTokens(attacker, "0")
await setTokens(protocolFeeBeneficiary, "0")
const { operator } = await deployOperator(operatorWallet)
// TestAllocationPolicy case 11: withdraw() reports a payout that exceeds the Sponsorship's DATA balance => no DATA is actually sent
const lyingSponsorship = await deploySponsorship(sharedContracts, {}, [], [], testAllocationPolicy, "11")
await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("15000"), "0x")).wait()
await (await operator.stake(lyingSponsorship.address, parseEther("5000"))).wait()
expect(await token.balanceOf(operator.address)).to.equal(parseEther("10000"))
const reportedPayout = await lyingSponsorship.connect(admin.provider).callStatic.withdraw({ from: operator.address })
expect(reportedPayout).to.equal(parseEther("5001")) // the lie: 5000 stake + 1 > what the Sponsorship holds

await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([lyingSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NoEarnings")
await expect(operator.withdrawEarningsFromSponsorships([lyingSponsorship.address]))
.to.be.revertedWithCustomError(operator, "NoEarnings")

expect(await token.balanceOf(operator.address)).to.equal(parseEther("10000"))
expect(await token.balanceOf(attacker.address)).to.equal(parseEther("0"))
expect(await token.balanceOf(protocolFeeBeneficiary.address)).to.equal(parseEther("0"))
expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("15000"))
})

it("can update operator cut fraction for himself, but NOT for others (and not >100%)", async function(): Promise<void> {
const { operator, contracts } = await deployOperator(operatorWallet)
const operator2 = await deployOperatorContract(contracts, operator2Wallet)
Expand Down
2 changes: 1 addition & 1 deletion packages/network-subgraphs/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:16-buster
FROM node:20-bookworm

# RUN apk add libsecret-dev
RUN apt update
Expand Down
Loading