From 27f84ad4fd19a99153f66839a39d232e2428d1e4 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 7 Feb 2025 18:42:08 -0300 Subject: [PATCH 01/24] feat: indexing fees / dips (wip) --- docs/action-queue.md | 4 +- .../indexer-agent/src/__tests__/indexer.ts | 1 + packages/indexer-agent/src/agent.ts | 186 ++++--- packages/indexer-agent/src/commands/start.ts | 44 +- packages/indexer-cli/src/__tests__/util.ts | 1 + packages/indexer-common/package.json | 3 + .../src/allocations/__tests__/tap.test.ts | 3 + .../__tests__/validate-queries.test.ts | 3 + .../src/allocations/escrow-accounts.ts | 31 ++ packages/indexer-common/src/graph-node.ts | 16 + packages/indexer-common/src/index.ts | 2 + .../__tests__/allocations.test.ts | 1 + .../src/indexer-management/__tests__/util.ts | 1 + .../src/indexer-management/allocations.ts | 33 +- .../src/indexer-management/client.ts | 8 +- .../src/indexer-management/models/index.ts | 5 +- .../models/indexing-agreement.ts | 211 +++++++ .../resolvers/allocations.ts | 42 +- .../src/indexing-fees/__tests__/dips.test.ts | 522 ++++++++++++++++++ .../indexer-common/src/indexing-fees/dips.ts | 327 +++++++++++ .../gateway-dips-service-client.ts | 168 ++++++ .../indexer-common/src/indexing-fees/index.ts | 1 + .../src/network-specification.ts | 4 + packages/indexer-common/src/network.ts | 30 +- packages/indexer-common/src/operator.ts | 24 +- .../indexer-common/src/query-fees/models.ts | 33 +- yarn.lock | 146 ++++- 27 files changed, 1747 insertions(+), 103 deletions(-) create mode 100644 packages/indexer-common/src/indexer-management/models/indexing-agreement.ts create mode 100644 packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts create mode 100644 packages/indexer-common/src/indexing-fees/dips.ts create mode 100644 packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts create mode 100644 packages/indexer-common/src/indexing-fees/index.ts diff --git a/docs/action-queue.md b/docs/action-queue.md index e2591e599..b5cf0c5fd 100644 --- a/docs/action-queue.md +++ b/docs/action-queue.md @@ -8,8 +8,8 @@ The action execution worker will only grab items from the action queue to execut ## Allocation management modes: - `auto`: The indexer-agent will act similarly to the legacy paradigm. When it identifies allocation actions it will add them to the queue with ActionStatus = `approved`; the execution worker process will pick up the approved actions within 30 seconds and execute them. -- `manual`: The indexer-agent will not add any items to the action queue in this mode. It will spin up an indexer-management server which can be interacted with manually or integrated with 3rd party tools to add actions to the action queue and execute them. -- `oversight`: The indexer-agent will add run its reconciliation loop to make allocation decisions and when actions are identified it will queue them. These actions will then require approval before they can be executed. +- `manual`: The indexer-agent will not add any items to the action queue in this mode. It will spin up an indexer-management server which can be interacted with manually or integrated with 3rd party tools to add actions to the action queue and execute them. An exception to this is indexing agreements (DIPs), for which actions will be queued and executed even in this mode. +- `oversight`: The indexer-agent will add run its reconciliation loop to make allocation decisions and when actions are identified it will queue them. These actions will then require approval before they can be executed. An exception to this is indexing agreements (DIPs), for which actions will be queued as approved and executed even in this mode. ## Actions CLI The indexer-cli provides an `actions` module for manually working with the action queue. It uses the #Graphql API hosted by the indexer management server to interact with the actions queue. diff --git a/packages/indexer-agent/src/__tests__/indexer.ts b/packages/indexer-agent/src/__tests__/indexer.ts index 69b3789bf..935a351cb 100644 --- a/packages/indexer-agent/src/__tests__/indexer.ts +++ b/packages/indexer-agent/src/__tests__/indexer.ts @@ -148,6 +148,7 @@ const setup = async () => { const network = await Network.create( logger, networkSpecification, + models, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 82e14c57e..5beac7979 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -63,7 +63,7 @@ const deploymentRuleInList = ( rule => rule.identifierType == SubgraphIdentifierType.DEPLOYMENT && new SubgraphDeploymentID(rule.identifier).toString() == - deployment.toString(), + deployment.toString(), ) !== undefined const uniqueDeploymentsOnly = ( @@ -291,6 +291,16 @@ export class Agent { { logger, milliseconds: requestIntervalSmall }, async () => { return this.multiNetworks.map(async ({ network, operator }) => { + if (network.specification.indexerOptions.enableDips) { + // There should be a DipsManager in the operator + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + logger.trace('Ensuring indexing rules for DIPS', { + protocolNetwork: network.specification.networkIdentifier, + }) + await operator.dipsManager.ensureAgreementRules() + } logger.trace('Fetching indexing rules', { protocolNetwork: network.specification.networkIdentifier, }) @@ -321,16 +331,18 @@ export class Agent { logger.warn(`Failed to obtain indexing rules, trying again later`, { error, }), - }, - ) + }) - // Skip fetching active deployments if the deployment management mode is manual and POI tracking is disabled + // Skip fetching active deployments if the deployment management mode is manual, DIPs is disabled, and POI tracking is disabled const activeDeployments: Eventual = sequentialTimerMap( { logger, milliseconds: requestIntervalLarge }, async () => { - if (this.deploymentManagement === DeploymentManagementMode.AUTO) { - logger.debug('Fetching active deployments') + if ( + this.deploymentManagement === DeploymentManagementMode.AUTO || + network.networkMonitor.poiDisputeMonitoringEnabled() + ) { + logger.trace('Fetching active deployments') const assignments = await this.graphNode.subgraphDeploymentsAssignments( SubgraphStatus.ACTIVE, @@ -729,9 +741,40 @@ export class Agent { } break case DeploymentManagementMode.MANUAL: - this.logger.debug( - `Skipping subgraph deployment reconciliation since DeploymentManagementMode = 'manual'`, - ) + if (network.specification.indexerOptions.enableDips) { + // Reconcile DIPs deployments anyways + this.logger.warn( + `Deployment management is manual, but DIPs is enabled. Reconciling DIPs deployments anyways.`, + ) + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + const dipsDeployments = + await operator.dipsManager.getActiveDipsDeployments() + const newTargetDeployments = new Set([ + ...activeDeployments, + ...dipsDeployments, + ]) + try { + await this.reconcileDeployments( + activeDeployments, + Array.from(newTargetDeployments), + eligibleAllocations, + ) + } catch (err) { + logger.warn( + `Exited early while reconciling deployments. Skipped reconciling actions.`, + { + err: indexerError(IndexerErrorCode.IE005, err), + }, + ) + return + } + } else { + this.logger.debug( + `Skipping subgraph deployment reconciliation since DeploymentManagementMode = 'manual'`, + ) + } break default: throw new Error( @@ -859,7 +902,7 @@ export class Agent { let status = rewardsPool!.referencePOI == allocation.poi || - rewardsPool!.referencePreviousPOI == allocation.poi + rewardsPool!.referencePreviousPOI == allocation.poi ? 'valid' : 'potential' @@ -1053,6 +1096,7 @@ export class Agent { maxAllocationEpochs: number, network: Network, operator: Operator, + forceAction: boolean = false, ): Promise { const logger = this.logger.child({ deployment: deploymentAllocationDecision.deployment.ipfsHash, @@ -1074,6 +1118,7 @@ export class Agent { logger, deploymentAllocationDecision, activeDeploymentAllocations, + forceAction, ) case true: { // If no active allocations and subgraph health passes safety check, create one @@ -1110,6 +1155,7 @@ export class Agent { logger, deploymentAllocationDecision, mostRecentlyClosedAllocation, + forceAction, ) } } else if (activeDeploymentAllocations.length > 0) { @@ -1118,6 +1164,7 @@ export class Agent { logger, deploymentAllocationDecision, activeDeploymentAllocations, + forceAction, ) } else { // Refresh any expiring allocations @@ -1134,6 +1181,7 @@ export class Agent { logger, deploymentAllocationDecision, expiringAllocations, + forceAction, ) } } @@ -1151,45 +1199,38 @@ export class Agent { // Filter out networks set to `manual` allocation management mode, and ensure the // Network Subgraph is NEVER allocated towards // -------------------------------------------------------------------------------- - const validatedAllocationDecisions = - await this.multiNetworks.mapNetworkMapped( - networkDeploymentAllocationDecisions, - async ( - { network }: NetworkAndOperator, - allocationDecisions: AllocationDecision[], - ) => { - if ( - network.specification.indexerOptions.allocationManagementMode === - AllocationManagementMode.MANUAL - ) { - this.logger.trace( - `Skipping allocation reconciliation since AllocationManagementMode = 'manual'`, - { - protocolNetwork: network.specification.networkIdentifier, - targetDeployments: allocationDecisions - .filter(decision => decision.toAllocate) - .map(decision => decision.deployment.ipfsHash), - }, - ) - return [] as AllocationDecision[] - } - const networkSubgraphDeployment = network.networkSubgraph.deployment - if ( - networkSubgraphDeployment && - !network.specification.indexerOptions.allocateOnNetworkSubgraph - ) { - const networkSubgraphIndex = allocationDecisions.findIndex( - decision => - decision.deployment.bytes32 == - networkSubgraphDeployment.id.bytes32, - ) - if (networkSubgraphIndex >= 0) { - allocationDecisions[networkSubgraphIndex].toAllocate = false - } - } - return allocationDecisions + const { network, operator } = this.networkAndOperator + let validatedAllocationDecisions = [...allocationDecisions] + + if ( + network.specification.indexerOptions.allocationManagementMode === + AllocationManagementMode.MANUAL + ) { + this.logger.debug( + `Skipping allocation reconciliation since AllocationManagementMode = 'manual'`, + { + protocolNetwork: network.specification.networkIdentifier, + targetDeployments: allocationDecisions + .filter(decision => decision.toAllocate) + .map(decision => decision.deployment.ipfsHash), }, ) + validatedAllocationDecisions = [] as AllocationDecision[] + } else { + const networkSubgraphDeployment = network.networkSubgraph.deployment + if ( + networkSubgraphDeployment && + !network.specification.indexerOptions.allocateOnNetworkSubgraph + ) { + const networkSubgraphIndex = validatedAllocationDecisions.findIndex( + decision => + decision.deployment.bytes32 == networkSubgraphDeployment.id.bytes32, + ) + if (networkSubgraphIndex >= 0) { + validatedAllocationDecisions[networkSubgraphIndex].toAllocate = false + } + } + } //---------------------------------------------------------------------------------------- // For every network, loop through all deployments and queue allocation actions if needed @@ -1241,7 +1282,7 @@ export class Agent { })), }) - return pMap(allocationDecisions, async decision => + await pMap(validatedAllocationDecisions, async decision => this.reconcileDeploymentAllocationAction( decision, activeAllocations, @@ -1251,40 +1292,39 @@ export class Agent { operator, ), ) - }, - ) - } + return + } // TODO: After indexer-service deprecation: Move to be an initialization check inside Network.create() async ensureSubgraphIndexing(deployment: string, networkIdentifier: string) { - try { - // TODO: Check both the local deployment and the external subgraph endpoint - // Make sure the subgraph is being indexed - await this.graphNode.ensure( - `indexer-agent/${deployment.slice(-10)}`, - new SubgraphDeploymentID(deployment), - ) + try { + // TODO: Check both the local deployment and the external subgraph endpoint + // Make sure the subgraph is being indexed + await this.graphNode.ensure( + `indexer-agent/${deployment.slice(-10)}`, + new SubgraphDeploymentID(deployment), + ) // Validate if the Network Subgraph belongs to the current provider's network. // This check must be performed after we ensure the Network Subgraph is being indexed. await validateProviderNetworkIdentifier( - networkIdentifier, - deployment, - this.graphNode, - this.logger, - ) - } catch (e) { - this.logger.warn( - 'Failed to deploy and validate Network Subgraph on index-nodes. Will use external subgraph endpoint instead', - e, - ) + networkIdentifier, + deployment, + this.graphNode, + this.logger, + ) + } catch(e) { + this.logger.warn( + 'Failed to deploy and validate Network Subgraph on index-nodes. Will use external subgraph endpoint instead', + e, + ) + } } - } async ensureAllSubgraphsIndexing(network: Network) { - // Network subgraph - if ( - network.specification.subgraphs.networkSubgraph.deployment !== undefined - ) { + // Network subgraph + if( + network.specification.subgraphs.networkSubgraph.deployment !== undefined + ) { await this.ensureSubgraphIndexing( network.specification.subgraphs.networkSubgraph.deployment, network.specification.networkIdentifier, diff --git a/packages/indexer-agent/src/commands/start.ts b/packages/indexer-agent/src/commands/start.ts index bf688485f..dd18c2872 100644 --- a/packages/indexer-agent/src/commands/start.ts +++ b/packages/indexer-agent/src/commands/start.ts @@ -303,6 +303,26 @@ export const start = { default: 1, group: 'Indexer Infrastructure', }) + .option('enable-dips', { + description: 'Whether to enable Indexing Fees (DIPs)', + type: 'boolean', + default: false, + group: 'Indexing Fees ("DIPs")', + }) + .option('dipper-endpoint', { + description: 'Gateway endpoint for DIPs receipts', + type: 'string', + array: false, + required: false, + group: 'Indexing Fees ("DIPs")', + }) + .option('dips-allocation-amount', { + description: 'Amount of GRT to allocate for DIPs', + type: 'number', + default: 1, + required: false, + group: 'Indexing Fees ("DIPs")', + }) .check(argv => { if ( !argv['network-subgraph-endpoint'] && @@ -330,11 +350,14 @@ export const start = { ) { return 'Invalid --rebate-claim-max-batch-size provided. Must be > 0 and an integer.' } + if (argv['enable-dips'] && !argv['dipper-endpoint']) { + return 'Invalid --dipper-endpoint provided. Must be provided when --enable-dips is true.' + } return true }) }, // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any - handler: (_argv: any) => {}, + handler: (_argv: any) => { }, } export async function createNetworkSpecification( @@ -365,6 +388,10 @@ export async function createNetworkSpecification( allocateOnNetworkSubgraph: argv.allocateOnNetworkSubgraph, register: argv.register, finalityTime: argv.chainFinalizeTime, + enableDips: argv.enableDips, + dipperEndpoint: argv.dipperEndpoint, + dipsAllocationAmount: argv.dipsAllocationAmount, + dipsEpochsMargin: argv.dipsEpochsMargin, } const transactionMonitoring = { @@ -583,7 +610,7 @@ export async function run( const networks: Network[] = await pMap( networkSpecifications, async (spec: NetworkSpecification) => - Network.create(logger, spec, queryFeeModels, graphNode, metrics), + Network.create(logger, spec, managementModels, queryFeeModels, graphNode, metrics), ) // -------------------------------------------------------------------------------- @@ -690,14 +717,13 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (collectReceiptsEndpoint) { logger.warn( "The option '--collect-receipts-endpoint' is deprecated. " + - "Please use the option '--gateway-endpoint' to inform the Gateway base URL.", + "Please use the option '--gateway-endpoint' to inform the Gateway base URL.", ) } if (gasIncreaseTimeout < advisedGasIncreaseTimeout) { logger.warn( - `Gas increase timeout is set to less than ${ - gasIncreaseTimeout / 1000 + `Gas increase timeout is set to less than ${gasIncreaseTimeout / 1000 } seconds. This may lead to high gas usage`, { gasIncreaseTimeout: gasIncreaseTimeout / 1000.0 }, ) @@ -706,14 +732,14 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (gasIncreaseFactor > advisedGasIncreaseTimeout) { logger.warn( `Gas increase factor is set to > ${advisedGasIncreaseFactor}. ` + - 'This may lead to high gas usage', + 'This may lead to high gas usage', { gasIncreaseFactor: gasIncreaseFactor }, ) } if (rebateClaimThreshold < voucherRedemptionThreshold) { logger.warn( 'Rebate single minimum claim value is less than voucher minimum redemption value, ' + - 'but claims depend on redemptions', + 'but claims depend on redemptions', { voucherRedemptionThreshold: formatGRT(voucherRedemptionThreshold), rebateClaimThreshold: formatGRT(rebateClaimThreshold), @@ -730,7 +756,7 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (rebateClaimMaxBatchSize > advisedRebateClaimMaxBatchSize) { logger.warn( `Setting the max batch size for rebate claims to more than ${advisedRebateClaimMaxBatchSize}` + - 'may result in batches that are too large to fit into a block', + 'may result in batches that are too large to fit into a block', { rebateClaimMaxBatchSize: rebateClaimMaxBatchSize }, ) } @@ -744,7 +770,7 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (voucherRedemptionMaxBatchSize > advisedVoucherRedemptionMaxBatchSize) { logger.warn( `Setting the max batch size for voucher redemptions to more than ${advisedVoucherRedemptionMaxBatchSize} ` + - 'may result in batches that are too large to fit into a block', + 'may result in batches that are too large to fit into a block', { voucherRedemptionMaxBatchSize: voucherRedemptionMaxBatchSize }, ) } diff --git a/packages/indexer-cli/src/__tests__/util.ts b/packages/indexer-cli/src/__tests__/util.ts index bf702416a..e4963bb37 100644 --- a/packages/indexer-cli/src/__tests__/util.ts +++ b/packages/indexer-cli/src/__tests__/util.ts @@ -93,6 +93,7 @@ export const setup = async (multiNetworksEnabled: boolean) => { const network = await Network.create( logger, testNetworkSpecification, + models, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-common/package.json b/packages/indexer-common/package.json index 7f43d61bd..e9086931f 100644 --- a/packages/indexer-common/package.json +++ b/packages/indexer-common/package.json @@ -23,6 +23,9 @@ }, "dependencies": { "@pinax/graph-networks-registry": "0.6.7", + "@bufbuild/protobuf": "2.2.3", + "@graphprotocol/dips-proto": "0.2.2", + "@grpc/grpc-js": "^1.12.6", "@graphprotocol/common-ts": "2.0.11", "@semiotic-labs/tap-contracts-bindings": "^1.2.1", "@thi.ng/heaps": "1.2.38", diff --git a/packages/indexer-common/src/allocations/__tests__/tap.test.ts b/packages/indexer-common/src/allocations/__tests__/tap.test.ts index d85e50c8f..798614198 100644 --- a/packages/indexer-common/src/allocations/__tests__/tap.test.ts +++ b/packages/indexer-common/src/allocations/__tests__/tap.test.ts @@ -7,6 +7,7 @@ import { TapSubgraphResponse, TapCollector, Allocation, + defineIndexerManagementModels, } from '@graphprotocol/indexer-common' import { Address, @@ -43,6 +44,7 @@ const setup = async () => { // Clearing the registry prevents duplicate metric registration in the default registry. metrics.registry.clear() sequelize = await connectDatabase(__DATABASE__) + const models = defineIndexerManagementModels(sequelize) queryFeeModels = defineQueryFeeModels(sequelize) sequelize = await sequelize.sync({ force: true }) @@ -57,6 +59,7 @@ const setup = async () => { const network = await Network.create( logger, testNetworkSpecification, + models, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-common/src/allocations/__tests__/validate-queries.test.ts b/packages/indexer-common/src/allocations/__tests__/validate-queries.test.ts index 0eaf46722..0ef0b3082 100644 --- a/packages/indexer-common/src/allocations/__tests__/validate-queries.test.ts +++ b/packages/indexer-common/src/allocations/__tests__/validate-queries.test.ts @@ -1,4 +1,5 @@ import { + defineIndexerManagementModels, defineQueryFeeModels, GraphNode, Network, @@ -36,6 +37,7 @@ const setup = async () => { // Clearing the registry prevents duplicate metric registration in the default registry. metrics.registry.clear() sequelize = await connectDatabase(__DATABASE__) + const models = defineIndexerManagementModels(sequelize) queryFeeModels = defineQueryFeeModels(sequelize) sequelize = await sequelize.sync({ force: true }) @@ -50,6 +52,7 @@ const setup = async () => { const network = await Network.create( logger, testNetworkSpecification, + models, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-common/src/allocations/escrow-accounts.ts b/packages/indexer-common/src/allocations/escrow-accounts.ts index 1d126fd94..45402171c 100644 --- a/packages/indexer-common/src/allocations/escrow-accounts.ts +++ b/packages/indexer-common/src/allocations/escrow-accounts.ts @@ -13,6 +13,14 @@ export type EscrowAccountResponse = { }[] } +export type EscrowSenderResponse = { + signer: { + sender: { + id: string + } + } +} + export class EscrowAccounts { constructor(private sendersBalances: Map) {} @@ -65,3 +73,26 @@ export const getEscrowAccounts = async ( } return EscrowAccounts.fromResponse(result.data) } + +export const getEscrowSenderForSigner = async ( + tapSubgraph: SubgraphClient, + signer: Address, +): Promise
=> { + const signerLower = signer.toLowerCase() + const result = await tapSubgraph.query( + gql` + query EscrowAccountQuery($signer: ID!) { + signer(id: $signer) { + sender { + id + } + } + } + `, + { signer: signerLower }, + ) + if (!result.data) { + throw `There was an error while querying Tap Subgraph. Errors: ${result.error}` + } + return toAddress(result.data.signer.sender.id) +} diff --git a/packages/indexer-common/src/graph-node.ts b/packages/indexer-common/src/graph-node.ts index f75d87b5e..21e902918 100644 --- a/packages/indexer-common/src/graph-node.ts +++ b/packages/indexer-common/src/graph-node.ts @@ -1026,6 +1026,22 @@ export class GraphNode { } } + public async entityCount(deployments: SubgraphDeploymentID[]): Promise { + // Query the entity count for each deployment using the indexingStatuses query + const query = ` + query entityCounts($deployments: [String!]!) { + indexingStatuses(subgraphs: $deployments) { + entityCount + } + } + ` + const result = await this.status + .query(query, { deployments: deployments.map((id) => id.ipfsHash) }) + .toPromise() + + return result.data.indexingStatuses.map((status) => status.entityCount) as number[] + } + public async proofOfIndexing( deployment: SubgraphDeploymentID, block: BlockPointer, diff --git a/packages/indexer-common/src/index.ts b/packages/indexer-common/src/index.ts index ab3eedd97..a74020745 100644 --- a/packages/indexer-common/src/index.ts +++ b/packages/indexer-common/src/index.ts @@ -3,6 +3,7 @@ export * from './allocations' export * from './async-cache' export * from './errors' export * from './indexer-management' +export * from './indexing-fees' export * from './graph-node' export * from './operator' export * from './network' @@ -17,3 +18,4 @@ export * from './parsers' export * as specification from './network-specification' export * from './multi-networks' export * from './sequential-timer' +export * from './indexing-fees' diff --git a/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts b/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts index eafd14665..6a1f90b53 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts @@ -62,6 +62,7 @@ const setup = async () => { const network = await Network.create( logger, testNetworkSpecification, + managementModels, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-common/src/indexer-management/__tests__/util.ts b/packages/indexer-common/src/indexer-management/__tests__/util.ts index 7a4204553..66521ee6e 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/util.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/util.ts @@ -58,6 +58,7 @@ export const createTestManagementClient = async ( const network = await Network.create( logger, networkSpecification, + managementModels, queryFeeModels, graphNode, metrics, diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index fcc614f07..0c9fbab9e 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -15,6 +15,7 @@ import { AllocationStatus, CloseAllocationResult, CreateAllocationResult, + DipsManager, fetchIndexingRules, GraphNode, indexerError, @@ -98,12 +99,17 @@ export type TransactionResult = | ActionFailure[] export class AllocationManager { + declare dipsManager: DipsManager | null constructor( private logger: Logger, private models: IndexerManagementModels, private graphNode: GraphNode, private network: Network, - ) {} + ) { + if (this.network.specification.indexerOptions.dipperEndpoint) { + this.dipsManager = new DipsManager(this.logger, this.models, this.network, this) + } + } async executeBatch( actions: Action[], @@ -519,6 +525,14 @@ export class AllocationManager { await upsertIndexingRule(logger, this.models, indexingRule) } + if (this.dipsManager) { + await this.dipsManager.tryUpdateAgreementAllocation( + deployment, + null, + toAddress(createAllocationEventLogs.allocationID), + ) + } + return { actionID, type: 'allocate', @@ -675,6 +689,15 @@ export class AllocationManager { await upsertIndexingRule(logger, this.models, neverIndexingRule) + if (this.dipsManager) { + await this.dipsManager.tryCancelAgreement(allocationID) + await this.dipsManager.tryUpdateAgreementAllocation( + allocation.subgraphDeployment.id.toString(), + toAddress(allocationID), + null, + ) + } + return { actionID, type: 'unallocate', @@ -974,6 +997,14 @@ export class AllocationManager { await upsertIndexingRule(logger, this.models, indexingRule) } + if (this.dipsManager) { + await this.dipsManager.tryUpdateAgreementAllocation( + subgraphDeploymentID.toString(), + toAddress(allocationID), + toAddress(createAllocationEventLogs.allocationID), + ) + } + return { actionID, type: 'reallocate', diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index fba61436a..eec99a68d 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -454,9 +454,11 @@ export interface IndexerManagementClientOptions { graphNode: GraphNode multiNetworks: MultiNetworks | undefined defaults: IndexerManagementDefaults + actionManager?: ActionManager | undefined } export class IndexerManagementClient extends Client { + declare actionManager: ActionManager | undefined private logger?: Logger private models: IndexerManagementModels @@ -465,6 +467,7 @@ export class IndexerManagementClient extends Client { this.logger = options.logger this.models = options.models + this.actionManager = options.actionManager } } @@ -503,5 +506,8 @@ export const createIndexerManagementClient = async ( context, }) - return new IndexerManagementClient({ url: 'no-op', exchanges: [exchange] }, options) + return new IndexerManagementClient( + { url: 'no-op', exchanges: [exchange] }, + { ...options, actionManager }, + ) } diff --git a/packages/indexer-common/src/indexer-management/models/index.ts b/packages/indexer-common/src/indexer-management/models/index.ts index 8d5ec55af..81a59f4d3 100644 --- a/packages/indexer-common/src/indexer-management/models/index.ts +++ b/packages/indexer-common/src/indexer-management/models/index.ts @@ -4,6 +4,7 @@ import { IndexingRuleModels, defineIndexingRuleModels } from './indexing-rule' import { CostModelModels, defineCostModelModels } from './cost-model' import { POIDisputeModels, definePOIDisputeModels } from './poi-dispute' import { ActionModels, defineActionModels } from './action' +import { defineIndexingFeesModels, IndexingFeesModels } from './indexing-agreement' export * from './cost-model' export * from './indexing-rule' @@ -13,7 +14,8 @@ export * from './action' export type IndexerManagementModels = IndexingRuleModels & CostModelModels & POIDisputeModels & - ActionModels + ActionModels & + IndexingFeesModels export const defineIndexerManagementModels = ( sequelize: Sequelize, @@ -24,4 +26,5 @@ export const defineIndexerManagementModels = ( defineIndexingRuleModels(sequelize), definePOIDisputeModels(sequelize), defineActionModels(sequelize), + defineIndexingFeesModels(sequelize), ) diff --git a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts new file mode 100644 index 000000000..b92c11116 --- /dev/null +++ b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts @@ -0,0 +1,211 @@ +import { toAddress, Address } from '@graphprotocol/common-ts' +import { + DataTypes, + Sequelize, + Model, + CreationOptional, + InferCreationAttributes, + InferAttributes, +} from 'sequelize' + +// Indexing Fees AKA "DIPs" + +export class IndexingAgreement extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional + declare signature: Buffer + declare signed_payload: Buffer + declare protocol_network: string + declare chain_id: string + declare base_price_per_epoch: string + declare price_per_entity: string + declare subgraph_deployment_id: string + declare service: string + declare payee: string + declare payer: string + declare deadline: Date + declare duration_epochs: bigint + declare max_initial_amount: string + declare max_ongoing_amount_per_epoch: string + declare min_epochs_per_collection: bigint + declare max_epochs_per_collection: bigint + declare created_at: Date + declare updated_at: Date + declare cancelled_at: Date | null + declare signed_cancellation_payload: Buffer | null + declare current_allocation_id: string | null + declare last_allocation_id: string | null + declare last_payment_collected_at: Date | null +} + +export interface IndexingFeesModels { + IndexingAgreement: typeof IndexingAgreement +} + +export const defineIndexingFeesModels = (sequelize: Sequelize): IndexingFeesModels => { + IndexingAgreement.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + signature: { + type: DataTypes.BLOB, + allowNull: false, + unique: true, + }, + signed_payload: { + type: DataTypes.BLOB, + allowNull: false, + }, + protocol_network: { + type: DataTypes.STRING(255), + allowNull: false, + }, + chain_id: { + type: DataTypes.STRING(255), + allowNull: false, + }, + base_price_per_epoch: { + type: DataTypes.DECIMAL(39), + allowNull: false, + }, + price_per_entity: { + type: DataTypes.DECIMAL(39), + allowNull: false, + }, + subgraph_deployment_id: { + type: DataTypes.STRING(255), + allowNull: false, + }, + service: { + type: DataTypes.CHAR(40), + allowNull: false, + get() { + const rawValue = this.getDataValue('service') + return toAddress(rawValue) + }, + set(value: Address) { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('service', addressWithoutPrefix) + }, + }, + payee: { + type: DataTypes.CHAR(40), + allowNull: false, + get() { + const rawValue = this.getDataValue('payee') + return toAddress(rawValue) + }, + set(value: Address) { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('payee', addressWithoutPrefix) + }, + }, + payer: { + type: DataTypes.CHAR(40), + allowNull: false, + get() { + const rawValue = this.getDataValue('payer') + return toAddress(rawValue) + }, + set(value: Address) { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('payer', addressWithoutPrefix) + }, + }, + deadline: { + type: DataTypes.DATE, + allowNull: false, + }, + duration_epochs: { + type: DataTypes.BIGINT, + allowNull: false, + }, + max_initial_amount: { + type: DataTypes.DECIMAL(39), + allowNull: false, + }, + max_ongoing_amount_per_epoch: { + type: DataTypes.DECIMAL(39), + allowNull: false, + }, + min_epochs_per_collection: { + type: DataTypes.BIGINT, + allowNull: false, + }, + max_epochs_per_collection: { + type: DataTypes.BIGINT, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + }, + cancelled_at: { + type: DataTypes.DATE, + allowNull: true, + }, + signed_cancellation_payload: { + type: DataTypes.BLOB, + allowNull: true, + }, + current_allocation_id: { + type: DataTypes.CHAR(40), + allowNull: true, + get() { + const rawValue = this.getDataValue('current_allocation_id') + if (!rawValue) { + return null + } + return toAddress(rawValue) + }, + set(value: Address | null) { + if (!value) { + this.setDataValue('current_allocation_id', null) + } else { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('current_allocation_id', addressWithoutPrefix) + } + }, + }, + last_allocation_id: { + type: DataTypes.CHAR(40), + allowNull: true, + get() { + const rawValue = this.getDataValue('last_allocation_id') + if (!rawValue) { + return null + } + return toAddress(rawValue) + }, + set(value: Address | null) { + if (!value) { + this.setDataValue('last_allocation_id', null) + } else { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('last_allocation_id', addressWithoutPrefix) + } + }, + }, + last_payment_collected_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + modelName: 'IndexingAgreement', + sequelize, + }, + ) + + return { + ['IndexingAgreement']: IndexingAgreement, + } +} diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 5d2e72690..89647ecb5 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -226,7 +226,7 @@ async function queryAllocations( let lastId = '' // eslint-disable-next-line @typescript-eslint/no-explicit-any const resultAllocations: any[] = [] - for (;;) { + for (; ;) { const pageVars = { ...filterVars, lastId, @@ -374,7 +374,13 @@ export default { amount: string protocolNetwork: string }, - { multiNetworks, graphNode, logger, models }: IndexerManagementResolverContext, + { + multiNetworks, + graphNode, + logger, + models, + actionManager, + }: IndexerManagementResolverContext, ): Promise => { logger.debug('Execute createAllocation() mutation', { deployment, @@ -530,8 +536,7 @@ export default { if (receipt === 'paused' || receipt === 'unauthorized') { throw indexerError( IndexerErrorCode.IE062, - `Allocation not created. ${ - receipt === 'paused' ? 'Network paused' : 'Operator not authorized' + `Allocation not created. ${receipt === 'paused' ? 'Network paused' : 'Operator not authorized' }`, ) } @@ -572,6 +577,14 @@ export default { await models.IndexingRule.upsert(indexingRule) + if (actionManager?.allocationManager?.dipsManager) { + await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + deployment, + null, + toAddress(createAllocationEventLogs.allocationID), + ) + } + // Since upsert succeeded, we _must_ have a rule const updatedRule = await models.IndexingRule.findOne({ where: { identifier: indexingRule.identifier }, @@ -611,7 +624,7 @@ export default { force: boolean protocolNetwork: string }, - { logger, models, multiNetworks }: IndexerManagementResolverContext, + { logger, models, multiNetworks, actionManager }: IndexerManagementResolverContext, ): Promise => { logger.debug('Execute closeAllocation() mutation', { allocationID: allocation, @@ -730,6 +743,15 @@ export default { await models.IndexingRule.upsert(offchainIndexingRule) + if (actionManager?.allocationManager?.dipsManager) { + await actionManager.allocationManager.dipsManager.tryCancelAgreement(allocation) + await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + allocationData.subgraphDeployment.id.toString(), + toAddress(allocation), + null, + ) + } + // Since upsert succeeded, we _must_ have a rule const updatedRule = await models.IndexingRule.findOne({ where: { identifier: offchainIndexingRule.identifier }, @@ -769,7 +791,7 @@ export default { force: boolean protocolNetwork: string }, - { logger, models, multiNetworks }: IndexerManagementResolverContext, + { logger, models, multiNetworks, actionManager }: IndexerManagementResolverContext, ): Promise => { logger = logger.child({ component: 'reallocateAllocationResolver', @@ -1037,6 +1059,14 @@ export default { await models.IndexingRule.upsert(indexingRule) + if (actionManager?.allocationManager?.dipsManager) { + await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + allocationData.subgraphDeployment.id.toString(), + toAddress(allocation), + toAddress(createAllocationEventLogs.allocationID), + ) + } + // Since upsert succeeded, we _must_ have a rule const updatedRule = await models.IndexingRule.findOne({ where: { identifier: indexingRule.identifier }, diff --git a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts new file mode 100644 index 000000000..204f8a884 --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -0,0 +1,522 @@ +import { + DipsManager, + GraphNode, + IndexerManagementModels, + Network, + QueryFeeModels, + defineIndexerManagementModels, + defineQueryFeeModels, + SubgraphIdentifierType, + IndexingDecisionBasis, + AllocationManager, + DipsCollector, + TapCollector, + createIndexerManagementClient, + Operator, + ActionManager, + IndexerManagementClient, +} from '@graphprotocol/indexer-common' +import { + connectDatabase, + createLogger, + createMetrics, + Logger, + Metrics, + parseGRT, + SubgraphDeploymentID, + toAddress, +} from '@graphprotocol/common-ts' +import { Sequelize } from 'sequelize' +import { testNetworkSpecification } from '../../indexer-management/__tests__/util' +import { BigNumber } from 'ethers' +import { CollectPaymentStatus } from '@graphprotocol/dips-proto/generated/gateway' + +// Make global Jest variables available +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const __DATABASE__: any +declare const __LOG_LEVEL__: never + +// Add these type declarations after the existing imports +let sequelize: Sequelize +let logger: Logger +let metrics: Metrics +let graphNode: GraphNode +let managementModels: IndexerManagementModels +let queryFeeModels: QueryFeeModels +let network: Network +let dipsCollector: DipsCollector +let indexerManagementClient: IndexerManagementClient +let operator: Operator +const networkSpecWithDips = { + ...testNetworkSpecification, + indexerOptions: { + ...testNetworkSpecification.indexerOptions, + enableDips: true, + dipperEndpoint: 'https://test-dipper-endpoint.xyz', + dipsAllocationAmount: parseGRT('1.0'), // Amount of GRT to allocate for DIPs + dipsEpochsMargin: 1, // Optional: Number of epochs margin for DIPs + }, +} + +const mockSubgraphDeployment = (id: string) => { + return { + id: new SubgraphDeploymentID(id), + ipfsHash: id, + deniedAt: null, + stakedTokens: BigNumber.from('1000'), + signalledTokens: BigNumber.from('1000'), + queryFeesAmount: BigNumber.from('0'), + protocolNetwork: 'eip155:421614', + } +} + +jest.spyOn(TapCollector.prototype, 'startRAVProcessing').mockImplementation(() => {}) +const startCollectionLoop = jest + .spyOn(DipsCollector.prototype, 'startCollectionLoop') + .mockImplementation(() => {}) +jest.spyOn(ActionManager.prototype, 'monitorQueue').mockImplementation(async () => {}) +const setup = async () => { + logger = createLogger({ + name: 'DIPs Test Logger', + async: false, + level: __LOG_LEVEL__ ?? 'error', + }) + metrics = createMetrics() + // Clearing the registry prevents duplicate metric registration in the default registry. + metrics.registry.clear() + + graphNode = new GraphNode( + logger, + 'https://test-admin-endpoint.xyz', + 'https://test-query-endpoint.xyz', + 'https://test-status-endpoint.xyz', + ) + + sequelize = await connectDatabase(__DATABASE__) + managementModels = defineIndexerManagementModels(sequelize) + queryFeeModels = defineQueryFeeModels(sequelize) + sequelize = await sequelize.sync({ force: true }) + + network = await Network.create( + logger, + networkSpecWithDips, + managementModels, + queryFeeModels, + graphNode, + metrics, + ) + dipsCollector = network.dipsCollector! + indexerManagementClient = await createIndexerManagementClient({ + models: managementModels, + graphNode, + logger, + defaults: { + globalIndexingRule: { + allocationAmount: parseGRT('1000'), + parallelAllocations: 1, + }, + }, + network, + }) + + operator = new Operator(logger, indexerManagementClient, networkSpecWithDips) +} + +const ensureGlobalIndexingRule = async () => { + await operator.ensureGlobalIndexingRule() + logger.debug('Ensured global indexing rule') +} + +const setupEach = async () => { + sequelize = await sequelize.sync({ force: true }) +} + +const teardownEach = async () => { + // Clear out query fee model tables + await queryFeeModels.allocationReceipts.truncate({ cascade: true }) + await queryFeeModels.vouchers.truncate({ cascade: true }) + await queryFeeModels.transferReceipts.truncate({ cascade: true }) + await queryFeeModels.transfers.truncate({ cascade: true }) + await queryFeeModels.allocationSummaries.truncate({ cascade: true }) + await queryFeeModels.scalarTapReceipts.truncate({ cascade: true }) + + // Clear out indexer management models + await managementModels.Action.truncate({ cascade: true }) + await managementModels.CostModel.truncate({ cascade: true }) + await managementModels.IndexingRule.truncate({ cascade: true }) + await managementModels.POIDispute.truncate({ cascade: true }) + + // Clear out indexing agreement model + await managementModels.IndexingAgreement.truncate({ cascade: true }) +} + +const teardownAll = async () => { + await sequelize.drop({}) +} + +describe('DipsManager', () => { + beforeAll(setup) + beforeEach(setupEach) + afterEach(teardownEach) + afterAll(teardownAll) + + // We have been rate-limited on CI as this test uses RPC providers, + // so we set its timeout to a higher value than usual. + jest.setTimeout(30_000) + + describe('initialization', () => { + test('creates DipsManager when dipperEndpoint is configured', () => { + const dipsManager = new DipsManager(logger, managementModels, network, null) + expect(dipsManager).toBeDefined() + }) + + test('throws error when dipperEndpoint is not configured', async () => { + const specWithoutDipper = { + ...testNetworkSpecification, + indexerOptions: { + ...testNetworkSpecification.indexerOptions, + dipperEndpoint: undefined, + }, + } + + metrics.registry.clear() + const networkWithoutDipper = await Network.create( + logger, + specWithoutDipper, + managementModels, + queryFeeModels, + graphNode, + metrics, + ) + expect( + () => new DipsManager(logger, managementModels, networkWithoutDipper, null), + ).toThrow('dipperEndpoint is not set') + }) + }) + + describe('agreement management', () => { + let dipsManager: DipsManager + const testDeploymentId = 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7' + const testAllocationId = 'abcd47df40c29949a75a6693c77834c00b8ad626' + const testAgreementId = '123e4567-e89b-12d3-a456-426614174000' + + beforeEach(async () => { + // Clear mock calls between tests + jest.clearAllMocks() + + const allocationManager = new AllocationManager( + logger, + managementModels, + graphNode, + network, + ) + + dipsManager = new DipsManager(logger, managementModels, network, allocationManager) + + // Create a test agreement + await managementModels.IndexingAgreement.create({ + id: testAgreementId, + subgraph_deployment_id: testDeploymentId, + current_allocation_id: testAllocationId, + last_allocation_id: null, + last_payment_collected_at: null, + cancelled_at: null, + min_epochs_per_collection: BigInt(1), + max_epochs_per_collection: BigInt(5), + payer: '123456df40c29949a75a6693c77834c00b8a5678', + signature: Buffer.from('1234', 'hex'), + signed_payload: Buffer.from('5678', 'hex'), + protocol_network: 'arbitrum-sepolia', + chain_id: 'eip155:1', + base_price_per_epoch: '100', + price_per_entity: '1', + service: 'deadbedf40c29949a75a2293c11834c00b8a1234', + payee: '1212564f40c29949a75a3423c11834c00b8aaaaa', + deadline: new Date(Date.now() + 86400000), // 1 day from now + duration_epochs: BigInt(10), + max_initial_amount: '1000', + max_ongoing_amount_per_epoch: '100', + created_at: new Date(), + updated_at: new Date(), + signed_cancellation_payload: null, + }) + }) + + test('cancels agreement when allocation is closed', async () => { + const client = dipsManager.gatewayDipsServiceClient + + client.CancelAgreement = jest.fn().mockResolvedValue({}) + + await dipsManager.tryCancelAgreement(testAllocationId) + + // Verify the client was called with correct parameters + expect((client.CancelAgreement as jest.Mock).mock.calls.length).toBe(1) + // TODO: Check the signed cancellation payload + expect((client.CancelAgreement as jest.Mock).mock.calls[0][0]).toEqual({ + version: 1, + signedCancellation: expect.any(Uint8Array), + }) + + const agreement = await managementModels.IndexingAgreement.findOne({ + where: { id: testAgreementId }, + }) + expect(agreement?.cancelled_at).toBeDefined() + }) + + test('handles errors when cancelling agreement', async () => { + const client = dipsManager.gatewayDipsServiceClient + client.CancelAgreement = jest + .fn() + .mockRejectedValueOnce(new Error('Failed to cancel')) + + await dipsManager.tryCancelAgreement(testAllocationId) + + const agreement = await managementModels.IndexingAgreement.findOne({ + where: { id: testAgreementId }, + }) + expect(agreement?.cancelled_at).toBeNull() + }) + + test('updates agreement allocation IDs during reallocation', async () => { + const newAllocationId = '5678bedf40c29945678a2293c15678c00b8a5678' + + await dipsManager.tryUpdateAgreementAllocation( + testDeploymentId, + toAddress(testAllocationId), + toAddress(newAllocationId), + ) + + const agreement = await managementModels.IndexingAgreement.findOne({ + where: { id: testAgreementId }, + }) + expect(agreement?.current_allocation_id).toBe(toAddress(newAllocationId)) + expect(agreement?.last_allocation_id).toBe(toAddress(testAllocationId)) + expect(agreement?.last_payment_collected_at).toBeNull() + }) + + test('creates indexing rules for active agreements', async () => { + await ensureGlobalIndexingRule() + // Mock fetch the subgraph deployment from the network subgraph + network.networkMonitor.subgraphDeployment = jest + .fn() + .mockResolvedValue(mockSubgraphDeployment(testDeploymentId)) + + await dipsManager.ensureAgreementRules() + + const rules = await managementModels.IndexingRule.findAll({ + where: { + identifier: testDeploymentId, + }, + }) + + expect(rules).toHaveLength(1) + expect(rules[0]).toMatchObject({ + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + allocationAmount: + network.specification.indexerOptions.dipsAllocationAmount.toString(), + autoRenewal: true, + allocationLifetime: 4, // max_epochs_per_collection - dipsEpochsMargin + }) + }) + + test('does not create or modify an indexing rule if it already exists', async () => { + await ensureGlobalIndexingRule() + // Create an indexing rule with the same identifier + await managementModels.IndexingRule.create({ + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + allocationLifetime: 16, + requireSupported: true, + safety: true, + protocolNetwork: 'eip155:421614', + allocationAmount: '1030', + }) + + // Mock fetch the subgraph deployment from the network subgraph + network.networkMonitor.subgraphDeployment = jest + .fn() + .mockResolvedValue(mockSubgraphDeployment(testDeploymentId)) + + await dipsManager.ensureAgreementRules() + + const rules = await managementModels.IndexingRule.findAll({ + where: { identifier: testDeploymentId }, + }) + expect(rules).toHaveLength(1) + expect(rules[0]).toMatchObject({ + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + allocationLifetime: 16, + requireSupported: true, + safety: true, + protocolNetwork: 'eip155:421614', + allocationAmount: '1030', + }) + }) + + test('returns active DIPs deployments', async () => { + const deployments = await dipsManager.getActiveDipsDeployments() + + expect(deployments).toHaveLength(1) + expect(deployments[0].ipfsHash).toBe(testDeploymentId) + }) + }) +}) + +describe('DipsCollector', () => { + beforeAll(setup) + beforeEach(setupEach) + afterEach(teardownEach) + afterAll(teardownAll) + + describe('initialization', () => { + test('creates DipsCollector when dipperEndpoint is configured', () => { + const dipsCollector = new DipsCollector( + logger, + managementModels, + queryFeeModels, + networkSpecWithDips, + network.tapCollector!, + network.wallet, + graphNode, + jest.fn(), + ) + expect(dipsCollector).toBeDefined() + }) + test('starts payment collection loop', () => { + const dipsCollector = new DipsCollector( + logger, + managementModels, + queryFeeModels, + networkSpecWithDips, + network.tapCollector!, + network.wallet, + graphNode, + jest.fn(), + ) + expect(dipsCollector).toBeDefined() + expect(startCollectionLoop).toHaveBeenCalled() + }) + test('throws error when dipperEndpoint is not configured', () => { + const specWithoutDipper = { + ...testNetworkSpecification, + indexerOptions: { + ...testNetworkSpecification.indexerOptions, + dipperEndpoint: undefined, + }, + } + expect( + () => + new DipsCollector( + logger, + managementModels, + queryFeeModels, + specWithoutDipper, + network.tapCollector!, + network.wallet, + graphNode, + jest.fn(), + ), + ).toThrow('dipperEndpoint is not set') + }) + }) + + describe('payment collection', () => { + const testDeploymentId = 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7' + const testAllocationId = 'abcd47df40c29949a75a6693c77834c00b8ad626' + const testAgreementId = '123e4567-e89b-12d3-a456-426614174000' + + beforeEach(async () => { + // Clear mock calls between tests + jest.clearAllMocks() + + // Create a test agreement + // Note last_allocation_id is set to the testAllocationId + // current_allocation_id is set to null so that we can collect payment + // (also last_payment_collected_at is set to null) + await managementModels.IndexingAgreement.create({ + id: testAgreementId, + subgraph_deployment_id: testDeploymentId, + current_allocation_id: null, + last_allocation_id: testAllocationId, + last_payment_collected_at: null, + cancelled_at: null, + min_epochs_per_collection: BigInt(1), + max_epochs_per_collection: BigInt(5), + payer: '123456df40c29949a75a6693c77834c00b8a5678', + signature: Buffer.from('1234', 'hex'), + signed_payload: Buffer.from('5678', 'hex'), + protocol_network: 'arbitrum-sepolia', + chain_id: 'eip155:1', + base_price_per_epoch: '100', + price_per_entity: '1', + service: 'deadbedf40c29949a75a2293c11834c00b8a1234', + payee: '1212564f40c29949a75a3423c11834c00b8aaaaa', + deadline: new Date(Date.now() + 86400000), // 1 day from now + duration_epochs: BigInt(10), + max_initial_amount: '1000', + max_ongoing_amount_per_epoch: '100', + created_at: new Date(), + updated_at: new Date(), + signed_cancellation_payload: null, + }) + graphNode.entityCount = jest.fn().mockResolvedValue([250000]) + }) + test('collects payment for a specific agreement', async () => { + const agreement = await managementModels.IndexingAgreement.findOne({ + where: { id: testAgreementId }, + }) + if (!agreement) { + throw new Error('Agreement not found') + } + + const client = dipsCollector.gatewayDipsServiceClient + + client.CollectPayment = jest.fn().mockResolvedValue({ + version: 1, + status: CollectPaymentStatus.ACCEPT, + tapReceipt: Buffer.from('1234', 'hex'), + }) + dipsCollector.gatewayDipsServiceMessagesCodec.decodeTapReceipt = jest + .fn() + .mockImplementation(() => { + logger.info('MOCK Decoding TAP receipt') + return { + allocation_id: toAddress(testAllocationId), + signer_address: toAddress('0xabcd56df41234949a75a6693c77834c00b8abbbb'), + signature: Buffer.from('1234', 'hex'), + timestamp_ns: 1234567890, + nonce: 1, + value: '1000', + } + }) + dipsCollector.escrowSenderGetter = jest.fn().mockImplementation(() => { + logger.info('MOCK Getting escrow sender for signer') + return toAddress('0x123456df40c29949a75a6693c77834c00b8a5678') + }) + + await dipsCollector.tryCollectPayment(agreement) + + expect(client.CollectPayment).toHaveBeenCalledWith({ + version: 1, + signedCollection: expect.any(Uint8Array), + }) + expect(agreement.last_payment_collected_at).not.toBeNull() + + const receipt = await queryFeeModels.scalarTapReceipts.findOne({ + where: { + allocation_id: testAllocationId, + }, + }) + expect(receipt).not.toBeNull() + expect(receipt?.signer_address).toBe( + toAddress('0xabcd56df41234949a75a6693c77834c00b8abbbb'), + ) + expect(receipt?.value).toBe('1000') + }) + }) +}) diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts new file mode 100644 index 000000000..0eede21ea --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -0,0 +1,327 @@ +import { + Address, + formatGRT, + Logger, + SubgraphDeploymentID, + toAddress, +} from '@graphprotocol/common-ts' +import { + AllocationManager, + getEscrowSenderForSigner, + GraphNode, + IndexerManagementModels, + IndexingDecisionBasis, + IndexingRuleAttributes, + Network, + QueryFeeModels, + sequentialTimerMap, + SubgraphClient, + SubgraphIdentifierType, + TapCollector, + upsertIndexingRule, +} from '@graphprotocol/indexer-common' +import { Op } from 'sequelize' + +import { + createGatewayDipsServiceClient, + GatewayDipsServiceMessagesCodec, +} from './gateway-dips-service-client' +import { + CollectPaymentStatus, + GatewayDipsServiceClientImpl, +} from '@graphprotocol/dips-proto/generated/gateway' +import { IndexingAgreement } from '../indexer-management/models/indexing-agreement' +import { NetworkSpecification } from '../network-specification' +import { Wallet } from 'ethers' + +const DIPS_COLLECTION_INTERVAL = 60_000 + +const uuidToHex = (uuid: string) => { + return `0x${uuid.replace(/-/g, '')}` +} + +const normalizeAddressForDB = (address: string) => { + return toAddress(address).toLowerCase().replace('0x', '') +} + +type GetEscrowSenderForSigner = ( + tapSubgraph: SubgraphClient, + signer: Address, +) => Promise
+export class DipsManager { + declare gatewayDipsServiceClient: GatewayDipsServiceClientImpl + declare gatewayDipsServiceMessagesCodec: GatewayDipsServiceMessagesCodec + constructor( + private logger: Logger, + private models: IndexerManagementModels, + private network: Network, + private parent: AllocationManager | null, + ) { + if (!this.network.specification.indexerOptions.dipperEndpoint) { + throw new Error('dipperEndpoint is not set') + } + this.gatewayDipsServiceClient = createGatewayDipsServiceClient( + this.network.specification.indexerOptions.dipperEndpoint, + ) + this.gatewayDipsServiceMessagesCodec = new GatewayDipsServiceMessagesCodec() + } + // Cancel an agreement associated to an allocation if it exists + async tryCancelAgreement(allocationId: string) { + const normalizedAllocationId = normalizeAddressForDB(allocationId) + const agreement = await this.models.IndexingAgreement.findOne({ + where: { + current_allocation_id: normalizedAllocationId, + cancelled_at: null, + }, + }) + if (agreement) { + try { + const cancellation = + await this.gatewayDipsServiceMessagesCodec.createSignedCancellationRequest( + uuidToHex(agreement.id), + this.network.wallet, + ) + await this.gatewayDipsServiceClient.CancelAgreement({ + version: 1, + signedCancellation: cancellation, + }) + + // Mark the agreement as cancelled + agreement.cancelled_at = new Date() + agreement.updated_at = new Date() + await agreement.save() + } catch (error) { + this.logger.error(`Error cancelling agreement ${agreement.id}`, { error }) + } + } + } + // Update the current and last allocation ids for an agreement if it exists + async tryUpdateAgreementAllocation( + deploymentId: string, + oldAllocationId: Address | null, + newAllocationId: Address | null, + ) { + const agreement = await this.models.IndexingAgreement.findOne({ + where: { + subgraph_deployment_id: deploymentId, + }, + }) + if (agreement) { + agreement.current_allocation_id = newAllocationId + agreement.last_allocation_id = oldAllocationId + agreement.last_payment_collected_at = null + agreement.updated_at = new Date() + await agreement.save() + } + } + async ensureAgreementRules() { + if (!this.parent) { + this.logger.error( + 'DipsManager has no parent AllocationManager, cannot ensure agreement rules', + ) + return + } + // Get all the indexing agreements that are not cancelled + const indexingAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: null, + }, + }) + // For each agreement, check that there is an indexing rule to always + // allocate to the agreement's subgraphDeploymentId, and if not, create one + for (const agreement of indexingAgreements) { + const subgraphDeploymentID = new SubgraphDeploymentID( + agreement.subgraph_deployment_id, + ) + this.logger.info( + `Checking if indexing rule exists for agreement ${ + agreement.id + }, deployment ${subgraphDeploymentID.toString()}`, + ) + // If there is not yet an indexingRule that deems this deployment worth allocating to, make one + const ruleExists = await this.parent.matchingRuleExists( + this.logger, + subgraphDeploymentID, + ) + if (!ruleExists) { + this.logger.info( + `Creating indexing rule for agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, + ) + const indexingRule = { + identifier: agreement.subgraph_deployment_id, + allocationAmount: formatGRT( + this.network.specification.indexerOptions.dipsAllocationAmount, + ), + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + protocolNetwork: this.network.specification.networkIdentifier, + autoRenewal: true, + allocationLifetime: Math.max( + Number(agreement.min_epochs_per_collection), + Number(agreement.max_epochs_per_collection) - + this.network.specification.indexerOptions.dipsEpochsMargin, + ), + } as Partial + + await upsertIndexingRule(this.logger, this.models, indexingRule) + } + } + } + async getActiveDipsDeployments(): Promise { + // Get all the indexing agreements that are not cancelled + const indexingAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: null, + }, + }) + return indexingAgreements.map( + (agreement) => new SubgraphDeploymentID(agreement.subgraph_deployment_id), + ) + } +} + +export class DipsCollector { + declare gatewayDipsServiceClient: GatewayDipsServiceClientImpl + declare gatewayDipsServiceMessagesCodec: GatewayDipsServiceMessagesCodec + constructor( + private logger: Logger, + private managementModels: IndexerManagementModels, + private queryFeeModels: QueryFeeModels, + private specification: NetworkSpecification, + private tapCollector: TapCollector, + private wallet: Wallet, + private graphNode: GraphNode, + public escrowSenderGetter: GetEscrowSenderForSigner, + ) { + if (!this.specification.indexerOptions.dipperEndpoint) { + throw new Error('dipperEndpoint is not set') + } + this.gatewayDipsServiceClient = createGatewayDipsServiceClient( + this.specification.indexerOptions.dipperEndpoint, + ) + this.gatewayDipsServiceMessagesCodec = new GatewayDipsServiceMessagesCodec() + } + + static create( + logger: Logger, + managementModels: IndexerManagementModels, + queryFeeModels: QueryFeeModels, + specification: NetworkSpecification, + tapCollector: TapCollector, + wallet: Wallet, + graphNode: GraphNode, + escrowSenderGetter?: GetEscrowSenderForSigner, + ) { + const collector = new DipsCollector( + logger, + managementModels, + queryFeeModels, + specification, + tapCollector, + wallet, + graphNode, + escrowSenderGetter ?? getEscrowSenderForSigner, + ) + collector.startCollectionLoop() + return collector + } + + startCollectionLoop() { + sequentialTimerMap( + { + logger: this.logger, + milliseconds: DIPS_COLLECTION_INTERVAL, + }, + async () => { + this.logger.debug('Running DIPs payment collection loop') + await this.collectAllPayments() + }, + { + onError: (err) => { + this.logger.error('Failed to collect DIPs payments', { err }) + }, + }, + ) + } + + // Collect payments for all outstanding agreements + async collectAllPayments() { + const outstandingAgreements = await this.managementModels.IndexingAgreement.findAll({ + where: { + last_payment_collected_at: null, + last_allocation_id: { + [Op.ne]: null, + }, + }, + }) + for (const agreement of outstandingAgreements) { + await this.tryCollectPayment(agreement) + } + } + async tryCollectPayment(agreement: IndexingAgreement) { + if (!agreement.last_allocation_id) { + this.logger.error(`Agreement ${agreement.id} has no last allocation id`) + return + } + const entityCounts = await this.graphNode.entityCount([ + new SubgraphDeploymentID(agreement.subgraph_deployment_id), + ]) + if (entityCounts.length === 0) { + this.logger.error(`Agreement ${agreement.id} has no entity count`) + return + } + const entityCount = entityCounts[0] + const collection = + await this.gatewayDipsServiceMessagesCodec.createSignedCollectionRequest( + uuidToHex(agreement.id), + agreement.last_allocation_id, + entityCount, + this.wallet, + ) + try { + this.logger.info(`Collecting payment for agreement ${agreement.id}`) + const response = await this.gatewayDipsServiceClient.CollectPayment({ + version: 1, + signedCollection: collection, + }) + if (response.status === CollectPaymentStatus.ACCEPT) { + if (!this.tapCollector) { + throw new Error('TapCollector not initialized') + } + // Store the tap receipt in the database + this.logger.info('Decoding TAP receipt for agreement') + const tapReceipt = this.gatewayDipsServiceMessagesCodec.decodeTapReceipt( + response.tapReceipt, + this.tapCollector?.tapContracts.tapVerifier.address, + ) + // Check that the signer of the TAP receipt is a signer + // on the corresponding escrow account for the payer (sender) of the + // indexing agreement + const escrowSender = await this.escrowSenderGetter( + this.tapCollector?.tapSubgraph, + tapReceipt.signer_address, + ) + if (escrowSender !== toAddress(agreement.payer)) { + // TODO: should we cancel the agreement here? + throw new Error( + 'Signer of TAP receipt is not a signer on the indexing agreement', + ) + } + if (tapReceipt.allocation_id !== toAddress(agreement.last_allocation_id)) { + throw new Error('Allocation ID mismatch') + } + await this.queryFeeModels.scalarTapReceipts.create(tapReceipt) + // Mark the agreement as having had a payment collected + agreement.last_payment_collected_at = new Date() + agreement.updated_at = new Date() + await agreement.save() + } else { + throw new Error(`Payment request not accepted: ${response.status}`) + } + } catch (error) { + this.logger.error(`Error collecting payment for agreement ${agreement.id}`, { + error, + }) + } + } +} diff --git a/packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts b/packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts new file mode 100644 index 000000000..1bfb832a5 --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts @@ -0,0 +1,168 @@ +import { Client, credentials } from '@grpc/grpc-js' +import { UnaryCallback } from '@grpc/grpc-js/build/src/client' +import { GatewayDipsServiceClientImpl } from '@graphprotocol/dips-proto/generated/gateway' +import { Wallet } from 'ethers' +import { + _TypedDataEncoder, + arrayify, + defaultAbiCoder, + recoverAddress, +} from 'ethers/lib/utils' +import { toAddress } from '@graphprotocol/common-ts' + +type RpcImpl = (service: string, method: string, data: Uint8Array) => Promise + +interface Rpc { + request: RpcImpl +} + +export const domainSalt = + '0xb4632c657c26dce5d4d7da1d65bda185b14ff8f905ddbb03ea0382ed06c5ef28' +export const chainId = 0xa4b1 // 42161 +export const cancelAgreementDomain = { + name: 'Graph Protocol Indexing Agreement Cancellation', + version: '0', + chainId: chainId, + salt: domainSalt, +} +export const cancelAgreementTypes = { + CancellationRequest: [{ name: 'agreement_id', type: 'bytes16' }], +} + +export const collectPaymentsDomain = { + name: 'Graph Protocol Indexing Agreement Collection', + version: '0', + chainId: chainId, + salt: domainSalt, +} +export const collectPaymentsTypes = { + CollectionRequest: [ + { name: 'agreement_id', type: 'bytes16' }, + { name: 'allocation_id', type: 'address' }, + { name: 'entity_count', type: 'uint64' }, + ], +} + +export class GatewayDipsServiceMessagesCodec { + async createSignedCancellationRequest( + agreementId: string, + wallet: Wallet, + ): Promise { + const signature = await wallet._signTypedData( + cancelAgreementDomain, + cancelAgreementTypes, + { agreement_id: agreementId }, + ) + return arrayify( + defaultAbiCoder.encode(['tuple(bytes16)', 'bytes'], [[agreementId], signature]), + ) + } + + async createSignedCollectionRequest( + agreementId: string, + allocationId: string, + entityCount: number, + wallet: Wallet, + ): Promise { + const signature = await wallet._signTypedData( + collectPaymentsDomain, + collectPaymentsTypes, + { + agreement_id: agreementId, + allocation_id: toAddress(allocationId), + entity_count: entityCount, + }, + ) + return arrayify( + defaultAbiCoder.encode( + ['tuple(bytes16, address, uint64)', 'bytes'], + [[agreementId, toAddress(allocationId), entityCount], signature], + ), + ) + } + + decodeTapReceipt(receipt: Uint8Array, verifyingContract: string) { + const [message, signature] = defaultAbiCoder.decode( + ['tuple(address,uint64,uint64,uint128)', 'bytes'], + receipt, + ) + + const [allocationId, timestampNs, nonce, value] = message + + // Recover the signer address from the signature + // compute the EIP-712 digest of the message + const domain = { + name: 'TAP', + version: '1', + chainId: chainId, + verifyingContract, + } + + const types = { + Receipt: [ + { name: 'allocation_id', type: 'address' }, + { name: 'timestamp_ns', type: 'uint64' }, + { name: 'nonce', type: 'uint64' }, + { name: 'value', type: 'uint128' }, + ], + } + + const digest = _TypedDataEncoder.hash(domain, types, { + allocation_id: allocationId, + timestamp_ns: timestampNs, + nonce: nonce, + value: value, + }) + const signerAddress = recoverAddress(digest, signature) + return { + allocation_id: toAddress(allocationId), + signer_address: toAddress(signerAddress), + signature: signature, + timestamp_ns: timestampNs, + nonce: nonce, + value: value, + } + } +} + +export const createRpc = (url: string): Rpc => { + const client = new Client(url, credentials.createInsecure()) + const request: RpcImpl = (service, method, data) => { + // Conventionally in gRPC, the request path looks like + // "package.names.ServiceName/MethodName", + // we therefore construct such a string + const path = `/${service}/${method}` + + return new Promise((resolve, reject) => { + // makeUnaryRequest transmits the result (and error) with a callback + // transform this into a promise! + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resultCallback: UnaryCallback = (err, res) => { + if (err) { + return reject(err) + } + resolve(res) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function passThrough(argument: any) { + return argument + } + + // Using passThrough as the deserialize functions + client.makeUnaryRequest( + path, + (d) => Buffer.from(d), + passThrough, + data, + resultCallback, + ) + }) + } + + return { request } +} + +export const createGatewayDipsServiceClient = (url: string) => { + const rpc = createRpc(url) + return new GatewayDipsServiceClientImpl(rpc) +} diff --git a/packages/indexer-common/src/indexing-fees/index.ts b/packages/indexer-common/src/indexing-fees/index.ts new file mode 100644 index 000000000..0b71f1b8e --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/index.ts @@ -0,0 +1 @@ +export * from './dips' diff --git a/packages/indexer-common/src/network-specification.ts b/packages/indexer-common/src/network-specification.ts index f683cdec5..4db7eb30b 100644 --- a/packages/indexer-common/src/network-specification.ts +++ b/packages/indexer-common/src/network-specification.ts @@ -58,6 +58,10 @@ export const IndexerOptions = z allocateOnNetworkSubgraph: z.boolean().default(false), register: z.boolean().default(true), finalityTime: positiveNumber().default(3600), + enableDips: z.boolean().default(false), + dipperEndpoint: z.string().url().optional(), + dipsAllocationAmount: GRT().default(1), + dipsEpochsMargin: positiveNumber().default(1), }) .strict() export type IndexerOptions = z.infer diff --git a/packages/indexer-common/src/network.ts b/packages/indexer-common/src/network.ts index 1b8d436e2..053a23ce7 100644 --- a/packages/indexer-common/src/network.ts +++ b/packages/indexer-common/src/network.ts @@ -29,12 +29,14 @@ import { AllocationReceiptCollector, SubgraphFreshnessChecker, monitorEligibleAllocations, + IndexerManagementModels, } from '.' import { resolveChainId } from './indexer-management' import { monitorEthBalance } from './utils' import { QueryFeeModels } from './query-fees' import { readFileSync } from 'fs' import { TapCollector } from './allocations/tap-collector' +import { DipsCollector } from './indexing-fees/dips' export class Network { logger: Logger @@ -49,10 +51,12 @@ export class Network { receiptCollector: AllocationReceiptCollector | undefined tapCollector: TapCollector | undefined + dipsCollector: DipsCollector | undefined specification: spec.NetworkSpecification paused: Eventual isOperator: Eventual - + queryFeeModels: QueryFeeModels + managementModels: IndexerManagementModels private constructor( logger: Logger, contracts: NetworkContracts, @@ -66,6 +70,9 @@ export class Network { specification: spec.NetworkSpecification, paused: Eventual, isOperator: Eventual, + queryFeeModels: QueryFeeModels, + managementModels: IndexerManagementModels, + dipsCollector: DipsCollector | undefined, ) { this.logger = logger this.contracts = contracts @@ -79,11 +86,15 @@ export class Network { this.specification = specification this.paused = paused this.isOperator = isOperator + this.queryFeeModels = queryFeeModels + this.managementModels = managementModels + this.dipsCollector = dipsCollector } static async create( parentLogger: Logger, specification: spec.NetworkSpecification, + managementModels: IndexerManagementModels, queryFeeModels: QueryFeeModels, graphNode: GraphNode, metrics: Metrics, @@ -311,6 +322,7 @@ export class Network { // * TAP Collector // -------------------------------------------------------------------------------- let tapCollector: TapCollector | undefined = undefined + let dipsCollector: DipsCollector | undefined = undefined if (tapContracts && tapSubgraph) { tapCollector = TapCollector.create({ logger, @@ -323,8 +335,19 @@ export class Network { tapSubgraph, networkSubgraph, }) + if (specification.indexerOptions.enableDips) { + dipsCollector = DipsCollector.create( + logger, + managementModels, + queryFeeModels, + specification, + tapCollector, + wallet, + graphNode, + ) + } } else { - logger.info(`RAV process not initiated. + logger.info(`RAV (and DIPs) process not initiated. Tap Contracts: ${!!tapContracts}. Tap Subgraph: ${!!tapSubgraph}.`) } @@ -345,6 +368,9 @@ export class Network { specification, paused, isOperator, + queryFeeModels, + managementModels, + dipsCollector, ) } diff --git a/packages/indexer-common/src/operator.ts b/packages/indexer-common/src/operator.ts index 1d97904c4..67c908437 100644 --- a/packages/indexer-common/src/operator.ts +++ b/packages/indexer-common/src/operator.ts @@ -16,6 +16,7 @@ import { specification as spec, Action, POIDisputeAttributes, + DipsManager, } from '@graphprotocol/indexer-common' import { Logger, formatGRT } from '@graphprotocol/common-ts' import { BigNumber, utils } from 'ethers' @@ -82,6 +83,10 @@ export class Operator { this.specification = specification } + get dipsManager(): DipsManager | null { + return this.indexerManagement.actionManager?.allocationManager?.dipsManager ?? null + } + // -------------------------------------------------------------------------------- // * Indexing Rules // -------------------------------------------------------------------------------- @@ -258,16 +263,26 @@ export class Operator { return result.data.actions } - async queueAction(action: ActionItem): Promise { + async queueAction(action: ActionItem, forceAction: boolean = false): Promise { let status = ActionStatus.QUEUED switch (this.specification.indexerOptions.allocationManagementMode) { case AllocationManagementMode.MANUAL: - throw Error(`Cannot queue actions when AllocationManagementMode = 'MANUAL'`) + if (forceAction) { + status = ActionStatus.APPROVED + } else { + throw Error(`Cannot queue actions when AllocationManagementMode = 'MANUAL'`) + } + break case AllocationManagementMode.AUTO: status = ActionStatus.APPROVED break case AllocationManagementMode.OVERSIGHT: - status = ActionStatus.QUEUED + if (forceAction) { + status = ActionStatus.APPROVED + } else { + status = ActionStatus.QUEUED + } + break } const actionInput = { @@ -336,6 +351,7 @@ export class Operator { logger: Logger, deploymentAllocationDecision: AllocationDecision, mostRecentlyClosedAllocation: Allocation | undefined, + forceAction: boolean = false, ): Promise { const desiredAllocationAmount = deploymentAllocationDecision.ruleMatch.rule ?.allocationAmount @@ -381,6 +397,7 @@ export class Operator { logger: Logger, deploymentAllocationDecision: AllocationDecision, activeDeploymentAllocations: Allocation[], + forceAction: boolean = false, ): Promise { const activeDeploymentAllocationsEligibleForClose = activeDeploymentAllocations.map( (allocation) => allocation.id, @@ -421,6 +438,7 @@ export class Operator { logger: Logger, deploymentAllocationDecision: AllocationDecision, expiredAllocations: Allocation[], + forceAction: boolean = false, ): Promise { if (deploymentAllocationDecision.ruleMatch.rule?.autoRenewal) { logger.info(`Reallocating expired allocations`, { diff --git a/packages/indexer-common/src/query-fees/models.ts b/packages/indexer-common/src/query-fees/models.ts index 095d60fe0..4b5d97d3b 100644 --- a/packages/indexer-common/src/query-fees/models.ts +++ b/packages/indexer-common/src/query-fees/models.ts @@ -5,19 +5,28 @@ import { TAPVerifier } from '@semiotic-labs/tap-contracts-bindings' export interface ScalarTapReceiptsAttributes { id: number - allocation_id: Address - signer_address: Address + allocation_id: string + signer_address: string signature: Uint8Array timestamp_ns: bigint nonce: bigint value: bigint error_log?: string } +export interface ScalarTapReceiptsCreationAttributes { + allocation_id: string + signer_address: string + signature: Uint8Array + timestamp_ns: bigint + nonce: bigint + value: bigint +} + export class ScalarTapReceipts - extends Model + extends Model implements ScalarTapReceiptsAttributes { - public id!: number + public id!: CreationOptional public allocation_id!: Address public signer_address!: Address public signature!: Uint8Array @@ -598,10 +607,26 @@ export function defineQueryFeeModels(sequelize: Sequelize): QueryFeeModels { allocation_id: { type: DataTypes.CHAR(40), allowNull: false, + get() { + const rawValue = this.getDataValue('allocation_id') + return toAddress(rawValue) + }, + set(value: Address) { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('allocation_id', addressWithoutPrefix) + }, }, signer_address: { type: DataTypes.CHAR(40), allowNull: false, + get() { + const rawValue = this.getDataValue('signer_address') + return toAddress(rawValue) + }, + set(value: Address) { + const addressWithoutPrefix = value.toLowerCase().replace('0x', '') + this.setDataValue('signer_address', addressWithoutPrefix) + }, }, signature: { type: DataTypes.BLOB, diff --git a/yarn.lock b/yarn.lock index 1536852f1..da8d1e87a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -329,6 +329,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@bufbuild/protobuf@2.2.3", "@bufbuild/protobuf@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.2.3.tgz#9cd136f6b687e63e9b517b3a54211ece942897ee" + integrity sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg== + "@cspotcode/source-map-consumer@0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" @@ -785,6 +790,13 @@ console-table-printer "^2.11.1" ethers "^5.6.0" +"@graphprotocol/dips-proto@0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@graphprotocol/dips-proto/-/dips-proto-0.2.2.tgz#3beece3e768b8a7d64bace959e0bf15a91c3ef53" + integrity sha512-pAcnHnZ3qs2NrjYEUm8sahY0MBaV5KXfQVg9wk6f3LlClS1hK3a9aqUCI0CUriuALWbTwceeGgiKv8UIrJx4GA== + dependencies: + "@bufbuild/protobuf" "^2.2.3" + "@graphprotocol/pino-sentry-simple@0.7.1": version "0.7.1" resolved "https://registry.npmjs.org/@graphprotocol/pino-sentry-simple/-/pino-sentry-simple-0.7.1.tgz" @@ -795,6 +807,24 @@ split2 "^3.1.1" through2 "^3.0.1" +"@grpc/grpc-js@^1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.12.6.tgz#a3586ffdfb6a1f5cd5b4866dec9074c4a1e65472" + integrity sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q== + dependencies: + "@grpc/proto-loader" "^0.7.13" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.7.13": + version "0.7.13" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.13.tgz#f6a44b2b7c9f7b609f5748c6eac2d420e37670cf" + integrity sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@humanwhocodes/config-array@^0.11.11", "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -1081,6 +1111,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@lerna/add@6.1.0": version "6.1.0" resolved "https://registry.npmjs.org/@lerna/add/-/add-6.1.0.tgz" @@ -2207,6 +2242,85 @@ version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@pinax/graph-networks-registry@0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@pinax/graph-networks-registry/-/graph-networks-registry-0.6.7.tgz#ceb994f3b31e2943b9c9d9b09dd86eb00d067c0e" + integrity sha512-xogeCEZ50XRMxpBwE3TZjJ8RCO8Guv39gDRrrKtlpDEDEMLm0MzD3A0SQObgj7aF7qTZNRTWzsuvQdxgzw25wQ== + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + +"@rushstack/node-core-library@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.12.0.tgz#de8d7d644811373ade64a926516b1ecfbe09394c" + integrity sha512-QSwwzgzWoil1SCQse+yCHwlhRxNv2dX9siPnAb9zR/UmMhac4mjMrlMZpk64BlCeOFi1kJKgXRkihSwRMbboAQ== + dependencies: + ajv "~8.13.0" + ajv-draft-04 "~1.0.0" + ajv-formats "~3.0.1" + fs-extra "~11.3.0" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.22.1" + semver "~7.5.4" + +"@rushstack/terminal@0.15.1": + version "0.15.1" + resolved "https://registry.yarnpkg.com/@rushstack/terminal/-/terminal-0.15.1.tgz#d6f4dea94748c92d3bf940460995096febb4d9c6" + integrity sha512-3vgJYwumcjoDOXU3IxZfd616lqOdmr8Ezj4OWgJZfhmiBK4Nh7eWcv8sU8N/HdzXcuHDXCRGn/6O2Q75QvaZMA== + dependencies: + "@rushstack/node-core-library" "5.12.0" + supports-color "~8.1.1" "@rushstack/ts-command-line@^4.7.7": version "4.16.0" @@ -2745,6 +2859,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.1.tgz#8b589bba9b2af0128796461a0979764562687e6f" integrity sha512-4LcJvuXQlv4lTHnxwyHQZ3uR9Zw2j7m1C9DfuwoTFQQP4Pmu04O6IfLYgMmHoOCt0nosItLLZAH+sOrRE0Bo8g== +"@types/node@>=13.7.0": + version "22.13.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.1.tgz#a2a3fefbdeb7ba6b89f40371842162fac0934f33" + integrity sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew== + dependencies: + undici-types "~6.20.0" + "@types/node@^12.12.54": version "12.20.55" resolved "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz" @@ -7531,6 +7652,11 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +long@^5.0.0: + version "5.2.4" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.4.tgz#ee651d5c7c25901cfca5e67220ae9911695e99b2" + integrity sha512-qtzLbJE8hq7VabR3mISmVGtoXP8KGc2Z/AT8OuqlYD7JTR3oqrgwdjnk07wpj1twXxYmgDXgoKVWUG/fReSzHg== + loose-envify@^1.0.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" @@ -9062,6 +9188,24 @@ proto-list@~1.2.1: resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protobufjs@^7.2.5: + version "7.4.0" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.4.0.tgz#7efe324ce9b3b61c82aae5de810d287bc08a248a" + integrity sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + protocols@^2.0.0, protocols@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz" @@ -11001,7 +11145,7 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.3.1, yargs@^17.6.2: +yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From c8b33c88fcfef7c041d3aaab6eb6fb746f0feb79 Mon Sep 17 00:00:00 2001 From: Daniel Werner Date: Thu, 17 Apr 2025 09:09:42 -0700 Subject: [PATCH 02/24] all: update dips code for multinetworks --- packages/indexer-agent/src/agent.ts | 196 +++++++++--------- packages/indexer-agent/src/commands/start.ts | 24 ++- .../resolvers/allocations.ts | 25 ++- .../src/indexing-fees/__tests__/dips.test.ts | 11 +- packages/indexer-common/src/operator.ts | 70 ++++--- yarn.lock | 31 +-- 6 files changed, 190 insertions(+), 167 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 5beac7979..3ccdda0bd 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -63,7 +63,7 @@ const deploymentRuleInList = ( rule => rule.identifierType == SubgraphIdentifierType.DEPLOYMENT && new SubgraphDeploymentID(rule.identifier).toString() == - deployment.toString(), + deployment.toString(), ) !== undefined const uniqueDeploymentsOnly = ( @@ -331,18 +331,16 @@ export class Agent { logger.warn(`Failed to obtain indexing rules, trying again later`, { error, }), - }) + }, + ) // Skip fetching active deployments if the deployment management mode is manual, DIPs is disabled, and POI tracking is disabled const activeDeployments: Eventual = sequentialTimerMap( { logger, milliseconds: requestIntervalLarge }, async () => { - if ( - this.deploymentManagement === DeploymentManagementMode.AUTO || - network.networkMonitor.poiDisputeMonitoringEnabled() - ) { - logger.trace('Fetching active deployments') + if (this.deploymentManagement === DeploymentManagementMode.AUTO) { + logger.debug('Fetching active deployments') const assignments = await this.graphNode.subgraphDeploymentsAssignments( SubgraphStatus.ACTIVE, @@ -741,40 +739,42 @@ export class Agent { } break case DeploymentManagementMode.MANUAL: - if (network.specification.indexerOptions.enableDips) { - // Reconcile DIPs deployments anyways - this.logger.warn( - `Deployment management is manual, but DIPs is enabled. Reconciling DIPs deployments anyways.`, - ) - if (!operator.dipsManager) { - throw new Error('DipsManager is not available') - } - const dipsDeployments = - await operator.dipsManager.getActiveDipsDeployments() - const newTargetDeployments = new Set([ - ...activeDeployments, - ...dipsDeployments, - ]) - try { - await this.reconcileDeployments( - activeDeployments, - Array.from(newTargetDeployments), - eligibleAllocations, + await this.multiNetworks.map(async ({ network, operator }) => { + if (network.specification.indexerOptions.enableDips) { + // Reconcile DIPs deployments anyways + this.logger.warn( + `Deployment management is manual, but DIPs is enabled. Reconciling DIPs deployments anyways.`, ) - } catch (err) { - logger.warn( - `Exited early while reconciling deployments. Skipped reconciling actions.`, - { - err: indexerError(IndexerErrorCode.IE005, err), - }, + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + const dipsDeployments = + await operator.dipsManager.getActiveDipsDeployments() + const newTargetDeployments = new Set([ + ...activeDeployments, + ...dipsDeployments, + ]) + try { + await this.reconcileDeployments( + activeDeployments, + Array.from(newTargetDeployments), + eligibleAllocations, + ) + } catch (err) { + logger.warn( + `Exited early while reconciling deployments. Skipped reconciling actions.`, + { + err: indexerError(IndexerErrorCode.IE005, err), + }, + ) + return + } + } else { + this.logger.debug( + `Skipping subgraph deployment reconciliation since DeploymentManagementMode = 'manual'`, ) - return } - } else { - this.logger.debug( - `Skipping subgraph deployment reconciliation since DeploymentManagementMode = 'manual'`, - ) - } + }) break default: throw new Error( @@ -902,7 +902,7 @@ export class Agent { let status = rewardsPool!.referencePOI == allocation.poi || - rewardsPool!.referencePreviousPOI == allocation.poi + rewardsPool!.referencePreviousPOI == allocation.poi ? 'valid' : 'potential' @@ -1199,38 +1199,45 @@ export class Agent { // Filter out networks set to `manual` allocation management mode, and ensure the // Network Subgraph is NEVER allocated towards // -------------------------------------------------------------------------------- - const { network, operator } = this.networkAndOperator - let validatedAllocationDecisions = [...allocationDecisions] - - if ( - network.specification.indexerOptions.allocationManagementMode === - AllocationManagementMode.MANUAL - ) { - this.logger.debug( - `Skipping allocation reconciliation since AllocationManagementMode = 'manual'`, - { - protocolNetwork: network.specification.networkIdentifier, - targetDeployments: allocationDecisions - .filter(decision => decision.toAllocate) - .map(decision => decision.deployment.ipfsHash), + const validatedAllocationDecisions = + await this.multiNetworks.mapNetworkMapped( + networkDeploymentAllocationDecisions, + async ( + { network }: NetworkAndOperator, + allocationDecisions: AllocationDecision[], + ) => { + if ( + network.specification.indexerOptions.allocationManagementMode === + AllocationManagementMode.MANUAL + ) { + this.logger.trace( + `Skipping allocation reconciliation since AllocationManagementMode = 'manual'`, + { + protocolNetwork: network.specification.networkIdentifier, + targetDeployments: allocationDecisions + .filter(decision => decision.toAllocate) + .map(decision => decision.deployment.ipfsHash), + }, + ) + return [] as AllocationDecision[] + } + const networkSubgraphDeployment = network.networkSubgraph.deployment + if ( + networkSubgraphDeployment && + !network.specification.indexerOptions.allocateOnNetworkSubgraph + ) { + const networkSubgraphIndex = allocationDecisions.findIndex( + decision => + decision.deployment.bytes32 == + networkSubgraphDeployment.id.bytes32, + ) + if (networkSubgraphIndex >= 0) { + allocationDecisions[networkSubgraphIndex].toAllocate = false + } + } + return allocationDecisions }, ) - validatedAllocationDecisions = [] as AllocationDecision[] - } else { - const networkSubgraphDeployment = network.networkSubgraph.deployment - if ( - networkSubgraphDeployment && - !network.specification.indexerOptions.allocateOnNetworkSubgraph - ) { - const networkSubgraphIndex = validatedAllocationDecisions.findIndex( - decision => - decision.deployment.bytes32 == networkSubgraphDeployment.id.bytes32, - ) - if (networkSubgraphIndex >= 0) { - validatedAllocationDecisions[networkSubgraphIndex].toAllocate = false - } - } - } //---------------------------------------------------------------------------------------- // For every network, loop through all deployments and queue allocation actions if needed @@ -1282,7 +1289,7 @@ export class Agent { })), }) - await pMap(validatedAllocationDecisions, async decision => + return pMap(allocationDecisions, async decision => this.reconcileDeploymentAllocationAction( decision, activeAllocations, @@ -1292,39 +1299,40 @@ export class Agent { operator, ), ) - return - } + }, + ) + } // TODO: After indexer-service deprecation: Move to be an initialization check inside Network.create() async ensureSubgraphIndexing(deployment: string, networkIdentifier: string) { - try { - // TODO: Check both the local deployment and the external subgraph endpoint - // Make sure the subgraph is being indexed - await this.graphNode.ensure( - `indexer-agent/${deployment.slice(-10)}`, - new SubgraphDeploymentID(deployment), - ) + try { + // TODO: Check both the local deployment and the external subgraph endpoint + // Make sure the subgraph is being indexed + await this.graphNode.ensure( + `indexer-agent/${deployment.slice(-10)}`, + new SubgraphDeploymentID(deployment), + ) // Validate if the Network Subgraph belongs to the current provider's network. // This check must be performed after we ensure the Network Subgraph is being indexed. await validateProviderNetworkIdentifier( - networkIdentifier, - deployment, - this.graphNode, - this.logger, - ) - } catch(e) { - this.logger.warn( - 'Failed to deploy and validate Network Subgraph on index-nodes. Will use external subgraph endpoint instead', - e, - ) - } + networkIdentifier, + deployment, + this.graphNode, + this.logger, + ) + } catch (e) { + this.logger.warn( + 'Failed to deploy and validate Network Subgraph on index-nodes. Will use external subgraph endpoint instead', + e, + ) } + } async ensureAllSubgraphsIndexing(network: Network) { - // Network subgraph - if( - network.specification.subgraphs.networkSubgraph.deployment !== undefined - ) { + // Network subgraph + if ( + network.specification.subgraphs.networkSubgraph.deployment !== undefined + ) { await this.ensureSubgraphIndexing( network.specification.subgraphs.networkSubgraph.deployment, network.specification.networkIdentifier, diff --git a/packages/indexer-agent/src/commands/start.ts b/packages/indexer-agent/src/commands/start.ts index dd18c2872..9e402a23a 100644 --- a/packages/indexer-agent/src/commands/start.ts +++ b/packages/indexer-agent/src/commands/start.ts @@ -357,7 +357,7 @@ export const start = { }) }, // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any - handler: (_argv: any) => { }, + handler: (_argv: any) => {}, } export async function createNetworkSpecification( @@ -610,7 +610,14 @@ export async function run( const networks: Network[] = await pMap( networkSpecifications, async (spec: NetworkSpecification) => - Network.create(logger, spec, managementModels, queryFeeModels, graphNode, metrics), + Network.create( + logger, + spec, + managementModels, + queryFeeModels, + graphNode, + metrics, + ), ) // -------------------------------------------------------------------------------- @@ -717,13 +724,14 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (collectReceiptsEndpoint) { logger.warn( "The option '--collect-receipts-endpoint' is deprecated. " + - "Please use the option '--gateway-endpoint' to inform the Gateway base URL.", + "Please use the option '--gateway-endpoint' to inform the Gateway base URL.", ) } if (gasIncreaseTimeout < advisedGasIncreaseTimeout) { logger.warn( - `Gas increase timeout is set to less than ${gasIncreaseTimeout / 1000 + `Gas increase timeout is set to less than ${ + gasIncreaseTimeout / 1000 } seconds. This may lead to high gas usage`, { gasIncreaseTimeout: gasIncreaseTimeout / 1000.0 }, ) @@ -732,14 +740,14 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (gasIncreaseFactor > advisedGasIncreaseTimeout) { logger.warn( `Gas increase factor is set to > ${advisedGasIncreaseFactor}. ` + - 'This may lead to high gas usage', + 'This may lead to high gas usage', { gasIncreaseFactor: gasIncreaseFactor }, ) } if (rebateClaimThreshold < voucherRedemptionThreshold) { logger.warn( 'Rebate single minimum claim value is less than voucher minimum redemption value, ' + - 'but claims depend on redemptions', + 'but claims depend on redemptions', { voucherRedemptionThreshold: formatGRT(voucherRedemptionThreshold), rebateClaimThreshold: formatGRT(rebateClaimThreshold), @@ -756,7 +764,7 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (rebateClaimMaxBatchSize > advisedRebateClaimMaxBatchSize) { logger.warn( `Setting the max batch size for rebate claims to more than ${advisedRebateClaimMaxBatchSize}` + - 'may result in batches that are too large to fit into a block', + 'may result in batches that are too large to fit into a block', { rebateClaimMaxBatchSize: rebateClaimMaxBatchSize }, ) } @@ -770,7 +778,7 @@ export function reviewArgumentsForWarnings(argv: AgentOptions, logger: Logger) { if (voucherRedemptionMaxBatchSize > advisedVoucherRedemptionMaxBatchSize) { logger.warn( `Setting the max batch size for voucher redemptions to more than ${advisedVoucherRedemptionMaxBatchSize} ` + - 'may result in batches that are too large to fit into a block', + 'may result in batches that are too large to fit into a block', { voucherRedemptionMaxBatchSize: voucherRedemptionMaxBatchSize }, ) } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 89647ecb5..ca4f2748f 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -226,7 +226,7 @@ async function queryAllocations( let lastId = '' // eslint-disable-next-line @typescript-eslint/no-explicit-any const resultAllocations: any[] = [] - for (; ;) { + for (;;) { const pageVars = { ...filterVars, lastId, @@ -536,7 +536,8 @@ export default { if (receipt === 'paused' || receipt === 'unauthorized') { throw indexerError( IndexerErrorCode.IE062, - `Allocation not created. ${receipt === 'paused' ? 'Network paused' : 'Operator not authorized' + `Allocation not created. ${ + receipt === 'paused' ? 'Network paused' : 'Operator not authorized' }`, ) } @@ -577,8 +578,10 @@ export default { await models.IndexingRule.upsert(indexingRule) - if (actionManager?.allocationManager?.dipsManager) { - await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + const allocationManager = + actionManager?.allocationManagers[network.specification.networkIdentifier] + if (allocationManager?.dipsManager) { + await allocationManager.dipsManager.tryUpdateAgreementAllocation( deployment, null, toAddress(createAllocationEventLogs.allocationID), @@ -743,9 +746,11 @@ export default { await models.IndexingRule.upsert(offchainIndexingRule) - if (actionManager?.allocationManager?.dipsManager) { - await actionManager.allocationManager.dipsManager.tryCancelAgreement(allocation) - await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + const allocationManager = + actionManager?.allocationManagers[network.specification.networkIdentifier] + if (allocationManager?.dipsManager) { + await allocationManager.dipsManager.tryCancelAgreement(allocation) + await allocationManager.dipsManager.tryUpdateAgreementAllocation( allocationData.subgraphDeployment.id.toString(), toAddress(allocation), null, @@ -1059,8 +1064,10 @@ export default { await models.IndexingRule.upsert(indexingRule) - if (actionManager?.allocationManager?.dipsManager) { - await actionManager.allocationManager.dipsManager.tryUpdateAgreementAllocation( + const allocationManager = + actionManager?.allocationManagers[network.specification.networkIdentifier] + if (allocationManager?.dipsManager) { + await allocationManager.dipsManager.tryUpdateAgreementAllocation( allocationData.subgraphDeployment.id.toString(), toAddress(allocation), toAddress(createAllocationEventLogs.allocationID), diff --git a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts index 204f8a884..bb784b2ff 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -15,6 +15,7 @@ import { Operator, ActionManager, IndexerManagementClient, + MultiNetworks, } from '@graphprotocol/indexer-common' import { connectDatabase, @@ -44,6 +45,7 @@ let graphNode: GraphNode let managementModels: IndexerManagementModels let queryFeeModels: QueryFeeModels let network: Network +let multiNetworks: MultiNetworks let dipsCollector: DipsCollector let indexerManagementClient: IndexerManagementClient let operator: Operator @@ -90,6 +92,7 @@ const setup = async () => { 'https://test-admin-endpoint.xyz', 'https://test-query-endpoint.xyz', 'https://test-status-endpoint.xyz', + 'https://test-ipfs-endpoint.xyz', ) sequelize = await connectDatabase(__DATABASE__) @@ -105,6 +108,12 @@ const setup = async () => { graphNode, metrics, ) + + multiNetworks = new MultiNetworks( + [network], + (n: Network) => n.specification.networkIdentifier, + ) + dipsCollector = network.dipsCollector! indexerManagementClient = await createIndexerManagementClient({ models: managementModels, @@ -116,7 +125,7 @@ const setup = async () => { parallelAllocations: 1, }, }, - network, + multiNetworks, }) operator = new Operator(logger, indexerManagementClient, networkSpecWithDips) diff --git a/packages/indexer-common/src/operator.ts b/packages/indexer-common/src/operator.ts index 67c908437..69c3c1866 100644 --- a/packages/indexer-common/src/operator.ts +++ b/packages/indexer-common/src/operator.ts @@ -84,7 +84,10 @@ export class Operator { } get dipsManager(): DipsManager | null { - return this.indexerManagement.actionManager?.allocationManager?.dipsManager ?? null + const network = this.specification.networkIdentifier + const allocationManager = + this.indexerManagement.actionManager?.allocationManagers[network] + return allocationManager?.dipsManager ?? null } // -------------------------------------------------------------------------------- @@ -380,15 +383,18 @@ export class Operator { } // Send AllocateAction to the queue - await this.queueAction({ - params: { - deploymentID: deploymentAllocationDecision.deployment.ipfsHash, - amount: formatGRT(desiredAllocationAmount), + await this.queueAction( + { + params: { + deploymentID: deploymentAllocationDecision.deployment.ipfsHash, + amount: formatGRT(desiredAllocationAmount), + }, + type: ActionType.ALLOCATE, + reason: deploymentAllocationDecision.reasonString(), + protocolNetwork: deploymentAllocationDecision.protocolNetwork, }, - type: ActionType.ALLOCATE, - reason: deploymentAllocationDecision.reasonString(), - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - }) + forceAction, + ) return } @@ -417,17 +423,20 @@ export class Operator { activeDeploymentAllocationsEligibleForClose, async (allocation) => { // Send unallocate action to the queue - await this.queueAction({ - params: { - allocationID: allocation, - deploymentID: deploymentAllocationDecision.deployment.ipfsHash, - poi: undefined, - force: false, - }, - type: ActionType.UNALLOCATE, - reason: deploymentAllocationDecision.reasonString(), - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - } as ActionItem) + await this.queueAction( + { + params: { + allocationID: allocation, + deploymentID: deploymentAllocationDecision.deployment.ipfsHash, + poi: undefined, + force: false, + }, + type: ActionType.UNALLOCATE, + reason: deploymentAllocationDecision.reasonString(), + protocolNetwork: deploymentAllocationDecision.protocolNetwork, + } as ActionItem, + forceAction, + ) }, { concurrency: 1 }, ) @@ -455,16 +464,19 @@ export class Operator { await pMap( expiredAllocations, async (allocation) => { - await this.queueAction({ - params: { - allocationID: allocation.id, - deploymentID: deploymentAllocationDecision.deployment.ipfsHash, - amount: formatGRT(desiredAllocationAmount), + await this.queueAction( + { + params: { + allocationID: allocation.id, + deploymentID: deploymentAllocationDecision.deployment.ipfsHash, + amount: formatGRT(desiredAllocationAmount), + }, + type: ActionType.REALLOCATE, + reason: `${deploymentAllocationDecision.reasonString()}:allocationExpiring`, // Need to update to include 'ExpiringSoon' + protocolNetwork: deploymentAllocationDecision.protocolNetwork, }, - type: ActionType.REALLOCATE, - reason: `${deploymentAllocationDecision.reasonString()}:allocationExpiring`, // Need to update to include 'ExpiringSoon' - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - }) + forceAction, + ) }, { stopOnError: false, diff --git a/yarn.lock b/yarn.lock index da8d1e87a..7bb68a772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2242,10 +2242,6 @@ version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@pinax/graph-networks-registry@0.6.7": - version "0.6.7" - resolved "https://registry.yarnpkg.com/@pinax/graph-networks-registry/-/graph-networks-registry-0.6.7.tgz#ceb994f3b31e2943b9c9d9b09dd86eb00d067c0e" - integrity sha512-xogeCEZ50XRMxpBwE3TZjJ8RCO8Guv39gDRrrKtlpDEDEMLm0MzD3A0SQObgj7aF7qTZNRTWzsuvQdxgzw25wQ== "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" @@ -2300,28 +2296,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@rushstack/node-core-library@5.12.0": - version "5.12.0" - resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.12.0.tgz#de8d7d644811373ade64a926516b1ecfbe09394c" - integrity sha512-QSwwzgzWoil1SCQse+yCHwlhRxNv2dX9siPnAb9zR/UmMhac4mjMrlMZpk64BlCeOFi1kJKgXRkihSwRMbboAQ== - dependencies: - ajv "~8.13.0" - ajv-draft-04 "~1.0.0" - ajv-formats "~3.0.1" - fs-extra "~11.3.0" - import-lazy "~4.0.0" - jju "~1.4.0" - resolve "~1.22.1" - semver "~7.5.4" - -"@rushstack/terminal@0.15.1": - version "0.15.1" - resolved "https://registry.yarnpkg.com/@rushstack/terminal/-/terminal-0.15.1.tgz#d6f4dea94748c92d3bf940460995096febb4d9c6" - integrity sha512-3vgJYwumcjoDOXU3IxZfd616lqOdmr8Ezj4OWgJZfhmiBK4Nh7eWcv8sU8N/HdzXcuHDXCRGn/6O2Q75QvaZMA== - dependencies: - "@rushstack/node-core-library" "5.12.0" - supports-color "~8.1.1" - "@rushstack/ts-command-line@^4.7.7": version "4.16.0" resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.16.0.tgz" @@ -10686,6 +10660,11 @@ underscore@^1.13.1: resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz" integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" From 4eb89490d337a2e672d8055524c68b91d3b9dc0b Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 9 May 2025 12:40:20 -0300 Subject: [PATCH 03/24] fix: manage dips deployments in any mode --- packages/indexer-agent/src/agent.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 3ccdda0bd..c05598edb 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -339,7 +339,13 @@ export class Agent { sequentialTimerMap( { logger, milliseconds: requestIntervalLarge }, async () => { - if (this.deploymentManagement === DeploymentManagementMode.AUTO) { + let dipsEnabled = false + await this.multiNetworks.map(async ({ network }) => { + if (network.specification.indexerOptions.enableDips) { + dipsEnabled = true + } + }) + if (this.deploymentManagement === DeploymentManagementMode.AUTO || dipsEnabled) { logger.debug('Fetching active deployments') const assignments = await this.graphNode.subgraphDeploymentsAssignments( From 0090a3a22d552efe8aa9c1719d2a820629322270 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 31 Mar 2025 13:21:58 -0300 Subject: [PATCH 04/24] fix: use the correct name for the indexing_agreements table --- .../src/indexer-management/models/indexing-agreement.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts index b92c11116..58a00f1e0 100644 --- a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts +++ b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts @@ -202,6 +202,7 @@ export const defineIndexingFeesModels = (sequelize: Sequelize): IndexingFeesMode { modelName: 'IndexingAgreement', sequelize, + tableName: 'indexing_agreements', }, ) From 3780d05bfedd2c3b53ac4ff5ba4a69283be99cd0 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 31 Mar 2025 16:38:12 -0300 Subject: [PATCH 05/24] fix: use the correct timestamp column names --- .../src/indexer-management/models/indexing-agreement.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts index 58a00f1e0..462456a7c 100644 --- a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts +++ b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts @@ -203,6 +203,9 @@ export const defineIndexingFeesModels = (sequelize: Sequelize): IndexingFeesMode modelName: 'IndexingAgreement', sequelize, tableName: 'indexing_agreements', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', }, ) From f1681dfdcce49e736d2fad9fa09e8dc92cb3cdc5 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 9 May 2025 12:58:35 -0300 Subject: [PATCH 06/24] fix: lint --- packages/indexer-agent/src/agent.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index c05598edb..52a4c3338 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -345,7 +345,10 @@ export class Agent { dipsEnabled = true } }) - if (this.deploymentManagement === DeploymentManagementMode.AUTO || dipsEnabled) { + if ( + this.deploymentManagement === DeploymentManagementMode.AUTO || + dipsEnabled + ) { logger.debug('Fetching active deployments') const assignments = await this.graphNode.subgraphDeploymentsAssignments( From ec6c084e0c03442ec311eed7c87c9c17c27f27ca Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 9 May 2025 16:35:59 -0300 Subject: [PATCH 07/24] chore: debug logging --- packages/indexer-agent/src/agent.ts | 6 ++++-- packages/indexer-common/src/indexing-fees/dips.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 52a4c3338..5b672aced 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -296,10 +296,12 @@ export class Agent { if (!operator.dipsManager) { throw new Error('DipsManager is not available') } - logger.trace('Ensuring indexing rules for DIPS', { + logger.debug('Ensuring indexing rules for DIPs', { protocolNetwork: network.specification.networkIdentifier, }) await operator.dipsManager.ensureAgreementRules() + } else { + logger.debug('DIPs is disabled, skipping indexing rule enforcement') } logger.trace('Fetching indexing rules', { protocolNetwork: network.specification.networkIdentifier, @@ -357,7 +359,7 @@ export class Agent { return assignments.map(assignment => assignment.id) } else { logger.info( - "Skipping fetching active deployments fetch since DeploymentManagementMode = 'manual' and POI tracking is disabled", + "Skipping fetching active deployments fetch since DeploymentManagementMode = 'manual' and DIPs is disabled", ) return [] } diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index 0eede21ea..af5c9fd8f 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -127,6 +127,7 @@ export class DipsManager { cancelled_at: null, }, }) + this.logger.debug(`Ensuring indexing rules for ${indexingAgreements.length} agreement${indexingAgreements.length === 1 ? '' : 's'}`) // For each agreement, check that there is an indexing rule to always // allocate to the agreement's subgraphDeploymentId, and if not, create one for (const agreement of indexingAgreements) { From 4e0e2e7db6fdfc4de811dbfc685822de7cc40221 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 9 May 2025 16:38:10 -0300 Subject: [PATCH 08/24] fix: lint again --- packages/indexer-agent/src/agent.ts | 4 +++- packages/indexer-common/src/indexing-fees/dips.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 5b672aced..f8302f59c 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -301,7 +301,9 @@ export class Agent { }) await operator.dipsManager.ensureAgreementRules() } else { - logger.debug('DIPs is disabled, skipping indexing rule enforcement') + logger.debug( + 'DIPs is disabled, skipping indexing rule enforcement', + ) } logger.trace('Fetching indexing rules', { protocolNetwork: network.specification.networkIdentifier, diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index af5c9fd8f..87380a83f 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -127,7 +127,11 @@ export class DipsManager { cancelled_at: null, }, }) - this.logger.debug(`Ensuring indexing rules for ${indexingAgreements.length} agreement${indexingAgreements.length === 1 ? '' : 's'}`) + this.logger.debug( + `Ensuring indexing rules for ${indexingAgreements.length} agreement${ + indexingAgreements.length === 1 ? '' : 's' + }`, + ) // For each agreement, check that there is an indexing rule to always // allocate to the agreement's subgraphDeploymentId, and if not, create one for (const agreement of indexingAgreements) { From b487bd27082c84fd77861ba189e8db4ba0d85a51 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 12 May 2025 10:47:41 -0300 Subject: [PATCH 09/24] fix: allow actions on deployments that are not published yet --- packages/indexer-common/src/actions.ts | 10 ---------- .../src/indexer-management/allocations.ts | 6 +----- .../indexer-common/src/indexer-management/monitor.ts | 11 +++++++++-- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index afa265d02..72641d432 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -122,16 +122,6 @@ export const validateActionInputs = async ( ) } - // Action must target an existing subgraph deployment - const subgraphDeployment = await networkMonitor.subgraphDeployment( - action.deploymentID, - ) - if (!subgraphDeployment) { - logger.warn( - `No subgraphDeployment with ipfsHash = '${action.deploymentID}' found on the network`, - ) - } - // Unallocate & reallocate actions must target an active allocationID if ([ActionType.UNALLOCATE, ActionType.REALLOCATE].includes(action.type)) { // allocationID must belong to active allocation diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 0c9fbab9e..b273fe585 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -1063,11 +1063,7 @@ export class AllocationManager { const subgraphDeployment = await this.network.networkMonitor.subgraphDeployment( subgraphDeploymentID.ipfsHash, ) - if (!subgraphDeployment) { - throw Error( - `SHOULD BE UNREACHABLE: No matching subgraphDeployment (${subgraphDeploymentID.ipfsHash}) found on the network`, - ) - } + return isDeploymentWorthAllocatingTowards(logger, subgraphDeployment, indexingRules) .toAllocate } diff --git a/packages/indexer-common/src/indexer-management/monitor.ts b/packages/indexer-common/src/indexer-management/monitor.ts index e09e43fef..c4f2cdfaf 100644 --- a/packages/indexer-common/src/indexer-management/monitor.ts +++ b/packages/indexer-common/src/indexer-management/monitor.ts @@ -505,7 +505,7 @@ export class NetworkMonitor { return subgraphs } - async subgraphDeployment(ipfsHash: string): Promise { + async subgraphDeployment(ipfsHash: string): Promise { try { const result = await this.networkSubgraph.checkedQuery( gql` @@ -542,7 +542,14 @@ export class NetworkMonitor { this.logger.warn( `SubgraphDeployment with ipfsHash = ${ipfsHash} not found on chain`, ) - return undefined + return { + id: new SubgraphDeploymentID(ipfsHash), + deniedAt: 1, + stakedTokens: BigNumber.from(0), + signalledTokens: BigNumber.from(0), + queryFeesAmount: BigNumber.from(0), + protocolNetwork: this.networkCAIPID, + } } return parseGraphQLSubgraphDeployment( From d1ae77d05470724477b5040b49a47e9d594ecc63 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 12 May 2025 10:52:17 -0300 Subject: [PATCH 10/24] fix: validate deployment ID in actions anyways --- packages/indexer-common/src/actions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 72641d432..d91387609 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -1,6 +1,6 @@ import { NetworkMonitor } from './indexer-management' import { AllocationStatus } from './allocations' -import { Logger } from '@graphprotocol/common-ts' +import { Logger, SubgraphDeploymentID } from '@graphprotocol/common-ts' import { WhereOperators, WhereOptions } from 'sequelize' import { Op } from 'sequelize' import { WhereAttributeHashValue } from 'sequelize/types/model' @@ -122,6 +122,14 @@ export const validateActionInputs = async ( ) } + try { + new SubgraphDeploymentID(action.deploymentID) + } catch (e) { + throw new Error( + `Invalid 'deploymentID' value: ${action.deploymentID}, error: ${e}`, + ) + } + // Unallocate & reallocate actions must target an active allocationID if ([ActionType.UNALLOCATE, ActionType.REALLOCATE].includes(action.type)) { // allocationID must belong to active allocation From 59fffae6c8f159b3965573d6c4f930028ec1ee41 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 12 May 2025 10:56:01 -0300 Subject: [PATCH 11/24] fix: use old validation, lint --- packages/indexer-common/src/actions.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index d91387609..afa265d02 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -1,6 +1,6 @@ import { NetworkMonitor } from './indexer-management' import { AllocationStatus } from './allocations' -import { Logger, SubgraphDeploymentID } from '@graphprotocol/common-ts' +import { Logger } from '@graphprotocol/common-ts' import { WhereOperators, WhereOptions } from 'sequelize' import { Op } from 'sequelize' import { WhereAttributeHashValue } from 'sequelize/types/model' @@ -122,11 +122,13 @@ export const validateActionInputs = async ( ) } - try { - new SubgraphDeploymentID(action.deploymentID) - } catch (e) { - throw new Error( - `Invalid 'deploymentID' value: ${action.deploymentID}, error: ${e}`, + // Action must target an existing subgraph deployment + const subgraphDeployment = await networkMonitor.subgraphDeployment( + action.deploymentID, + ) + if (!subgraphDeployment) { + logger.warn( + `No subgraphDeployment with ipfsHash = '${action.deploymentID}' found on the network`, ) } From 3db46f9e91761d163a23b7d038b82e76623894bf Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 23 May 2025 18:00:51 -0300 Subject: [PATCH 12/24] fix: ensure agreement allocation ids are stored if the allocation confirmation step fails --- packages/indexer-agent/src/agent.ts | 10 ++++++ .../indexer-common/src/indexing-fees/dips.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index f8302f59c..7dfc518da 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -808,6 +808,16 @@ export class Agent { }) return } + + await this.multiNetworks.mapNetworkMapped(activeAllocations, async ({ network, operator }, activeAllocations: Allocation[]) => { + if (network.specification.indexerOptions.enableDips) { + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + this.logger.debug(`Matching agreement allocations for network ${network.specification.networkIdentifier}`) + await operator.dipsManager.matchAgreementAllocations(activeAllocations) + } + }) }, ) } diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index 87380a83f..cb2879251 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -6,6 +6,8 @@ import { toAddress, } from '@graphprotocol/common-ts' import { + ActionStatus, + Allocation, AllocationManager, getEscrowSenderForSigner, GraphNode, @@ -183,6 +185,37 @@ export class DipsManager { (agreement) => new SubgraphDeploymentID(agreement.subgraph_deployment_id), ) } + async matchAgreementAllocations(allocations: Allocation[]) { + const indexingAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: null, + }, + }) + for (const agreement of indexingAgreements) { + const allocation = allocations.find( + (allocation) => allocation.subgraphDeployment.id.toString() === new SubgraphDeploymentID(agreement.subgraph_deployment_id).toString(), + ) + const actions = await this.models.Action.findAll({ + where: { + deploymentID: agreement.subgraph_deployment_id, + status: { + [Op.or]: [ActionStatus.PENDING, ActionStatus.QUEUED, ActionStatus.APPROVED, ActionStatus.DEPLOYING], + } + }, + }) + if (allocation && actions.length === 0) { + const currentAllocationId = agreement.current_allocation_id != null ? toAddress(agreement.current_allocation_id) : null + if (currentAllocationId !== allocation.id) { + this.logger.warn(`Found mismatched allocation for agreement ${agreement.id}, updating from ${currentAllocationId} to ${allocation.id}`) + await this.tryUpdateAgreementAllocation( + agreement.subgraph_deployment_id, + currentAllocationId, + allocation.id, + ) + } + } + } + } } export class DipsCollector { From 3f1016fd51c71b2e1c1d6d61f4e060d51209bc10 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 11:06:41 -0300 Subject: [PATCH 13/24] fix: lint --- packages/indexer-agent/src/agent.ts | 23 ++++++++++++------- .../indexer-common/src/indexing-fees/dips.ts | 22 ++++++++++++++---- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 7dfc518da..c9ded1918 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -809,15 +809,22 @@ export class Agent { return } - await this.multiNetworks.mapNetworkMapped(activeAllocations, async ({ network, operator }, activeAllocations: Allocation[]) => { - if (network.specification.indexerOptions.enableDips) { - if (!operator.dipsManager) { - throw new Error('DipsManager is not available') + await this.multiNetworks.mapNetworkMapped( + activeAllocations, + async ({ network, operator }, activeAllocations: Allocation[]) => { + if (network.specification.indexerOptions.enableDips) { + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + this.logger.debug( + `Matching agreement allocations for network ${network.specification.networkIdentifier}`, + ) + await operator.dipsManager.matchAgreementAllocations( + activeAllocations, + ) } - this.logger.debug(`Matching agreement allocations for network ${network.specification.networkIdentifier}`) - await operator.dipsManager.matchAgreementAllocations(activeAllocations) - } - }) + }, + ) }, ) } diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index cb2879251..ee00deaec 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -193,20 +193,32 @@ export class DipsManager { }) for (const agreement of indexingAgreements) { const allocation = allocations.find( - (allocation) => allocation.subgraphDeployment.id.toString() === new SubgraphDeploymentID(agreement.subgraph_deployment_id).toString(), + (allocation) => + allocation.subgraphDeployment.id.toString() === + new SubgraphDeploymentID(agreement.subgraph_deployment_id).toString(), ) const actions = await this.models.Action.findAll({ where: { deploymentID: agreement.subgraph_deployment_id, status: { - [Op.or]: [ActionStatus.PENDING, ActionStatus.QUEUED, ActionStatus.APPROVED, ActionStatus.DEPLOYING], - } + [Op.or]: [ + ActionStatus.PENDING, + ActionStatus.QUEUED, + ActionStatus.APPROVED, + ActionStatus.DEPLOYING, + ], + }, }, }) if (allocation && actions.length === 0) { - const currentAllocationId = agreement.current_allocation_id != null ? toAddress(agreement.current_allocation_id) : null + const currentAllocationId = + agreement.current_allocation_id != null + ? toAddress(agreement.current_allocation_id) + : null if (currentAllocationId !== allocation.id) { - this.logger.warn(`Found mismatched allocation for agreement ${agreement.id}, updating from ${currentAllocationId} to ${allocation.id}`) + this.logger.warn( + `Found mismatched allocation for agreement ${agreement.id}, updating from ${currentAllocationId} to ${allocation.id}`, + ) await this.tryUpdateAgreementAllocation( agreement.subgraph_deployment_id, currentAllocationId, From f661e3983774476015d9f7accef540e090793177 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 11:53:16 -0300 Subject: [PATCH 14/24] fix: dont requireSupported, use NEVER as blocklist --- .../src/indexer-management/monitor.ts | 2 +- .../indexer-common/src/indexing-fees/dips.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/indexer-common/src/indexer-management/monitor.ts b/packages/indexer-common/src/indexer-management/monitor.ts index c4f2cdfaf..e2f99ac70 100644 --- a/packages/indexer-common/src/indexer-management/monitor.ts +++ b/packages/indexer-common/src/indexer-management/monitor.ts @@ -544,7 +544,7 @@ export class NetworkMonitor { ) return { id: new SubgraphDeploymentID(ipfsHash), - deniedAt: 1, + deniedAt: 1, // We assume the deployment won't be eligible for rewards if it's not found stakedTokens: BigNumber.from(0), signalledTokens: BigNumber.from(0), queryFeesAmount: BigNumber.from(0), diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index ee00deaec..74bbf7b7a 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -150,6 +150,26 @@ export class DipsManager { this.logger, subgraphDeploymentID, ) + // Check if there is an indexing rule saying we should NEVER allocate to this one, consider it blocklisted + const allDeploymentRules = await this.models.IndexingRule.findAll({ + where: { + identifierType: SubgraphIdentifierType.DEPLOYMENT, + }, + }) + const blocklistedRule = allDeploymentRules.find( + (rule) => + new SubgraphDeploymentID(rule.identifier).bytes32 === + subgraphDeploymentID.bytes32 && + rule.decisionBasis === IndexingDecisionBasis.NEVER, + ) + if (blocklistedRule) { + this.logger.info( + `Blocklisted deployment ${subgraphDeploymentID.toString()}, skipping indexing rule creation`, + ) + // TODO: cancel the agreement + return + } + if (!ruleExists) { this.logger.info( `Creating indexing rule for agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, @@ -168,6 +188,7 @@ export class DipsManager { Number(agreement.max_epochs_per_collection) - this.network.specification.indexerOptions.dipsEpochsMargin, ), + requireSupported: false, } as Partial await upsertIndexingRule(this.logger, this.models, indexingRule) From fb378040656642a03d9c6d83a8672cf2e447452f Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 12:26:24 -0300 Subject: [PATCH 15/24] fix: include dips deployments that are not published when evaluating --- packages/indexer-agent/src/agent.ts | 35 ++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index c9ded1918..7df1e6850 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -374,15 +374,34 @@ export class Agent { }, ) - const networkDeployments: Eventual> = + const networkAndDipsDeployments: Eventual> = sequentialTimerMap( { logger, milliseconds: requestIntervalSmall }, async () => - await this.multiNetworks.map(({ network }) => { + await this.multiNetworks.map(async ({ network, operator }) => { logger.trace('Fetching network deployments', { protocolNetwork: network.specification.networkIdentifier, }) - return network.networkMonitor.subgraphDeployments() + const deployments = network.networkMonitor.subgraphDeployments() + if (network.specification.indexerOptions.enableDips) { + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + const resolvedDeployments = await deployments + const dipsDeployments = await Promise.all( + (await operator.dipsManager.getActiveDipsDeployments()).map( + deployment => + network.networkMonitor.subgraphDeployment(deployment.ipfsHash), + ) + ) + for (const deployment of dipsDeployments) { + if (resolvedDeployments.find(d => d.id.bytes32 === deployment.id.bytes32) == null) { + resolvedDeployments.push(deployment) + } + } + return resolvedDeployments + } + return deployments }), { onError: error => @@ -446,13 +465,13 @@ export class Agent { const intermediateNetworkDeploymentAllocationDecisions: Eventual< NetworkMapped > = join({ - networkDeployments, + networkAndDipsDeployments, indexingRules, }).tryMap( - ({ indexingRules, networkDeployments }) => { + ({ indexingRules, networkAndDipsDeployments }) => { return mapValues( - this.multiNetworks.zip(indexingRules, networkDeployments), - ([indexingRules, networkDeployments]: [ + this.multiNetworks.zip(indexingRules, networkAndDipsDeployments), + ([indexingRules, networkAndDipsDeployments]: [ IndexingRuleAttributes[], SubgraphDeployment[], ]) => { @@ -461,7 +480,7 @@ export class Agent { logger.trace('Evaluating which deployments are worth allocating to') return indexingRules.length === 0 ? [] - : evaluateDeployments(logger, networkDeployments, indexingRules) + : evaluateDeployments(logger, networkAndDipsDeployments, indexingRules) }, ) }, From f79ed404a5656c9008523db62e30274fc3e40c4e Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 12:59:43 -0300 Subject: [PATCH 16/24] fix: matching --- packages/indexer-agent/src/agent.ts | 83 +++++++++++-------- .../indexer-common/src/indexing-fees/dips.ts | 18 +++- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index 7df1e6850..c6da44360 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -374,43 +374,50 @@ export class Agent { }, ) - const networkAndDipsDeployments: Eventual> = - sequentialTimerMap( - { logger, milliseconds: requestIntervalSmall }, - async () => - await this.multiNetworks.map(async ({ network, operator }) => { - logger.trace('Fetching network deployments', { - protocolNetwork: network.specification.networkIdentifier, - }) - const deployments = network.networkMonitor.subgraphDeployments() - if (network.specification.indexerOptions.enableDips) { - if (!operator.dipsManager) { - throw new Error('DipsManager is not available') - } - const resolvedDeployments = await deployments - const dipsDeployments = await Promise.all( - (await operator.dipsManager.getActiveDipsDeployments()).map( - deployment => - network.networkMonitor.subgraphDeployment(deployment.ipfsHash), - ) - ) - for (const deployment of dipsDeployments) { - if (resolvedDeployments.find(d => d.id.bytes32 === deployment.id.bytes32) == null) { - resolvedDeployments.push(deployment) - } + const networkAndDipsDeployments: Eventual< + NetworkMapped + > = sequentialTimerMap( + { logger, milliseconds: requestIntervalSmall }, + async () => + await this.multiNetworks.map(async ({ network, operator }) => { + logger.trace('Fetching network deployments', { + protocolNetwork: network.specification.networkIdentifier, + }) + const deployments = network.networkMonitor.subgraphDeployments() + if (network.specification.indexerOptions.enableDips) { + if (!operator.dipsManager) { + throw new Error('DipsManager is not available') + } + const resolvedDeployments = await deployments + const dipsDeployments = await Promise.all( + (await operator.dipsManager.getActiveDipsDeployments()).map( + deployment => + network.networkMonitor.subgraphDeployment( + deployment.ipfsHash, + ), + ), + ) + for (const deployment of dipsDeployments) { + if ( + resolvedDeployments.find( + d => d.id.bytes32 === deployment.id.bytes32, + ) == null + ) { + resolvedDeployments.push(deployment) } - return resolvedDeployments } - return deployments - }), - { - onError: error => - logger.warn( - `Failed to obtain network deployments, trying again later`, - { error }, - ), - }, - ) + return resolvedDeployments + } + return deployments + }), + { + onError: error => + logger.warn( + `Failed to obtain network deployments, trying again later`, + { error }, + ), + }, + ) const eligibleTransferDeployments: Eventual< NetworkMapped @@ -480,7 +487,11 @@ export class Agent { logger.trace('Evaluating which deployments are worth allocating to') return indexingRules.length === 0 ? [] - : evaluateDeployments(logger, networkAndDipsDeployments, indexingRules) + : evaluateDeployments( + logger, + networkAndDipsDeployments, + indexingRules, + ) }, ) }, diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index 74bbf7b7a..5f3b28ca4 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -213,10 +213,14 @@ export class DipsManager { }, }) for (const agreement of indexingAgreements) { + this.logger.trace(`Matching agreement ${agreement.id}`, { + agreement, + allocations, + }) const allocation = allocations.find( (allocation) => - allocation.subgraphDeployment.id.toString() === - new SubgraphDeploymentID(agreement.subgraph_deployment_id).toString(), + allocation.subgraphDeployment.id.bytes32 === + new SubgraphDeploymentID(agreement.subgraph_deployment_id).bytes32, ) const actions = await this.models.Action.findAll({ where: { @@ -231,11 +235,21 @@ export class DipsManager { }, }, }) + this.logger.trace(`Found ${actions.length} actions for agreement ${agreement.id}`, { + actions, + }) if (allocation && actions.length === 0) { const currentAllocationId = agreement.current_allocation_id != null ? toAddress(agreement.current_allocation_id) : null + this.logger.trace( + `Current allocation id for agreement ${agreement.id} is ${currentAllocationId}`, + { + currentAllocationId, + allocation, + }, + ) if (currentAllocationId !== allocation.id) { this.logger.warn( `Found mismatched allocation for agreement ${agreement.id}, updating from ${currentAllocationId} to ${allocation.id}`, From 24d5f9f306fd629d444e2fa3380df611d7962dd0 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 16:51:20 -0300 Subject: [PATCH 17/24] fix: use DIPs-specific decision basis --- .../models/indexing-rule.ts | 1 + .../src/indexing-fees/__tests__/dips.test.ts | 71 ++++++++++++++- .../indexer-common/src/indexing-fees/dips.ts | 89 ++++++++++++++----- packages/indexer-common/src/subgraphs.ts | 9 ++ 4 files changed, 148 insertions(+), 22 deletions(-) diff --git a/packages/indexer-common/src/indexer-management/models/indexing-rule.ts b/packages/indexer-common/src/indexer-management/models/indexing-rule.ts index 76ee2b265..cb0eaebcf 100644 --- a/packages/indexer-common/src/indexer-management/models/indexing-rule.ts +++ b/packages/indexer-common/src/indexer-management/models/indexing-rule.ts @@ -9,6 +9,7 @@ export enum IndexingDecisionBasis { NEVER = 'never', ALWAYS = 'always', OFFCHAIN = 'offchain', + DIPS = 'dips', } export const INDEXING_RULE_GLOBAL = 'global' diff --git a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts index bb784b2ff..370303e8d 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -322,7 +322,7 @@ describe('DipsManager', () => { expect(rules[0]).toMatchObject({ identifier: testDeploymentId, identifierType: SubgraphIdentifierType.DEPLOYMENT, - decisionBasis: IndexingDecisionBasis.ALWAYS, + decisionBasis: IndexingDecisionBasis.DIPS, allocationAmount: network.specification.indexerOptions.dipsAllocationAmount.toString(), autoRenewal: true, @@ -367,6 +367,75 @@ describe('DipsManager', () => { }) }) + test('removes DIPs indexing rule for cancelled agreement', async () => { + await dipsManager.ensureAgreementRules() + const rule = await managementModels.IndexingRule.findOne({ + where: { + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.DIPS, + }, + }) + expect(rule).toBeDefined() + await managementModels.IndexingAgreement.update( + { + cancelled_at: new Date(), + }, + { + where: { id: testAgreementId }, + }, + ) + await dipsManager.ensureAgreementRules() + const ruleAfter = await managementModels.IndexingRule.findOne({ + where: { + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.DIPS, + }, + }) + expect(ruleAfter).toBeNull() + }) + + test('does not remove pre-existing non-DIPS indexing rule', async () => { + // Create an indexing rule with the same identifier + await managementModels.IndexingRule.create({ + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + allocationLifetime: 16, + requireSupported: true, + safety: true, + protocolNetwork: 'eip155:421614', + allocationAmount: '1030', + }) + await dipsManager.ensureAgreementRules() + const ruleBefore = await managementModels.IndexingRule.findOne({ + where: { + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + }, + }) + expect(ruleBefore).toBeDefined() + await managementModels.IndexingAgreement.update( + { + cancelled_at: new Date(), + }, + { + where: { id: testAgreementId }, + }, + ) + await dipsManager.ensureAgreementRules() + const ruleAfter = await managementModels.IndexingRule.findOne({ + where: { + identifier: testDeploymentId, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + }, + }) + expect(ruleAfter).toBeDefined() + }) + test('returns active DIPs deployments', async () => { const deployments = await dipsManager.getActiveDipsDeployments() diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index 5f3b28ca4..acca4787f 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -78,25 +78,30 @@ export class DipsManager { }) if (agreement) { try { - const cancellation = - await this.gatewayDipsServiceMessagesCodec.createSignedCancellationRequest( - uuidToHex(agreement.id), - this.network.wallet, - ) - await this.gatewayDipsServiceClient.CancelAgreement({ - version: 1, - signedCancellation: cancellation, - }) - - // Mark the agreement as cancelled - agreement.cancelled_at = new Date() - agreement.updated_at = new Date() - await agreement.save() + await this._tryCancelAgreement(agreement) } catch (error) { this.logger.error(`Error cancelling agreement ${agreement.id}`, { error }) } } } + async _tryCancelAgreement(agreement: IndexingAgreement) { + try { + const cancellation = + await this.gatewayDipsServiceMessagesCodec.createSignedCancellationRequest( + uuidToHex(agreement.id), + this.network.wallet, + ) + await this.gatewayDipsServiceClient.CancelAgreement({ + version: 1, + signedCancellation: cancellation, + }) + agreement.cancelled_at = new Date() + agreement.updated_at = new Date() + await agreement.save() + } catch (error) { + this.logger.error(`Error cancelling agreement ${agreement.id}`, { error }) + } + } // Update the current and last allocation ids for an agreement if it exists async tryUpdateAgreementAllocation( deploymentId: string, @@ -130,7 +135,7 @@ export class DipsManager { }, }) this.logger.debug( - `Ensuring indexing rules for ${indexingAgreements.length} agreement${ + `Ensuring indexing rules for ${indexingAgreements.length} active agreement${ indexingAgreements.length === 1 ? '' : 's' }`, ) @@ -166,11 +171,8 @@ export class DipsManager { this.logger.info( `Blocklisted deployment ${subgraphDeploymentID.toString()}, skipping indexing rule creation`, ) - // TODO: cancel the agreement - return - } - - if (!ruleExists) { + await this._tryCancelAgreement(agreement) + } else if (!ruleExists) { this.logger.info( `Creating indexing rule for agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, ) @@ -180,7 +182,7 @@ export class DipsManager { this.network.specification.indexerOptions.dipsAllocationAmount, ), identifierType: SubgraphIdentifierType.DEPLOYMENT, - decisionBasis: IndexingDecisionBasis.ALWAYS, + decisionBasis: IndexingDecisionBasis.DIPS, protocolNetwork: this.network.specification.networkIdentifier, autoRenewal: true, allocationLifetime: Math.max( @@ -194,6 +196,51 @@ export class DipsManager { await upsertIndexingRule(this.logger, this.models, indexingRule) } } + + const cancelledAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: { + [Op.ne]: null, + }, + }, + }) + this.logger.debug( + `Ensuring no DIPs indexing rules for ${ + cancelledAgreements.length + } cancelled agreement${cancelledAgreements.length === 1 ? '' : 's'}`, + ) + for (const agreement of cancelledAgreements) { + this.logger.info( + `Checking if indexing rule exists for cancelled agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, + ) + // First check if there is another agreement that is not cancelled that has the same deployment id + const otherAgreement = indexingAgreements.find( + (a) => + a.subgraph_deployment_id === agreement.subgraph_deployment_id && + a.id !== agreement.id, + ) + if (otherAgreement) { + this.logger.info( + `Another agreement ${otherAgreement.id} exists for deployment ${agreement.subgraph_deployment_id}, skipping removal of DIPs indexing rule`, + ) + continue + } + const rule = await this.models.IndexingRule.findOne({ + where: { + identifier: agreement.subgraph_deployment_id, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.DIPS, + }, + }) + if (rule) { + this.logger.info( + `Removing DIPs indexing rule for cancelled agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, + ) + await this.models.IndexingRule.destroy({ + where: { id: rule.id }, + }) + } + } } async getActiveDipsDeployments(): Promise { // Get all the indexing agreements that are not cancelled diff --git a/packages/indexer-common/src/subgraphs.ts b/packages/indexer-common/src/subgraphs.ts index 9ffb3fb06..85c07dc08 100644 --- a/packages/indexer-common/src/subgraphs.ts +++ b/packages/indexer-common/src/subgraphs.ts @@ -157,6 +157,7 @@ export enum ActivationCriteria { OFFCHAIN = 'offchain', INVALID_ALLOCATION_AMOUNT = 'invalid_allocation_amount', L2_TRANSFER_SUPPORT = 'l2_transfer_support', + DIPS = 'dips', } interface RuleMatch { @@ -256,6 +257,14 @@ export function isDeploymentWorthAllocatingTowards( deployment.protocolNetwork, ) + case IndexingDecisionBasis.DIPS: + return new AllocationDecision( + deployment.id, + deploymentRule, + true, + ActivationCriteria.DIPS, + deployment.protocolNetwork, + ) case IndexingDecisionBasis.ALWAYS: return new AllocationDecision( deployment.id, From 931774f1d09954028b17d9641fa28000e1fed604 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 16:58:19 -0300 Subject: [PATCH 18/24] fix: parseDecisionBasis --- packages/indexer-common/src/rules.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/indexer-common/src/rules.ts b/packages/indexer-common/src/rules.ts index e03bfd40e..0d91f8f24 100644 --- a/packages/indexer-common/src/rules.ts +++ b/packages/indexer-common/src/rules.ts @@ -4,9 +4,9 @@ import { parseGRT } from '@graphprotocol/common-ts' import { validateNetworkIdentifier } from './parsers' export const parseDecisionBasis = (s: string): IndexingDecisionBasis => { - if (!['always', 'never', 'rules', 'offchain'].includes(s)) { + if (!['always', 'never', 'rules', 'offchain', 'dips'].includes(s)) { throw new Error( - `Unknown decision basis "${s}". Supported: always, never, rules, offchain`, + `Unknown decision basis "${s}". Supported: always, never, rules, offchain, dips`, ) } else { return s as IndexingDecisionBasis From 6c489c1d06ba81c362dd1b756f3a34674ac9525c Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 17:52:07 -0300 Subject: [PATCH 19/24] fix: migration --- .../19-add-dips-to-decision-basis.ts | 35 +++++++++++++++++++ .../models/indexing-rule.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts diff --git a/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts new file mode 100644 index 000000000..031f5c56f --- /dev/null +++ b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts @@ -0,0 +1,35 @@ +import type { Logger } from '@graphprotocol/common-ts' +import type { QueryInterface } from 'sequelize' + +interface MigrationContext { + queryInterface: QueryInterface + logger: Logger +} + +interface Context { + context: MigrationContext +} + +export async function up({ context }: Context): Promise { + const { queryInterface, logger } = context + + const tables = await queryInterface.showAllTables() + logger.debug('Adding dips to decision basis') + + await queryInterface.sequelize.query( + `ALTER TYPE "enum_IndexingRules_decisionBasis" ADD VALUE 'dips'`, + ) + + logger.info('Migration completed') +} + +export async function down({ context }: Context): Promise { + const { queryInterface, logger } = context + + logger.info('Removing dips from decision basis') + await queryInterface.sequelize.query( + `ALTER TYPE "enum_IndexingRules_decisionBasis" DROP VALUE 'dips'`, + ) + + logger.info('Migration completed') +} diff --git a/packages/indexer-common/src/indexer-management/models/indexing-rule.ts b/packages/indexer-common/src/indexer-management/models/indexing-rule.ts index cb0eaebcf..6f79212d4 100644 --- a/packages/indexer-common/src/indexer-management/models/indexing-rule.ts +++ b/packages/indexer-common/src/indexer-management/models/indexing-rule.ts @@ -246,7 +246,7 @@ export const defineIndexingRuleModels = (sequelize: Sequelize): IndexingRuleMode allowNull: true, }, decisionBasis: { - type: DataTypes.ENUM('rules', 'never', 'always', 'offchain'), + type: DataTypes.ENUM('rules', 'never', 'always', 'offchain', 'dips'), allowNull: false, defaultValue: 'rules', }, From 9903420e1ef30e3d31545ffd339f5356ace41535 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 18:12:15 -0300 Subject: [PATCH 20/24] fix: cleanup --- .../src/db/migrations/19-add-dips-to-decision-basis.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts index 031f5c56f..587dde671 100644 --- a/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts +++ b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts @@ -13,7 +13,6 @@ interface Context { export async function up({ context }: Context): Promise { const { queryInterface, logger } = context - const tables = await queryInterface.showAllTables() logger.debug('Adding dips to decision basis') await queryInterface.sequelize.query( From 76c9975b1d68676aa5841a898073217202cc48d9 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Tue, 27 May 2025 18:19:17 -0300 Subject: [PATCH 21/24] fix: test --- .../indexer-common/src/indexing-fees/__tests__/dips.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts index 370303e8d..be56353f9 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -138,6 +138,7 @@ const ensureGlobalIndexingRule = async () => { const setupEach = async () => { sequelize = await sequelize.sync({ force: true }) + await ensureGlobalIndexingRule() } const teardownEach = async () => { @@ -304,7 +305,6 @@ describe('DipsManager', () => { }) test('creates indexing rules for active agreements', async () => { - await ensureGlobalIndexingRule() // Mock fetch the subgraph deployment from the network subgraph network.networkMonitor.subgraphDeployment = jest .fn() @@ -331,7 +331,6 @@ describe('DipsManager', () => { }) test('does not create or modify an indexing rule if it already exists', async () => { - await ensureGlobalIndexingRule() // Create an indexing rule with the same identifier await managementModels.IndexingRule.create({ identifier: testDeploymentId, From 73245eab6149b6051632db89f522d864f6fbd2e1 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Wed, 28 May 2025 11:39:37 -0300 Subject: [PATCH 22/24] fix: migration --- .../db/migrations/19-add-dips-to-decision-basis.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts index 587dde671..793ef3ab3 100644 --- a/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts +++ b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts @@ -13,11 +13,15 @@ interface Context { export async function up({ context }: Context): Promise { const { queryInterface, logger } = context - logger.debug('Adding dips to decision basis') - - await queryInterface.sequelize.query( - `ALTER TYPE "enum_IndexingRules_decisionBasis" ADD VALUE 'dips'`, - ) + if (await queryInterface.tableExists('IndexingRules')) { + logger.debug('Adding dips to decision basis') + + await queryInterface.sequelize.query( + `ALTER TYPE "enum_IndexingRules_decisionBasis" ADD VALUE 'dips'`, + ) + } else { + logger.debug('IndexingRules table does not exist, skipping migration') + } logger.info('Migration completed') } From 329faaae24806fb3f3fa3f58db5301955f9f3f75 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Wed, 28 May 2025 14:15:45 -0300 Subject: [PATCH 23/24] fix: grapql --- packages/indexer-common/src/indexer-management/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index eec99a68d..2f00d67ba 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -38,6 +38,7 @@ const SCHEMA_SDL = gql` never always offchain + dips } enum IdentifierType { From 70392282251286b253122eaf94bbf6b1329123f7 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 30 May 2025 17:07:05 -0300 Subject: [PATCH 24/24] fix: match allocations for cancelled agreements --- .../indexer-common/src/indexing-fees/dips.ts | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index acca4787f..dc939ba64 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -260,10 +260,7 @@ export class DipsManager { }, }) for (const agreement of indexingAgreements) { - this.logger.trace(`Matching agreement ${agreement.id}`, { - agreement, - allocations, - }) + this.logger.trace(`Matching active agreement ${agreement.id}`) const allocation = allocations.find( (allocation) => allocation.subgraphDeployment.id.bytes32 === @@ -282,9 +279,7 @@ export class DipsManager { }, }, }) - this.logger.trace(`Found ${actions.length} actions for agreement ${agreement.id}`, { - actions, - }) + this.logger.trace(`Found ${actions.length} actions for agreement ${agreement.id}`) if (allocation && actions.length === 0) { const currentAllocationId = agreement.current_allocation_id != null @@ -309,6 +304,54 @@ export class DipsManager { } } } + // Now we find the cancelled agreements and check if their allocation is still active + const cancelledAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: { + [Op.ne]: null, + }, + current_allocation_id: { + [Op.ne]: null, + }, + }, + }) + for (const agreement of cancelledAgreements) { + this.logger.trace(`Matching cancelled agreement ${agreement.id}`) + const allocation = allocations.find( + (allocation) => + allocation.subgraphDeployment.id.bytes32 === + new SubgraphDeploymentID(agreement.subgraph_deployment_id).bytes32, + ) + if (allocation == null && agreement.current_allocation_id != null) { + const actions = await this.models.Action.findAll({ + where: { + deploymentID: agreement.subgraph_deployment_id, + status: { + [Op.or]: [ + ActionStatus.PENDING, + ActionStatus.QUEUED, + ActionStatus.APPROVED, + ActionStatus.DEPLOYING, + ], + }, + }, + }) + if (actions.length > 0) { + this.logger.warn( + `Found active actions for cancelled agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}, skipping matching allocation`, + ) + continue + } + this.logger.info( + `Updating last allocation id for cancelled agreement ${agreement.id}, deployment ${agreement.subgraph_deployment_id}`, + ) + await this.tryUpdateAgreementAllocation( + agreement.subgraph_deployment_id, + toAddress(agreement.current_allocation_id), + null, + ) + } + } } }