Skip to content

Commit 842445d

Browse files
committed
fix: add a rounding error protection when receiving subgraphs or signal (OZ H-01)
1 parent 821c93a commit 842445d

File tree

7 files changed

+235
-28
lines changed

7 files changed

+235
-28
lines changed

contracts/l2/curation/IL2Curation.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,17 @@ interface IL2Curation {
2929
external
3030
view
3131
returns (uint256);
32+
33+
/**
34+
* @notice Calculate the amount of tokens that would be recovered if minting signal with
35+
* the input tokens and then burning it. This can be used to compute rounding error.
36+
* This function does not account for curation tax.
37+
* @param _subgraphDeploymentID Subgraph deployment for which to mint signal
38+
* @param _tokensIn Amount of tokens used to mint signal
39+
* @return Amount of tokens that would be recovered after minting and burning signal
40+
*/
41+
function tokensToSignalToTokensNoTax(bytes32 _subgraphDeploymentID, uint256 _tokensIn)
42+
external
43+
view
44+
returns (uint256);
3245
}

contracts/l2/curation/L2Curation.sol

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,28 @@ contract L2Curation is CurationV2Storage, GraphUpgradeable, IL2Curation {
421421
return _tokensToSignal(_subgraphDeploymentID, _tokensIn);
422422
}
423423

424+
/**
425+
* @notice Calculate the amount of tokens that would be recovered if minting signal with
426+
* the input tokens and then burning it. This can be used to compute rounding error.
427+
* This function does not account for curation tax.
428+
* @param _subgraphDeploymentID Subgraph deployment for which to mint signal
429+
* @param _tokensIn Amount of tokens used to mint signal
430+
* @return Amount of tokens that would be recovered after minting and burning signal
431+
*/
432+
function tokensToSignalToTokensNoTax(bytes32 _subgraphDeploymentID, uint256 _tokensIn)
433+
external
434+
view
435+
override
436+
returns (uint256)
437+
{
438+
require(_tokensIn != 0, "Can't calculate with 0 tokens");
439+
uint256 signal = _tokensToSignal(_subgraphDeploymentID, _tokensIn);
440+
CurationPool memory curationPool = pools[_subgraphDeploymentID];
441+
uint256 poolSignalAfter = getCurationPoolSignal(_subgraphDeploymentID).add(signal);
442+
uint256 poolTokensAfter = curationPool.tokens.add(_tokensIn);
443+
return poolTokensAfter.mul(signal).div(poolSignalAfter);
444+
}
445+
424446
/**
425447
* @notice Calculate number of tokens to get when burning signal from a curation pool.
426448
* @param _subgraphDeploymentID Subgraph deployment for which to burn signal

contracts/l2/discovery/IL2GNS.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,11 @@ interface IL2GNS is ICallhookReceiver {
4646
* @return L2 subgraph ID
4747
*/
4848
function getAliasedL2SubgraphID(uint256 _l1SubgraphID) external pure returns (uint256);
49+
50+
/**
51+
* @notice Return the unaliased L1 subgraph ID from a transferred L2 subgraph ID
52+
* @param _l2SubgraphID L2 subgraph ID
53+
* @return L1subgraph ID
54+
*/
55+
function getUnaliasedL1SubgraphID(uint256 _l2SubgraphID) external pure returns (uint256);
4956
}

contracts/l2/discovery/L2GNS.sol

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,17 @@ import { IL2Curation } from "../curation/IL2Curation.sol";
2626
contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
2727
using SafeMathUpgradeable for uint256;
2828

29+
/// Offset added to an L1 subgraph ID to compute the L2 subgraph ID alias
2930
uint256 public constant SUBGRAPH_ID_ALIAS_OFFSET =
3031
uint256(0x1111000000000000000000000000000000000000000000000000000000001111);
3132

