From 63204cb06cbecbe98b8cac1c27cfc43f7bdac001 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Thu, 24 Jul 2025 20:01:03 +0000 Subject: [PATCH 1/3] feat: add DIPs (Distributed Indexing Payments) support This squashed commit adds support for DIPs (Distributed Indexing Payments), which allows indexers to receive indexing fees for indexing subgraphs requested via the DIPs system. Key changes: - Add 'dips' as a new IndexingDecisionBasis enum value - Add indexing agreements model and database support - Add DIPs client for interacting with the gateway DIPs service - Support for matching DIPs agreements with allocations - Allow actions on deployments that are not published yet (for DIPs) - Update allocation management to handle DIPs-based allocations - Add proper handling for cancelled agreements Co-authored-by: Multiple contributors from the DIPs development team --- REBASE_STRATEGY.md | 232 +++++++ docs/action-queue.md | 4 +- .../indexer-agent/src/__tests__/indexer.ts | 1 + packages/indexer-agent/src/agent.ts | 166 ++++- packages/indexer-agent/src/commands/start.ts | 36 +- .../19-add-dips-to-decision-basis.ts | 38 ++ 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 | 39 +- .../src/indexer-management/client.ts | 9 +- .../src/indexer-management/models/index.ts | 5 +- .../models/indexing-agreement.ts | 215 +++++++ .../models/indexing-rule.ts | 3 +- .../src/indexer-management/monitor.ts | 11 +- .../resolvers/allocations.ts | 49 +- .../src/indexing-fees/__tests__/dips.test.ts | 599 ++++++++++++++++++ .../indexer-common/src/indexing-fees/dips.ts | 502 +++++++++++++++ .../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 | 98 ++- .../indexer-common/src/query-fees/models.ts | 33 +- packages/indexer-common/src/rules.ts | 4 +- packages/indexer-common/src/subgraphs.ts | 9 + yarn.lock | 125 +++- 33 files changed, 2353 insertions(+), 89 deletions(-) create mode 100644 REBASE_STRATEGY.md create mode 100644 packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts 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/REBASE_STRATEGY.md b/REBASE_STRATEGY.md new file mode 100644 index 000000000..eef3a438d --- /dev/null +++ b/REBASE_STRATEGY.md @@ -0,0 +1,232 @@ +# DIPs-Horizon Rebase Strategy + +## Overview +This document tracks the merge conflict resolution strategy for rebasing DIPs (Distributed Indexing Payments) onto the Horizon branch. + +- **DIPs Branch**: Adds distributed indexing payments functionality +- **Horizon Branch**: Adds Graph Horizon protocol upgrade with GraphTally/RAV v2 + +## Conflict Files (8 total) + +### 1. packages/indexer-common/src/network.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- Imports `GraphTallyCollector` and `encodeRegistrationData` +- Adds `graphTallyCollector: GraphTallyCollector | undefined` property +- Adds `isHorizon: Eventual` property +- Creates GraphTallyCollector instance for RAV v2 + +**DIPs Changes**: +- Imports `DipsCollector` +- Adds `dipsCollector: DipsCollector | undefined` property +- Adds `queryFeeModels: QueryFeeModels` property +- Adds `managementModels: IndexerManagementModels` property + +**Key Conflicts**: +1. Import section (lines 41-46) +2. Class properties (lines 58-72) +3. Constructor parameters (lines 86-92) +4. Constructor body (lines 106-112) +5. Network instantiation (lines 396-399) + +**Resolution Strategy**: +- [ ] Merge both collectors (GraphTallyCollector AND DipsCollector) +- [ ] Keep all properties from both branches +- [ ] Update constructor to accept all parameters +- [ ] Ensure both collectors can be initialized + +--- + +### 2. packages/indexer-common/src/operator.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- `createAllocation` method takes `isHorizon: boolean` parameter +- Uses `isHorizon` to determine `isLegacy` flag on actions +- Passes `isLegacy: !isHorizon` to queueAction +- Also sets `isLegacy: allocation.isLegacy` when closing allocations + +**DIPs Changes**: +- `createAllocation` method takes `forceAction: boolean = false` parameter +- `queueAction` method signature changed to `async queueAction(action: ActionItem, forceAction: boolean = false)` +- Passes forceAction as second parameter to queueAction + +**Key Conflicts**: +1. createAllocation method signature (line 366-370) +2. queueAction calls - Horizon passes object with isLegacy, DIPs passes forceAction as 2nd param +3. closeEligibleAllocations also has forceAction parameter in DIPs +4. refreshExpiredAllocations has similar conflicts + +**Resolution Strategy**: +- [ ] Need both isHorizon AND forceAction parameters in allocation methods +- [ ] Update method signatures: `createAllocation(logger, decision, lastClosed, isHorizon, forceAction = false)` +- [ ] Merge queueAction calls to include both isLegacy (from Horizon) and forceAction (from DIPs) + +--- + +### 3. packages/indexer-common/src/query-fees/models.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- Uses simpler Model type: `extends Model` +- id property is `public id!: number` + +**DIPs Changes**: +- Uses Model with creation attributes: `extends Model` +- id property is `public id!: CreationOptional` + +**Key Conflicts**: +- Single conflict at line 28-37 in ScalarTapReceipts class definition + +**Resolution Strategy**: +- [ ] Use DIPs version (more complete typing with CreationOptional) + +--- + +### 4. packages/indexer-common/package.json +**Status**: ❌ Unresolved + +**Horizon Changes**: +- `@graphprotocol/common-ts`: "3.0.1" (newer) +- `@graphprotocol/toolshed`: "0.6.5" +- `@semiotic-labs/tap-contracts-bindings`: "2.0.0" (newer) + +**DIPs Changes**: +- `@graphprotocol/common-ts`: "2.0.11" (older) +- `@semiotic-labs/tap-contracts-bindings`: "^1.2.1" (older) +- Adds DIPs-specific dependencies: + - `@bufbuild/protobuf`: "2.2.3" + - `@graphprotocol/dips-proto`: "0.2.2" + - `@grpc/grpc-js`: "^1.12.6" + +**Key Conflicts**: +- Dependency version mismatches + +**Resolution Strategy**: +- [ ] Use Horizon's newer versions +- [ ] Add DIPs-specific dependencies + +--- + +### 5. packages/indexer-common/src/indexer-management/allocations.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- Empty constructor body + +**DIPs Changes**: +- Constructor initializes DipsManager if dipperEndpoint is configured +- Adds `dipsManager: DipsManager | null` property + +**Key Conflicts**: +- Constructor body (lines 131-139) + +**Resolution Strategy**: +- [ ] Keep DIPs initialization logic + +--- + +### 6. packages/indexer-common/src/indexer-management/resolvers/allocations.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- Destructures `graphNode` from resolver context + +**DIPs Changes**: +- Destructures `actionManager` from resolver context + +**Key Conflicts**: +- reallocateAllocation resolver context destructuring (lines 1720-1724) + +**Resolution Strategy**: +- [ ] Include BOTH in destructuring: `{ logger, models, multiNetworks, graphNode, actionManager }` +- [ ] The IndexerManagementResolverContext interface already has both properties + +--- + +### 7. packages/indexer-agent/src/agent.ts +**Status**: ❌ Unresolved + +**Horizon Changes**: +- Passes `isHorizon` to createAllocation + +**DIPs Changes**: +- Passes `forceAction` to createAllocation + +**Key Conflicts**: +- createAllocation call (lines 1243-1247) + +**Resolution Strategy**: +- [ ] Pass both parameters: `createAllocation(logger, decision, lastClosed, isHorizon, forceAction)` + +--- + +### 8. yarn.lock +**Status**: ❌ Unresolved + +**Resolution Strategy**: +- [ ] Will regenerate after resolving package.json conflicts + +--- + +## General Notes +- Both branches introduce different payment/collection systems that need to coexist +- Horizon introduces protocol upgrade detection and legacy/horizon mode switching +- DIPs introduces indexing agreements and gateway payment integration + +## Important Context +- **Current branch**: dips-horizon-rebase +- **Base commit**: Squashed DIPs changes into single commit (35ceac2a) on top of 32d8f174 +- **Rebase status**: `git rebase origin/horizon` in progress with conflicts +- **To continue rebase**: After resolving conflicts, use `git add ` then `git rebase --continue` +- **To abort**: `git rebase --abort` if needed + +## Key Files/Imports Added by Each Branch +**Horizon**: +- `GraphTallyCollector` from './allocations/graph-tally-collector' +- `encodeRegistrationData` from '@graphprotocol/toolshed' +- `isHorizon` property for protocol upgrade detection +- `isLegacy` flag on actions + +**DIPs**: +- `DipsCollector` from './indexing-fees/dips' +- `DipsManager` class +- DIPs-specific dependencies in package.json +- `forceAction` parameter for manual allocation management +- New directory: `indexing-fees/` with DIPs implementation + +## Important Note: Method Call Analysis + +**Call sites found for modified methods:** +- `createAllocation`: Only 1 call in agent.ts (already in conflict) +- `closeEligibleAllocations`: 2 calls in agent.ts (already have forceAction parameter) +- `refreshExpiredAllocations`: 1 call in agent.ts (already has forceAction parameter) +- `queueAction`: 5 calls in operator.ts (all in conflicts) + +**Good news**: All method calls appear to be either: +1. Already in merge conflicts (so we'll handle them) +2. Already updated with the DIPs parameters (forceAction) + +**Action needed**: When resolving conflicts, ensure we add BOTH parameters where needed. + +## Resolution Summary + +### High Priority Decisions Needed: +1. **Method Signatures**: Most conflicts are about method parameters. We need both `isHorizon` (from Horizon) AND `forceAction` (from DIPs) +2. **Collectors**: We need both GraphTallyCollector (Horizon) and DipsCollector (DIPs) to coexist +3. **Dependencies**: Use Horizon's newer versions but add DIPs-specific dependencies + +### Recommended Approach: +1. Start with package.json - merge dependencies +2. Fix network.ts - ensure both collectors can exist +3. Fix operator.ts - update method signatures to accept both parameters +4. Fix agent.ts - pass both parameters +5. Fix remaining files with minor conflicts +6. Regenerate yarn.lock + +### Key Principle: +Both payment systems (Horizon's GraphTally and DIPs) should coexist. The system should support: +- Legacy allocations (pre-Horizon) +- Horizon allocations (with GraphTally/RAV v2) +- DIPs agreements (with distributed indexing payments) \ No newline at end of file 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 7ddfb0175..034f20ebf 100644 --- a/packages/indexer-agent/src/__tests__/indexer.ts +++ b/packages/indexer-agent/src/__tests__/indexer.ts @@ -147,6 +147,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 cd3e25737..bc034b80f 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -298,6 +298,20 @@ 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.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, }) @@ -331,12 +345,21 @@ export class Agent { }, ) - // 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) { + 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( @@ -345,7 +368,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 [] } @@ -358,24 +381,50 @@ export class Agent { }, ) - const networkDeployments: Eventual> = - sequentialTimerMap( - { logger, milliseconds: requestIntervalSmall }, - async () => - await this.multiNetworks.map(({ network }) => { - logger.trace('Fetching network deployments', { - protocolNetwork: network.specification.networkIdentifier, - }) - return network.networkMonitor.subgraphDeployments() - }), - { - onError: error => - logger.warn( - `Failed to obtain network deployments, trying again later`, - { error }, - ), - }, - ) + 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 }, + ), + }, + ) const eligibleTransferDeployments: Eventual< NetworkMapped @@ -430,13 +479,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[], ]) => { @@ -445,7 +494,11 @@ export class Agent { logger.trace('Evaluating which deployments are worth allocating to') return indexingRules.length === 0 ? [] - : evaluateDeployments(logger, networkDeployments, indexingRules) + : evaluateDeployments( + logger, + networkAndDipsDeployments, + indexingRules, + ) }, ) }, @@ -737,9 +790,42 @@ export class Agent { } break case DeploymentManagementMode.MANUAL: - this.logger.debug( - `Skipping subgraph deployment reconciliation since DeploymentManagementMode = 'manual'`, - ) + 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.`, + ) + 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( @@ -760,6 +846,23 @@ 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, + ) + } + }, + ) }, ) } @@ -1076,6 +1179,7 @@ export class Agent { maxAllocationDuration: HorizonTransitionValue, network: Network, operator: Operator, + forceAction: boolean = false, ): Promise { const logger = this.logger.child({ deployment: deploymentAllocationDecision.deployment.ipfsHash, @@ -1099,6 +1203,7 @@ export class Agent { logger, deploymentAllocationDecision, activeDeploymentAllocations, + forceAction, ) case true: { // If no active allocations and subgraph health passes safety check, create one @@ -1136,6 +1241,7 @@ export class Agent { deploymentAllocationDecision, mostRecentlyClosedAllocation, isHorizon, + forceAction, ) } } else if (activeDeploymentAllocations.length > 0) { @@ -1144,6 +1250,7 @@ export class Agent { logger, deploymentAllocationDecision, activeDeploymentAllocations, + forceAction, ) } else { // Refresh any expiring allocations @@ -1160,6 +1267,7 @@ export class Agent { logger, deploymentAllocationDecision, expiringAllocations, + forceAction, ) } } diff --git a/packages/indexer-agent/src/commands/start.ts b/packages/indexer-agent/src/commands/start.ts index c5e1c4a54..1ece72417 100644 --- a/packages/indexer-agent/src/commands/start.ts +++ b/packages/indexer-agent/src/commands/start.ts @@ -327,6 +327,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'] && @@ -354,6 +374,9 @@ 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 }) }, @@ -391,6 +414,10 @@ export async function createNetworkSpecification( register: argv.register, maxProvisionInitialSize: argv.maxProvisionInitialSize, finalityTime: argv.chainFinalizeTime, + enableDips: argv.enableDips, + dipperEndpoint: argv.dipperEndpoint, + dipsAllocationAmount: argv.dipsAllocationAmount, + dipsEpochsMargin: argv.dipsEpochsMargin, } const transactionMonitoring = { @@ -611,7 +638,14 @@ 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, + ), ) // -------------------------------------------------------------------------------- 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..793ef3ab3 --- /dev/null +++ b/packages/indexer-agent/src/db/migrations/19-add-dips-to-decision-basis.ts @@ -0,0 +1,38 @@ +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 + + 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') +} + +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-cli/src/__tests__/util.ts b/packages/indexer-cli/src/__tests__/util.ts index 41df97e13..51dc630bf 100644 --- a/packages/indexer-cli/src/__tests__/util.ts +++ b/packages/indexer-cli/src/__tests__/util.ts @@ -108,6 +108,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 5b50ff9e5..7f0ae968b 100644 --- a/packages/indexer-common/package.json +++ b/packages/indexer-common/package.json @@ -23,10 +23,13 @@ }, "dependencies": { "@pinax/graph-networks-registry": "0.6.7", + "@bufbuild/protobuf": "2.2.3", "@graphprotocol/common-ts": "3.0.1", + "@graphprotocol/dips-proto": "0.2.2", "@graphprotocol/horizon": "0.4.1", "@graphprotocol/subgraph-service": "0.4.1", "@graphprotocol/toolshed": "0.6.5", + "@grpc/grpc-js": "^1.12.6", "@semiotic-labs/tap-contracts-bindings": "2.0.0", "@thi.ng/heaps": "1.2.38", "@types/lodash.clonedeep": "^4.5.7", diff --git a/packages/indexer-common/src/allocations/__tests__/tap.test.ts b/packages/indexer-common/src/allocations/__tests__/tap.test.ts index b13ecace6..16ded8f55 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 8b2f53191..ab80e44cc 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 144f63b45..210e2cefc 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, @@ -121,12 +122,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[], @@ -770,6 +776,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', @@ -994,6 +1008,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', @@ -1451,6 +1474,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', @@ -1600,11 +1631,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/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 88a2ff93d..a7c30edd1 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -44,6 +44,7 @@ const SCHEMA_SDL = gql` never always offchain + dips } enum IdentifierType { @@ -538,9 +539,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 @@ -549,6 +552,7 @@ export class IndexerManagementClient extends Client { this.logger = options.logger this.models = options.models + this.actionManager = options.actionManager } } @@ -593,5 +597,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..462456a7c --- /dev/null +++ b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts @@ -0,0 +1,215 @@ +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, + tableName: 'indexing_agreements', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + ) + + return { + ['IndexingAgreement']: IndexingAgreement, + } +} 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 594191537..6ee5fca39 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' @@ -245,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', }, diff --git a/packages/indexer-common/src/indexer-management/monitor.ts b/packages/indexer-common/src/indexer-management/monitor.ts index 845a973f7..3a9fdfa75 100644 --- a/packages/indexer-common/src/indexer-management/monitor.ts +++ b/packages/indexer-common/src/indexer-management/monitor.ts @@ -644,7 +644,7 @@ export class NetworkMonitor { return subgraphs } - async subgraphDeployment(ipfsHash: string): Promise { + async subgraphDeployment(ipfsHash: string): Promise { try { const result = await this.networkSubgraph.checkedQuery( gql` @@ -681,7 +681,14 @@ export class NetworkMonitor { this.logger.warn( `SubgraphDeployment with ipfsHash = ${ipfsHash} not found on chain`, ) - return undefined + return { + id: new SubgraphDeploymentID(ipfsHash), + 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), + protocolNetwork: this.networkCAIPID, + } } return parseGraphQLSubgraphDeployment( diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index dda8f24d0..c1c076f8b 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -1432,7 +1432,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, @@ -1529,6 +1535,16 @@ export default { await models.IndexingRule.upsert(indexingRule) + const allocationManager = + actionManager?.allocationManagers[network.specification.networkIdentifier] + if (allocationManager?.dipsManager) { + await 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 }, @@ -1572,7 +1588,7 @@ export default { force: boolean protocolNetwork: string }, - { logger, models, multiNetworks }: IndexerManagementResolverContext, + { logger, models, multiNetworks, actionManager }: IndexerManagementResolverContext, ): Promise => { logger.debug('Execute closeAllocation() mutation', { allocationID: allocation, @@ -1648,6 +1664,17 @@ export default { await models.IndexingRule.upsert(offchainIndexingRule) + 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, + ) + } + // Since upsert succeeded, we _must_ have a rule const updatedRule = await models.IndexingRule.findOne({ where: { identifier: offchainIndexingRule.identifier }, @@ -1690,7 +1717,13 @@ export default { force: boolean protocolNetwork: string }, - { logger, models, multiNetworks, graphNode }: IndexerManagementResolverContext, + { + logger, + models, + multiNetworks, + graphNode, + actionManager, + }: IndexerManagementResolverContext, ): Promise => { logger = logger.child({ component: 'reallocateAllocationResolver', @@ -1813,6 +1846,16 @@ export default { await models.IndexingRule.upsert(indexingRule) + const allocationManager = + actionManager?.allocationManagers[network.specification.networkIdentifier] + if (allocationManager?.dipsManager) { + await 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..be56353f9 --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -0,0 +1,599 @@ +import { + DipsManager, + GraphNode, + IndexerManagementModels, + Network, + QueryFeeModels, + defineIndexerManagementModels, + defineQueryFeeModels, + SubgraphIdentifierType, + IndexingDecisionBasis, + AllocationManager, + DipsCollector, + TapCollector, + createIndexerManagementClient, + Operator, + ActionManager, + IndexerManagementClient, + MultiNetworks, +} 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 multiNetworks: MultiNetworks +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', + 'https://test-ipfs-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, + ) + + multiNetworks = new MultiNetworks( + [network], + (n: Network) => n.specification.networkIdentifier, + ) + + dipsCollector = network.dipsCollector! + indexerManagementClient = await createIndexerManagementClient({ + models: managementModels, + graphNode, + logger, + defaults: { + globalIndexingRule: { + allocationAmount: parseGRT('1000'), + parallelAllocations: 1, + }, + }, + multiNetworks, + }) + + 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 }) + await ensureGlobalIndexingRule() +} + +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 () => { + // 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.DIPS, + 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 () => { + // 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('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() + + 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..dc939ba64 --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -0,0 +1,502 @@ +import { + Address, + formatGRT, + Logger, + SubgraphDeploymentID, + toAddress, +} from '@graphprotocol/common-ts' +import { + ActionStatus, + Allocation, + 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 { + 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, + 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, + }, + }) + this.logger.debug( + `Ensuring indexing rules for ${indexingAgreements.length} active 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) { + 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, + ) + // 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`, + ) + await this._tryCancelAgreement(agreement) + } else 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.DIPS, + 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, + ), + requireSupported: false, + } as Partial + + 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 + const indexingAgreements = await this.models.IndexingAgreement.findAll({ + where: { + cancelled_at: null, + }, + }) + return indexingAgreements.map( + (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) { + this.logger.trace(`Matching active agreement ${agreement.id}`) + const allocation = allocations.find( + (allocation) => + allocation.subgraphDeployment.id.bytes32 === + new SubgraphDeploymentID(agreement.subgraph_deployment_id).bytes32, + ) + const actions = await this.models.Action.findAll({ + where: { + deploymentID: agreement.subgraph_deployment_id, + status: { + [Op.or]: [ + ActionStatus.PENDING, + ActionStatus.QUEUED, + ActionStatus.APPROVED, + ActionStatus.DEPLOYING, + ], + }, + }, + }) + this.logger.trace(`Found ${actions.length} actions for agreement ${agreement.id}`) + 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}`, + ) + await this.tryUpdateAgreementAllocation( + agreement.subgraph_deployment_id, + currentAllocationId, + allocation.id, + ) + } + } + } + // 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, + ) + } + } + } +} + +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 cfcad72d1..5610231b9 100644 --- a/packages/indexer-common/src/network-specification.ts +++ b/packages/indexer-common/src/network-specification.ts @@ -69,6 +69,10 @@ export const IndexerOptions = z }) .default(0), 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 84689e513..e4f12f0fa 100644 --- a/packages/indexer-common/src/network.ts +++ b/packages/indexer-common/src/network.ts @@ -32,6 +32,7 @@ import { NetworkMonitor, SubgraphFreshnessChecker, monitorEligibleAllocations, + IndexerManagementModels, } from '.' import { resolveChainId } from './indexer-management' import { monitorEthBalance } from './utils' @@ -39,6 +40,7 @@ import { QueryFeeModels } from './query-fees' import { TapCollector } from './allocations/tap-collector' import { GraphTallyCollector } from './allocations/graph-tally-collector' import { encodeRegistrationData } from '@graphprotocol/toolshed' +import { DipsCollector } from './indexing-fees/dips' export class Network { logger: Logger @@ -51,11 +53,13 @@ export class Network { tapCollector: TapCollector | undefined graphTallyCollector: GraphTallyCollector | undefined + dipsCollector: DipsCollector | undefined specification: spec.NetworkSpecification paused: Eventual isOperator: Eventual isHorizon: Eventual - + queryFeeModels: QueryFeeModels + managementModels: IndexerManagementModels private constructor( logger: Logger, contracts: GraphHorizonContracts & SubgraphServiceContracts, @@ -70,6 +74,9 @@ export class Network { paused: Eventual, isOperator: Eventual, isHorizon: Eventual, + queryFeeModels: QueryFeeModels, + managementModels: IndexerManagementModels, + dipsCollector: DipsCollector | undefined, ) { this.logger = logger this.contracts = contracts @@ -84,11 +91,15 @@ export class Network { this.paused = paused this.isOperator = isOperator this.isHorizon = isHorizon + 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, @@ -298,6 +309,7 @@ export class Network { // * TAP Collector // -------------------------------------------------------------------------------- let tapCollector: TapCollector | undefined = undefined + let dipsCollector: DipsCollector | undefined = undefined if (tapContracts && tapSubgraph) { tapCollector = TapCollector.create({ logger, @@ -310,8 +322,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}.`) } @@ -354,6 +377,9 @@ export class Network { paused, isOperator, isHorizon, + queryFeeModels, + managementModels, + dipsCollector, ) } diff --git a/packages/indexer-common/src/operator.ts b/packages/indexer-common/src/operator.ts index b83c2ba1e..f85905aaf 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 { hexlify } from 'ethers' @@ -82,6 +83,13 @@ export class Operator { this.specification = specification } + get dipsManager(): DipsManager | null { + const network = this.specification.networkIdentifier + const allocationManager = + this.indexerManagement.actionManager?.allocationManagers[network] + return allocationManager?.dipsManager ?? null + } + // -------------------------------------------------------------------------------- // * Indexing Rules // -------------------------------------------------------------------------------- @@ -260,16 +268,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 = { @@ -346,6 +364,7 @@ export class Operator { deploymentAllocationDecision: AllocationDecision, mostRecentlyClosedAllocation: Allocation | undefined, isHorizon: boolean, + forceAction: boolean = false, ): Promise { const desiredAllocationAmount = deploymentAllocationDecision.ruleMatch.rule ?.allocationAmount @@ -375,16 +394,19 @@ export class Operator { } // Send AllocateAction to the queue - isLegacy value depends on the horizon upgrade - 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, + isLegacy: !isHorizon, }, - type: ActionType.ALLOCATE, - reason: deploymentAllocationDecision.reasonString(), - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - isLegacy: !isHorizon, - }) + forceAction, + ) return } @@ -393,6 +415,7 @@ export class Operator { logger: Logger, deploymentAllocationDecision: AllocationDecision, activeDeploymentAllocations: Allocation[], + forceAction: boolean = false, ): Promise { // Make sure to close all active allocations on the way out if (activeDeploymentAllocations.length > 0) { @@ -409,18 +432,21 @@ export class Operator { activeDeploymentAllocations, async (allocation) => { // Send unallocate action to the queue - isLegacy value depends on the allocation being closed - await this.queueAction({ - params: { - allocationID: allocation.id, - deploymentID: deploymentAllocationDecision.deployment.ipfsHash, - poi: undefined, - force: false, - }, - type: ActionType.UNALLOCATE, - reason: deploymentAllocationDecision.reasonString(), - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - isLegacy: allocation.isLegacy, - } as ActionItem) + await this.queueAction( + { + params: { + allocationID: allocation.id, + deploymentID: deploymentAllocationDecision.deployment.ipfsHash, + poi: undefined, + force: false, + }, + type: ActionType.UNALLOCATE, + reason: deploymentAllocationDecision.reasonString(), + protocolNetwork: deploymentAllocationDecision.protocolNetwork, + isLegacy: allocation.isLegacy, + } as ActionItem, + forceAction, + ) }, { concurrency: 1 }, ) @@ -431,6 +457,7 @@ export class Operator { logger: Logger, deploymentAllocationDecision: AllocationDecision, expiredAllocations: Allocation[], + forceAction: boolean = false, ): Promise { if (deploymentAllocationDecision.ruleMatch.rule?.autoRenewal) { logger.info(`Reallocating expired allocations`, { @@ -448,17 +475,20 @@ 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, + isLegacy: allocation.isLegacy, }, - type: ActionType.REALLOCATE, - reason: `${deploymentAllocationDecision.reasonString()}:allocationExpiring`, // Need to update to include 'ExpiringSoon' - protocolNetwork: deploymentAllocationDecision.protocolNetwork, - isLegacy: allocation.isLegacy, - }) + forceAction, + ) }, { stopOnError: false, diff --git a/packages/indexer-common/src/query-fees/models.ts b/packages/indexer-common/src/query-fees/models.ts index 78570cb95..e3c9c328c 100644 --- a/packages/indexer-common/src/query-fees/models.ts +++ b/packages/indexer-common/src/query-fees/models.ts @@ -7,19 +7,28 @@ import { BytesLike } from 'ethers' 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 @@ -926,10 +935,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/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 diff --git a/packages/indexer-common/src/subgraphs.ts b/packages/indexer-common/src/subgraphs.ts index 02a47292b..9c8fb29a1 100644 --- a/packages/indexer-common/src/subgraphs.ts +++ b/packages/indexer-common/src/subgraphs.ts @@ -152,6 +152,7 @@ export enum ActivationCriteria { OFFCHAIN = 'offchain', INVALID_ALLOCATION_AMOUNT = 'invalid_allocation_amount', L2_TRANSFER_SUPPORT = 'l2_transfer_support', + DIPS = 'dips', } interface RuleMatch { @@ -251,6 +252,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, diff --git a/yarn.lock b/yarn.lock index d5d4f7c57..8c9d7cbf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -286,6 +286,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" @@ -568,6 +573,13 @@ prom-client "14.2.0" sequelize "6.33.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/horizon@0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@graphprotocol/horizon/-/horizon-0.4.1.tgz#b29f0944eeb9d4b50fc586e533904b9e51f18ade" @@ -606,6 +618,24 @@ hardhat "^2.22.16" json5 "^2.2.3" +"@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" @@ -892,6 +922,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.yarnpkg.com/@lerna/add/-/add-6.1.0.tgz#0f09495c5e1af4c4f316344af34b6d1a91b15b19" @@ -2162,6 +2197,59 @@ 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.13.0": version "5.13.0" resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.13.0.tgz#f79d6868b74be102eee75b93c37be45fb9b47ead" @@ -2769,6 +2857,13 @@ dependencies: undici-types "~6.19.2" +"@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.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" @@ -7802,6 +7897,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.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -9380,6 +9480,24 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" 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.2" resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.2.tgz#822e8fcdcb3df5356538b3e91bfd890b067fd0a4" @@ -10962,6 +11080,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +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== + undici-types@~6.21.0: version "6.21.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" @@ -11450,7 +11573,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.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From b60f396d6a062457a93de6ef8e74e7f2440d964c Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 25 Jul 2025 17:45:55 +0000 Subject: [PATCH 2/3] feat: complete DIPs integration with ethers v6 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated all DIPs-related code to use ethers v6 API - Migrated from BigNumberish to bigint for all numeric operations - Fixed provider and signer initialization patterns - Updated test suite to use new ethers v6 patterns - Removed temporary migration documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- REBASE_STRATEGY.md | 232 ------------------ .../src/indexer-management/monitor.ts | 6 +- .../resolvers/allocations.ts | 4 +- .../src/indexing-fees/__tests__/dips.test.ts | 7 +- .../indexer-common/src/indexing-fees/dips.ts | 8 +- .../gateway-dips-service-client.ts | 31 ++- 6 files changed, 26 insertions(+), 262 deletions(-) delete mode 100644 REBASE_STRATEGY.md diff --git a/REBASE_STRATEGY.md b/REBASE_STRATEGY.md deleted file mode 100644 index eef3a438d..000000000 --- a/REBASE_STRATEGY.md +++ /dev/null @@ -1,232 +0,0 @@ -# DIPs-Horizon Rebase Strategy - -## Overview -This document tracks the merge conflict resolution strategy for rebasing DIPs (Distributed Indexing Payments) onto the Horizon branch. - -- **DIPs Branch**: Adds distributed indexing payments functionality -- **Horizon Branch**: Adds Graph Horizon protocol upgrade with GraphTally/RAV v2 - -## Conflict Files (8 total) - -### 1. packages/indexer-common/src/network.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- Imports `GraphTallyCollector` and `encodeRegistrationData` -- Adds `graphTallyCollector: GraphTallyCollector | undefined` property -- Adds `isHorizon: Eventual` property -- Creates GraphTallyCollector instance for RAV v2 - -**DIPs Changes**: -- Imports `DipsCollector` -- Adds `dipsCollector: DipsCollector | undefined` property -- Adds `queryFeeModels: QueryFeeModels` property -- Adds `managementModels: IndexerManagementModels` property - -**Key Conflicts**: -1. Import section (lines 41-46) -2. Class properties (lines 58-72) -3. Constructor parameters (lines 86-92) -4. Constructor body (lines 106-112) -5. Network instantiation (lines 396-399) - -**Resolution Strategy**: -- [ ] Merge both collectors (GraphTallyCollector AND DipsCollector) -- [ ] Keep all properties from both branches -- [ ] Update constructor to accept all parameters -- [ ] Ensure both collectors can be initialized - ---- - -### 2. packages/indexer-common/src/operator.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- `createAllocation` method takes `isHorizon: boolean` parameter -- Uses `isHorizon` to determine `isLegacy` flag on actions -- Passes `isLegacy: !isHorizon` to queueAction -- Also sets `isLegacy: allocation.isLegacy` when closing allocations - -**DIPs Changes**: -- `createAllocation` method takes `forceAction: boolean = false` parameter -- `queueAction` method signature changed to `async queueAction(action: ActionItem, forceAction: boolean = false)` -- Passes forceAction as second parameter to queueAction - -**Key Conflicts**: -1. createAllocation method signature (line 366-370) -2. queueAction calls - Horizon passes object with isLegacy, DIPs passes forceAction as 2nd param -3. closeEligibleAllocations also has forceAction parameter in DIPs -4. refreshExpiredAllocations has similar conflicts - -**Resolution Strategy**: -- [ ] Need both isHorizon AND forceAction parameters in allocation methods -- [ ] Update method signatures: `createAllocation(logger, decision, lastClosed, isHorizon, forceAction = false)` -- [ ] Merge queueAction calls to include both isLegacy (from Horizon) and forceAction (from DIPs) - ---- - -### 3. packages/indexer-common/src/query-fees/models.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- Uses simpler Model type: `extends Model` -- id property is `public id!: number` - -**DIPs Changes**: -- Uses Model with creation attributes: `extends Model` -- id property is `public id!: CreationOptional` - -**Key Conflicts**: -- Single conflict at line 28-37 in ScalarTapReceipts class definition - -**Resolution Strategy**: -- [ ] Use DIPs version (more complete typing with CreationOptional) - ---- - -### 4. packages/indexer-common/package.json -**Status**: ❌ Unresolved - -**Horizon Changes**: -- `@graphprotocol/common-ts`: "3.0.1" (newer) -- `@graphprotocol/toolshed`: "0.6.5" -- `@semiotic-labs/tap-contracts-bindings`: "2.0.0" (newer) - -**DIPs Changes**: -- `@graphprotocol/common-ts`: "2.0.11" (older) -- `@semiotic-labs/tap-contracts-bindings`: "^1.2.1" (older) -- Adds DIPs-specific dependencies: - - `@bufbuild/protobuf`: "2.2.3" - - `@graphprotocol/dips-proto`: "0.2.2" - - `@grpc/grpc-js`: "^1.12.6" - -**Key Conflicts**: -- Dependency version mismatches - -**Resolution Strategy**: -- [ ] Use Horizon's newer versions -- [ ] Add DIPs-specific dependencies - ---- - -### 5. packages/indexer-common/src/indexer-management/allocations.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- Empty constructor body - -**DIPs Changes**: -- Constructor initializes DipsManager if dipperEndpoint is configured -- Adds `dipsManager: DipsManager | null` property - -**Key Conflicts**: -- Constructor body (lines 131-139) - -**Resolution Strategy**: -- [ ] Keep DIPs initialization logic - ---- - -### 6. packages/indexer-common/src/indexer-management/resolvers/allocations.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- Destructures `graphNode` from resolver context - -**DIPs Changes**: -- Destructures `actionManager` from resolver context - -**Key Conflicts**: -- reallocateAllocation resolver context destructuring (lines 1720-1724) - -**Resolution Strategy**: -- [ ] Include BOTH in destructuring: `{ logger, models, multiNetworks, graphNode, actionManager }` -- [ ] The IndexerManagementResolverContext interface already has both properties - ---- - -### 7. packages/indexer-agent/src/agent.ts -**Status**: ❌ Unresolved - -**Horizon Changes**: -- Passes `isHorizon` to createAllocation - -**DIPs Changes**: -- Passes `forceAction` to createAllocation - -**Key Conflicts**: -- createAllocation call (lines 1243-1247) - -**Resolution Strategy**: -- [ ] Pass both parameters: `createAllocation(logger, decision, lastClosed, isHorizon, forceAction)` - ---- - -### 8. yarn.lock -**Status**: ❌ Unresolved - -**Resolution Strategy**: -- [ ] Will regenerate after resolving package.json conflicts - ---- - -## General Notes -- Both branches introduce different payment/collection systems that need to coexist -- Horizon introduces protocol upgrade detection and legacy/horizon mode switching -- DIPs introduces indexing agreements and gateway payment integration - -## Important Context -- **Current branch**: dips-horizon-rebase -- **Base commit**: Squashed DIPs changes into single commit (35ceac2a) on top of 32d8f174 -- **Rebase status**: `git rebase origin/horizon` in progress with conflicts -- **To continue rebase**: After resolving conflicts, use `git add ` then `git rebase --continue` -- **To abort**: `git rebase --abort` if needed - -## Key Files/Imports Added by Each Branch -**Horizon**: -- `GraphTallyCollector` from './allocations/graph-tally-collector' -- `encodeRegistrationData` from '@graphprotocol/toolshed' -- `isHorizon` property for protocol upgrade detection -- `isLegacy` flag on actions - -**DIPs**: -- `DipsCollector` from './indexing-fees/dips' -- `DipsManager` class -- DIPs-specific dependencies in package.json -- `forceAction` parameter for manual allocation management -- New directory: `indexing-fees/` with DIPs implementation - -## Important Note: Method Call Analysis - -**Call sites found for modified methods:** -- `createAllocation`: Only 1 call in agent.ts (already in conflict) -- `closeEligibleAllocations`: 2 calls in agent.ts (already have forceAction parameter) -- `refreshExpiredAllocations`: 1 call in agent.ts (already has forceAction parameter) -- `queueAction`: 5 calls in operator.ts (all in conflicts) - -**Good news**: All method calls appear to be either: -1. Already in merge conflicts (so we'll handle them) -2. Already updated with the DIPs parameters (forceAction) - -**Action needed**: When resolving conflicts, ensure we add BOTH parameters where needed. - -## Resolution Summary - -### High Priority Decisions Needed: -1. **Method Signatures**: Most conflicts are about method parameters. We need both `isHorizon` (from Horizon) AND `forceAction` (from DIPs) -2. **Collectors**: We need both GraphTallyCollector (Horizon) and DipsCollector (DIPs) to coexist -3. **Dependencies**: Use Horizon's newer versions but add DIPs-specific dependencies - -### Recommended Approach: -1. Start with package.json - merge dependencies -2. Fix network.ts - ensure both collectors can exist -3. Fix operator.ts - update method signatures to accept both parameters -4. Fix agent.ts - pass both parameters -5. Fix remaining files with minor conflicts -6. Regenerate yarn.lock - -### Key Principle: -Both payment systems (Horizon's GraphTally and DIPs) should coexist. The system should support: -- Legacy allocations (pre-Horizon) -- Horizon allocations (with GraphTally/RAV v2) -- DIPs agreements (with distributed indexing payments) \ No newline at end of file diff --git a/packages/indexer-common/src/indexer-management/monitor.ts b/packages/indexer-common/src/indexer-management/monitor.ts index 3a9fdfa75..b8c2c3741 100644 --- a/packages/indexer-common/src/indexer-management/monitor.ts +++ b/packages/indexer-common/src/indexer-management/monitor.ts @@ -684,9 +684,9 @@ export class NetworkMonitor { return { id: new SubgraphDeploymentID(ipfsHash), 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), + stakedTokens: 0n, + signalledTokens: 0n, + queryFeesAmount: 0n, protocolNetwork: this.networkCAIPID, } } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index c1c076f8b..38e7d6d83 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -1541,7 +1541,7 @@ export default { await allocationManager.dipsManager.tryUpdateAgreementAllocation( deployment, null, - toAddress(createAllocationEventLogs.allocationID), + toAddress(allocationId), ) } @@ -1852,7 +1852,7 @@ export default { await allocationManager.dipsManager.tryUpdateAgreementAllocation( allocationData.subgraphDeployment.id.toString(), toAddress(allocation), - toAddress(createAllocationEventLogs.allocationID), + toAddress(newAllocationId), ) } 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 be56353f9..7189e560f 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -29,7 +29,6 @@ import { } 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 @@ -65,9 +64,9 @@ const mockSubgraphDeployment = (id: string) => { id: new SubgraphDeploymentID(id), ipfsHash: id, deniedAt: null, - stakedTokens: BigNumber.from('1000'), - signalledTokens: BigNumber.from('1000'), - queryFeesAmount: BigNumber.from('0'), + stakedTokens: 1000n, + signalledTokens: 1000n, + queryFeesAmount: 0n, protocolNetwork: 'eip155:421614', } } diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index dc939ba64..15f76b6a5 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -34,7 +34,7 @@ import { } from '@graphprotocol/dips-proto/generated/gateway' import { IndexingAgreement } from '../indexer-management/models/indexing-agreement' import { NetworkSpecification } from '../network-specification' -import { Wallet } from 'ethers' +import { BaseWallet } from 'ethers' const DIPS_COLLECTION_INTERVAL = 60_000 @@ -364,7 +364,7 @@ export class DipsCollector { private queryFeeModels: QueryFeeModels, private specification: NetworkSpecification, private tapCollector: TapCollector, - private wallet: Wallet, + private wallet: BaseWallet, private graphNode: GraphNode, public escrowSenderGetter: GetEscrowSenderForSigner, ) { @@ -383,7 +383,7 @@ export class DipsCollector { queryFeeModels: QueryFeeModels, specification: NetworkSpecification, tapCollector: TapCollector, - wallet: Wallet, + wallet: BaseWallet, graphNode: GraphNode, escrowSenderGetter?: GetEscrowSenderForSigner, ) { @@ -467,7 +467,7 @@ export class DipsCollector { this.logger.info('Decoding TAP receipt for agreement') const tapReceipt = this.gatewayDipsServiceMessagesCodec.decodeTapReceipt( response.tapReceipt, - this.tapCollector?.tapContracts.tapVerifier.address, + this.tapCollector?.tapContracts.tapVerifier.target.toString(), ) // Check that the signer of the TAP receipt is a signer // on the corresponding escrow account for the payer (sender) of the 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 index 1bfb832a5..1b8019cf8 100644 --- a/packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts +++ b/packages/indexer-common/src/indexing-fees/gateway-dips-service-client.ts @@ -1,13 +1,7 @@ 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 { BaseWallet, TypedDataEncoder, getBytes, AbiCoder, recoverAddress } from 'ethers' import { toAddress } from '@graphprotocol/common-ts' type RpcImpl = (service: string, method: string, data: Uint8Array) => Promise @@ -46,15 +40,16 @@ export const collectPaymentsTypes = { export class GatewayDipsServiceMessagesCodec { async createSignedCancellationRequest( agreementId: string, - wallet: Wallet, + wallet: BaseWallet, ): Promise { - const signature = await wallet._signTypedData( + const signature = await wallet.signTypedData( cancelAgreementDomain, cancelAgreementTypes, { agreement_id: agreementId }, ) - return arrayify( - defaultAbiCoder.encode(['tuple(bytes16)', 'bytes'], [[agreementId], signature]), + const abiCoder = AbiCoder.defaultAbiCoder() + return getBytes( + abiCoder.encode(['tuple(bytes16)', 'bytes'], [[agreementId], signature]), ) } @@ -62,9 +57,9 @@ export class GatewayDipsServiceMessagesCodec { agreementId: string, allocationId: string, entityCount: number, - wallet: Wallet, + wallet: BaseWallet, ): Promise { - const signature = await wallet._signTypedData( + const signature = await wallet.signTypedData( collectPaymentsDomain, collectPaymentsTypes, { @@ -73,8 +68,9 @@ export class GatewayDipsServiceMessagesCodec { entity_count: entityCount, }, ) - return arrayify( - defaultAbiCoder.encode( + const abiCoder = AbiCoder.defaultAbiCoder() + return getBytes( + abiCoder.encode( ['tuple(bytes16, address, uint64)', 'bytes'], [[agreementId, toAddress(allocationId), entityCount], signature], ), @@ -82,7 +78,8 @@ export class GatewayDipsServiceMessagesCodec { } decodeTapReceipt(receipt: Uint8Array, verifyingContract: string) { - const [message, signature] = defaultAbiCoder.decode( + const abiCoder = AbiCoder.defaultAbiCoder() + const [message, signature] = abiCoder.decode( ['tuple(address,uint64,uint64,uint128)', 'bytes'], receipt, ) @@ -107,7 +104,7 @@ export class GatewayDipsServiceMessagesCodec { ], } - const digest = _TypedDataEncoder.hash(domain, types, { + const digest = TypedDataEncoder.hash(domain, types, { allocation_id: allocationId, timestamp_ns: timestampNs, nonce: nonce, From a83e0b9a9bb195f9ac234e3b4a3ac8e70188a137 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Fri, 1 Aug 2025 21:27:17 +0000 Subject: [PATCH 3/3] feat: implement receipt-based payment system for DIPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DipsReceipt model with snake_case fields matching database conventions - Update DipsCollector to store Receipt IDs instead of TAP receipts - Implement GetReceiptById polling in collectAllPayments method - Update to @graphprotocol/dips-proto 0.3.0 for new proto definitions - Remove TAP receipt dependencies from DIPs payment flow - Add comprehensive logging for payment status transitions This completes the indexer-agent implementation for the new DIPs Safe payment system, replacing TAP receipts with an asynchronous Receipt ID based approach. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/indexer-common/package.json | 2 +- .../src/indexer-management/models/index.ts | 1 + .../models/indexing-agreement.ts | 95 +++++++++++++++ .../src/indexing-fees/__tests__/dips.test.ts | 14 +-- .../indexer-common/src/indexing-fees/dips.ts | 111 +++++++++++------- packages/indexer-common/src/network.ts | 2 - yarn.lock | 8 +- 7 files changed, 170 insertions(+), 63 deletions(-) diff --git a/packages/indexer-common/package.json b/packages/indexer-common/package.json index 7f0ae968b..97ce6969b 100644 --- a/packages/indexer-common/package.json +++ b/packages/indexer-common/package.json @@ -25,7 +25,7 @@ "@pinax/graph-networks-registry": "0.6.7", "@bufbuild/protobuf": "2.2.3", "@graphprotocol/common-ts": "3.0.1", - "@graphprotocol/dips-proto": "0.2.2", + "@graphprotocol/dips-proto": "0.3.0", "@graphprotocol/horizon": "0.4.1", "@graphprotocol/subgraph-service": "0.4.1", "@graphprotocol/toolshed": "0.6.5", diff --git a/packages/indexer-common/src/indexer-management/models/index.ts b/packages/indexer-common/src/indexer-management/models/index.ts index 81a59f4d3..26f52cb63 100644 --- a/packages/indexer-common/src/indexer-management/models/index.ts +++ b/packages/indexer-common/src/indexer-management/models/index.ts @@ -10,6 +10,7 @@ export * from './cost-model' export * from './indexing-rule' export * from './poi-dispute' export * from './action' +export * from './indexing-agreement' export type IndexerManagementModels = IndexingRuleModels & CostModelModels & 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 462456a7c..1fceaee34 100644 --- a/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts +++ b/packages/indexer-common/src/indexer-management/models/indexing-agreement.ts @@ -6,6 +6,7 @@ import { CreationOptional, InferCreationAttributes, InferAttributes, + ForeignKey, } from 'sequelize' // Indexing Fees AKA "DIPs" @@ -40,8 +41,26 @@ export class IndexingAgreement extends Model< declare last_payment_collected_at: Date | null } +export type DipsReceiptStatus = 'PENDING' | 'SUBMITTED' | 'FAILED' + +export class DipsReceipt extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string // Primary key - Receipt ID from Dipper + declare agreement_id: ForeignKey + declare amount: string + declare status: DipsReceiptStatus + declare transaction_hash: string | null + declare error_message: string | null + declare created_at: CreationOptional + declare updated_at: CreationOptional + declare retry_count: CreationOptional +} + export interface IndexingFeesModels { IndexingAgreement: typeof IndexingAgreement + DipsReceipt: typeof DipsReceipt } export const defineIndexingFeesModels = (sequelize: Sequelize): IndexingFeesModels => { @@ -209,7 +228,83 @@ export const defineIndexingFeesModels = (sequelize: Sequelize): IndexingFeesMode }, ) + DipsReceipt.init( + { + id: { + type: DataTypes.STRING(255), + primaryKey: true, + allowNull: false, + }, + agreement_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: IndexingAgreement, + key: 'id', + }, + }, + amount: { + type: DataTypes.DECIMAL(39), + allowNull: false, + }, + status: { + type: DataTypes.ENUM('PENDING', 'SUBMITTED', 'FAILED'), + allowNull: false, + defaultValue: 'PENDING', + }, + transaction_hash: { + type: DataTypes.CHAR(66), + allowNull: true, + }, + error_message: { + type: DataTypes.TEXT, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + }, + retry_count: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + }, + { + modelName: 'DipsReceipt', + sequelize, + tableName: 'dips_receipts', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['agreement_id'], + }, + { + fields: ['status'], + }, + ], + }, + ) + + // Define associations + DipsReceipt.belongsTo(IndexingAgreement, { + foreignKey: 'agreement_id', + as: 'agreement', + }) + + IndexingAgreement.hasMany(DipsReceipt, { + foreignKey: 'agreement_id', + as: 'receipts', + }) + return { ['IndexingAgreement']: IndexingAgreement, + ['DipsReceipt']: DipsReceipt, } } 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 7189e560f..3fe1d46b5 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts @@ -454,12 +454,9 @@ describe('DipsCollector', () => { const dipsCollector = new DipsCollector( logger, managementModels, - queryFeeModels, networkSpecWithDips, - network.tapCollector!, network.wallet, graphNode, - jest.fn(), ) expect(dipsCollector).toBeDefined() }) @@ -467,12 +464,9 @@ describe('DipsCollector', () => { const dipsCollector = new DipsCollector( logger, managementModels, - queryFeeModels, networkSpecWithDips, - network.tapCollector!, network.wallet, graphNode, - jest.fn(), ) expect(dipsCollector).toBeDefined() expect(startCollectionLoop).toHaveBeenCalled() @@ -490,12 +484,9 @@ describe('DipsCollector', () => { new DipsCollector( logger, managementModels, - queryFeeModels, specWithoutDipper, - network.tapCollector!, network.wallet, graphNode, - jest.fn(), ), ).toThrow('dipperEndpoint is not set') }) @@ -570,10 +561,7 @@ describe('DipsCollector', () => { value: '1000', } }) - dipsCollector.escrowSenderGetter = jest.fn().mockImplementation(() => { - logger.info('MOCK Getting escrow sender for signer') - return toAddress('0x123456df40c29949a75a6693c77834c00b8a5678') - }) + // escrowSenderGetter has been removed from DipsCollector await dipsCollector.tryCollectPayment(agreement) diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index 15f76b6a5..7b8c66c18 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -9,17 +9,14 @@ import { ActionStatus, Allocation, AllocationManager, - getEscrowSenderForSigner, + DipsReceiptStatus, GraphNode, IndexerManagementModels, IndexingDecisionBasis, IndexingRuleAttributes, Network, - QueryFeeModels, sequentialTimerMap, - SubgraphClient, SubgraphIdentifierType, - TapCollector, upsertIndexingRule, } from '@graphprotocol/indexer-common' import { Op } from 'sequelize' @@ -46,10 +43,6 @@ 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 @@ -361,12 +354,9 @@ export class DipsCollector { constructor( private logger: Logger, private managementModels: IndexerManagementModels, - private queryFeeModels: QueryFeeModels, private specification: NetworkSpecification, - private tapCollector: TapCollector, private wallet: BaseWallet, private graphNode: GraphNode, - public escrowSenderGetter: GetEscrowSenderForSigner, ) { if (!this.specification.indexerOptions.dipperEndpoint) { throw new Error('dipperEndpoint is not set') @@ -380,22 +370,16 @@ export class DipsCollector { static create( logger: Logger, managementModels: IndexerManagementModels, - queryFeeModels: QueryFeeModels, specification: NetworkSpecification, - tapCollector: TapCollector, wallet: BaseWallet, graphNode: GraphNode, - escrowSenderGetter?: GetEscrowSenderForSigner, ) { const collector = new DipsCollector( logger, managementModels, - queryFeeModels, specification, - tapCollector, wallet, graphNode, - escrowSenderGetter ?? getEscrowSenderForSigner, ) collector.startCollectionLoop() return collector @@ -421,6 +405,7 @@ export class DipsCollector { // Collect payments for all outstanding agreements async collectAllPayments() { + // Part 1: Collect new payments const outstandingAgreements = await this.managementModels.IndexingAgreement.findAll({ where: { last_payment_collected_at: null, @@ -432,6 +417,53 @@ export class DipsCollector { for (const agreement of outstandingAgreements) { await this.tryCollectPayment(agreement) } + + // Part 2: Poll pending receipts + await this.pollPendingReceipts() + } + + async pollPendingReceipts() { + // Find all pending receipts + const pendingReceipts = await this.managementModels.DipsReceipt.findAll({ + where: { + status: 'PENDING', + }, + }) + + if (pendingReceipts.length === 0) { + return + } + + this.logger.info(`Polling ${pendingReceipts.length} pending receipts`) + + for (const receipt of pendingReceipts) { + try { + const statusResponse = await this.gatewayDipsServiceClient.GetReceiptById({ + version: 1, + receiptId: receipt.id, + }) + + if (statusResponse.status !== receipt.status) { + const oldStatus = receipt.status + receipt.status = statusResponse.status as DipsReceiptStatus + receipt.transaction_hash = statusResponse.transactionHash || null + receipt.error_message = statusResponse.errorMessage || null + await receipt.save() + + this.logger.info( + `Receipt ${receipt.id} status updated from ${oldStatus} to ${statusResponse.status}`, + { + receiptId: receipt.id, + oldStatus: oldStatus, + newStatus: statusResponse.status, + transactionHash: statusResponse.transactionHash, + }, + ) + } + } catch (error) { + this.logger.error(`Error polling receipt ${receipt.id}`, { error }) + } + } } async tryCollectPayment(agreement: IndexingAgreement) { if (!agreement.last_allocation_id) { @@ -460,36 +492,29 @@ export class DipsCollector { 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.target.toString(), - ) - // 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) + const receiptId = response.receiptId + const amount = response.amount + + // Store the receipt ID in the database + this.logger.info(`Received receipt ID ${receiptId} for agreement ${agreement.id}`) + + // Create DipsReceipt record with PENDING status + await this.managementModels.DipsReceipt.create({ + id: receiptId, + agreement_id: agreement.id, + amount: amount, + status: 'PENDING', + retry_count: 0, + }) + // Mark the agreement as having had a payment collected agreement.last_payment_collected_at = new Date() agreement.updated_at = new Date() await agreement.save() + + this.logger.info( + `Payment collection initiated for agreement ${agreement.id}, receipt ID: ${receiptId}`, + ) } else { throw new Error(`Payment request not accepted: ${response.status}`) } diff --git a/packages/indexer-common/src/network.ts b/packages/indexer-common/src/network.ts index e4f12f0fa..6918c9157 100644 --- a/packages/indexer-common/src/network.ts +++ b/packages/indexer-common/src/network.ts @@ -326,9 +326,7 @@ export class Network { dipsCollector = DipsCollector.create( logger, managementModels, - queryFeeModels, specification, - tapCollector, wallet, graphNode, ) diff --git a/yarn.lock b/yarn.lock index 8c9d7cbf3..64f1325e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -573,10 +573,10 @@ prom-client "14.2.0" sequelize "6.33.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== +"@graphprotocol/dips-proto@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@graphprotocol/dips-proto/-/dips-proto-0.3.0.tgz#97eccaabdf449479fb083865d4697e3c0725f870" + integrity sha512-zZ6mqG/OKe21PxOunnhWkz2qq5zZl+JD0n7PSFgVNq61J8lZs22Jal78utvOSsJd/E5vyNoSxELGCOyZmc4QJw== dependencies: "@bufbuild/protobuf" "^2.2.3"