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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"lint:json": "prettier -w --cache --log-level warn 'packages/**/*.json' '.changeset/**/*.json' '*.json'",
"lint:yaml": "npx yaml-lint .github/**/*.{yml,yaml} packages/contracts/task/config/*.yml; prettier -w --cache --log-level warn 'packages/**/*.{yml,yaml}' '.github/**/*.{yml,yaml}'",
"test": "pnpm build && pnpm -r run test:self",
"test:prod": "FOUNDRY_PROFILE=prod pnpm test",
"test:coverage": "pnpm build && pnpm -r run build:self:coverage && pnpm -r run test:coverage:self"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { RewardsManager } from '@graphprotocol/contracts'
import { IERC165__factory, IIssuanceTarget__factory, IRewardsManager__factory } from '@graphprotocol/interfaces/types'
import {
IERC165__factory,
IIssuanceTarget__factory,
IProviderEligibilityManagement__factory,
IRewardsManager__factory,
} from '@graphprotocol/interfaces/types'
import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk'
import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'
import { expect } from 'chai'
Expand Down Expand Up @@ -78,6 +83,11 @@ describe('RewardsManager interfaces', () => {
expect(supports).to.be.true
})

it('should support IProviderEligibilityManagement interface', async function () {
const supports = await rewardsManager.supportsInterface(IProviderEligibilityManagement__factory.interfaceId)
expect(supports).to.be.true
})

it('should return false for unsupported interfaces', async function () {
// Test with an unknown interface ID
const unknownInterfaceId = '0x12345678' // Random interface ID
Expand Down
2 changes: 1 addition & 1 deletion packages/data-edge/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const config: HardhatUserConfig = {
solidity: {
compilers: [
{
version: '0.8.12',
version: '0.8.35',
settings: {
optimizer: {
enabled: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// solhint-disable no-unused-vars

pragma solidity ^0.8.27;

/**
* @title AfterCollectionGasReportingMock
* @author Edge & Node
* @notice Test-only mock used by the warm-path `CallbackGasProbe` boundary test. Returns
* `gasleft()` from `afterCollection` so the probe can read back the forwarded-gas value via
* returndata, and provides a no-op `isEligible` for the warm-up staticcall.
*
* @dev `afterCollection` shares its selector with `IAgreementOwner.afterCollection` but
* intentionally diverges on return type (returns `uint256` so the probe can decode the
* gasleft sample). Production dispatch in `RecurringCollector._postCollectCallback` discards
* returndata, so this divergence does not affect the gas accounting under measurement.
*/
contract AfterCollectionGasReportingMock {
/// @notice No-op warm-up target. Returning a value is irrelevant — the probe only runs
/// this to warm `payer`'s entry on the access list before the timed CALL.
/// @param payer Ignored.
/// @return Always true.
function isEligible(address payer) external pure returns (bool) {
payer;
return true;
}

/// @notice Returns the `gasleft()` value observed at function entry. View so the probe
/// can be invoked via `eth_call` (Hardhat `staticCall`) without committing state.
/// @param agreementId Ignored.
/// @param tokens Ignored.
/// @return The result of `gasleft()` at function entry.
function afterCollection(bytes16 agreementId, uint256 tokens) external view returns (uint256) {
agreementId;
tokens;
return gasleft();
}
}
69 changes: 64 additions & 5 deletions packages/horizon/contracts/mocks/CallbackGasProbe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
pragma solidity ^0.8.27;

import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol";
import { IAgreementOwner } from "@graphprotocol/interfaces/contracts/horizon/IAgreementOwner.sol";

/**
* @title CallbackGasProbe
* @author Edge & Node
* @notice Test-only contract that replicates the precheck + STATICCALL pattern used by
* `RecurringCollector._preCollectCallbacks` for the eligibility path. Exists so that
* @notice Test-only contract that replicates the precheck + STATICCALL/CALL patterns used by
* `RecurringCollector._preCollectCallbacks` (eligibility, cold path) and
* `RecurringCollector._postCollectCallback` (afterCollection, warm path). Exists so that
* Hardhat-side tests can verify, on a real EIP-2929-applying EVM (foundry's REVM in this
* project does not differentiate cold/warm in `gasleft()`-derived measurements), that
* `CALLBACK_GAS_OVERHEAD` covers the cold-account access cost on the staticcall.
* `CALLBACK_GAS_OVERHEAD` covers both the cold-account access cost on the staticcall and the
* warm-call dispatch overhead on the after-collection CALL.
*
* @dev MUST be kept in sync with the equivalent block in `RecurringCollector.sol`. If the
* @dev MUST be kept in sync with the equivalent blocks in `RecurringCollector.sol`. If the
* production constants (`MAX_PAYER_CALLBACK_GAS`, `CALLBACK_GAS_OVERHEAD`) or the precheck /
* staticcall sequence change, mirror the change here. This probe is not deployed to any
* staticcall / call sequence change, mirror the change here. This probe is not deployed to any
* production network.
*/
contract CallbackGasProbe {
Expand All @@ -24,6 +27,7 @@ contract CallbackGasProbe {

error CallbackGasProbeInsufficientCallbackGas();
error CallbackGasProbeNotEligible();
error CallbackGasProbeAfterCollectionFailed();

/**
* @notice Re-runs the eligibility precheck + STATICCALL exactly as
Expand Down Expand Up @@ -54,4 +58,59 @@ contract CallbackGasProbe {
revert CallbackGasProbeNotEligible();
}
}

/**
* @notice Re-runs the after-collection precheck + CALL exactly as
* `RecurringCollector._postCollectCallback` does, against `payer`. Warms the payer first
* (mirroring the eligibility staticcall site that runs ahead of the after-callback in
* `_collect`) so the CALL itself measures warm-path δ.
*
* Returns the `gasleft()` the callee observed at function entry, captured from the
* callee's 32-byte return word. Boundary tests use this to assert that, at the lowest
* outer gas at which the precheck just clears, the forwarded gas stays within tolerance
* of `MAX_PAYER_CALLBACK_GAS` — i.e. `CALLBACK_GAS_OVERHEAD ≥ δ_warm`. Reverts with
* `CallbackGasProbeInsufficientCallbackGas` if the precheck blocks, or
* `CallbackGasProbeAfterCollectionFailed` if the CALL itself fails.
*
* @dev Diverges from production in exactly one respect: it reads back the callee's
* returndata so the test can observe the warm-path forwarded-gas value. Production
* dispatch in `_postCollectCallback` discards returndata via `call(..., 0, 0)`. The
* gas accounting up to and through the CALL opcode is identical.
* @param payer The contract whose `afterCollection` should be invoked.
* @return received The `gasleft()` value the callee saw at function entry.
*/
function probeAfterCollection(address payer) external returns (uint256 received) {
// Warm payer's account access list. In production, `_preCollectCallbacks` is the
// first to touch `payer` (eligibility staticcall), so by the time
// `_postCollectCallback` issues its CALL the account is warm. Replicate that here
// with a staticcall whose return value we don't care about.
bytes memory eligCd = abi.encodeCall(IProviderEligibility.isEligible, (address(0)));
// solhint-disable-next-line no-inline-assembly
assembly {
// Output buffer is irrelevant — we ignore the result; this exists only to warm payer.
pop(staticcall(MAX_PAYER_CALLBACK_GAS, payer, add(eligCd, 0x20), mload(eligCd), 0, 0))
}

// Precheck — same expression as `_postCollectCallback`.
if (gasleft() < (MAX_PAYER_CALLBACK_GAS * 64) / 63 + CALLBACK_GAS_OVERHEAD) {
revert CallbackGasProbeInsufficientCallbackGas();
}

// CALL afterCollection — same opcode and gas limit as `_postCollectCallback`.
bytes memory cd = abi.encodeCall(IAgreementOwner.afterCollection, (bytes16(0), 0));
bool ok;
// solhint-disable-next-line no-inline-assembly
assembly {
ok := call(MAX_PAYER_CALLBACK_GAS, payer, 0, add(cd, 0x20), mload(cd), 0, 0)
}
if (!ok) revert CallbackGasProbeAfterCollectionFailed();

// Capture the 32-byte gasleft value the callee returned. Note RC discards returndata
// here (call(..., 0, 0)); we read it back only so the test can assert on it.
// solhint-disable-next-line no-inline-assembly
assembly {
returndatacopy(0, 0, 32)
received := mload(0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,7 @@ contract RecurringCollector is
}
}

/**
* @notice List of pause guardians and their allowed status
* @param pauseGuardian The address to check
* @return Whether the address is a pause guardian
*/
/// @inheritdoc IRecurringCollector
function pauseGuardians(address pauseGuardian) public view override returns (bool) {
return _getStorage().pauseGuardians[pauseGuardian];
}
Expand Down Expand Up @@ -208,12 +204,8 @@ contract RecurringCollector is
emit PauseGuardianSet(_pauseGuardian, _allowed);
}

/**
* @inheritdoc IPaymentsCollector
* @notice Initiate a payment collection through the payments protocol.
* See {IPaymentsCollector.collect}.
* @dev Caller must be the data service the RCA was issued to.
*/
/// @inheritdoc IPaymentsCollector
/// @dev Caller must be the data service the RCA was issued to.
function collect(
IGraphPayments.PaymentTypes paymentType,
bytes calldata data
Expand All @@ -225,11 +217,7 @@ contract RecurringCollector is
}
}

/**
* @inheritdoc IRecurringCollector
* @notice Accept a Recurring Collection Agreement.
* @dev Caller must be the data service the RCA was issued to.
*/
/// @inheritdoc IRecurringCollector
function accept(
RecurringCollectionAgreement calldata rca,
bytes calldata signature
Expand Down Expand Up @@ -321,12 +309,7 @@ contract RecurringCollector is
agreement.updateNonce = 0;
}

/**
* @inheritdoc IRecurringCollector
* @notice Cancel a Recurring Collection Agreement.
* See {IRecurringCollector.cancel}.
* @dev Caller must be the data service for the agreement.
*/
/// @inheritdoc IRecurringCollector
function cancel(bytes16 agreementId, CancelAgreementBy by) external whenNotPaused {
RecurringCollectorStorage storage $ = _getStorage();
AgreementData storage agreement = $.agreements[agreementId];
Expand All @@ -351,13 +334,9 @@ contract RecurringCollector is
emit AgreementCanceled(agreement.dataService, agreement.payer, agreement.serviceProvider, agreementId, by);
}

/**
* @inheritdoc IRecurringCollector
* @notice Update a Recurring Collection Agreement.
* @dev Caller must be the data service for the agreement.
* @dev Note: Updated pricing terms apply immediately and will affect the next collection
* for the entire period since lastCollectionAt.
*/
/// @inheritdoc IRecurringCollector
/// @dev Updated pricing terms apply immediately and affect the next collection for the
/// entire period since lastCollectionAt.
function update(RecurringCollectionAgreementUpdate calldata rcau, bytes calldata signature) external whenNotPaused {
AgreementData storage agreement = _requireValidUpdateTarget(rcau.agreementId);

Expand Down Expand Up @@ -1351,8 +1330,11 @@ contract RecurringCollector is
);

if (block.timestamp <= rcau.deadline) {
uint256 collectionStart = _a.state == AgreementState.Accepted
? _agreementCollectionStartAt(_a)
: block.timestamp;
uint256 maxPendingClaim = _maxClaim(
block.timestamp,
collectionStart,
rcau.endsAt,
rcau.maxSecondsPerCollection,
rcau.maxOngoingTokensPerSecond,
Expand Down
23 changes: 19 additions & 4 deletions packages/horizon/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,30 @@ src = 'contracts'
out = 'build'
libs = ["node_modules"]
test = 'test'
cache_path = 'cache_forge'
fs_permissions = [{ access = "read", path = "./"}]
optimizer = true
optimizer_runs = 100
cache_path = 'cache_forge'
fs_permissions = [{ access = "read", path = "./" }]
solc_version = '0.8.35'
evm_version = 'cancun'
# Suppress solc warnings emitted from third-party code we don't control
# (e.g. @openzeppelin/foundry-upgrades' state-mutability suggestions).
ignored_warnings_from = ["node_modules"]

# Exclude test files from coverage reports
no_match_coverage = "(^test/|/mocks/)"

# Opt-in profile for production-matching bytecode (viaIR + optimizer).
# Use FOUNDRY_PROFILE=prod for CI / pre-merge runs that should mirror what hardhat
# compiles for deploy. Default profile stays optimizer-off / viaIR-off for fast iteration.
[profile.prod]
optimizer = true
optimizer_runs = 100
via_ir = true

# Lint configuration
[lint]
# Disable lint-on-build: forge build's auto-lint scans test/ files which use bytes32("...")
# literal IDs that trigger spurious unsafe-typecast warnings. `pnpm lint:forge` (scoped to
# contracts/) remains the canonical lint path for production code.
lint_on_build = false
ignore = ["contracts/mocks/imports.sol"]
exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"]
9 changes: 0 additions & 9 deletions packages/horizon/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@ if (isProjectBuilt(__dirname)) {
const baseConfig = hardhatBaseConfig(require)
const config: HardhatUserConfig = {
...baseConfig,
solidity: {
version: '0.8.27',
settings: {
optimizer: {
enabled: true,
runs: 20,
},
},
},
etherscan: {
...baseConfig.etherscan,
},
Expand Down
Loading