33+
/// Maximum rounding error when receiving signal tokens from L1, in parts-per-million.
34+
/// If the error from minting signal is above this, tokens will be sent back to the curator.
35+
uint256 public constant MAX_ROUNDING_ERROR = 1000;
36+
37+
/// @dev 100% expressed in parts-per-million
38+
uint256 private constant MAX_PPM = 1000000;
39+
3240
/// @dev Emitted when a subgraph is received from L1 through the bridge
3341
event SubgraphReceivedFromL1(
3442
uint256 indexed _l1SubgraphID,
@@ -122,10 +130,32 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
122130
require(_subgraphDeploymentID != 0, "GNS: deploymentID != 0");
123131

124132
IL2Curation curation = IL2Curation(address(curation()));
125-
// Update pool: constant nSignal, vSignal can change (w/no slippage protection)
126-
// Buy all signal from the new deployment
127-
uint256 vSignal = curation.mintTaxFree(_subgraphDeploymentID, transferData.tokens);
128-
uint256 nSignal = vSignalToNSignal(_l2SubgraphID, vSignal);
133+
134+
uint256 vSignal;
135+
uint256 nSignal;
136+
uint256 roundingError;
137+
uint256 tokens = transferData.tokens;
138+
{
139+
uint256 tokensAfter = curation.tokensToSignalToTokensNoTax(
140+
_subgraphDeploymentID,
141+
tokens
142+
);
143+
roundingError = tokens.sub(tokensAfter).mul(MAX_PPM).div(tokens);
144+
}
145+
if (roundingError <= MAX_ROUNDING_ERROR) {
146+
vSignal = curation.mintTaxFree(_subgraphDeploymentID, tokens);
147+
nSignal = vSignalToNSignal(_l2SubgraphID, vSignal);
148+
emit SignalMinted(_l2SubgraphID, msg.sender, nSignal, vSignal, tokens);
149+
emit SubgraphUpgraded(_l2SubgraphID, vSignal, tokens, _subgraphDeploymentID);
150+
} else {
151+
graphToken().transfer(msg.sender, tokens);
152+
emit CuratorBalanceReturnedToBeneficiary(
153+
getUnaliasedL1SubgraphID(_l2SubgraphID),
154+
msg.sender,
155+
tokens
156+
);
157+
emit SubgraphUpgraded(_l2SubgraphID, vSignal, 0, _subgraphDeploymentID);
158+
}
129159

130160
subgraphData.disabled = false;
131161
subgraphData.vSignal = vSignal;
@@ -134,16 +164,8 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
134164
subgraphData.subgraphDeploymentID = _subgraphDeploymentID;
135165
// Set the token metadata
136166
_setSubgraphMetadata(_l2SubgraphID, _subgraphMetadata);
137-
138167
emit SubgraphPublished(_l2SubgraphID, _subgraphDeploymentID, fixedReserveRatio);
139-
emit SubgraphUpgraded(
140-
_l2SubgraphID,
141-
subgraphData.vSignal,
142-
transferData.tokens,
143-
_subgraphDeploymentID
144-
);
145168
emit SubgraphVersionUpdated(_l2SubgraphID, _subgraphDeploymentID, _versionMetadata);
146-
emit SignalMinted(_l2SubgraphID, msg.sender, nSignal, vSignal, transferData.tokens);
147169
emit SubgraphL2TransferFinalized(_l2SubgraphID);
148170
}
149171

@@ -227,6 +249,20 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
227249
return _l1SubgraphID + SUBGRAPH_ID_ALIAS_OFFSET;
228250
}
229251

