Skip to content

Commit

Permalink
Add collection of failed treasury fees
Browse files Browse the repository at this point in the history
  • Loading branch information
Mike-CZ committed Nov 26, 2024
1 parent 67e94dd commit 3742645
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 24 deletions.
31 changes: 30 additions & 1 deletion contracts/sfc/SFC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pragma solidity 0.8.27;

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import {Decimal} from "../common/Decimal.sol";
import {NodeDriverAuth} from "./NodeDriverAuth.sol";
import {ConstantsManager} from "./ConstantsManager.sol";
Expand All @@ -13,7 +14,7 @@ import {Version} from "../version/Version.sol";
* @notice The SFC maintains a list of validators and delegators and distributes rewards to them.
* @custom:security-contact security@fantom.foundation
*/
contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, Version {
uint256 internal constant OK_STATUS = 0;
uint256 internal constant WITHDRAWN_BIT = 1;
uint256 internal constant OFFLINE_BIT = 1 << 3;
Expand Down Expand Up @@ -50,6 +51,9 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
// total stake of active (OK_STATUS) validators (total weight)
uint256 public totalActiveStake;

// unresolved fees that failed to be send to the treasury
uint256 public unresolvedTreasuryFees;

// delegator => validator ID => stashed rewards (to be claimed/restaked)
mapping(address delegator => mapping(uint256 validatorID => uint256 stashedRewards)) internal _rewardsStash;

Expand Down Expand Up @@ -190,6 +194,10 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
error ValidatorNotSlashed();
error RefundRatioTooHigh();

// treasury
error TreasuryNotSet();
error NoUnresolvedTreasuryFees();

event DeactivatedValidator(uint256 indexed validatorID, uint256 deactivatedEpoch, uint256 deactivatedTime);
event ChangedValidatorStatus(uint256 indexed validatorID, uint256 status);
event CreatedValidator(
Expand All @@ -207,6 +215,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
event UpdatedSlashingRefundRatio(uint256 indexed validatorID, uint256 refundRatio);
event RefundedSlashedLegacyDelegation(address indexed delegator, uint256 indexed validatorID, uint256 amount);
event AnnouncedRedirection(address indexed from, address indexed to);
event TreasuryFeesResolved(uint256 amount);

modifier onlyDriver() {
if (!isNode(msg.sender)) {
Expand All @@ -226,6 +235,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
) external initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
__ReentrancyGuard_init();
currentSealedEpoch = sealedEpoch;
node = NodeDriverAuth(nodeDriver);
c = ConstantsManager(_c);
Expand Down Expand Up @@ -419,6 +429,22 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
}
}

/// Resolve failed treasury transfers and send the unresolved fees to the treasury address.
function resolveTreasuryFees() external nonReentrant {
if (treasuryAddress == address(0)) {
revert TreasuryNotSet();
}
if (unresolvedTreasuryFees == 0) {
revert NoUnresolvedTreasuryFees();
}
(bool success, ) = treasuryAddress.call{value: unresolvedTreasuryFees, gas: 1000000}("");
if (!success) {
revert TransferFailed();
}
emit TreasuryFeesResolved(unresolvedTreasuryFees);
unresolvedTreasuryFees = 0;
}

/// burnFTM allows SFC to burn an arbitrary amount of FTM tokens.
function burnFTM(uint256 amount) external onlyOwner {
_burnFTM(amount);
Expand Down Expand Up @@ -909,6 +935,9 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version {
if (!success) {
// ignore treasury transfer failure
// the treasury failure must not endanger the epoch sealing

// store the unresolved treasury fees to be resolved later
unresolvedTreasuryFees += feeShare;
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions contracts/test/FailingReceiver.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.27;

contract FailingReceiver {
// Fallback function to reject any received Ether
receive() external payable {
revert("Forced transfer failure");
}
}
92 changes: 75 additions & 17 deletions test/SFC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,10 @@ describe('SFC', () => {
});

it('Should succeed and seal epochs', async function () {
const validatorsMetrics: Map<number, ValidatorMetrics> = new Map();
const validatorsMetrics: Map<bigint, ValidatorMetrics> = new Map();
const validatorIDs = await this.sfc.lastValidatorID();

for (let i = 0; i < validatorIDs; i++) {
for (let i = 1n; i <= validatorIDs; i++) {
validatorsMetrics.set(i, {
offlineTime: 0,
offlineBlocks: 0,
Expand All @@ -661,8 +661,8 @@ describe('SFC', () => {
const offlineBlocks = [];
const uptimes = [];
const originatedTxsFees = [];
for (let i = 0; i < validatorIDs; i++) {
allValidators.push(i + 1);
for (let i = 1n; i <= validatorIDs; i++) {
allValidators.push(i);
offlineTimes.push(validatorsMetrics.get(i)!.offlineTime);
offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks);
uptimes.push(validatorsMetrics.get(i)!.uptime);
Expand All @@ -674,11 +674,69 @@ describe('SFC', () => {
await this.sfc.sealEpochValidators(allValidators);
});

describe('Treasury', () => {
it('Should revert when treasury is not set', async function () {
await expect(this.sfc.resolveTreasuryFees()).to.be.revertedWithCustomError(this.sfc, 'TreasuryNotSet');
});

it('Should revert when no unresolved treasury fees are available', async function () {
const treasury = ethers.Wallet.createRandom();
await this.sfc.connect(this.owner).updateTreasuryAddress(treasury);
await expect(this.sfc.resolveTreasuryFees()).to.be.revertedWithCustomError(
this.sfc,
'NoUnresolvedTreasuryFees',
);
});

it('Should succeed and resolve treasury fees', async function () {
// set treasury as failing receiver to trigger treasury fee accumulation
const failingReceiver = await ethers.deployContract('FailingReceiver');
await this.sfc.connect(this.owner).updateTreasuryAddress(failingReceiver);

// set validators metrics and their fees
const validatorsMetrics: Map<bigint, ValidatorMetrics> = new Map();
const validatorIDs = await this.sfc.lastValidatorID();
for (let i = 1n; i <= validatorIDs; i++) {
validatorsMetrics.set(i, {
offlineTime: 0,
offlineBlocks: 0,
uptime: 24 * 60 * 60,
originatedTxsFee: ethers.parseEther('100'),
});
}

// seal epoch to trigger fees calculation and distribution
await this.blockchainNode.sealEpoch(24 * 60 * 60, validatorsMetrics);

const fees =
(validatorIDs * ethers.parseEther('100') * (await this.constants.treasuryFeeShare())) / BigInt(1e18);
expect(await this.sfc.unresolvedTreasuryFees()).to.equal(fees);

// update treasury to a valid receiver
const treasury = ethers.Wallet.createRandom();
await this.sfc.connect(this.owner).updateTreasuryAddress(treasury);

// set sfc some balance to cover treasury fees
// the funds cannot be sent directly as it rejects any incoming transfers
await ethers.provider.send('hardhat_setBalance', [
await this.sfc.getAddress(),
ethers.toBeHex(ethers.parseEther('1000')),
]);

// resolve treasury fees
const tx = await this.sfc.resolveTreasuryFees();
await expect(tx).to.emit(this.sfc, 'TreasuryFeesResolved').withArgs(fees);
await expect(tx).to.changeEtherBalance(treasury, fees);
await expect(tx).to.changeEtherBalance(this.sfc, -fees);
expect(await this.sfc.unresolvedTreasuryFees()).to.equal(0);
});
});

it('Should succeed and seal epoch on Validators', async function () {
const validatorsMetrics: Map<number, ValidatorMetrics> = new Map();
const validatorsMetrics: Map<bigint, ValidatorMetrics> = new Map();
const validatorIDs = await this.sfc.lastValidatorID();

for (let i = 0; i < validatorIDs; i++) {
for (let i = 1n; i <= validatorIDs; i++) {
validatorsMetrics.set(i, {
offlineTime: 0,
offlineBlocks: 0,
Expand All @@ -692,8 +750,8 @@ describe('SFC', () => {
const offlineBlocks = [];
const uptimes = [];
const originatedTxsFees = [];
for (let i = 0; i < validatorIDs; i++) {
allValidators.push(i + 1);
for (let i = 1n; i <= validatorIDs; i++) {
allValidators.push(i);
offlineTimes.push(validatorsMetrics.get(i)!.offlineTime);
offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks);
uptimes.push(validatorsMetrics.get(i)!.uptime);
Expand Down Expand Up @@ -746,10 +804,10 @@ describe('SFC', () => {
});

it('Should revert when calling sealEpoch if not NodeDriver', async function () {
const validatorsMetrics: Map<number, ValidatorMetrics> = new Map();
const validatorsMetrics: Map<bigint, ValidatorMetrics> = new Map();
const validatorIDs = await this.sfc.lastValidatorID();

for (let i = 0; i < validatorIDs; i++) {
for (let i = 1n; i <= validatorIDs; i++) {
validatorsMetrics.set(i, {
offlineTime: 0,
offlineBlocks: 0,
Expand All @@ -763,8 +821,8 @@ describe('SFC', () => {
const offlineBlocks = [];
const uptimes = [];
const originatedTxsFees = [];
for (let i = 0; i < validatorIDs; i++) {
allValidators.push(i + 1);
for (let i = 1n; i <= validatorIDs; i++) {
allValidators.push(i);
offlineTimes.push(validatorsMetrics.get(i)!.offlineTime);
offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks);
uptimes.push(validatorsMetrics.get(i)!.uptime);
Expand Down Expand Up @@ -982,7 +1040,7 @@ describe('SFC', () => {
// validator online 100% of time in the first epoch => average 100%
await this.blockchainNode.sealEpoch(
100,
new Map<number, ValidatorMetrics>([[this.validatorId as number, new ValidatorMetrics(0, 0, 100, 0n)]]),
new Map<bigint, ValidatorMetrics>([[this.validatorId, new ValidatorMetrics(0, 0, 100, 0n)]]),
);
expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal(
1000000000000000000n,
Expand All @@ -991,7 +1049,7 @@ describe('SFC', () => {
// validator online 20% of time in the second epoch => average 60%
await this.blockchainNode.sealEpoch(
100,
new Map<number, ValidatorMetrics>([[this.validatorId as number, new ValidatorMetrics(0, 0, 20, 0n)]]),
new Map<bigint, ValidatorMetrics>([[this.validatorId, new ValidatorMetrics(0, 0, 20, 0n)]]),
);
expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal(
600000000000000000n,
Expand All @@ -1000,7 +1058,7 @@ describe('SFC', () => {
// validator online 30% of time in the third epoch => average 50%
await this.blockchainNode.sealEpoch(
100,
new Map<number, ValidatorMetrics>([[this.validatorId as number, new ValidatorMetrics(0, 0, 30, 0n)]]),
new Map<bigint, ValidatorMetrics>([[this.validatorId, new ValidatorMetrics(0, 0, 30, 0n)]]),
);
expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal(
500000000000000000n,
Expand All @@ -1010,7 +1068,7 @@ describe('SFC', () => {
for (let i = 0; i < 10; i++) {
await this.blockchainNode.sealEpoch(
100,
new Map<number, ValidatorMetrics>([[this.validatorId as number, new ValidatorMetrics(0, 0, 50, 0n)]]),
new Map<bigint, ValidatorMetrics>([[this.validatorId, new ValidatorMetrics(0, 0, 50, 0n)]]),
);
expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal(
500000000000000000n,
Expand All @@ -1020,7 +1078,7 @@ describe('SFC', () => {
// (50 * 10 + 28) / 11 = 48
await this.blockchainNode.sealEpoch(
100,
new Map<number, ValidatorMetrics>([[this.validatorId as number, new ValidatorMetrics(0, 0, 28, 0n)]]),
new Map<bigint, ValidatorMetrics>([[this.validatorId, new ValidatorMetrics(0, 0, 28, 0n)]]),
);
expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal(
480000000000000000n,
Expand Down
12 changes: 6 additions & 6 deletions test/helpers/BlockchainNode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SFCUnitTestI } from '../../typechain-types';
import { UnitTestSFC } from '../../typechain-types';
import { TransactionResponse } from 'ethers';
import { ethers } from 'hardhat';

Expand All @@ -17,11 +17,11 @@ class ValidatorMetrics {
}

class BlockchainNode {
public readonly sfc: SFCUnitTestI;
public validatorWeights: Map<number, bigint>;
public nextValidatorWeights: Map<number, bigint>;
public readonly sfc: UnitTestSFC;
public validatorWeights: Map<bigint, bigint>;
public nextValidatorWeights: Map<bigint, bigint>;

constructor(sfc: SFCUnitTestI) {
constructor(sfc: UnitTestSFC) {
this.sfc = sfc;
this.validatorWeights = new Map();
this.nextValidatorWeights = new Map();
Expand All @@ -44,7 +44,7 @@ class BlockchainNode {
}
}

async sealEpoch(duration: number, validatorMetrics?: Map<number, ValidatorMetrics>) {
async sealEpoch(duration: number, validatorMetrics?: Map<bigint, ValidatorMetrics>) {
const validatorIds = Array.from(this.validatorWeights.keys());
const nextValidatorIds = Array.from(this.nextValidatorWeights.keys());

Expand Down

0 comments on commit 3742645

Please sign in to comment.