From a4af5569bf89687e5c7730cc8a13966dd7770782 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 14 May 2026 18:14:04 -0300 Subject: [PATCH] feat(dips): use on-chain flow without signature --- .../__tests__/accept-proposals.test.ts | 89 ++++++++++++++---- .../__tests__/offer-verifier.test.ts | 94 +++++++++++++++++++ .../__tests__/pending-rca-consumer.test.ts | 57 +++++++++-- .../indexer-common/src/indexing-fees/dips.ts | 87 +++++++++++++++-- .../indexer-common/src/indexing-fees/index.ts | 1 + .../src/indexing-fees/offer-verifier.ts | 67 +++++++++++++ .../src/indexing-fees/pending-rca-consumer.ts | 70 +++++++++++++- .../indexer-common/src/indexing-fees/types.ts | 11 ++- 8 files changed, 436 insertions(+), 40 deletions(-) create mode 100644 packages/indexer-common/src/indexing-fees/__tests__/offer-verifier.test.ts create mode 100644 packages/indexer-common/src/indexing-fees/offer-verifier.ts diff --git a/packages/indexer-common/src/indexing-fees/__tests__/accept-proposals.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/accept-proposals.test.ts index bcd33115d..81258c1bc 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/accept-proposals.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/accept-proposals.test.ts @@ -32,24 +32,7 @@ function createMockProposal( id: 'proposal-1', status: 'pending', createdAt: new Date(), - signedRca: { - rca: { - deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), - endsAt: BigInt(Math.floor(Date.now() / 1000) + 86400), - payer: '0x1111111111111111111111111111111111111111', - dataService: '0x2222222222222222222222222222222222222222', - serviceProvider: '0x3333333333333333333333333333333333333333', - maxInitialTokens: 10000n, - maxOngoingTokensPerSecond: 100n, - minSecondsPerCollection: 3600n, - maxSecondsPerCollection: 86400n, - conditions: 0n, - nonce: 42n, - metadata: '0x', - }, - signature: '0xaabbccdd', - }, - signedPayload: new Uint8Array(), + agreementId: '0xabcd1234567890abcdef1234567890ab', payer: '0x1111111111111111111111111111111111111111', serviceProvider: '0x3333333333333333333333333333333333333333', dataService: '0x2222222222222222222222222222222222222222', @@ -59,7 +42,9 @@ function createMockProposal( maxOngoingTokensPerSecond: 100n, minSecondsPerCollection: 3600n, maxSecondsPerCollection: 86400n, + conditions: 0n, nonce: 42n, + metadata: '0x', subgraphDeploymentId: deployment, tokensPerSecond: 1000n, tokensPerEntityPerSecond: 50n, @@ -139,6 +124,14 @@ function createMockNetwork() { RewardsManager: { isDenied: jest.fn().mockResolvedValue(false), }, + RecurringCollector: { + hashRCA: jest.fn().mockResolvedValue('0x' + 'aa'.repeat(32)), + }, + }, + indexingPaymentsSubgraph: { + query: jest.fn().mockResolvedValue({ + data: { offer: { offerHash: '0x' + 'aa'.repeat(32) } }, + }), }, transactionManager: { executeTransaction: jest.fn(), @@ -245,6 +238,66 @@ describe('DipsManager.acceptPendingProposals', () => { expect(consumer.markRejected).not.toHaveBeenCalled() }) + test('skips proposal when offer is not yet on subgraph (stays pending)', async () => { + const proposal = createMockProposal() + const consumer = createMockConsumer([proposal]) + const models = createMockModels() + const network = createMockNetwork() + ;(network.indexingPaymentsSubgraph!.query as jest.Mock).mockResolvedValue({ + data: { offer: null }, + }) + const dm = createDipsManager(network, models, consumer) + + await dm.acceptPendingProposals([]) + + expect(consumer.markAccepted).not.toHaveBeenCalled() + expect(consumer.markRejected).not.toHaveBeenCalled() + }) + + test('rejects proposal on offer hash mismatch', async () => { + const proposal = createMockProposal() + const consumer = createMockConsumer([proposal]) + const models = createMockModels() + const network = createMockNetwork() + ;(network.indexingPaymentsSubgraph!.query as jest.Mock).mockResolvedValue({ + data: { offer: { offerHash: '0x' + 'cc'.repeat(32) } }, + }) + const dm = createDipsManager(network, models, consumer) + + await dm.acceptPendingProposals([]) + + expect(consumer.markRejected).toHaveBeenCalledWith(proposal.id, 'offer_hash_mismatch') + expect(consumer.markAccepted).not.toHaveBeenCalled() + }) + + test('passes empty signature to acceptIndexingAgreement on present offer', async () => { + const proposal = createMockProposal() + const allocation = createMockAllocation(proposal.subgraphDeploymentId.bytes32) + const consumer = createMockConsumer([proposal]) + const models = createMockModels() + const network = createMockNetwork() + ;(network.transactionManager.executeTransaction as jest.Mock).mockResolvedValue({ + hash: '0xtx', + }) + const dm = createDipsManager(network, models, consumer) + + await dm.acceptPendingProposals([allocation]) + + // The executeTransaction call passes an estimateGas thunk and a send thunk. + // Invoke the estimateGas thunk to verify acceptIndexingAgreement.estimateGas + // was called with the expected args (including the empty signature). + const calls = (network.transactionManager.executeTransaction as jest.Mock).mock.calls + expect(calls.length).toBeGreaterThan(0) + await calls[0][0]() + const estimateGasSpy = network.contracts.SubgraphService.acceptIndexingAgreement + .estimateGas as jest.Mock + expect(estimateGasSpy).toHaveBeenCalledWith( + allocation.id, + expect.objectContaining({ payer: proposal.payer }), + '0x', + ) + }) + describe('with existing allocation', () => { test('accepts proposal on-chain and marks accepted', async () => { const proposal = createMockProposal() diff --git a/packages/indexer-common/src/indexing-fees/__tests__/offer-verifier.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/offer-verifier.test.ts new file mode 100644 index 000000000..5216a081f --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/__tests__/offer-verifier.test.ts @@ -0,0 +1,94 @@ +import { createLogger, Logger } from '@graphprotocol/common-ts' +import { OfferVerifier } from '../offer-verifier' +import { SubgraphClient } from '../../subgraph-client' + +const TEST_ID = '0xabcd1234567890abcdef1234567890ab' +const TEST_HASH = '0x' + 'aa'.repeat(32) +const DIFFERENT_HASH = '0x' + 'bb'.repeat(32) + +let logger: Logger + +beforeAll(() => { + logger = createLogger({ + name: 'OfferVerifier Test', + async: false, + level: 'error', + }) +}) + +function mockSubgraph( + result: { data?: unknown; errors?: unknown } | Error, +): SubgraphClient { + if (result instanceof Error) { + return { + query: jest.fn().mockRejectedValue(result), + } as unknown as SubgraphClient + } + return { + query: jest.fn().mockResolvedValue(result), + } as unknown as SubgraphClient +} + +describe('OfferVerifier', () => { + test('returns present when offer hash matches', async () => { + const subgraph = mockSubgraph({ + data: { offer: { offerHash: TEST_HASH } }, + }) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result).toEqual({ status: 'present', offerHash: TEST_HASH }) + }) + + test('matches hashes case-insensitively', async () => { + const subgraph = mockSubgraph({ + data: { offer: { offerHash: TEST_HASH.toUpperCase().replace('0X', '0x') } }, + }) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result.status).toBe('present') + }) + + test('returns not_yet when offer is null', async () => { + const subgraph = mockSubgraph({ data: { offer: null } }) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result).toEqual({ status: 'not_yet' }) + }) + + test('returns hash_mismatch when hashes differ', async () => { + const subgraph = mockSubgraph({ + data: { offer: { offerHash: DIFFERENT_HASH } }, + }) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result).toEqual({ + status: 'hash_mismatch', + onChainHash: DIFFERENT_HASH, + }) + }) + + test('returns unavailable on GraphQL errors', async () => { + const subgraph = mockSubgraph({ + errors: [{ message: 'subgraph not synced' }], + }) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result.status).toBe('unavailable') + }) + + test('returns unavailable on network error', async () => { + const subgraph = mockSubgraph(new Error('ECONNREFUSED')) + const v = new OfferVerifier(subgraph, logger) + const result = await v.checkOffer(TEST_ID, TEST_HASH) + expect(result.status).toBe('unavailable') + }) + + test('queries with the agreement id as the entity key', async () => { + const subgraph = mockSubgraph({ data: { offer: null } }) + const v = new OfferVerifier(subgraph, logger) + await v.checkOffer(TEST_ID, TEST_HASH) + expect((subgraph.query as jest.Mock).mock.calls[0][1]).toEqual({ + id: TEST_ID, + }) + }) +}) diff --git a/packages/indexer-common/src/indexing-fees/__tests__/pending-rca-consumer.test.ts b/packages/indexer-common/src/indexing-fees/__tests__/pending-rca-consumer.test.ts index c2bd732bc..4fca9c668 100644 --- a/packages/indexer-common/src/indexing-fees/__tests__/pending-rca-consumer.test.ts +++ b/packages/indexer-common/src/indexing-fees/__tests__/pending-rca-consumer.test.ts @@ -19,7 +19,7 @@ const TEST_DATA_SERVICE = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' const TEST_SERVICE_PROVIDER = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' const TEST_DEPLOYMENT_BYTES32 = '0x0100000000000000000000000000000000000000000000000000000000000000' -const TEST_SIGNATURE = '0xaabbccdd' +const TEST_SIGNATURE = '0x' function encodeTestPayload(overrides?: { deadline?: bigint @@ -28,6 +28,7 @@ function encodeTestPayload(overrides?: { tokensPerEntityPerSecond?: bigint minSecondsPerCollection?: number maxSecondsPerCollection?: number + signature?: string }): Buffer { const tokensPerSecond = overrides?.tokensPerSecond ?? 1000n const tokensPerEntityPerSecond = overrides?.tokensPerEntityPerSecond ?? 50n @@ -66,7 +67,7 @@ function encodeTestPayload(overrides?: { nonce: 42n, metadata: metadataEncoded, }, - signature: TEST_SIGNATURE, + signature: overrides?.signature ?? TEST_SIGNATURE, }, ], ) @@ -130,11 +131,16 @@ describe('PendingRcaConsumer', () => { expect(p.subgraphDeploymentId).toBeInstanceOf(SubgraphDeploymentID) expect(p.subgraphDeploymentId.bytes32).toBe(TEST_DEPLOYMENT_BYTES32) - expect(p.signedRca).toBeDefined() - expect(p.signedRca.rca.payer.toLowerCase()).toBe(TEST_PAYER.toLowerCase()) - expect(p.signedRca.signature).toBe(TEST_SIGNATURE) - - expect(p.signedPayload).toBeInstanceOf(Uint8Array) + // bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce))) + const expectedAgreementId = ethers + .keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address', 'uint64', 'uint256'], + [TEST_PAYER, TEST_DATA_SERVICE, TEST_SERVICE_PROVIDER, 1700000000n, 42n], + ), + ) + .slice(0, 34) // 0x + 32 hex chars = bytes16 + expect(p.agreementId).toBe(expectedAgreementId.toLowerCase()) }) test('queries only pending rows', async () => { @@ -213,6 +219,43 @@ describe('PendingRcaConsumer', () => { expect(proposals[0].tokensPerSecond).toBe(100n) expect(proposals[1].tokensPerSecond).toBe(200n) }) + + test('rejects rows with non-empty signature (producer regression)', async () => { + const errorSpy = jest.fn() + const testLogger = { + ...logger, + error: errorSpy, + info: jest.fn(), + warn: jest.fn(), + child: () => testLogger, + } as unknown as Logger + + const badPayload = encodeTestPayload({ signature: '0xdeadbeef' }) + + const model = createMockModel([ + { + id: 'bad-sig-uuid', + signed_payload: badPayload, + version: 2, + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const consumer = new PendingRcaConsumer(testLogger, model) + const proposals = await consumer.getPendingProposals() + + expect(proposals).toHaveLength(0) + expect(model.update).toHaveBeenCalledWith( + { status: 'rejected' }, + { where: { id: 'bad-sig-uuid' } }, + ) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('non-empty signature'), + expect.any(Object), + ) + }) }) describe('markAccepted', () => { diff --git a/packages/indexer-common/src/indexing-fees/dips.ts b/packages/indexer-common/src/indexing-fees/dips.ts index c9e4837d5..be33e84cc 100644 --- a/packages/indexer-common/src/indexing-fees/dips.ts +++ b/packages/indexer-common/src/indexing-fees/dips.ts @@ -28,6 +28,7 @@ import { SubgraphIndexingAgreement, } from './agreement-monitor' import { CollectionTracker } from './collection-tracker' +import { OfferVerifier } from './offer-verifier' // POIs are computed against a recent-but-not-tip block to avoid reorg edge cases. const RECENT_BLOCK_OFFSET = 10 @@ -35,6 +36,7 @@ const RECENT_BLOCK_OFFSET = 10 export class DipsManager { declare pendingRcaConsumer: PendingRcaConsumer declare collectionTracker: CollectionTracker + declare offerVerifier: OfferVerifier | null constructor( private logger: Logger, private models: IndexerManagementModels, @@ -47,6 +49,9 @@ export class DipsManager { this.collectionTracker = new CollectionTracker( this.network.specification.indexerOptions.dipsCollectionTarget, ) + this.offerVerifier = this.network.indexingPaymentsSubgraph + ? new OfferVerifier(this.network.indexingPaymentsSubgraph, this.logger) + : null } async ensureAgreementRules() { if (!this.parent) { @@ -276,6 +281,52 @@ export class DipsManager { return } + if (!this.offerVerifier) { + this.logger.error( + 'Indexing payments subgraph not configured; on-chain accept pre-flight cannot run. ' + + 'Set indexingPaymentsSubgraph in the network specification. ' + + 'Proposal remains pending until configuration is fixed.', + { proposalId: proposal.id, agreementId: proposal.agreementId }, + ) + return + } + + const expectedHash = await this.computeRcaHash(proposal) + const offerResult = await this.offerVerifier.checkOffer( + proposal.agreementId, + expectedHash, + ) + + if (offerResult.status === 'not_yet') { + this.logger.debug('Offer not yet on subgraph; leaving proposal pending', { + proposalId: proposal.id, + agreementId: proposal.agreementId, + }) + return + } + + if (offerResult.status === 'unavailable') { + // OfferVerifier already logged at warn; just leave the row pending. + return + } + + if (offerResult.status === 'hash_mismatch') { + this.logger.warn( + 'Rejecting proposal: on-chain offerHash does not match local RCA hash', + { + proposalId: proposal.id, + agreementId: proposal.agreementId, + onChainHash: offerResult.onChainHash, + expectedHash, + }, + ) + await consumer.markRejected(proposal.id, 'offer_hash_mismatch') + await this.cleanupDipsRule(consumer, proposal) + return + } + + // offerResult.status === 'present' — proceed to accept. + const allocation = activeAllocations.find( (a) => a.subgraphDeployment.id.bytes32 === proposal.subgraphDeploymentId.bytes32, ) @@ -298,19 +349,21 @@ export class DipsManager { deployment: proposal.subgraphDeploymentId.ipfsHash, }) + const rca = this.toContractRca(proposal) + try { const receipt = await this.network.transactionManager.executeTransaction( async () => this.network.contracts.SubgraphService.acceptIndexingAgreement.estimateGas( allocation.id, - proposal.signedRca.rca, - proposal.signedRca.signature, + rca, + '0x', ), async (gasLimit) => this.network.contracts.SubgraphService.acceptIndexingAgreement( allocation.id, - proposal.signedRca.rca, - proposal.signedRca.signature, + rca, + '0x', { gasLimit }, ), this.logger.child({ @@ -424,11 +477,12 @@ export class DipsManager { ) // Build acceptIndexingAgreement calldata + const rca = this.toContractRca(proposal) const acceptTx = await this.network.contracts.SubgraphService.acceptIndexingAgreement.populateTransaction( allocationId, - proposal.signedRca.rca, - proposal.signedRca.signature, + rca, + '0x', ) // Atomic multicall @@ -744,6 +798,27 @@ export class DipsManager { return 'collected' } + private toContractRca(proposal: DecodedRcaProposal) { + return { + deadline: proposal.deadline, + endsAt: proposal.endsAt, + payer: proposal.payer, + dataService: proposal.dataService, + serviceProvider: proposal.serviceProvider, + maxInitialTokens: proposal.maxInitialTokens, + maxOngoingTokensPerSecond: proposal.maxOngoingTokensPerSecond, + minSecondsPerCollection: proposal.minSecondsPerCollection, + maxSecondsPerCollection: proposal.maxSecondsPerCollection, + conditions: proposal.conditions, + nonce: proposal.nonce, + metadata: proposal.metadata, + } + } + + private async computeRcaHash(proposal: DecodedRcaProposal): Promise { + return this.network.contracts.RecurringCollector.hashRCA(this.toContractRca(proposal)) + } + private async handleAcceptError( consumer: PendingRcaConsumer, proposal: DecodedRcaProposal, diff --git a/packages/indexer-common/src/indexing-fees/index.ts b/packages/indexer-common/src/indexing-fees/index.ts index 0f8dba604..0b3faa3dc 100644 --- a/packages/indexer-common/src/indexing-fees/index.ts +++ b/packages/indexer-common/src/indexing-fees/index.ts @@ -1,3 +1,4 @@ export * from './dips' export * from './types' export * from './pending-rca-consumer' +export * from './offer-verifier' diff --git a/packages/indexer-common/src/indexing-fees/offer-verifier.ts b/packages/indexer-common/src/indexing-fees/offer-verifier.ts new file mode 100644 index 000000000..df4666da1 --- /dev/null +++ b/packages/indexer-common/src/indexing-fees/offer-verifier.ts @@ -0,0 +1,67 @@ +import gql from 'graphql-tag' +import { Logger } from '@graphprotocol/common-ts' +import { SubgraphClient } from '../subgraph-client' + +export type OfferCheckResult = + | { status: 'present'; offerHash: string } + | { status: 'not_yet' } + | { status: 'hash_mismatch'; onChainHash: string } + | { status: 'unavailable' } + +const OFFER_QUERY = gql` + query GetOffer($id: Bytes!) { + offer(id: $id) { + offerHash + } + } +` + +// Pre-flights the on-chain RCA offer via the indexing-payments-subgraph. +// +// - `present`: an Offer entity exists for the agreement id and its offerHash +// matches the locally-computed expected hash; safe to send accept tx. +// - `not_yet`: subgraph returned no Offer entity. Producer either hasn't +// called RecurringCollector.offer() yet or the subgraph is lagging the +// chain head. Caller should leave the row pending and retry. +// - `hash_mismatch`: an Offer exists but its hash differs from expected. +// Real producer/consumer disagreement; caller should reject the row. +// - `unavailable`: subgraph HTTP/GraphQL error. Treat as transient. +export class OfferVerifier { + constructor( + private subgraph: SubgraphClient, + private logger: Logger, + ) {} + + async checkOffer(agreementId: string, expectedHash: string): Promise { + let result: { data?: { offer: { offerHash: string } | null }; errors?: unknown } + try { + result = await this.subgraph.query(OFFER_QUERY, { id: agreementId }) + } catch (err) { + this.logger.warn('Offer pre-flight: subgraph query threw', { + agreementId, + error: err instanceof Error ? err.message : String(err), + }) + return { status: 'unavailable' } + } + + if (result.errors) { + this.logger.warn('Offer pre-flight: subgraph returned GraphQL errors', { + agreementId, + errors: result.errors, + }) + return { status: 'unavailable' } + } + + const offer = result.data?.offer + if (!offer) { + return { status: 'not_yet' } + } + + const onChainHash = offer.offerHash.toLowerCase() + if (onChainHash === expectedHash.toLowerCase()) { + return { status: 'present', offerHash: offer.offerHash } + } + + return { status: 'hash_mismatch', onChainHash: offer.offerHash } + } +} diff --git a/packages/indexer-common/src/indexing-fees/pending-rca-consumer.ts b/packages/indexer-common/src/indexing-fees/pending-rca-consumer.ts index d14d3a984..daf9e28ec 100644 --- a/packages/indexer-common/src/indexing-fees/pending-rca-consumer.ts +++ b/packages/indexer-common/src/indexing-fees/pending-rca-consumer.ts @@ -1,3 +1,4 @@ +import { ethers } from 'ethers' import { Logger, SubgraphDeploymentID } from '@graphprotocol/common-ts' import { decodeSignedRCA, @@ -21,7 +22,12 @@ export class PendingRcaConsumer { const decoded: DecodedRcaProposal[] = [] for (const row of rows) { try { - decoded.push(this.decodeRow(row)) + const proposal = await this.decodeRow(row) + if (proposal === null) { + // Decoder already marked the row rejected and logged + continue + } + decoded.push(proposal) } catch (error) { this.logger.warn(`Failed to decode pending RCA proposal ${row.id}, skipping`, { error, @@ -49,21 +55,53 @@ export class PendingRcaConsumer { } } - private decodeRow(row: PendingRcaProposal): DecodedRcaProposal { + // Returns the decoded proposal, or null if the row was rejected here + // (non-empty signature — producer regression, marked rejected in the DB + // before returning). + // + // Decode failures from toolshed (malformed payload, bad metadata, etc.) + // propagate as throws; the caller skip-logs them, leaving the row pending + // for the next cycle. + private async decodeRow(row: PendingRcaProposal): Promise { const signedPayload = new Uint8Array(row.signed_payload) const signedRca = decodeSignedRCA(signedPayload) - const { rca } = signedRca + const { rca, signature } = signedRca + + if (signature && signature !== '0x') { + const sigByteLength = Math.max(0, (signature.length - 2) / 2) + this.logger.error( + `Pending RCA proposal ${row.id} has non-empty signature (producer regression); rejecting`, + { id: row.id, signatureLength: sigByteLength }, + ) + try { + await this.markRejected(row.id, 'non_empty_signature') + } catch (err) { + this.logger.error('Failed to mark non-empty-signature proposal as rejected', { + id: row.id, + error: err instanceof Error ? err.message : String(err), + }) + } + return null + } const metadata = decodeAcceptIndexingAgreementMetadata(rca.metadata) const terms = decodeIndexingAgreementTermsV1(metadata.terms) + const agreementId = deriveAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce, + ) + return { id: row.id, status: row.status, createdAt: row.created_at, - signedRca, - signedPayload, + agreementId, + payer: rca.payer, serviceProvider: rca.serviceProvider, dataService: rca.dataService, @@ -73,7 +111,9 @@ export class PendingRcaConsumer { maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, minSecondsPerCollection: rca.minSecondsPerCollection, maxSecondsPerCollection: rca.maxSecondsPerCollection, + conditions: rca.conditions, nonce: rca.nonce, + metadata: rca.metadata, subgraphDeploymentId: new SubgraphDeploymentID(metadata.subgraphDeploymentId), tokensPerSecond: terms.tokensPerSecond, @@ -81,3 +121,23 @@ export class PendingRcaConsumer { } } } + +// Derives the bytes16 on-chain agreement id from the RCA identity fields. +// +// Mirrors the contract: bytes16(keccak256(abi.encode(payer, dataService, +// serviceProvider, deadline, nonce))). +// +// Returned as a lowercase 0x-prefixed 34-char hex string (0x + 32 hex chars). +export function deriveAgreementId( + payer: string, + dataService: string, + serviceProvider: string, + deadline: bigint, + nonce: bigint, +): string { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address', 'uint64', 'uint256'], + [payer, dataService, serviceProvider, deadline, nonce], + ) + return ethers.keccak256(encoded).slice(0, 34).toLowerCase() +} diff --git a/packages/indexer-common/src/indexing-fees/types.ts b/packages/indexer-common/src/indexing-fees/types.ts index 01a9dfc8f..8a0d1d60f 100644 --- a/packages/indexer-common/src/indexing-fees/types.ts +++ b/packages/indexer-common/src/indexing-fees/types.ts @@ -1,5 +1,4 @@ import { SubgraphDeploymentID } from '@graphprotocol/common-ts' -import { SignedRCA } from '@graphprotocol/toolshed' export interface DecodedRcaProposal { // From DB row @@ -7,9 +6,11 @@ export interface DecodedRcaProposal { status: string createdAt: Date - // Decoded from signed_payload (via toolshed) - signedRca: SignedRCA - signedPayload: Uint8Array + // Locally derived bytes16 on-chain agreement id (0x-prefixed lowercase). + // Derived from (payer, dataService, serviceProvider, deadline, nonce). + agreementId: string + + // Decoded from signed_payload (via toolshed). Signature is required to be empty. payer: string serviceProvider: string dataService: string @@ -19,7 +20,9 @@ export interface DecodedRcaProposal { maxOngoingTokensPerSecond: bigint minSecondsPerCollection: bigint maxSecondsPerCollection: bigint + conditions: bigint nonce: bigint + metadata: string // Decoded from metadata (via toolshed) subgraphDeploymentId: SubgraphDeploymentID