diff --git a/schema.graphql b/schema.graphql index 78ce3ef2..8be66f1f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -106,6 +106,8 @@ type GraphNetwork @entity(immutable: false) { totalTokensSignalledAutoMigrate: BigDecimal! "Total GRT currently curating to a specific version" totalTokensSignalledDirectly: BigDecimal! + "Whether totalTokensSignalled, and on Arbitrum its auto-migrating and direct parts, include every curator query fee paid into curation pools. Unset only on a deployment grafted from a version that left them out, until its next update adds them" + curationFeesInSignalTotal: Boolean # Query fees globals "Total query fees generated in the network" diff --git a/src/mappings/helpers/helpers.ts b/src/mappings/helpers/helpers.ts index 4d814039..20478596 100644 --- a/src/mappings/helpers/helpers.ts +++ b/src/mappings/helpers/helpers.ts @@ -690,7 +690,49 @@ export function createEpoch(startBlock: i32, epochLength: i32, epochNumber: i32) export function loadGraphNetwork(): GraphNetwork { // Should only be called whenever we are sure a GraphNetwork entity exists. // This is only made to centralize the load statements that are everywhere - return GraphNetwork.load('1')! + let graphNetwork = GraphNetwork.load('1')! + countPastCurationFeesInSignal(graphNetwork) + return graphNetwork +} + +// Versions that left curation fees out of totalTokensSignalled fell short by exactly the fees paid, +// and a graft inherits that, so add them back once and re-split the total by the value of the GNS +// contract's shares. Not on L1, which subtracts signal sent to L2 twice, so this would not match. +function countPastCurationFeesInSignal(graphNetwork: GraphNetwork): void { + if (graphNetwork.curationFeesInSignalTotal) return + graphNetwork.totalTokensSignalled = graphNetwork.totalTokensSignalled.plus( + graphNetwork.totalCuratorQueryFees, + ) + graphNetwork.curationFeesInSignalTotal = true + if (!addresses.isL1) splitSignalByGnsShareValue(graphNetwork) + // Save now so later loads in this block see the flag instead of walking every GNS signal again + graphNetwork.save() +} + +function splitSignalByGnsShareValue(graphNetwork: GraphNetwork): void { + let autoMigrate = BigDecimal.fromString('0') + let gnsCurator = Curator.load(graphNetwork.gns.toHexString()) + if (gnsCurator != null) { + let gnsSignals = gnsCurator.signals.load() + for (let i = 0; i < gnsSignals.length; i++) { + let gnsSignal = gnsSignals[i] + if (gnsSignal.signal.isZero()) continue + let deployment = SubgraphDeployment.load(gnsSignal.subgraphDeployment)! + if (deployment.signalAmount.isZero()) continue + autoMigrate = autoMigrate + .plus( + gnsSignal.signal + .toBigDecimal() + .times(deployment.signalledTokens.toBigDecimal()) + .div(deployment.signalAmount.toBigDecimal()), + ) + .truncate(18) + } + } + graphNetwork.totalTokensSignalledAutoMigrate = autoMigrate + graphNetwork.totalTokensSignalledDirectly = graphNetwork.totalTokensSignalled + .toBigDecimal() + .minus(autoMigrate) } export function createOrLoadGraphNetwork( @@ -756,6 +798,7 @@ export function createOrLoadGraphNetwork( graphNetwork.totalTokensSignalled = BigInt.fromI32(0) graphNetwork.totalTokensSignalledAutoMigrate = BigDecimal.fromString('0') graphNetwork.totalTokensSignalledDirectly = BigDecimal.fromString('0') + graphNetwork.curationFeesInSignalTotal = true graphNetwork.totalQueryFees = BigInt.fromI32(0) graphNetwork.totalIndexerQueryFeesCollected = BigInt.fromI32(0) @@ -839,6 +882,7 @@ export function createOrLoadGraphNetwork( graphNetwork.save() } + countPastCurationFeesInSignal(graphNetwork) if (!addresses.isL1) { graphNetwork.currentL1BlockNumber = getL1BlockNumber() @@ -983,9 +1027,9 @@ function createGraphAccountName( graphAccountName.graphAccount = graphAccount graphAccountName.save() // check that this name is not already used by another graph account (changing ownership) - // If so, remove the old owner, and set the new one + // If so, move the name from its former owner to the new one } else if (graphAccountName.graphAccount != graphAccount) { - // Only update the old graph account if it exists + // Only update the former owner's graph account if it exists if (graphAccountName.graphAccount != null) { // Set defaultDisplayName to null if they lost ownership of this name let oldGraphAccount = GraphAccount.load(graphAccountName.graphAccount!)! @@ -1242,13 +1286,8 @@ export function calculateCapacitiesLegacy(indexer: Indexer): Indexer { } export function calculatePricePerShare(deployment: SubgraphDeployment): BigDecimal { - // TODO check why there's a deviation from the values of the bancor formula - // Ideally this would be a 1 to 1 recreation of the share sell formula, but due to - // implementation issues for that formula on AssemblyScript (mainly BigDecimal missing pow implementation) - // I decided to use an approximation derived from testing. - - // This value could be wrong unfortunately, so we should ideally find a workaround later - // to implement the actual sell share formula for 1 share. + // TODO: implement the bancor sell formula for 1 share exactly. This is an approximation found + // by testing, because AssemblyScript's BigDecimal has no pow, so it can deviate from the formula. // reserve ratio multiplier = MAX_WEIGHT / reserveRatio = 1M (ppm) / reserveRatio // HOTFIX for now, if deployment.reserveRatio -> 0, use a known previous default @@ -1264,42 +1303,6 @@ export function calculatePricePerShare(deployment: SubgraphDeployment): BigDecim return pricePerShare } -// export function createOrLoadNetwork(id: string): Network { -// let network = Network.load(id) -// if (network == null) { -// network = new Network(id) - -// network.save() -// } -// return network as Network -// } - -// export function createOrLoadSubgraphCategory(id: string): SubgraphCategory { -// let category = SubgraphCategory.load(id) -// if (category == null) { -// category = new SubgraphCategory(id) - -// category.save() -// } -// return category as SubgraphCategory -// } - -// export function createOrLoadSubgraphCategoryRelation( -// categoryId: string, -// subgraphMetadataId: string, -// ): SubgraphCategoryRelation { -// let id = joinID([categoryId, subgraphMetadataId]) -// let relation = SubgraphCategoryRelation.load(id) -// if (relation == null) { -// relation = new SubgraphCategoryRelation(id) -// relation.metadata = subgraphMetadataId -// relation.category = categoryId - -// relation.save() -// } -// return relation as SubgraphCategoryRelation -// } - export function updateCurrentDeploymentLinks( oldDeployment: SubgraphDeployment | null, newDeployment: SubgraphDeployment | null, @@ -1342,6 +1345,31 @@ export function updateCurrentDeploymentLinks( } } +// Curation fees raise a pool's signalled tokens without minting signal, so count them here. +// Every share gains the same value: the GNS contract's shares take their part as +// auto-migrating signal and the rest counts as signalled directly. +export function addCurationFeesToNetworkSignal( + graphNetwork: GraphNetwork, + deployment: SubgraphDeployment, + fees: BigInt, +): void { + graphNetwork.totalTokensSignalled = graphNetwork.totalTokensSignalled.plus(fees) + let toGns = BigDecimal.fromString('0') + let gnsSignal = Signal.load(joinID([graphNetwork.gns.toHexString(), deployment.id])) + if (gnsSignal != null && !deployment.signalAmount.isZero()) { + toGns = fees + .toBigDecimal() + .times(gnsSignal.signal.toBigDecimal()) + .div(deployment.signalAmount.toBigDecimal()) + } + graphNetwork.totalTokensSignalledAutoMigrate = graphNetwork.totalTokensSignalledAutoMigrate + .plus(toGns) + .truncate(18) + graphNetwork.totalTokensSignalledDirectly = graphNetwork.totalTokensSignalledDirectly + .plus(fees.toBigDecimal().minus(toGns)) + .truncate(18) +} + export function batchUpdateSubgraphSignalledTokens(deployment: SubgraphDeployment): void { for (let i = 0; i < deployment.subgraphCount; i++) { let id = deployment.id.concat('-').concat(BigInt.fromI32(i).toString()) diff --git a/src/mappings/staking.ts b/src/mappings/staking.ts index 5eac0814..b7bc0faf 100644 --- a/src/mappings/staking.ts +++ b/src/mappings/staking.ts @@ -1,4 +1,4 @@ -import { BigInt, BigDecimal, Bytes } from '@graphprotocol/graph-ts' +import { BigInt, BigDecimal } from '@graphprotocol/graph-ts' import { StakeDeposited, StakeWithdrawn, @@ -28,7 +28,6 @@ import { GraphAccount, Delegator, DelegatedStake, - IndexerQueryFeePaymentAggregation, } from '../types/schema' import { @@ -43,6 +42,7 @@ import { updateDelegationExchangeRate, calculatePricePerShare, batchUpdateSubgraphSignalledTokens, + addCurationFeesToNetworkSignal, createOrLoadGraphNetwork, calculateCapacities, createOrLoadIndexerQueryFeePaymentAggregation, @@ -499,6 +499,7 @@ export function handleAllocationCollected(event: AllocationCollected): void { graphNetwork.totalCuratorQueryFees = graphNetwork.totalCuratorQueryFees.plus( event.params.curationFees, ) + addCurationFeesToNetworkSignal(graphNetwork, deployment, event.params.curationFees) graphNetwork.totalTaxedQueryFees = graphNetwork.totalTaxedQueryFees.plus(taxedFees) graphNetwork.totalUnclaimedQueryFeeRebates = graphNetwork.totalUnclaimedQueryFeeRebates.plus( event.params.rebateFees, @@ -804,6 +805,7 @@ export function handleRebateCollected(event: RebateCollected): void { graphNetwork.totalCuratorQueryFees = graphNetwork.totalCuratorQueryFees.plus( event.params.curationFees, ) + addCurationFeesToNetworkSignal(graphNetwork, deployment, event.params.curationFees) graphNetwork.totalTaxedQueryFees = graphNetwork.totalTaxedQueryFees.plus(event.params.protocolTax) graphNetwork.totalUnclaimedQueryFeeRebates = graphNetwork.totalUnclaimedQueryFeeRebates.plus( event.params.queryFees, diff --git a/src/mappings/subgraphService.ts b/src/mappings/subgraphService.ts index 69b390b8..bc94666a 100644 --- a/src/mappings/subgraphService.ts +++ b/src/mappings/subgraphService.ts @@ -1,6 +1,6 @@ import { BigDecimal, BigInt, ByteArray, Bytes, crypto, ethereum, log } from "@graphprotocol/graph-ts" import { AllocationClosed, AllocationCreated, AllocationResized, CurationCutSet, DelegationRatioSet, IndexingRewardsCollected, MaxPOIStalenessSet, POIPresented, ProvisionTokensRangeSet, QueryFeesCollected, RewardsDestinationSet, ServiceProviderRegistered, StakeToFeesRatioSet, ThawingPeriodRangeSet, VerifierCutRangeSet } from "../types/SubgraphService/SubgraphService" -import { batchUpdateSubgraphSignalledTokens, calculatePricePerShare, createOrLoadDataService, createOrLoadGraphNetwork, createOrLoadEpoch,createOrLoadIndexerQueryFeePaymentAggregation, createOrLoadPaymentSource, createOrLoadProvision, createOrLoadSubgraphDeployment, joinID, updateDelegationExchangeRate, calculateCapacities, loadGraphNetwork } from "./helpers/helpers" +import { addCurationFeesToNetworkSignal, batchUpdateSubgraphSignalledTokens, calculatePricePerShare, createOrLoadDataService, createOrLoadGraphNetwork, createOrLoadEpoch,createOrLoadIndexerQueryFeePaymentAggregation, createOrLoadPaymentSource, createOrLoadProvision, createOrLoadSubgraphDeployment, joinID, calculateCapacities, loadGraphNetwork } from "./helpers/helpers" import { Allocation, Indexer, PoiSubmission, SubgraphDeployment } from "../types/schema" import { addresses } from "../../config/addresses" import { tuplePrefixBytes } from "./helpers/decoder" @@ -510,6 +510,7 @@ export function handleQueryFeesCollected(event: QueryFeesCollected): void { graphNetwork.totalCuratorQueryFees = graphNetwork.totalCuratorQueryFees.plus( event.params.tokensCurators, ) + addCurationFeesToNetworkSignal(graphNetwork, deployment, event.params.tokensCurators) graphNetwork.totalIndexerQueryFeeRebates = graphNetwork.totalIndexerQueryFeeRebates.plus( indexerQueryFees, ) diff --git a/tests/curation.test.ts b/tests/curation.test.ts index 8e7729f0..ee9691d1 100644 --- a/tests/curation.test.ts +++ b/tests/curation.test.ts @@ -1,4 +1,4 @@ -import { Address, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts' +import { Address, BigDecimal, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts' import { assert, @@ -23,10 +23,18 @@ import { mockStakeDeposited, mockAllocationCreated, mockStakeDelegated } from '. import { mockSignalled, mockBurned, mockParameterUpdated } from './factories/curation' -import { createOrLoadGraphNetwork } from '../src/mappings/helpers/helpers' +import { + addCurationFeesToNetworkSignal, + createOrLoadGraphNetwork, + loadGraphNetwork, +} from '../src/mappings/helpers/helpers' + +import { GraphNetwork, SubgraphDeployment } from '../src/types/schema' import { mockTransfer } from './factories/graphToken' +import { addresses } from '../config/addresses' + // CONSTANT ADDRESS OR IDS const graphID = '0x0000000000000000000000000000000000000000' const graphAddress = Address.fromString(graphID) @@ -309,6 +317,137 @@ describe('Burned', () => { }) }) +describe('Curation fees', () => { + beforeAll(() => { + let graphNetwork = createOrLoadGraphNetwork(blockNumber, controllerAddress) + graphNetwork.epochLength = epochLength + graphNetwork.lastLengthUpdateBlock = 1 + graphNetwork.save() + handleTransfer(mockTransfer(graphAddress, curatorAddress, value)) + + // The GNS contract holds 1 of the pool's 4 shares and a curator holds the other 3 + let gnsAddress = Address.fromBytes(graphNetwork.gns) + let zero = BigInt.fromI32(0) + let deployment = subgraphDeploymentAddress + handleSignalled(mockSignalled(gnsAddress, deployment, value, BigInt.fromI32(1), zero)) + handleSignalled(mockSignalled(curatorAddress, deployment, value, BigInt.fromI32(3), zero)) + }) + + afterAll(() => { + clearStore() + }) + + test('splits fees between auto-migrating and direct signal by share of the pool', () => { + let graphNetwork = GraphNetwork.load('1')! + let total = graphNetwork.totalTokensSignalled + let autoMigrate = graphNetwork.totalTokensSignalledAutoMigrate + let directly = graphNetwork.totalTokensSignalledDirectly + + let deployment = SubgraphDeployment.load(subgraphDeploymentID)! + addCurationFeesToNetworkSignal(graphNetwork, deployment, BigInt.fromI32(100)) + graphNetwork.save() + + let totalAfter = total.plus(BigInt.fromI32(100)).toString() + let autoMigrateAfter = autoMigrate.plus(BigDecimal.fromString('25')).toString() + let directlyAfter = directly.plus(BigDecimal.fromString('75')).toString() + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', totalAfter) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalledAutoMigrate', autoMigrateAfter) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalledDirectly', directlyAfter) + }) +}) + +// The GNS contract signals 100 GRT for 1 share and a curator 300 GRT for 3, 100 GRT of fees go +// into the pool, then the curator burns 1 share. countFee says if the fee counts in signal totals. +function curateAndPayFee(countFee: boolean): void { + let graphNetwork = createOrLoadGraphNetwork(blockNumber, controllerAddress) + graphNetwork.epochLength = epochLength + graphNetwork.lastLengthUpdateBlock = 1 + graphNetwork.save() + handleTransfer(mockTransfer(graphAddress, curatorAddress, value)) + + let gns = Address.fromBytes(graphNetwork.gns) + let pool = subgraphDeploymentAddress + let zero = BigInt.fromI32(0) + handleSignalled(mockSignalled(gns, pool, BigInt.fromI32(100), BigInt.fromI32(1), zero)) + handleSignalled(mockSignalled(curatorAddress, pool, BigInt.fromI32(300), BigInt.fromI32(3), zero)) + + // What a fee handler does to the pool and the network + let fee = BigInt.fromI32(100) + let deployment = SubgraphDeployment.load(subgraphDeploymentID)! + deployment.signalledTokens = deployment.signalledTokens.plus(fee) + deployment.save() + graphNetwork = loadGraphNetwork() + graphNetwork.totalCuratorQueryFees = graphNetwork.totalCuratorQueryFees.plus(fee) + if (countFee) addCurationFeesToNetworkSignal(graphNetwork, deployment, fee) + graphNetwork.save() + + handleBurned(mockBurned(curatorAddress, pool, BigInt.fromI32(125), BigInt.fromI32(1))) +} + +describe('Curation fees paid before they were counted', () => { + afterAll(() => { + clearStore() + }) + + test('are added to the signal total once on a deployment grafted without them', () => { + // A grafted base: 100 GRT in the curation contract, 30 of it fees the total left out + let graphNetwork = createOrLoadGraphNetwork(blockNumber, controllerAddress) + graphNetwork.totalTokensSignalled = BigInt.fromI32(70) + graphNetwork.totalCuratorQueryFees = BigInt.fromI32(30) + graphNetwork.unset('curationFeesInSignalTotal') + graphNetwork.save() + + loadGraphNetwork().save() + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', '100') + assert.fieldEquals('GraphNetwork', '1', 'curationFeesInSignalTotal', 'true') + + loadGraphNetwork().save() + createOrLoadGraphNetwork(blockNumber, controllerAddress).save() + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', '100') + }) + + test('are not added again on a network indexed from the start', () => { + clearStore() + let graphNetwork = createOrLoadGraphNetwork(blockNumber, controllerAddress) + graphNetwork.totalCuratorQueryFees = BigInt.fromI32(30) + graphNetwork.save() + + loadGraphNetwork().save() + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', '0') + assert.fieldEquals('GraphNetwork', '1', 'curationFeesInSignalTotal', 'true') + }) + + test('are split as a full sync would on Arbitrum, while L1 keeps its old split', () => { + clearStore() + curateAndPayFee(true) + let fullSync = GraphNetwork.load('1')! + + // A base grafted from a version that left the fee out of all 3 totals + clearStore() + curateAndPayFee(false) + let graphNetwork = loadGraphNetwork() + let autoMigrateBefore = graphNetwork.totalTokensSignalledAutoMigrate + let directlyBefore = graphNetwork.totalTokensSignalledDirectly + assert.assertTrue(autoMigrateBefore < fullSync.totalTokensSignalledAutoMigrate) + assert.assertTrue(directlyBefore < fullSync.totalTokensSignalledDirectly) + graphNetwork.unset('curationFeesInSignalTotal') + graphNetwork.save() + + loadGraphNetwork().save() + let total = fullSync.totalTokensSignalled + let autoMigrate = addresses.isL1 ? autoMigrateBefore : fullSync.totalTokensSignalledAutoMigrate + let directly = addresses.isL1 ? directlyBefore : fullSync.totalTokensSignalledDirectly + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', total.toString()) + assert.fieldEquals( + 'GraphNetwork', + '1', + 'totalTokensSignalledAutoMigrate', + autoMigrate.toString(), + ) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalledDirectly', directly.toString()) + }) +}) + describe('ParameterUpdated', () => { beforeAll(() => { // We need epoch length and delegation ratio to be nonzero for these tests diff --git a/tests/staking.test.ts b/tests/staking.test.ts index a8fdcbdc..871722fe 100644 --- a/tests/staking.test.ts +++ b/tests/staking.test.ts @@ -43,7 +43,7 @@ import { } from './factories/staking' import { createOrLoadGraphNetwork, loadGraphNetwork, getHorizonDelegatedStakeIDFromLegacy } from '../src/mappings/helpers/helpers' import { mockTransfer } from './factories/graphToken' -import { GraphNetwork, GraphAccount } from '../src/types/schema' +import { GraphAccount } from '../src/types/schema' // CONSTANT ADDRESS OR IDS const graphID = '0x0000000000000000000000000000000000000000' @@ -777,6 +777,9 @@ describe('ALLOCATION LIFE CYCLE', () => { rebateFees.toString(), ) assert.fieldEquals('GraphNetwork', '1', 'totalCuratorQueryFees', curationFees.toString()) + // Curation fees are paid into the curation pool, so they are signal too + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalled', curationFees.toString()) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensSignalledDirectly', curationFees.toString()) assert.fieldEquals( 'GraphNetwork', '1',