252+
/**
253+
* @notice Return the unaliased L1 subgraph ID from a transferred L2 subgraph ID
254+
* @param _l2SubgraphID L2 subgraph ID
255+
* @return L1subgraph ID
256+
*/
257+
function getUnaliasedL1SubgraphID(uint256 _l2SubgraphID)
258+
public
259+
pure
260+
override
261+
returns (uint256)
262+
{
263+
return _l2SubgraphID - SUBGRAPH_ID_ALIAS_OFFSET;
264+
}
265+
230266
/**
231267
* @dev Receive a subgraph from L1.
232268
* This function will initialize a subgraph received through the bridge,
@@ -278,13 +314,21 @@ contract L2GNS is GNS, L2GNSV1Storage, IL2GNS {
278314
IL2GNS.SubgraphL2TransferData storage transferData = subgraphL2TransferData[l2SubgraphID];
279315
SubgraphData storage subgraphData = _getSubgraphData(l2SubgraphID);
280316

317+
IL2Curation curation = IL2Curation(address(curation()));
318+
uint256 roundingError;
319+
if (transferData.l2Done && !subgraphData.disabled) {
320+
uint256 tokensAfter = curation.tokensToSignalToTokensNoTax(
321+
subgraphData.subgraphDeploymentID,
322+
_tokensIn
323+
);
324+
roundingError = _tokensIn.sub(tokensAfter).mul(MAX_PPM).div(_tokensIn);
325+
}
281326
// If subgraph transfer wasn't finished, we should send the tokens to the curator
282-
if (!transferData.l2Done || subgraphData.disabled) {
327+
if (!transferData.l2Done || subgraphData.disabled || roundingError > MAX_ROUNDING_ERROR) {
283328
graphToken().transfer(_curator, _tokensIn);
284329
emit CuratorBalanceReturnedToBeneficiary(_l1SubgraphID, _curator, _tokensIn);
285330
} else {
286331
// Get name signal to mint for tokens deposited
287-
IL2Curation curation = IL2Curation(address(curation()));
288332
uint256 vSignal = curation.mintTaxFree(subgraphData.subgraphDeploymentID, _tokensIn);
289333
uint256 nSignal = vSignalToNSignal(l2SubgraphID, vSignal);
290334

contracts/staking/Staking.sol

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -358,18 +358,13 @@ abstract contract Staking is StakingV4Storage, GraphUpgradeable, IStakingBase, M
358358
AllocationState allocState = _getAllocationState(_allocationID);
359359
require(allocState != AllocationState.Null, "!collect");
360360

361-
// Get allocation
362-
Allocation storage alloc = __allocations[_allocationID];
363-
// The allocation must've been opened at least 1 epoch ago,
364-
// to prevent manipulation of the curation or delegation pools
365-
require(alloc.createdAtEpoch < epochManager().currentEpoch(), "!epoch");
366-
367361
// If the query fees are zero, we don't want to revert
368362
// but we also don't need to do anything, so just return
369363
if (_tokens == 0) {
370364
return;
371365
}
372366

367+
Allocation storage alloc = __allocations[_allocationID];
373368
bytes32 subgraphDeploymentID = alloc.subgraphDeploymentID;
374369

375370
uint256 queryFees = _tokens; // Tokens collected from the channel

test/l2/l2GNS.test.ts

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getL2SignerFromL1,
1111
setAccountBalance,
1212
latestBlock,
13+
deriveChannelKey,
1314
} from '../lib/testHelpers'
1415
import { L2FixtureContracts, NetworkFixture } from '../lib/fixtures'
1516
import { toBN } from '../lib/testHelpers'
@@ -30,6 +31,7 @@ import {
3031
} from '../lib/gnsUtils'
3132
import { L2Curation } from '../../build/types/L2Curation'
3233
import { GraphToken } from '../../build/types/GraphToken'
34+
import { IL2Staking } from '../../build/types/IL2Staking'
3335

3436
const { HashZero } = ethers.constants
3537

@@ -43,6 +45,7 @@ interface L1SubgraphParams {
4345
describe('L2GNS', () => {
4446
let me: Account
4547
let other: Account
48+
let attacker: Account
4649
let governor: Account
4750
let mockRouter: Account
4851
let mockL1GRT: Account
@@ -56,6 +59,7 @@ describe('L2GNS', () => {
5659
let gns: L2GNS
5760
let curation: L2Curation
5861
let grt: GraphToken
62+
let staking: IL2Staking
5963

6064
let newSubgraph0: PublishSubgraph
6165
let newSubgraph1: PublishSubgraph
@@ -114,12 +118,21 @@ describe('L2GNS', () => {
114118

115119
before(async function () {
116120
newSubgraph0 = buildSubgraph()
117-
;[me, other, governor, mockRouter, mockL1GRT, mockL1Gateway, mockL1GNS, mockL1Staking] =
118-
await getAccounts()
121+
;[
122+
me,
123+
other,
124+
governor,
125+
mockRouter,
126+
mockL1GRT,
127+
mockL1Gateway,
128+
mockL1GNS,
129+
mockL1Staking,
130+
attacker,
131+
] = await getAccounts()
119132

120133
fixture = new NetworkFixture()
121134
fixtureContracts = await fixture.loadL2(governor.signer)
122-
;({ l2GraphTokenGateway, gns, curation, grt } = fixtureContracts)
135+
;({ l2GraphTokenGateway, gns, curation, grt, staking } = fixtureContracts)
123136

124137
await grt.connect(governor.signer).mint(me.address, toGRT('10000'))
125138
await fixture.configureL2Bridge(
@@ -398,6 +411,67 @@ describe('L2GNS', () => {
398411
.emit(gns, 'SignalMinted')
399412
.withArgs(l2SubgraphId, me.address, expectedNSignal, expectedSignal, curatedTokens)
400413
})
414+
it('protects the owner against a rounding attack', async function () {
415+
const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } =
416+
await defaultL1SubgraphParams()
417+
const collectTokens = curatedTokens.mul(20)
418+
419+
await staking.connect(governor.signer).setCurationPercentage(100000)
420+
421+
// Set up an indexer account with some stake
422+
await grt.connect(governor.signer).mint(attacker.address, toGRT('1000000'))
423+
// Curate 1 wei GRT by minting 1 GRT and burning most of it
424+
await grt.connect(attacker.signer).approve(curation.address, toBN(1))
425+
await curation.connect(attacker.signer).mint(newSubgraph0.subgraphDeploymentID, toBN(1), 0)
426+
427+
// Check this actually gave us 1 wei signal
428+
expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq(1)
429+
await grt.connect(attacker.signer).approve(staking.address, toGRT('1000000'))
430+
await staking.connect(attacker.signer).stake(toGRT('100000'))
431+
const channelKey = deriveChannelKey()
432+
// Allocate to the same deployment ID
433+
await staking
434+
.connect(attacker.signer)
435+
.allocateFrom(
436+
attacker.address,
437+
newSubgraph0.subgraphDeploymentID,
438+
toGRT('100000'),
439+
channelKey.address,
440+
randomHexBytes(32),
441+
await channelKey.generateProof(attacker.address),
442+
)
443+
// Spoof some query fees, 10% of which will go to the Curation pool
444+
await staking.connect(attacker.signer).collect(collectTokens, channelKey.address)
445+
// The curation pool now has 1 wei shares and a lot of tokens, so the rounding attack is prepared
446+
// But L2GNS will protect the owner by sending the tokens
447+
const callhookData = defaultAbiCoder.encode(
448+
['uint8', 'uint256', 'address'],
449+
[toBN(0), l1SubgraphId, me.address],
450+
)
451+
await gatewayFinalizeTransfer(mockL1GNS.address, gns.address, curatedTokens, callhookData)
452+
453+
const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId)
454+
const tx = gns
455+
.connect(me.signer)
456+
.finishSubgraphTransferFromL1(
457+
l2SubgraphId,
458+
newSubgraph0.subgraphDeploymentID,
459+
subgraphMetadata,
460+
versionMetadata,
461+
)
462+
await expect(tx)
463+
.emit(gns, 'SubgraphPublished')
464+
.withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, DEFAULT_RESERVE_RATIO)
465+
await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(l2SubgraphId, subgraphMetadata)
466+
await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary')
467+
await expect(tx)
468+
.emit(gns, 'SubgraphUpgraded')
469+
.withArgs(l2SubgraphId, 0, 0, newSubgraph0.subgraphDeploymentID)
470+
await expect(tx)
471+
.emit(gns, 'SubgraphVersionUpdated')
472+
.withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata)
473+
await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId)
474+
})
401475
it('cannot be called by someone other than the subgraph owner', async function () {
402476
const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } =
403477
await defaultL1SubgraphParams()
@@ -741,6 +815,63 @@ describe('L2GNS', () => {
741815
expect(gnsBalanceAfter).eq(gnsBalanceBefore)
742816
})
743817

818+
it('protects the curator against a rounding attack', async function () {
819+
// Transfer a subgraph from L1 with only 1 wei GRT of curated signal
820+
const { l1SubgraphId, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams()
821+
const curatedTokens = toBN('1')
822+
await transferMockSubgraphFromL1(
823+
l1SubgraphId,
824+
curatedTokens,
825+
subgraphMetadata,
826+
versionMetadata,
827+
)
828+
// Prepare the rounding attack by setting up an indexer and collecting a lot of query fees
829+
const curatorTokens = toGRT('10000')
830+
const collectTokens = curatorTokens.mul(20)
831+
await staking.connect(governor.signer).setCurationPercentage(100000)
832+
// Set up an indexer account with some stake
833+
await grt.connect(governor.signer).mint(attacker.address, toGRT('1000000'))
834+
835+
await grt.connect(attacker.signer).approve(staking.address, toGRT('1000000'))
836+
await staking.connect(attacker.signer).stake(toGRT('100000'))
837+
const channelKey = deriveChannelKey()
838+
// Allocate to the same deployment ID
839+
await staking
840+
.connect(attacker.signer)
841+
.allocateFrom(
842+
attacker.address,
843+
newSubgraph0.subgraphDeploymentID,
844+
toGRT('100000'),
845+
channelKey.address,
846+
randomHexBytes(32),
847+
await channelKey.generateProof(attacker.address),
848+
)
849+
// Spoof some query fees, 10% of which will go to the Curation pool
850+
await staking.connect(attacker.signer).collect(collectTokens, channelKey.address)
851+
852+
const callhookData = defaultAbiCoder.encode(
853+
['uint8', 'uint256', 'address'],
854+
[toBN(1), l1SubgraphId, me.address],
855+
)
856+
const curatorTokensBefore = await grt.balanceOf(me.address)
857+
const gnsBalanceBefore = await grt.balanceOf(gns.address)
858+
const tx = gatewayFinalizeTransfer(
859+
mockL1GNS.address,
860+
gns.address,
861+
curatorTokens,
862+
callhookData,
863+
)
864+
await expect(tx)
865+
.emit(gns, 'CuratorBalanceReturnedToBeneficiary')
866+
.withArgs(l1SubgraphId, me.address, curatorTokens)
867+
const curatorTokensAfter = await grt.balanceOf(me.address)
868+
expect(curatorTokensAfter).eq(curatorTokensBefore.add(curatorTokens))
869+
const gnsBalanceAfter = await grt.balanceOf(gns.address)
870+
// gatewayFinalizeTransfer will mint the tokens that are sent to the curator,
871+
// so the GNS balance should be the same
872+
expect(gnsBalanceAfter).eq(gnsBalanceBefore)
873+
})
874+
744875
it('if a subgraph was deprecated after transfer, it returns the tokens to the beneficiary', async function () {
745876
const mockL1GNSL2Alias = await getL2SignerFromL1(mockL1GNS.address)
746877
// Eth for gas:

test/staking/allocation.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,6 @@ describe('Staking:Allocation', () => {
633633
await expect(tx).revertedWith('!alloc')
634634
})
635635

636-
it('reject collect if allocation has been open for less than 1 epoch', async function () {
637-
const tx = staking.connect(indexer.signer).collect(tokensToCollect, allocationID)
638-
await expect(tx).revertedWith('!epoch')
639-
})
640-
641636
it('reject collect if allocation does not exist', async function () {
642637
const invalidAllocationID = randomHexBytes(20)
643638
const tx = staking.connect(assetHolder.signer).collect(tokensToCollect, invalidAllocationID)

0 commit comments

Comments
 (0)