Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
},
"patchedDependencies": {
"typechain@8.3.2": "patches/typechain@8.3.2.patch",
"rocketh@0.17.13": "patches/rocketh@0.17.13.patch"
"rocketh@0.17.13": "patches/rocketh@0.17.13.patch",
"@rocketh/read-execute@0.17.8": "patches/@rocketh__read-execute@0.17.8.patch"
}
},
"lint-staged": {
Expand Down
7 changes: 7 additions & 0 deletions packages/deployment/config/arbitrumOne.json5
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
eligibilityOracle: 'RewardsEligibilityOracleA',
},

RecurringAgreementManager: {
// Wired to the same REO as RewardsManager. RAM stays dormant at launch
// (0 issuance + RecurringCollector paused) — this only pre-configures its
// eligibility oracle so it matches RM.
eligibilityOracle: 'RewardsEligibilityOracleA',
},

IssuanceAllocator: {
// Explicit issuance allocation table, by target contract name. The rates must
// sum to issuancePerBlock, which must equal RM's on-chain issuance rate — the
Expand Down
79 changes: 65 additions & 14 deletions packages/deployment/deploy/gip/0088/eligibility_integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import {
} from '@graphprotocol/deployment/lib/contract-registry.js'
import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
import { getResolvedSettingsForEnv } from '@graphprotocol/deployment/lib/deployment-config.js'
import { ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
import { assumeUpgraded, ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
import {
createGovernanceTxBuilder,
executeTxBatchDirect,
saveGovernanceTx,
} from '@graphprotocol/deployment/lib/execute-governance.js'
import { requireContracts } from '@graphprotocol/deployment/lib/issuance-deploy-utils.js'
import { createActionModule } from '@graphprotocol/deployment/lib/script-factories.js'
import { syncComponentsFromRegistry } from '@graphprotocol/deployment/lib/sync-utils.js'
import type { TxBuilder } from '@graphprotocol/deployment/lib/tx-builder.js'
import { graph } from '@graphprotocol/deployment/rocketh/deploy.js'
import type { Environment } from '@rocketh/core/types'
import type { PublicClient } from 'viem'
Expand All @@ -30,19 +36,43 @@ import type { PublicClient } from 'viem'
async function integrateOracle(
env: Environment,
client: PublicClient,
builder: TxBuilder,
governor: string,
canSign: boolean,
targetLabel: string,
targetEntry: RegistryEntry,
oracleName: EligibilityOracleContractName | undefined,
): Promise<void> {
): Promise<boolean> {
if (!oracleName) {
env.showMessage(`\n ○ ${targetLabel}: no eligibility oracle configured — skipping\n`)
return
return false
}

const reoEntry = eligibilityOracleContract(oracleName)
await syncComponentsFromRegistry(env, [reoEntry, targetEntry])
const [reo, target] = requireContracts(env, [reoEntry, targetEntry])

const applyOpts = {
contractName: `${targetEntry.name}-REO`,
contractAddress: target.address,
canExecuteDirectly: canSign,
executor: governor,
// Append to the shared batch; the caller executes/saves once for all targets.
builder,
}

// Sequenced-bundle generation: the target proxy isn't upgraded yet, so the
// oracle getter would revert. Skip the probe/idempotency read and emit the
// set-oracle TX unconditionally. The resulting bundle is sequenced-only —
// execute it after the upgrade bundle (nonce order enforces this).
if (assumeUpgraded()) {
const result = await applyConfiguration(env, client, [createRMIntegrationCondition(reo.address)], {
...applyOpts,
assumeUndone: true,
})
return result.changesNeeded
}

// Skip only if the target isn't upgraded yet (no oracle getter). Once it
// supports the getter, config is the source of truth: applyConfiguration is
// idempotent — it re-points the oracle to the configured REO when the current
Expand All @@ -56,17 +86,11 @@ async function integrateOracle(
} catch {
// Function not available — target not upgraded, skip
env.showMessage(`\n ○ ${targetLabel} does not support getProviderEligibilityOracle — skipping\n`)
return
return false
}

const { governor, canSign } = await canSignAsGovernor(env)

await applyConfiguration(env, client, [createRMIntegrationCondition(reo.address)], {
contractName: `${target.name}-REO`,
contractAddress: target.address,
canExecuteDirectly: canSign,
executor: governor,
})
const result = await applyConfiguration(env, client, [createRMIntegrationCondition(reo.address)], applyOpts)
return result.changesNeeded
}

/**
Expand All @@ -88,21 +112,48 @@ export default createActionModule(
async (env) => {
const settings = await getResolvedSettingsForEnv(env)
const client = graph.getPublicClient(env) as PublicClient
const { governor, canSign } = await canSignAsGovernor(env)

await integrateOracle(
// One shared batch for every configured target (RM and/or RAM), so both
// setProviderEligibilityOracle TXs land in a single governance bundle.
const builder = await createGovernanceTxBuilder(env, 'gip-0088-eligibility-integrate', {
name: 'GIP-0088 Eligibility Integration',
description: 'Set the provider eligibility oracle on RewardsManager and RecurringAgreementManager',
})

const rmChanged = await integrateOracle(
env,
client,
builder,
governor,
canSign,
'RM',
Contracts.horizon.RewardsManager,
settings.rewardsManager.eligibilityOracle,
)
await integrateOracle(
const ramChanged = await integrateOracle(
env,
client,
builder,
governor,
canSign,
'RAM',
Contracts.issuance.RecurringAgreementManager,
settings.recurringAgreementManager.eligibilityOracle,
)

if (!rmChanged && !ramChanged) {
env.showMessage('\n✅ Eligibility oracles already match config — nothing to do\n')
return
}

if (canSign) {
env.showMessage('\n🔨 Executing eligibility integration batch...\n')
await executeTxBatchDirect(env, builder, governor)
env.showMessage('\n✅ Eligibility integration complete\n')
} else {
saveGovernanceTx(env, builder, 'GIP-0088 Eligibility Integration')
}
},
{
// Ordering anchor for a combined `--tags GIP-0088` run: REO-A always deploys
Expand Down
48 changes: 43 additions & 5 deletions packages/deployment/deploy/gip/0088/issuance_connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
GRAPH_TOKEN_ABI,
ISSUANCE_ALLOCATOR_ABI,
ISSUANCE_TARGET_ABI,
REWARDS_MANAGER_DEPRECATED_ABI,
SET_TARGET_ALLOCATION_ABI,
} from '@graphprotocol/deployment/lib/abis.js'
import { getTargetChainIdFromEnv } from '@graphprotocol/deployment/lib/address-book-utils.js'
Expand All @@ -11,7 +12,7 @@ import {
} from '@graphprotocol/deployment/lib/contract-checks.js'
import { Contracts } from '@graphprotocol/deployment/lib/contract-registry.js'
import { canSignAsGovernor } from '@graphprotocol/deployment/lib/controller-utils.js'
import { ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
import { assumeUpgraded, ComponentTags, GoalTags } from '@graphprotocol/deployment/lib/deployment-tags.js'
import {
createGovernanceTxBuilder,
executeTxBatchDirect,
Expand Down Expand Up @@ -68,9 +69,18 @@ export default createActionModule(
// Create viem client for direct contract calls
const client = graph.getPublicClient(env) as PublicClient

// Check if RewardsManager supports IIssuanceTarget (has been upgraded)
// Throws error if not upgraded
await requireRewardsManagerUpgraded(client, rmAddress, env)
const sequenced = assumeUpgraded()

// Check if RewardsManager supports IIssuanceTarget (has been upgraded).
// Throws if not upgraded — skipped under sequenced generation, where this
// bundle is built to execute right after the upgrade bundle (nonce order).
if (!sequenced) {
await requireRewardsManagerUpgraded(client, rmAddress, env)
} else {
env.showMessage(
'\n⚠ Sequenced generation: RM upgrade assumed — this bundle is SEQUENCED-ONLY and valid only AFTER the upgrade bundle executes (nonce order).\n',
)
}

const targetChainId = await getTargetChainIdFromEnv(env)

Expand All @@ -86,7 +96,35 @@ export default createActionModule(
// Sub-flags drive both the per-line status display and which TXs the build-batch needs.
env.showMessage('📋 Checking current activation state...\n')

const connect = await checkIssuanceConnectComplete(client, iaAddress, rmAddress, gtAddress)
// Sequenced generation: RM isn't upgraded yet, so RM.getIssuanceAllocator (the
// iaIntegrated read inside checkIssuanceConnectComplete) would revert. Assume the
// RM-side wiring is undone and emit it. The rate invariant below is still enforced
// — both rates are readable on the un-upgraded RM. IA-side reads (default target)
// stay live in the TX-build section downstream.
const connect = sequenced
? {
complete: false,
iaIntegrated: false,
iaMinter: false,
rmAllocationShape: false,
fullyAllocated: false,
iaRate: (await client.readContract({
address: iaAddress as `0x${string}`,
abi: ISSUANCE_ALLOCATOR_ABI,
functionName: 'getIssuancePerBlock',
})) as bigint,
rmRate: (await client.readContract({
address: rmAddress as `0x${string}`,
abi: REWARDS_MANAGER_DEPRECATED_ABI,
functionName: 'issuancePerBlock',
})) as bigint,
get ratesAligned(): boolean {
return this.iaRate === this.rmRate
},
currentIssuanceAllocator: '(unknown — RM not upgraded)',
rmAllocation: { selfMintingRate: 0n, allocatorMintingRate: 0n },
}
: await checkIssuanceConnectComplete(client, iaAddress, rmAddress, gtAddress)

env.showMessage(
` IA integrated: ${connect.iaIntegrated ? '✓' : '✗'} (current: ${connect.currentIssuanceAllocator})`,
Expand Down
25 changes: 20 additions & 5 deletions packages/deployment/deploy/gip/0088/upgrade/04_upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,22 @@ const func: DeployScriptModule = async (env) => {

const settings = await getResolvedSettingsForEnv(env)

// RM-dependent config (setDefaultReclaimAddress / setRevertOnIneligible) can only
// run once RM is on its new implementation. If the RM upgrade is part of THIS batch,
// it executes (ordered before these config TXs) within the same atomic governance
// execution, so include them in the same bundle rather than deferring to a second
// governance round.
const rmUpgradeInBatch = gathered.sourceNames.some((n) => n.includes(Contracts.horizon.RewardsManager.name))

env.showMessage('\nOutstanding configuration:')
const existingCount = await collectExistingContractConfig(env, builder, client, pauseGuardian, settings)
const existingCount = await collectExistingContractConfig(
env,
builder,
client,
pauseGuardian,
settings,
rmUpgradeInBatch,
)
const newCount = await collectDeferredNewContractConfig(env, builder, client, targetChainId, governor, pauseGuardian)

const total = gathered.txCount + existingCount + newCount
Expand Down Expand Up @@ -153,15 +167,16 @@ function gatherProxyUpgrades(txDir: string, builder: TxBuilder) {
* horizon-Ignition infrastructure; the dynamic role check is the source of truth):
*
* - RC.setPauseGuardian
* - RM.setDefaultReclaimAddress (only when RM has been upgraded)
* - RM.setRevertOnIneligible (driven by config; only when RM has been upgraded)
* - RM.setDefaultReclaimAddress (when RM is upgraded, or its upgrade is in this batch)
* - RM.setRevertOnIneligible (driven by config; same RM-upgrade gating)
*/
async function collectExistingContractConfig(
env: Environment,
builder: TxBuilder,
client: PublicClient,
pauseGuardian: string,
settings: ResolvedSettings,
rmUpgradeInBatch: boolean,
): Promise<number> {
let added = 0

Expand Down Expand Up @@ -194,7 +209,7 @@ async function collectExistingContractConfig(
const rm = env.getOrNull(Contracts.horizon.RewardsManager.name)
if (reclaim && rm) {
const reclaimRMCheck = await checkReclaimRMIntegration(client, rm.address, reclaim.address)
if (!reclaimRMCheck.done && reclaimRMCheck.reason !== 'RM not upgraded') {
if (!reclaimRMCheck.done && (reclaimRMCheck.reason !== 'RM not upgraded' || rmUpgradeInBatch)) {
builder.addTx({
to: rm.address,
value: '0',
Expand All @@ -213,7 +228,7 @@ async function collectExistingContractConfig(
if (rm) {
const desiredRevert = settings.rewardsManager.revertOnIneligible
const revertCheck = await checkRMRevertOnIneligible(client, rm.address, desiredRevert)
if (!revertCheck.done && revertCheck.reason !== 'RM not upgraded') {
if (!revertCheck.done && (revertCheck.reason !== 'RM not upgraded' || rmUpgradeInBatch)) {
builder.addTx({
to: rm.address,
value: '0',
Expand Down
51 changes: 51 additions & 0 deletions packages/deployment/docs/Gip0088Runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,57 @@ prior stage. Abort is clean before [G4](#gate-g4); after, recovery needs a
follow-up governance batch — see
[GovernanceWorkflow.md](GovernanceWorkflow.md).

## Sequenced bundle generation (single council signing session)

The default flow generates each governance bundle only _after_ the previous one
executes on-chain — every activation goal reads live state and gates on the RM
upgrade ([S6](#stage-s6)/[S8](#stage-s8) skip or exit until RM is upgraded). On
mainnet, where the council signs M-of-N over days, that forces one signing round
per stage. To hand the council **every** GIP-0088 bundle at once, set
`GIP_0088_ASSUME_UPGRADED=1` when generating the activation bundles:

```bash
# Upgrade bundle — generated normally (already carries the RM-gated config:
# setDefaultReclaimAddress + setRevertOnIneligible, ordered after the RM upgrade)
pnpm hardhat deploy --tags GIP-0088:upgrade,upgrade --network arbitrumOne

# Activation bundles — generated ahead of the upgrade executing
GIP_0088_ASSUME_UPGRADED=1 pnpm hardhat deploy --tags GIP-0088:eligibility-integrate --network arbitrumOne
GIP_0088_ASSUME_UPGRADED=1 pnpm hardhat deploy --tags GIP-0088:issuance-connect --network arbitrumOne
```

With the flag, `eligibility-integrate` and `issuance-connect` skip the
"is RM upgraded on-chain" guard and the post-upgrade idempotency reads (which
would revert against the old implementation) and emit their full tx set.

**Execution — nonce order is load-bearing.** These activation bundles are
**sequenced-only**: valid only when executed _after_ the upgrade bundle. Queue
them on the council Safe in order:

| Safe nonce | Bundle |
| ---------- | ----------------------------------------------- |
| N | `gip-0088-upgrades.json` (upgrades + RM config) |
| N+1 | `eligibility-integrate` bundle |
| N+2 | `gip-0088-issuance-connect.json` |

The Safe executes in strict nonce order, so RM is upgraded by the time N+1/N+2
run, and the council reviews + signs all three in one session. If bundle N fails,
N+1/N+2 are blocked rather than executing against an un-upgraded RM.

- **Kept:** the `issuance-connect` rate invariant
(`IA.issuancePerBlock == RM.issuancePerBlock`) is still enforced — it reads
`RM.issuancePerBlock`, which exists on the un-upgraded RM.
- **Dropped:** idempotency. The flag blind-emits the full set, so use it only for
the initial sequenced generation — **not** for re-runs or recovery, where the
default (guarded) mode reads live state and emits only the remaining work.
- **`issuance-allocate`** stays a no-op in the DIPs-dormant config (RM already
100% from `issuance-connect`), so it needs no bundle. On a DIPs-active config,
generate it too, with the flag, as an `N+3` sequenced bundle.

This mode trades the staged per-goal review gates ([G7](#gate-g7)/[G9](#gate-g9))
for a single up-front review of all bundles — a deliberate choice for the
one-session council workflow, not the default.

## Activating DIPs later

DIPs ship dormant (see [Phase C](#phase-c--activation)). Turning them on is the
Expand Down
Loading