Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -59,7 +42,9 @@ function createMockProposal(
maxOngoingTokensPerSecond: 100n,
minSecondsPerCollection: 3600n,
maxSecondsPerCollection: 86400n,
conditions: 0n,
nonce: 42n,
metadata: '0x',
subgraphDeploymentId: deployment,
tokensPerSecond: 1000n,
tokensPerEntityPerSecond: 50n,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -66,7 +67,7 @@ function encodeTestPayload(overrides?: {
nonce: 42n,
metadata: metadataEncoded,
},
signature: TEST_SIGNATURE,
signature: overrides?.signature ?? TEST_SIGNATURE,
},
],
)
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading