diff --git a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts deleted file mode 100644 index a680ec28c..000000000 --- a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { L2Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { Controller } from '@graphprotocol/contracts' -import { GNS } from '@graphprotocol/contracts' -import { - formatGRT, - GraphNetworkContracts, - helpers, - randomAddress, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, Event, Signer, utils } from 'ethers' -import { parseEther } from 'ethers/lib/utils' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { AddressZero } = constants - -const MAX_PPM = 1000000 - -const chunkify = (total: BigNumber, maxChunks = 10): Array => { - const chunks: BigNumber[] = [] - while (total.gt(0) && maxChunks > 0) { - const m = 1000000 - const p = Math.floor(Math.random() * m) - const n = total.mul(p).div(m) - chunks.push(n) - total = total.sub(n) - maxChunks-- - } - if (total.gt(0)) { - chunks.push(total) - } - return chunks -} - -const toFloat = (n: BigNumber) => parseFloat(formatGRT(n)) -const toRound = (n: number) => n.toPrecision(11) - -describe('L2Curation:Config', () => { - const graph = hre.graph() - const defaults = graph.graphConfig.defaults - let me: SignerWithAddress - let governor: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let curation: L2Curation - - before(async function () { - ;[me] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor, true) - curation = contracts.L2Curation as L2Curation - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('defaultReserveRatio', function () { - it('should be fixed to MAX_PPM', async function () { - // Set right in the constructor - expect(await curation.connect(me).defaultReserveRatio()).eq(MAX_PPM) - }) - it('cannot be changed because the setter is not implemented', async function () { - const tx = curation.connect(governor).setDefaultReserveRatio(10) - await expect(tx).revertedWith('Not implemented in L2') - }) - }) - - describe('minimumCurationDeposit', function () { - it('should set `minimumCurationDeposit`', async function () { - // Set right in the constructor - expect(await curation.minimumCurationDeposit()).eq(defaults.curation.l2MinimumCurationDeposit) - - // Can set if allowed - const newValue = toBN('100') - await curation.connect(governor).setMinimumCurationDeposit(newValue) - expect(await curation.minimumCurationDeposit()).eq(newValue) - }) - - it('reject set `minimumCurationDeposit` if out of bounds', async function () { - const tx = curation.connect(governor).setMinimumCurationDeposit(0) - await expect(tx).revertedWith('Minimum curation deposit cannot be 0') - }) - - it('reject set `minimumCurationDeposit` if not allowed', async function () { - const tx = curation.connect(me).setMinimumCurationDeposit(defaults.curation.minimumCurationDeposit) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('curationTaxPercentage', function () { - it('should set `curationTaxPercentage`', async function () { - const curationTaxPercentage = defaults.curation.curationTaxPercentage - - // Set new value - await curation.connect(governor).setCurationTaxPercentage(0) - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - }) - - it('reject set `curationTaxPercentage` if out of bounds', async function () { - const tx = curation.connect(governor).setCurationTaxPercentage(MAX_PPM + 1) - await expect(tx).revertedWith('Curation tax percentage must be below or equal to MAX_PPM') - }) - - it('reject set `curationTaxPercentage` if not allowed', async function () { - const tx = curation.connect(me).setCurationTaxPercentage(0) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('curationTokenMaster', function () { - it('should set `curationTokenMaster`', async function () { - const newCurationTokenMaster = curation.address - await curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - }) - - it('reject set `curationTokenMaster` to empty value', async function () { - const newCurationTokenMaster = AddressZero - const tx = curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Token master must be non-empty') - }) - - it('reject set `curationTokenMaster` to non-contract', async function () { - const newCurationTokenMaster = randomAddress() - const tx = curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Token master must be a contract') - }) - - it('reject set `curationTokenMaster` if not allowed', async function () { - const newCurationTokenMaster = curation.address - const tx = curation.connect(me).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Only Controller governor') - }) - }) -}) - -describe('L2Curation', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - let curator: SignerWithAddress - let subgraphServiceMock: SignerWithAddress - let gnsImpersonator: Signer - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let curation: L2Curation - let grt: GraphToken - let controller: Controller - let gns: GNS - - // Test values - const signalAmountFor1000Tokens = toGRT('1000.0') - const signalAmountForMinimumCuration = toBN('1') - const subgraphDeploymentID = randomHexBytes() - const curatorTokens = toGRT('1000000000') - const tokensToDeposit = toGRT('1000') - const tokensToCollect = toGRT('2000') - - async function calcLinearBondingCurve( - supply: BigNumber, - reserveBalance: BigNumber, - depositAmount: BigNumber, - ): Promise { - // Handle the initialization of the bonding curve - if (supply.eq(0)) { - const minDeposit = await curation.minimumCurationDeposit() - if (depositAmount.lt(minDeposit)) { - throw new Error('deposit must be above minimum') - } - const minSupply = signalAmountForMinimumCuration - return (await calcLinearBondingCurve(minSupply, minDeposit, depositAmount.sub(minDeposit))) + toFloat(minSupply) - } - // Calculate bonding curve in the test - return toFloat(supply) * (toFloat(depositAmount) / toFloat(reserveBalance)) - } - - const shouldMint = async (tokensToDeposit: BigNumber, expectedSignal: BigNumber) => { - // Before state - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(curator.address) - const beforeCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Calculations - const curationTaxPercentage = await curation.curationTaxPercentage() - const curationTax = tokensToDeposit.mul(toBN(curationTaxPercentage)).div(toBN(MAX_PPM)) - - // Curate - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - await expect(tx) - .emit(curation, 'Signalled') - .withArgs(curator.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, curationTax) - - // After state - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(curator.address) - const afterCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.sub(tokensToDeposit)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.add(expectedSignal)) - // Allocated and balance updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToDeposit.sub(curationTax))) - expect(afterPoolSignal).eq(beforePoolSignal.add(expectedSignal)) - // Pool reserveRatio is deprecated and therefore always zero in L2 - expect(afterPool.reserveRatio).eq(0) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.add(tokensToDeposit.sub(curationTax))) - // Total supply is reduced to curation tax burning - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply.sub(curationTax)) - } - - const shouldMintTaxFree = async (tokensToDeposit: BigNumber, expectedSignal: BigNumber) => { - // Before state - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(gns.address) - const beforeCuratorSignal = await curation.getCuratorSignal(gns.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Curate - const tx = curation.connect(gnsImpersonator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx) - .emit(curation, 'Signalled') - .withArgs(gns.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, 0) - - // After state - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(gns.address) - const afterCuratorSignal = await curation.getCuratorSignal(gns.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.sub(tokensToDeposit)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.add(expectedSignal)) - // Allocated and balance updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToDeposit)) - expect(afterPoolSignal).eq(beforePoolSignal.add(expectedSignal)) - // Pool reserveRatio is deprecated and therefore always zero in L2 - expect(afterPool.reserveRatio).eq(0) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.add(tokensToDeposit)) - // Total supply is reduced to curation tax burning - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply) - } - - const shouldBurn = async (signalToRedeem: BigNumber, expectedTokens: BigNumber) => { - // Before balances - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(curator.address) - const beforeCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Redeem - const tx = curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, 0) - await expect(tx) - .emit(curation, 'Burned') - .withArgs(curator.address, subgraphDeploymentID, expectedTokens, signalToRedeem) - - // After balances - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(curator.address) - const afterCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.add(expectedTokens)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.sub(signalToRedeem)) - // Curation balance updated - expect(afterPool.tokens).eq(beforePool.tokens.sub(expectedTokens)) - expect(afterPoolSignal).eq(beforePoolSignal.sub(signalToRedeem)) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.sub(expectedTokens)) - // Total supply is conserved - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply) - } - - const shouldCollect = async (tokensToCollect: BigNumber) => { - // Before state - const beforePool = await curation.pools(subgraphDeploymentID) - const beforeTotalBalance = await grt.balanceOf(curation.address) - - // Source of tokens must be the staking for this to work - await grt.connect(subgraphServiceMock).transfer(curation.address, tokensToCollect) - const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).emit(curation, 'Collected').withArgs(subgraphDeploymentID, tokensToCollect) - - // After state - const afterPool = await curation.pools(subgraphDeploymentID) - const afterTotalBalance = await grt.balanceOf(curation.address) - - // State updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToCollect)) - expect(afterTotalBalance).eq(beforeTotalBalance.add(tokensToCollect)) - } - - before(async function () { - // Use stakingMock so we can call collect - ;[me, curator, subgraphServiceMock] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor, true) - curation = contracts.Curation as L2Curation - grt = contracts.GraphToken as GraphToken - controller = contracts.Controller - gns = contracts.GNS as GNS - - gnsImpersonator = await helpers.impersonateAccount(gns.address) - await helpers.setBalance(gns.address, parseEther('1')) - // Give some funds to the curator and GNS impersonator and approve the curation contract - await grt.connect(governor).mint(curator.address, curatorTokens) - await grt.connect(curator).approve(curation.address, curatorTokens) - await grt.connect(governor).mint(gns.address, curatorTokens) - await grt.connect(gnsImpersonator).approve(curation.address, curatorTokens) - - // Give some funds to the staking contract and approve the curation contract - await grt.connect(governor).mint(subgraphServiceMock.address, tokensToCollect) - await grt.connect(subgraphServiceMock).approve(curation.address, tokensToCollect) - - // Set the subgraph service - await curation.connect(governor).setSubgraphService(subgraphServiceMock.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('bonding curve', function () { - const tokensToDeposit = curatorTokens - - it('reject convert signal to tokens if subgraph deployment not initted', async function () { - const tx = curation.signalToTokens(subgraphDeploymentID, toGRT('100')) - await expect(tx).revertedWith('Subgraph deployment must be curated to perform calculations') - }) - - it('convert signal to tokens', async function () { - // Curate - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Conversion - const signal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const expectedTokens = await curation.signalToTokens(subgraphDeploymentID, signal) - expect(expectedTokens).eq(tokensToDeposit) - }) - - it('convert signal to tokens (with curation tax)', async function () { - // Set curation tax - const curationTaxPercentage = 50000 // 5% - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - - // Curate - const expectedCurationTax = tokensToDeposit.mul(curationTaxPercentage).div(MAX_PPM) - const { 1: curationTax } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Conversion - const signal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const tokens = await curation.signalToTokens(subgraphDeploymentID, signal) - expect(tokens).eq(tokensToDeposit.sub(expectedCurationTax)) - expect(expectedCurationTax).eq(curationTax) - }) - - it('convert tokens to signal', async function () { - // Conversion - const tokens = toGRT('1000') - const { 0: signal } = await curation.tokensToSignal(subgraphDeploymentID, tokens) - expect(signal).eq(signalAmountFor1000Tokens) - }) - - it('convert tokens to signal if non-curated subgraph', async function () { - // Conversion - const nonCuratedSubgraphDeploymentID = randomHexBytes() - const tokens = toGRT('0') - const tx = curation.tokensToSignal(nonCuratedSubgraphDeploymentID, tokens) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - }) - - describe('curate', function () { - it('reject deposit below minimum tokens required', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toBN('2')) - const tokensToDeposit = (await curation.minimumCurationDeposit()).sub(toBN(1)) - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - - it('should deposit on a subgraph deployment', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const expectedSignal = signalAmountForMinimumCuration // tax = 0 due to rounding - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve (and account for curation tax)', async function () { - // Set curation tax - await curation.connect(governor).setCurationTaxPercentage(50000) // 5% - - // Mint - const tokensToDeposit = toGRT('1000') - const { 0: expectedSignal } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should revert curate if over slippage', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, expectedSignal.add(1)) - await expect(tx).revertedWith('Slippage protection') - }) - - it('should pay a minimum of 1 wei GRT in tax when depositing small amounts', async function () { - // Set minimum curation deposit - await contracts.Curation.connect(governor).setMinimumCurationDeposit('1') - - // Set curation tax to 1% - await contracts.Curation.connect(governor).setCurationTaxPercentage(10000) - - // Deposit a small amount where tax would be less than 1 wei - const tokensToDeposit = '99' - - const expectedTokens = '98' - const expectedSignal = '98' - const expectedTax = 1 - - const tx = contracts.Curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, expectedSignal) - - await expect(tx) - .emit(contracts.Curation, 'Signalled') - .withArgs(curator.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, expectedTax) - - const burnTx = contracts.Curation.connect(curator).burn(subgraphDeploymentID, expectedSignal, expectedTokens) - - await expect(burnTx) - .emit(contracts.Curation, 'Burned') - .withArgs(curator.address, subgraphDeploymentID, expectedTokens, expectedSignal) - }) - }) - - describe('curate tax free (from GNS)', function () { - it('can not be called by anyone other than GNS', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const tx = curation.connect(curator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx).revertedWith('Only the GNS can call this') - }) - - it('reject deposit below minimum tokens required', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toBN('2')) - const tokensToDeposit = (await curation.minimumCurationDeposit()).sub(toBN(1)) - const tx = curation.connect(gnsImpersonator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - - it('should deposit on a subgraph deployment', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const expectedSignal = signalAmountForMinimumCuration - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve (and with zero tax)', async function () { - // Set curation tax - await curation.connect(governor).setCurationTaxPercentage(50000) // 5% - - // Mint - const tokensToDeposit = toGRT('1000') - const expectedSignal = await curation.tokensToSignalNoTax(subgraphDeploymentID, tokensToDeposit) - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - }) - - describe('collect', function () { - context('> not curated', function () { - it('reject collect tokens distributed to the curation pool', async function () { - // Source of tokens must be the staking for this to work - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Subgraph deployment must be curated to collect fees') - }) - }) - - context('> curated', function () { - beforeEach(async function () { - await curation.connect(curator).mint(subgraphDeploymentID, toGRT('1000'), 0) - }) - - it('reject collect tokens distributed from invalid address', async function () { - const tx = curation.connect(me).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Caller must be the subgraph service') - }) - - it('should collect tokens distributed to the curation pool', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - await shouldCollect(toGRT('1')) - await shouldCollect(toGRT('10')) - await shouldCollect(toGRT('100')) - await shouldCollect(toGRT('200')) - await shouldCollect(toGRT('500.25')) - }) - - it('should collect tokens and then unsignal all', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - // Collect increase the pool reserves - await shouldCollect(toGRT('100')) - - // When we burn signal we should get more tokens than initially curated - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - await shouldBurn(signalToRedeem, toGRT('1100')) - }) - - it('should collect tokens and then unsignal multiple times', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - // Collect increase the pool reserves - const tokensToCollect = toGRT('100') - await shouldCollect(tokensToCollect) - - // Unsignal partially - const signalOutRemainder = toGRT(1) - const signalOutPartial = (await curation.getCuratorSignal(curator.address, subgraphDeploymentID)).sub( - signalOutRemainder, - ) - const tx1 = await curation.connect(curator).burn(subgraphDeploymentID, signalOutPartial, 0) - const r1 = await tx1.wait() - const event1 = curation.interface.parseLog(r1.events[2]).args - const tokensOut1 = event1.tokens - - // Collect increase the pool reserves - await shouldCollect(tokensToCollect) - - // Unsignal the rest - const tx2 = await curation.connect(curator).burn(subgraphDeploymentID, signalOutRemainder, 0) - const r2 = await tx2.wait() - const event2 = curation.interface.parseLog(r2.events[2]).args - const tokensOut2 = event2.tokens - - expect(tokensOut1.add(tokensOut2)).eq(toGRT('1000').add(tokensToCollect.mul(2))) - }) - }) - }) - - describe('burn', function () { - beforeEach(async function () { - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - }) - - it('reject redeem more than a curator owns', async function () { - const tx = curation.connect(me).burn(subgraphDeploymentID, toGRT('1'), 0) - await expect(tx).revertedWith('Cannot burn more signal than you own') - }) - - it('reject redeem zero signal', async function () { - const tx = curation.connect(me).burn(subgraphDeploymentID, toGRT('0'), 0) - await expect(tx).revertedWith('Cannot burn zero signal') - }) - - it('should allow to redeem *partially*', async function () { - // Redeem just one signal - const signalToRedeem = toGRT('1') - const expectedTokens = toGRT('1') - await shouldBurn(signalToRedeem, expectedTokens) - }) - - it('should allow to redeem *fully*', async function () { - // Get all signal of the curator - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - await shouldBurn(signalToRedeem, expectedTokens) - }) - - it('should allow to redeem back below minimum deposit', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - - // Redeem "almost" all signal - const signal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const signalToRedeem = signal.sub(toGRT('0.000001')) - const expectedTokens = await curation.signalToTokens(subgraphDeploymentID, signalToRedeem) - await shouldBurn(signalToRedeem, expectedTokens) - - // The pool should have less tokens that required by minimumCurationDeposit - const afterPool = await curation.pools(subgraphDeploymentID) - expect(afterPool.tokens).lt(await curation.minimumCurationDeposit()) - - // Should be able to deposit more after being under minimumCurationDeposit - const tokensToDeposit = toGRT('1') - const { 0: expectedSignal } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should revert redeem if over slippage', async function () { - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - - const tx = curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, expectedTokens.add(1)) - await expect(tx).revertedWith('Slippage protection') - }) - - it('should not re-deploy the curation token when signal is reset', async function () { - const beforeSubgraphPool = await curation.pools(subgraphDeploymentID) - - // Burn all the signal - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - await shouldBurn(signalToRedeem, expectedTokens) - - // Mint again on the same subgraph - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Check state - const afterSubgraphPool = await curation.pools(subgraphDeploymentID) - expect(afterSubgraphPool.gcs).eq(beforeSubgraphPool.gcs) - }) - }) - - describe('conservation', function () { - it('should match multiple deposits and redeems back to initial state', async function () { - this.timeout(60000) // increase timeout for test runner - - const totalDeposits = toGRT('1000000000') - - // Signal multiple times - let totalSignal = toGRT('0') - for (const tokensToDeposit of chunkify(totalDeposits, 10)) { - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - totalSignal = totalSignal.add(signal) - } - - // Redeem signal multiple times - let totalTokens = toGRT('0') - for (const signalToRedeem of chunkify(totalSignal, 10)) { - const tx = await curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const tokens = event.args['tokens'] - totalTokens = totalTokens.add(tokens) - // console.log('<', formatEther(signalToRedeem), '=', formatEther(tokens)) - } - - // Conservation of work - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - expect(afterPool.tokens).eq(toGRT('0')) - expect(afterPoolSignal).eq(toGRT('0')) - expect(await curation.isCurated(subgraphDeploymentID)).eq(false) - expect(totalDeposits).eq(totalTokens) - }) - }) - - describe('multiple minting', function () { - it('should mint the same signal every time due to the flat bonding curve', async function () { - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint the same GCS due to bonding curve! - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - toGRT('1'), // should mint below minimum deposit - ] - for (const tokensToDeposit of tokensToDepositMany) { - const expectedSignal = await calcLinearBondingCurve( - await curation.getCurationPoolSignal(subgraphDeploymentID), - await curation.getCurationPoolTokens(subgraphDeploymentID), - tokensToDeposit, - ) - // SIGNAL_PER_MINIMUM_DEPOSIT should always give the same ratio - expect(tokensToDeposit.div(toGRT(expectedSignal))).eq(1) - - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - expect(toRound(toFloat(toBN(signal)))).eq(toRound(expectedSignal)) - } - }) - - it('should mint when using a different ratio between GRT and signal', async function () { - this.timeout(60000) // increase timeout for test runner - - // Setup edge case with 1 GRT = 1 wei signal - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint proportionally the same GCS due to linear bonding curve... - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - toGRT('1'), - ] - - // Mint multiple times - for (const tokensToDeposit of tokensToDepositMany) { - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - expect(tokensToDeposit).eq(signal.mul(toGRT('1'))) // we compare 1 GRT : 1 wei ratio - } - }) - }) -}) diff --git a/packages/subgraph-discovery/foundry.toml b/packages/subgraph-discovery/foundry.toml new file mode 100644 index 000000000..35b9b51af --- /dev/null +++ b/packages/subgraph-discovery/foundry.toml @@ -0,0 +1,22 @@ +[profile.default] +src = 'test' +out = 'forge-artifacts' +test = 'test' +libs = ["node_modules"] +cache_path = 'cache_forge' +auto_detect_remappings = false +remappings = [ + "@graphprotocol/=node_modules/@graphprotocol/", + "forge-std/=node_modules/forge-std/src/", +] +solc_version = '0.8.35' +evm_version = 'cancun' +fs_permissions = [{ access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] +ignored_warnings_from = ["node_modules"] + +[lint] +# Disable lint-on-build: forge's lint walker follows symlinks without cycle detection and +# infinite-loops on this package's pnpm-hoisted node_modules. +lint_on_build = false +exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"] +ignore = ["node_modules/**", "**/node_modules/**"] diff --git a/packages/subgraph-discovery/package.json b/packages/subgraph-discovery/package.json new file mode 100644 index 000000000..c3e3c899a --- /dev/null +++ b/packages/subgraph-discovery/package.json @@ -0,0 +1,19 @@ +{ + "name": "@graphprotocol/subgraph-discovery", + "version": "0.0.0", + "private": true, + "description": "The Graph subgraph discovery and curation contracts", + "license": "GPL-2.0-or-later", + "scripts": { + "build": "pnpm build:dep", + "build:dep": "pnpm --filter '@graphprotocol/subgraph-discovery^...' run build:self", + "clean": "rm -rf forge-artifacts/ cache_forge/", + "test": "pnpm build && pnpm test:self", + "test:self": "forge test" + }, + "devDependencies": { + "@graphprotocol/contracts": "workspace:^", + "@graphprotocol/interfaces": "workspace:^", + "forge-std": "catalog:" + } +} diff --git a/packages/subgraph-discovery/test/unit/Setup.t.sol b/packages/subgraph-discovery/test/unit/Setup.t.sol new file mode 100644 index 000000000..4b4f4ca7d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/Setup.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "./shared/DiscoveryTest.t.sol"; + +contract SetupTest is DiscoveryTest { + function test_Setup_Wired() public view { + assertEq(nft.minter(), address(gns)); + assertEq(curation.subgraphService(), subgraphService); + assertEq(gns.counterpartGNSAddress(), l1GNS); + } +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol new file mode 100644 index 000000000..f6ffd171f --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IGraphTokenLike { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event MinterAdded(address indexed account); + event MinterRemoved(address indexed account); + + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer(address to, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + function approve(address spender, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function burn(uint256 amount) external; + + function burnFrom(address account, uint256 amount) external; + + function mint(address to, uint256 amount) external; + + function addMinter(address account) external; + + function removeMinter(address account) external; + + function renounceMinter() external; + + function isMinter(address account) external view returns (bool); + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function nonces(address owner) external view returns (uint256); + + function governor() external view returns (address); + + function transferOwnership(address newGovernor) external; + + function acceptOwnership() external; +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol new file mode 100644 index 000000000..deba3ee0d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IL2CurationLike { + event Signalled( + address indexed curator, + bytes32 indexed subgraphDeploymentID, + uint256 tokens, + uint256 signal, + uint256 curationTax + ); + event Burned(address indexed curator, bytes32 indexed subgraphDeploymentID, uint256 tokens, uint256 signal); + event Collected(bytes32 indexed subgraphDeploymentID, uint256 tokens); + + function initialize( + address controller, + address curationTokenMaster, + uint32 curationTaxPercentage, + uint256 minimumCurationDeposit + ) external; + + function pools( + bytes32 subgraphDeploymentID + ) external view returns (uint256 tokens, uint32 reserveRatio, address gcs); + + function defaultReserveRatio() external view returns (uint32); + + function setDefaultReserveRatio(uint32 defaultReserveRatio) external; + + function minimumCurationDeposit() external view returns (uint256); + + function setMinimumCurationDeposit(uint256 minimumCurationDeposit) external; + + function curationTaxPercentage() external view returns (uint32); + + function setCurationTaxPercentage(uint32 percentage) external; + + function curationTokenMaster() external view returns (address); + + function setCurationTokenMaster(address curationTokenMaster) external; + + function subgraphService() external view returns (address); + + function setSubgraphService(address subgraphService) external; + + function mint( + bytes32 subgraphDeploymentID, + uint256 tokensIn, + uint256 signalOutMin + ) external returns (uint256 signalOut, uint256 curationTax); + + function mintTaxFree(bytes32 subgraphDeploymentID, uint256 tokensIn) external returns (uint256); + + function burn(bytes32 subgraphDeploymentID, uint256 signalIn, uint256 tokensOutMin) external returns (uint256); + + function collect(bytes32 subgraphDeploymentID, uint256 tokens) external; + + function isCurated(bytes32 subgraphDeploymentID) external view returns (bool); + + function getCuratorSignal(address curator, bytes32 subgraphDeploymentID) external view returns (uint256); + + function getCurationPoolSignal(bytes32 subgraphDeploymentID) external view returns (uint256); + + function getCurationPoolTokens(bytes32 subgraphDeploymentID) external view returns (uint256); + + function tokensToSignal(bytes32 subgraphDeploymentID, uint256 tokensIn) external view returns (uint256, uint256); + + function tokensToSignalNoTax(bytes32 subgraphDeploymentID, uint256 tokensIn) external view returns (uint256); + + function tokensToSignalToTokensNoTax( + bytes32 subgraphDeploymentID, + uint256 tokensIn + ) external view returns (uint256); + + function signalToTokens(bytes32 subgraphDeploymentID, uint256 signalIn) external view returns (uint256); +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol new file mode 100644 index 000000000..dff9d4d54 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IL2GNSLike { + event SubgraphMetadataUpdated(uint256 indexed subgraphID, bytes32 subgraphMetadata); + event SubgraphVersionUpdated( + uint256 indexed subgraphID, + bytes32 indexed subgraphDeploymentID, + bytes32 versionMetadata + ); + event SignalMinted( + uint256 indexed subgraphID, + address indexed curator, + uint256 nSignalCreated, + uint256 vSignalCreated, + uint256 tokensDeposited + ); + event SignalBurned( + uint256 indexed subgraphID, + address indexed curator, + uint256 nSignalBurnt, + uint256 vSignalBurnt, + uint256 tokensReceived + ); + event SubgraphPublished(uint256 indexed subgraphID, bytes32 indexed subgraphDeploymentID, uint32 reserveRatio); + event SubgraphUpgraded( + uint256 indexed subgraphID, + uint256 vSignalCreated, + uint256 tokensSignalled, + bytes32 indexed subgraphDeploymentID + ); + event SubgraphDeprecated(uint256 indexed subgraphID, uint256 withdrawableGRT); + event SubgraphReceivedFromL1( + uint256 indexed _l1SubgraphID, + uint256 indexed _l2SubgraphID, + address indexed _owner, + uint256 _tokens + ); + event SubgraphL2TransferFinalized(uint256 indexed _l2SubgraphID); + event CuratorBalanceReceived( + uint256 indexed _l1SubgraphId, + uint256 indexed _l2SubgraphID, + address indexed _l2Curator, + uint256 _tokens + ); + event CuratorBalanceReturnedToBeneficiary( + uint256 indexed _l1SubgraphID, + address indexed _l2Curator, + uint256 _tokens + ); + + function initialize(address controller, address subgraphNFT) external; + + function approveAll() external; + + function setCounterpartGNSAddress(address counterpart) external; + + function counterpartGNSAddress() external view returns (address); + + function ownerTaxPercentage() external view returns (uint32); + + function setOwnerTaxPercentage(uint32 ownerTaxPercentage) external; + + function nextAccountSeqID(address account) external view returns (uint256); + + function publishNewSubgraph( + bytes32 subgraphDeploymentID, + bytes32 versionMetadata, + bytes32 subgraphMetadata + ) external; + + function publishNewVersion(uint256 subgraphID, bytes32 subgraphDeploymentID, bytes32 versionMetadata) external; + + function publishNewVersion( + uint256 subgraphID, + bytes32 subgraphDeploymentID, + bytes32 versionMetadata, + uint256 signalOutMin + ) external; + + function deprecateSubgraph(uint256 subgraphID) external; + + function mintSignal(uint256 subgraphID, uint256 tokensIn, uint256 nSignalOutMin) external; + + function burnSignal(uint256 subgraphID, uint256 nSignal, uint256 tokensOutMin) external; + + function onTokenTransfer(address from, uint256 amount, bytes calldata data) external; + + function finishSubgraphTransferFromL1( + uint256 l2SubgraphID, + bytes32 subgraphDeploymentID, + bytes32 subgraphMetadata, + bytes32 versionMetadata + ) external; + + function ownerOf(uint256 tokenID) external view returns (address); + + function subgraphs( + uint256 subgraphID + ) + external + view + returns ( + uint256 vSignal, + uint256 nSignal, + bytes32 subgraphDeploymentID, + uint32 reserveRatio, + bool disabled, + uint256 withdrawableGRT + ); + + function subgraphL2TransferData( + uint256 subgraphID + ) external view returns (uint256 tokens, bool l2Done, uint256 subgraphReceivedOnL2BlockNumber); + + function getAliasedL2SubgraphID(uint256 l1SubgraphID) external pure returns (uint256); + + function getCuratorSignal(uint256 subgraphID, address curator) external view returns (uint256); + + function tokensToNSignal(uint256 subgraphID, uint256 tokensIn) external view returns (uint256, uint256, uint256); + + function nSignalToTokens(uint256 subgraphID, uint256 nSignalIn) external view returns (uint256, uint256); + + function vSignalToNSignal(uint256 subgraphID, uint256 vSignalIn) external view returns (uint256); +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol b/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol new file mode 100644 index 000000000..0e9109514 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface ISubgraphNFTLike { + function ownerOf(uint256 tokenId) external view returns (address); + + function minter() external view returns (address); + + function setMinter(address minter) external; + + function setTokenDescriptor(address tokenDescriptor) external; + + function tokenURI(uint256 tokenId) external view returns (string memory); +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol new file mode 100644 index 000000000..15e74b1df --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract BondingCurveTest is L2CurationSharedTest { + function test_BondingCurve_RevertWhen_SignalToTokensNotCurated() public { + vm.expectRevert(bytes("Subgraph deployment must be curated to perform calculations")); + curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, 100 ether); + } + + function test_BondingCurve_ConvertSignalToTokens() public { + _mint(CURATOR_TOKENS); + + uint256 signal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + assertEq(curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signal), CURATOR_TOKENS); + } + + function test_BondingCurve_ConvertSignalToTokensWithCurationTax() public { + uint32 curationTaxPercentage = 50_000; // 5% + vm.prank(governor); + curation.setCurationTaxPercentage(curationTaxPercentage); + + uint256 expectedCurationTax = (CURATOR_TOKENS * curationTaxPercentage) / MAX_PPM; + (, uint256 curationTax) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, CURATOR_TOKENS); + _mint(CURATOR_TOKENS); + + uint256 signal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + assertEq(curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signal), CURATOR_TOKENS - expectedCurationTax); + assertEq(expectedCurationTax, curationTax); + } + + function test_BondingCurve_ConvertTokensToSignal() public view { + (uint256 signal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + assertEq(signal, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_BondingCurve_RevertWhen_ConvertTokensToSignalBelowMinimum() public { + vm.expectRevert(bytes("Curation deposit is below minimum required")); + curation.tokensToSignal(keccak256("nonCuratedSubgraphDeploymentID"), 0); + } + + function test_BondingCurve_TokensToSignalToTokensNoTax() public { + _mint(TOKENS_TO_DEPOSIT); + + // Flat curve: depositing tokens and redeeming the resulting signal returns the same tokens + assertEq(curation.tokensToSignalToTokensNoTax(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_DEPOSIT), TOKENS_TO_DEPOSIT); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol new file mode 100644 index 000000000..896fcd66b --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract BurnTest is L2CurationSharedTest { + function setUp() public override { + super.setUp(); + _mint(TOKENS_TO_DEPOSIT); + } + + function test_Burn_RevertWhen_MoreThanOwned() public { + vm.expectRevert(bytes("Cannot burn more signal than you own")); + vm.prank(me); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, 1 ether, 0); + } + + function test_Burn_RevertWhen_ZeroSignal() public { + vm.expectRevert(bytes("Cannot burn zero signal")); + vm.prank(me); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, 0, 0); + } + + function test_Burn_Partially() public { + _shouldBurn(1 ether, 1 ether); + } + + function test_Burn_Fully() public { + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, TOKENS_TO_DEPOSIT); + } + + function test_Burn_BackBelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(1 ether); + + // Redeem "almost" all signal + uint256 signal = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + uint256 signalToRedeem = signal - 0.000001 ether; + uint256 expectedTokens = curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem); + _shouldBurn(signalToRedeem, expectedTokens); + + // The pool should have less tokens than required by minimumCurationDeposit + (uint256 poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertLt(poolTokens, curation.minimumCurationDeposit()); + + // Should be able to deposit more after being under minimumCurationDeposit + (uint256 expectedSignal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1 ether); + _shouldMint(1 ether, expectedSignal); + } + + function test_Burn_RevertWhen_OverSlippage() public { + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + + vm.expectRevert(bytes("Slippage protection")); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem, TOKENS_TO_DEPOSIT + 1); + } + + function test_Burn_DoesNotRedeployCurationTokenWhenSignalReset() public { + (, , address beforeGcs) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + + // Burn all the signal + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, TOKENS_TO_DEPOSIT); + + // Mint again on the same subgraph + _mint(TOKENS_TO_DEPOSIT); + + (, , address afterGcs) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterGcs, beforeGcs); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol new file mode 100644 index 000000000..6f292e4af --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract CollectTest is L2CurationSharedTest { + modifier curated() { + _mint(1000 ether); + _; + } + + // -- > not curated -- + + function test_NotCurated_RevertWhen_Collect() public { + vm.expectRevert(bytes("Subgraph deployment must be curated to collect fees")); + vm.prank(subgraphService); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_COLLECT); + } + + // -- > curated -- + + function test_Curated_RevertWhen_CallerNotSubgraphService() public curated { + vm.expectRevert(bytes("Caller must be the subgraph service")); + vm.prank(me); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_COLLECT); + } + + function test_Curated_CollectTokens() public curated { + _shouldCollect(1 ether); + _shouldCollect(10 ether); + _shouldCollect(100 ether); + _shouldCollect(200 ether); + _shouldCollect(500.25 ether); + } + + function test_Curated_CollectThenBurnAll() public curated { + // Collect increases the pool reserves + _shouldCollect(100 ether); + + // When we burn signal we should get more tokens than initially curated + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, 1100 ether); + } + + function test_Curated_CollectThenBurnMultipleTimes() public curated { + uint256 tokensToCollect = 100 ether; + _shouldCollect(tokensToCollect); + + // Burn partially + uint256 signalOutRemainder = 1 ether; + uint256 signalOutPartial = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID) - signalOutRemainder; + vm.prank(curator); + uint256 tokensOut1 = curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalOutPartial, 0); + + _shouldCollect(tokensToCollect); + + // Burn the rest + vm.prank(curator); + uint256 tokensOut2 = curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalOutRemainder, 0); + + assertEq(tokensOut1 + tokensOut2, 1000 ether + tokensToCollect * 2); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol new file mode 100644 index 000000000..99a165e81 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "../shared/DiscoveryTest.t.sol"; + +contract ConfigTest is DiscoveryTest { + uint32 internal constant MAX_PPM = 1_000_000; + uint256 internal constant DEFAULT_MINIMUM_CURATION_DEPOSIT = 1; + uint32 internal constant DEFAULT_CURATION_TAX_PERCENTAGE = 10_000; + + // -- defaultReserveRatio -- + + function test_DefaultReserveRatio_FixedToMaxPPM() public view { + assertEq(curation.defaultReserveRatio(), MAX_PPM); + } + + function test_DefaultReserveRatio_RevertWhen_Set() public { + vm.expectRevert(bytes("Not implemented in L2")); + vm.prank(governor); + curation.setDefaultReserveRatio(10); + } + + // -- minimumCurationDeposit -- + + function test_MinimumCurationDeposit_Set() public { + assertEq(curation.minimumCurationDeposit(), DEFAULT_MINIMUM_CURATION_DEPOSIT); + + vm.prank(governor); + curation.setMinimumCurationDeposit(100); + assertEq(curation.minimumCurationDeposit(), 100); + } + + function test_MinimumCurationDeposit_RevertWhen_Zero() public { + vm.expectRevert(bytes("Minimum curation deposit cannot be 0")); + vm.prank(governor); + curation.setMinimumCurationDeposit(0); + } + + function test_MinimumCurationDeposit_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setMinimumCurationDeposit(DEFAULT_MINIMUM_CURATION_DEPOSIT); + } + + // -- curationTaxPercentage -- + + function test_CurationTaxPercentage_Set() public { + vm.startPrank(governor); + curation.setCurationTaxPercentage(0); + assertEq(curation.curationTaxPercentage(), 0); + curation.setCurationTaxPercentage(DEFAULT_CURATION_TAX_PERCENTAGE); + assertEq(curation.curationTaxPercentage(), DEFAULT_CURATION_TAX_PERCENTAGE); + vm.stopPrank(); + } + + function test_CurationTaxPercentage_RevertWhen_OutOfBounds() public { + vm.expectRevert(bytes("Curation tax percentage must be below or equal to MAX_PPM")); + vm.prank(governor); + curation.setCurationTaxPercentage(MAX_PPM + 1); + } + + function test_CurationTaxPercentage_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setCurationTaxPercentage(0); + } + + // -- curationTokenMaster -- + + function test_CurationTokenMaster_Set() public { + vm.prank(governor); + curation.setCurationTokenMaster(address(curation)); + assertEq(curation.curationTokenMaster(), address(curation)); + } + + function test_CurationTokenMaster_RevertWhen_Empty() public { + vm.expectRevert(bytes("Token master must be non-empty")); + vm.prank(governor); + curation.setCurationTokenMaster(address(0)); + } + + function test_CurationTokenMaster_RevertWhen_NotContract() public { + vm.expectRevert(bytes("Token master must be a contract")); + vm.prank(governor); + curation.setCurationTokenMaster(makeAddr("notContract")); + } + + function test_CurationTokenMaster_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setCurationTokenMaster(address(curation)); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol new file mode 100644 index 000000000..dd20afd1d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract ConservationTest is L2CurationSharedTest { + function testFuzz_Conservation_DepositsAndRedeemsMatch( + uint256[10] memory depositCuts, + uint256[10] memory redeemCuts + ) public { + uint256 totalDeposits = 1_000_000_000 ether; + + // Signal multiple times + uint256 totalSignal; + uint256 remaining = totalDeposits; + for (uint256 i = 0; i < 10; i++) { + uint256 chunk = i == 9 ? remaining : bound(depositCuts[i], 0, remaining); + remaining -= chunk; + if (chunk == 0) continue; + totalSignal += _mint(chunk); + } + assertEq(totalSignal, curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID)); + + // Redeem signal multiple times + uint256 totalTokens; + remaining = totalSignal; + for (uint256 i = 0; i < 10; i++) { + uint256 chunk = i == 9 ? remaining : bound(redeemCuts[i], 0, remaining); + remaining -= chunk; + if (chunk == 0) continue; + vm.prank(curator); + totalTokens += curation.burn(SUBGRAPH_DEPLOYMENT_ID, chunk, 0); + } + + // Conservation of work + (uint256 poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(poolTokens, 0); + assertEq(curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID), 0); + assertFalse(curation.isCurated(SUBGRAPH_DEPLOYMENT_ID)); + assertEq(totalTokens, totalDeposits); + assertEq(grt.balanceOf(curator), CURATOR_TOKENS); + assertEq(grt.balanceOf(address(curation)), 0); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol new file mode 100644 index 000000000..aa611dc0e --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; + +contract CurateTest is L2CurationSharedTest { + function test_Curate_RevertWhen_BelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(2); + uint256 tokensToDeposit = curation.minimumCurationDeposit() - 1; + + vm.expectRevert(bytes("Curation deposit is below minimum required")); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, 0); + } + + function test_Curate_DepositOnSubgraphDeployment() public { + _shouldMint(curation.minimumCurationDeposit(), SIGNAL_AMOUNT_FOR_MINIMUM_CURATION); + } + + function test_Curate_SignalAccordingToBondingCurve() public { + _shouldMint(1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_Curate_SignalAccordingToBondingCurveWithCurationTax() public { + vm.prank(governor); + curation.setCurationTaxPercentage(50_000); // 5% + + (uint256 expectedSignal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + _shouldMint(1000 ether, expectedSignal); + } + + function test_Curate_RevertWhen_OverSlippage() public { + vm.expectRevert(bytes("Slippage protection")); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, 1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS + 1); + } + + function test_Curate_PaysMinimumOneWeiTaxOnSmallDeposits() public { + vm.startPrank(governor); + curation.setMinimumCurationDeposit(1); + curation.setCurationTaxPercentage(10_000); // 1% + vm.stopPrank(); + + // Deposit a small amount where tax would be less than 1 wei + uint256 tokensToDeposit = 99; + uint256 expectedTokens = 98; + uint256 expectedSignal = 98; + uint256 expectedTax = 1; + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(curator, SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, expectedTax); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Burned(curator, SUBGRAPH_DEPLOYMENT_ID, expectedTokens, expectedSignal); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, expectedSignal, expectedTokens); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol new file mode 100644 index 000000000..d8aeeb552 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract CurateTaxFreeTest is L2CurationSharedTest { + function test_CurateTaxFree_RevertWhen_NotGNS() public { + uint256 tokensToDeposit = curation.minimumCurationDeposit(); + + vm.expectRevert(bytes("Only the GNS can call this")); + vm.prank(curator); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + } + + function test_CurateTaxFree_RevertWhen_BelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(2); + uint256 tokensToDeposit = curation.minimumCurationDeposit() - 1; + + vm.expectRevert(bytes("Curation deposit is below minimum required")); + vm.prank(address(gns)); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + } + + function test_CurateTaxFree_DepositOnSubgraphDeployment() public { + _shouldMintTaxFree(curation.minimumCurationDeposit(), SIGNAL_AMOUNT_FOR_MINIMUM_CURATION); + } + + function test_CurateTaxFree_SignalAccordingToBondingCurve() public { + _shouldMintTaxFree(1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_CurateTaxFree_SignalAccordingToBondingCurveWithZeroTax() public { + vm.prank(governor); + curation.setCurationTaxPercentage(50_000); // 5% + + uint256 expectedSignal = curation.tokensToSignalNoTax(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + _shouldMintTaxFree(1000 ether, expectedSignal); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol new file mode 100644 index 000000000..a239703fe --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "../shared/DiscoveryTest.t.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; + +abstract contract L2CurationSharedTest is DiscoveryTest { + uint32 internal constant MAX_PPM = 1_000_000; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID = keccak256("subgraphDeploymentID"); + uint256 internal constant SIGNAL_AMOUNT_FOR_1000_TOKENS = 1000 ether; + uint256 internal constant SIGNAL_AMOUNT_FOR_MINIMUM_CURATION = 1; + uint256 internal constant CURATOR_TOKENS = 1_000_000_000 ether; + uint256 internal constant TOKENS_TO_DEPOSIT = 1000 ether; + uint256 internal constant TOKENS_TO_COLLECT = 2000 ether; + + struct State { + uint256 tokenTotalSupply; + uint256 curatorTokens; + uint256 curatorSignal; + uint256 poolTokens; + uint256 poolSignal; + uint256 totalTokens; + } + + function setUp() public virtual override { + super.setUp(); + + // Curator and GNS funds (GNS already approved curation in approveAll) + vm.startPrank(governor); + grt.mint(curator, CURATOR_TOKENS); + grt.mint(address(gns), CURATOR_TOKENS); + grt.mint(subgraphService, TOKENS_TO_COLLECT); + vm.stopPrank(); + vm.prank(curator); + grt.approve(address(curation), CURATOR_TOKENS); + vm.prank(subgraphService); + grt.approve(address(curation), TOKENS_TO_COLLECT); + } + + function _state(address account) internal view returns (State memory s) { + s.tokenTotalSupply = grt.totalSupply(); + s.curatorTokens = grt.balanceOf(account); + s.curatorSignal = curation.getCuratorSignal(account, SUBGRAPH_DEPLOYMENT_ID); + (s.poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + s.poolSignal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + s.totalTokens = grt.balanceOf(address(curation)); + } + + function _shouldMint(uint256 tokensToDeposit, uint256 expectedSignal) internal { + State memory before = _state(curator); + uint256 curationTax = (tokensToDeposit * curation.curationTaxPercentage()) / MAX_PPM; + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(curator, SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, curationTax); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, 0); + + State memory afterState = _state(curator); + (, uint32 reserveRatio, ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterState.curatorTokens, before.curatorTokens - tokensToDeposit); + assertEq(afterState.curatorSignal, before.curatorSignal + expectedSignal); + assertEq(afterState.poolTokens, before.poolTokens + tokensToDeposit - curationTax); + assertEq(afterState.poolSignal, before.poolSignal + expectedSignal); + // Pool reserveRatio is deprecated and therefore always zero in L2 + assertEq(reserveRatio, 0); + assertEq(afterState.totalTokens, before.totalTokens + tokensToDeposit - curationTax); + // Total supply is reduced by the curation tax burn + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply - curationTax); + } + + function _shouldMintTaxFree(uint256 tokensToDeposit, uint256 expectedSignal) internal { + State memory before = _state(address(gns)); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(address(gns), SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, 0); + vm.prank(address(gns)); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + + State memory afterState = _state(address(gns)); + (, uint32 reserveRatio, ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterState.curatorTokens, before.curatorTokens - tokensToDeposit); + assertEq(afterState.curatorSignal, before.curatorSignal + expectedSignal); + assertEq(afterState.poolTokens, before.poolTokens + tokensToDeposit); + assertEq(afterState.poolSignal, before.poolSignal + expectedSignal); + assertEq(reserveRatio, 0); + assertEq(afterState.totalTokens, before.totalTokens + tokensToDeposit); + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply); + } + + function _shouldBurn(uint256 signalToRedeem, uint256 expectedTokens) internal { + State memory before = _state(curator); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Burned(curator, SUBGRAPH_DEPLOYMENT_ID, expectedTokens, signalToRedeem); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem, 0); + + State memory afterState = _state(curator); + assertEq(afterState.curatorTokens, before.curatorTokens + expectedTokens); + assertEq(afterState.curatorSignal, before.curatorSignal - signalToRedeem); + assertEq(afterState.poolTokens, before.poolTokens - expectedTokens); + assertEq(afterState.poolSignal, before.poolSignal - signalToRedeem); + assertEq(afterState.totalTokens, before.totalTokens - expectedTokens); + // Total supply is conserved + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply); + } + + function _shouldCollect(uint256 tokensToCollect) internal { + (uint256 beforePoolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + uint256 beforeTotalBalance = grt.balanceOf(address(curation)); + + vm.prank(subgraphService); + grt.transfer(address(curation), tokensToCollect); + vm.expectEmit(address(curation)); + emit IL2CurationLike.Collected(SUBGRAPH_DEPLOYMENT_ID, tokensToCollect); + vm.prank(subgraphService); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, tokensToCollect); + + (uint256 afterPoolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterPoolTokens, beforePoolTokens + tokensToCollect); + assertEq(grt.balanceOf(address(curation)), beforeTotalBalance + tokensToCollect); + } + + function _mint(uint256 tokens) internal returns (uint256 signal) { + vm.prank(curator); + (signal, ) = curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokens, 0); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol new file mode 100644 index 000000000..05d71f212 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract MultipleMintingTest is L2CurationSharedTest { + function _tokensToDepositMany() private pure returns (uint256[8] memory) { + return + [ + uint256(1000 ether), // should mint if we start with number above minimum deposit + 1000 ether, // every time it should mint the same GCS due to the bonding curve + 1000 ether, + 1000 ether, + 2000 ether, + 2000 ether, + 123 ether, + 1 ether // should mint below minimum deposit + ]; + } + + /// Linear bonding curve computed independently of the contract + function _calcLinearBondingCurve( + uint256 supply, + uint256 reserveBalance, + uint256 depositAmount + ) private view returns (uint256) { + // Handle the initialization of the bonding curve + if (supply == 0) { + uint256 minDeposit = curation.minimumCurationDeposit(); + require(depositAmount >= minDeposit, "deposit must be above minimum"); + uint256 minSupply = SIGNAL_AMOUNT_FOR_MINIMUM_CURATION; + return _calcLinearBondingCurve(minSupply, minDeposit, depositAmount - minDeposit) + minSupply; + } + return (supply * depositAmount) / reserveBalance; + } + + function test_MultipleMinting_SameSignalDueToFlatBondingCurve() public { + uint256[8] memory tokensToDepositMany = _tokensToDepositMany(); + for (uint256 i = 0; i < tokensToDepositMany.length; i++) { + uint256 tokensToDeposit = tokensToDepositMany[i]; + uint256 expectedSignal = _calcLinearBondingCurve( + curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID), + curation.getCurationPoolTokens(SUBGRAPH_DEPLOYMENT_ID), + tokensToDeposit + ); + // SIGNAL_PER_MINIMUM_DEPOSIT should always give the same ratio + assertEq(tokensToDeposit / expectedSignal, 1); + + assertEq(_mint(tokensToDeposit), expectedSignal); + } + } + + function test_MultipleMinting_DifferentRatioBetweenGRTAndSignal() public { + // Setup edge case with 1 GRT = 1 wei signal + vm.prank(governor); + curation.setMinimumCurationDeposit(1 ether); + + uint256[8] memory tokensToDepositMany = _tokensToDepositMany(); + for (uint256 i = 0; i < tokensToDepositMany.length; i++) { + uint256 tokensToDeposit = tokensToDepositMany[i]; + // 1 GRT : 1 wei signal ratio + assertEq(tokensToDeposit, _mint(tokensToDeposit) * 1 ether); + } + } +} diff --git a/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol b/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol new file mode 100644 index 000000000..6df628d5f --- /dev/null +++ b/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ICallhookReceiver } from "@graphprotocol/interfaces/contracts/contracts/gateway/ICallhookReceiver.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; + +/// Stands in for L2GraphTokenGateway: mints first, then forwards the callhook, like finalizeInboundTransfer +contract L2GatewayMock { + address public immutable token; + + constructor(address token_) { + token = token_; + } + + function deliver(address gns, address l1From, uint256 amount, bytes calldata data) external { + IGraphTokenLike(token).mint(gns, amount); + ICallhookReceiver(gns).onTokenTransfer(l1From, amount, data); + } +} diff --git a/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol b/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol new file mode 100644 index 000000000..9414df07d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { ProtocolFixture } from "./ProtocolFixture.t.sol"; +import { L2GatewayMock } from "../mocks/L2GatewayMock.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; +import { ISubgraphNFTLike } from "../interfaces/ISubgraphNFTLike.sol"; + +/// L2 discovery stack: L2GraphToken, L2Curation, SubgraphNFT and L2GNS, with a mock L2 gateway. +/// There is no staking, rewards manager or epoch manager. +abstract contract DiscoveryTest is ProtocolFixture { + string internal constant L2_GRAPH_TOKEN = + string.concat(PKG_ARTIFACTS, "l2/token/L2GraphToken.sol/L2GraphToken.json"); + string internal constant GRAPH_CURATION_TOKEN = + string.concat(PKG_ARTIFACTS, "curation/GraphCurationToken.sol/GraphCurationToken.json"); + string internal constant L2_CURATION = string.concat(PKG_ARTIFACTS, "l2/curation/L2Curation.sol/L2Curation.json"); + string internal constant SUBGRAPH_NFT = string.concat(PKG_ARTIFACTS, "discovery/SubgraphNFT.sol/SubgraphNFT.json"); + string internal constant SUBGRAPH_NFT_DESCRIPTOR = + string.concat(PKG_ARTIFACTS, "discovery/SubgraphNFTDescriptor.sol/SubgraphNFTDescriptor.json"); + string internal constant L2_GNS = string.concat(PKG_ARTIFACTS, "l2/discovery/L2GNS.sol/L2GNS.json"); + + IGraphTokenLike internal grt; + IL2CurationLike internal curation; + IL2GNSLike internal gns; + ISubgraphNFTLike internal nft; + L2GatewayMock internal gatewayMock; + address internal curationTokenMaster; + + address internal me = makeAddr("me"); + address internal other = makeAddr("other"); + address internal curator = makeAddr("curator"); + address internal attacker = makeAddr("attacker"); + address internal l1GNS = makeAddr("l1GNS"); + address internal subgraphService = makeAddr("subgraphService"); + + function setUp() public virtual { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + grt = IGraphTokenLike( + _deployBehindProxy( + _deployArtifact(L2_GRAPH_TOKEN, ""), + abi.encodeWithSignature("initialize(address)", governor) + ) + ); + gatewayMock = new L2GatewayMock(address(grt)); + vm.startPrank(governor); + grt.addMinter(address(gatewayMock)); + grt.addMinter(governor); + vm.stopPrank(); + + curationTokenMaster = _deployArtifact(GRAPH_CURATION_TOKEN, ""); + + curation = IL2CurationLike( + _deployBehindProxy( + _deployArtifact(L2_CURATION, ""), + abi.encodeCall(IL2CurationLike.initialize, (address(controller), curationTokenMaster, 0, 1)) + ) + ); + + nft = ISubgraphNFTLike(_deployArtifact(SUBGRAPH_NFT, abi.encode(governor))); + address descriptor = _deployArtifact(SUBGRAPH_NFT_DESCRIPTOR, ""); + vm.prank(governor); + nft.setTokenDescriptor(descriptor); + + gns = IL2GNSLike( + _deployBehindProxy( + _deployArtifact(L2_GNS, ""), + abi.encodeCall(IL2GNSLike.initialize, (address(controller), address(nft))) + ) + ); + vm.prank(governor); + nft.setMinter(address(gns)); + + _registerContract("GraphToken", address(grt)); + _registerContract("Curation", address(curation)); + _registerContract("GNS", address(gns)); + _registerContract("GraphTokenGateway", address(gatewayMock)); + IManaged(address(curation)).syncAllContracts(); + IManaged(address(gns)).syncAllContracts(); + + gns.approveAll(); + vm.startPrank(governor); + gns.setCounterpartGNSAddress(l1GNS); + curation.setSubgraphService(subgraphService); + vm.stopPrank(); + } +} diff --git a/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol b/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0e89ae6..287d911a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1058,6 +1058,18 @@ importers: specifier: 'catalog:' version: 1.7.0 + packages/subgraph-discovery: + devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + forge-std: + specifier: 'catalog:' + version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 + packages/subgraph-service: devDependencies: '@graphprotocol/contracts': diff --git a/scripts/contracts-migration/test-mapping/t3a.md b/scripts/contracts-migration/test-mapping/t3a.md new file mode 100644 index 000000000..e7885b594 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t3a.md @@ -0,0 +1,57 @@ +# T3a test mapping: L2Curation + +New tests are in `packages/subgraph-discovery`. Each `describe` of the old file has its own file under `test/unit/l2-curation/`; the shared mint/burn/collect helpers and curator funding are in `L2CurationShared.t.sol`, on top of the package's `DiscoveryTest` base. `test/unit/Setup.t.sol::test_Setup_Wired` checks the base wiring and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › defaultReserveRatio › should be fixed to MAX_PPM | test/unit/l2-curation/Config.t.sol::test_DefaultReserveRatio_FixedToMaxPPM | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › defaultReserveRatio › cannot be changed because the setter is not implemented | test/unit/l2-curation/Config.t.sol::test_DefaultReserveRatio_RevertWhen_Set | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › should set `minimumCurationDeposit` | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_Set | Initial value 1 (`l2MinimumCurationDeposit`) | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › reject set `minimumCurationDeposit` if out of bounds | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_RevertWhen_Zero | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › reject set `minimumCurationDeposit` if not allowed | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › should set `curationTaxPercentage` | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_Set | Also asserts the getter after each set | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › reject set `curationTaxPercentage` if out of bounds | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_RevertWhen_OutOfBounds | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › reject set `curationTaxPercentage` if not allowed | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › should set `curationTokenMaster` | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_Set | Also asserts the getter | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` to empty value | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_Empty | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` to non-contract | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_NotContract | Random address becomes `makeAddr` | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` if not allowed | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › reject convert signal to tokens if subgraph deployment not initted | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_RevertWhen_SignalToTokensNotCurated | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert signal to tokens | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertSignalToTokens | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert signal to tokens (with curation tax) | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertSignalToTokensWithCurationTax | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert tokens to signal | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertTokensToSignal | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert tokens to signal if non-curated subgraph | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_RevertWhen_ConvertTokensToSignalBelowMinimum | Random deployment ID becomes a fixed hash | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › reject deposit below minimum tokens required | test/unit/l2-curation/Curate.t.sol::test_Curate_RevertWhen_BelowMinimumDeposit | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should deposit on a subgraph deployment | test/unit/l2-curation/Curate.t.sol::test_Curate_DepositOnSubgraphDeployment | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should get signal according to bonding curve | test/unit/l2-curation/Curate.t.sol::test_Curate_SignalAccordingToBondingCurve | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should get signal according to bonding curve (and account for curation tax) | test/unit/l2-curation/Curate.t.sol::test_Curate_SignalAccordingToBondingCurveWithCurationTax | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should revert curate if over slippage | test/unit/l2-curation/Curate.t.sol::test_Curate_RevertWhen_OverSlippage | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should pay a minimum of 1 wei GRT in tax when depositing small amounts | test/unit/l2-curation/Curate.t.sol::test_Curate_PaysMinimumOneWeiTaxOnSmallDeposits | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › can not be called by anyone other than GNS | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_RevertWhen_NotGNS | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › reject deposit below minimum tokens required | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_RevertWhen_BelowMinimumDeposit | `vm.prank(address(gns))` replaces the GNS impersonator | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should deposit on a subgraph deployment | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_DepositOnSubgraphDeployment | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should get signal according to bonding curve | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_SignalAccordingToBondingCurve | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should get signal according to bonding curve (and with zero tax) | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_SignalAccordingToBondingCurveWithZeroTax | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > not curated › reject collect tokens distributed to the curation pool | test/unit/l2-curation/Collect.t.sol::test_NotCurated_RevertWhen_Collect | Leftover `setContractProxy("Staking")` dropped: `collect` only checks `subgraphService` | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › reject collect tokens distributed from invalid address | test/unit/l2-curation/Collect.t.sol::test_Curated_RevertWhen_CallerNotSubgraphService | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens distributed to the curation pool | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectTokens | Leftover `setContractProxy("Staking")` dropped | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens and then unsignal all | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectThenBurnAll | Leftover `setContractProxy("Staking")` dropped | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens and then unsignal multiple times | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectThenBurnMultipleTimes | Leftover `setContractProxy("Staking")` dropped; tokens read from `burn`'s return value instead of the `Burned` log | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › reject redeem more than a curator owns | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_MoreThanOwned | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › reject redeem zero signal | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_ZeroSignal | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem _partially_ | test/unit/l2-curation/Burn.t.sol::test_Burn_Partially | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem _fully_ | test/unit/l2-curation/Burn.t.sol::test_Burn_Fully | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem back below minimum deposit | test/unit/l2-curation/Burn.t.sol::test_Burn_BackBelowMinimumDeposit | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should revert redeem if over slippage | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_OverSlippage | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should not re-deploy the curation token when signal is reset | test/unit/l2-curation/Burn.t.sol::test_Burn_DoesNotRedeployCurationTokenWhenSignalReset | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › conservation › should match multiple deposits and redeems back to initial state | test/unit/l2-curation/Conservation.t.sol::testFuzz_Conservation_DepositsAndRedeemsMatch | `Math.random` chunking becomes fuzzed cuts (10 deposit, 10 redeem chunks); signal and tokens from return values; also asserts curator and pool GRT balances return to initial | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › multiple minting › should mint the same signal every time due to the flat bonding curve | test/unit/l2-curation/MultipleMinting.t.sol::test_MultipleMinting_SameSignalDueToFlatBondingCurve | Expected signal computed in integers, so asserted exactly (was float, 11 significant digits) | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › multiple minting › should mint when using a different ratio between GRT and signal | test/unit/l2-curation/MultipleMinting.t.sol::test_MultipleMinting_DifferentRatioBetweenGRTAndSignal | | + +## Coverage + +MISSING initialize: `L2Curation.initialize` is called by `DiscoveryTest` through `abi.encodeCall` in the proxy init data (`acceptProxyAndCall`), not by a `.initialize(` call; every test runs it. `GraphCurationToken.initialize` is only callable once per clone and is called by L2Curation when it creates a pool's curation token, on the first `mint`/`mintTaxFree` of every curate test. + +MISSING burnFrom: `GraphCurationToken.burnFrom` is `onlyOwner` (the owner is L2Curation) and is reached through every `curation.burn(` call. `GraphCurationToken.mint` is likewise reached only through `curation.mint` / `curation.mintTaxFree` (the grep matches `.mint(` on curation). + +Additions with no old test: `test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_TokensToSignalToTokensNoTax` calls `tokensToSignalToTokensNoTax` directly (the old suite reached it only through L2GNS).