Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(horizon): deploy horizon with Hardhat Ignition #1025

Merged
merged 11 commits into from
Oct 1, 2024

Conversation

tmigone
Copy link
Contributor

@tmigone tmigone commented Sep 10, 2024

No description provided.

@tmigone tmigone marked this pull request as draft September 11, 2024 20:53
Copy link

openzeppelin-code bot commented Sep 12, 2024

feat(horizon): deploy horizon with Hardhat Ignition

Generated at commit: b4a5fe46abf6db9b7d4fb3f9773b24bd53f9a1ee

🚨 Report Summary

Severity Level Results
Contracts Critical
High
Medium
Low
Note
Total
2
4
0
15
41
62
Dependencies Critical
High
Medium
Low
Note
Total
0
0
0
0
0
0

For more details view the full report in OpenZeppelin Code Inspector

Copy link

@alcuadrado alcuadrado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really good, @tmigone! While I didn't spend enough time to understand the architecture of Horizon, I looked for unsage patterns and the structure of the modules, and it does look like what we expect to see in a project this size.

If left a few comments, including a few questions about things we'd love to know to see if/how we can make Ignition better.

@@ -0,0 +1,45 @@
{
"GraphProxyAdmin": {
"governor": "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an example of a repeated value that could have been global, right?

I think we could add something like:

"$global": {
   "governor": "...."
}

to this same json file, and then during execution we first do a lookup by module, and if not present in $global.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes exactly!

const GraphPaymentsImplementation = m.contract('GraphPayments',
[Controller, protocolPaymentCut],
{
after: [PeripheryRegistered, HorizonRegistered],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be (after a small change in Ignition)

Suggested change
after: [PeripheryRegistered, HorizonRegistered],
after: [GraphPeripheryModule, HorizonProxiesModule],

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be sweet 🙏

@@ -0,0 +1,16 @@
import { ignition } from 'hardhat'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, what do you use this script for? How is it different from directly calling the ignition task?

Copy link
Contributor Author

@tmigone tmigone Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I initially added this because one of the contract calls during setup was reverting and I wanted to log some stuff, so I should probably remove it.

But now that I think of it, there is one potential use case where it might be handy. For our products and internal tooling we use a contract address book with a specific format which is different from the one ignition outputs. Changing everything to support ignition's format seems more complicated than just adding a post-deployment processing step to convert between formats. Though ... perhaps it's best to keep that post processing on an isolated hardhat task and run ignition through CLI. What do you think?

Copy link

@alcuadrado alcuadrado Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's a valid use case, especially for existing systems like yours that are adopting Ignition.

We could have a subtask that runs after a successful Ignition deployment, where you can run your data transformation. WDYT?

Something like:

subtask(ON_IGNITION_DEPLOYMENT, ({rusults}) => {
    // save them in your format
});

The default implementation would be just a no-op.

/cc @zoeyTM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that sounds great

// Note that this module uses a dummy contract as the implementation as the proxy requires a valid contract
// address to be deployed
export function deployWithOZProxy(m: IgnitionModuleBuilder, contractName: string) {
const deployer = m.getAccount(0)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! We were expecting this pattern: you use functions to create parts of the module and use explicit IDs to disambiguate between calls.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this easy to come up with? I guess we can document it better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was fairly straightforward to realize. Definitely nitpicking but maybe you could mention this pattern in this section of the docs: https://hardhat.org/ignition/docs/guides/creating-modules#future--ids

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well now that i re-read it, the existing docs already sort of cover it, so not sure if it's worth further clarifying. Perhaps would be better as an example in the examples repo?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created an issue, as I think we could have a short guide about this: NomicFoundation/hardhat-ignition#813

export function deployWithOZProxy(m: IgnitionModuleBuilder, contractName: string) {
const deployer = m.getAccount(0)

const dummy = m.contract('Dummy', [], { id: `OZProxyDummy_${contractName}` })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Dummy in this case more of an EmptyImplementation? is there anything Ignition could do to improve on this? Or do you think it's an OZ issue?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you could also do is pass the implementation future to this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there is anything Ignition could do better here, it's really a limitation of the OZ proxy not accepting 0x00 as a valid implementation address (or an address without code deployed to it).

Agreed that using the implementation future is cleaner, and in some other instances that's what we are doing (for example for GraphProxy), but in this specific case there is sort of a circular dependency here where the implementation itself needs to know the proxy address before it's deployed, but at the same time the proxy needs the implementation address... Again this is a combination of OZ proxies and some Horizon weirdness (which I hope we can fix in the future).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Happy to help in any way if you want to explore an alternative in the future.

import GraphProxyAdminModule from '../periphery/GraphProxyAdmin'
import GraphProxyArtifact from '@graphprotocol/contracts/build/contracts/contracts/upgrades/GraphProxy.sol/GraphProxy.json'

export function deployWithGraphProxy(m: IgnitionModuleBuilder, contract: { name: string, artifact?: Artifact, args?: ArgumentType[] }, options?: ContractOptions) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I reviewed the OZ version of this helper in more detail as I'm more familiar with it.

const { Proxy: PaymentsEscrowProxy, ProxyAdmin: PaymentsEscrowProxyAdmin } = deployWithOZProxy(m, 'PaymentsEscrow')

// Register the proxies in the controller
const setProxyHorizonStaking = m.call(Controller, 'setContractProxy', [ethers.keccak256(ethers.toUtf8Bytes('Staking')), HorizonStakingProxy], { id: 'setContractProxy_HorizonStaking' })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this hashing is a custom pattern, is it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes!

const acceptCall = m.call(GraphProxyAdmin, 'acceptProxy', [HorizonStakingImplementation, HorizonStakingProxy], { after: [upgradeCall] })

// Load contract with implementation ABI
const HorizonStaking = m.contractAt('HorizonStaking', HorizonStakingProxy, { after: [acceptCall], id: 'HorizonStaking_Instance' })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is contractAt forcing you to use explicit ids too often? Or are you setting it here for a stylistic preference?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I guess this happens when you do "deploy implementation", "contractAt implementation". I'll think about how to improve this.

Maybe with something like what you did here: #1025 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah had to set it because of that!

Perhaps Ignition could detect this pattern:

const proxy = m.contract('Proxy')
const implementation = m.contract('ContractName')
const proxyWithImplementationABI = m.contractAt('ContractName', proxy)

And automatically add something to avoid id collision on the contractAt. But it's also not a big deal I think.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming, I created an issue to keep track of this: NomicFoundation/hardhat-ignition#814

import { deployWithGraphProxy } from '../proxy/GraphProxy'

import ControllerModule from './Controller'
import EpochManagerArtifact from '@graphprotocol/contracts/build/contracts/contracts/epochs/EpochManager.sol/EpochManager.json'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed these imports in a few files. Are these npm packages that you had already published? Are these contracts verified already? I'm mostly asking to understand:

  1. Are you loading from node_module or from this monorepo? If the latter, why?
  2. Should we do anything with its build info/sources so that then it can be verified easily?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. So, Horizon project (packages/horizon) relies on some contracts from the current version of the protocol (packages/contracts) which remain mostly unchanged. In order to be able to use modern tooling and make it easier for auditors we decided to spin up a new package (horizon) where we put all the new stuff then eventually the old stuff that's required will be moved to horizon and contracts will be deprecated.

So to answer your questions, anything we are pulling from @graphprotocol/contracts is being pulled from the monorepo package contracts and those are already deployed to production and verified. I don't know what would happen if I tried to verify something from the contracts package from within horizon 🤔 but in our specific case that will never happen as anything we pull from there should already be verified.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think we are good for now. But it's an interesting use case. I think we may want to let you provide their build infos. Do you keep track of them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow! Keep track of what?

args: [deployer],
})

// TODO: move this mint to a testnet only module

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How have you been managing testnet/mainnet deployment differences so far?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was actually wondering that. So far I have not been managing it at all. Everything so far assumes it's a testnet deployment, I'm curious what patterns you've seen for this?

I can think of two approaches:

  1. Add testnet/mainnet modules with specific config/steps for each environment. Those modules would import the main Horizon module, deploy everything in there then add on top of that.
  2. Deploy with a script instead of CLI and have any logic in there to deal with differences.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another idea that we had explored was allowing the users to create "chain specific" modules, that would behave different depending on the chainId. Imagine something like this:

const mod = chainSpecificModule("Mod", 
{
   1: (m) => {
      // mainnet logic
   },
   31337: (m) => {
      // hardhat network logic
   }
})

(ignore the horrible syntax)

This could work, especially if all you do are operations inside of those modules. If you need to export contracts, maybe we'd have to force you to export the same contract futures or something like that, so that it plays nicely with typescript.

Do you normally do ops there?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another pattern would be something like having your main module, and then for testnets you have testnet modules, which useModule(main), and run those ops. This feels closer to extending a class for testing purposes.

The disadvantage there is that is may force you to structure main in a certain way so that you can do those ops.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That last pattern you described is what I had in mind. Chain specific modules would be really helpful though, there are certain operations for which I think it makes a lot of sense (for example, minting initial supply of tokens on a testnet)

@tmigone tmigone marked this pull request as ready for review September 17, 2024 20:09
Copy link

socket-security bot commented Sep 24, 2024

New and removed dependencies detected. Learn more about Socket for GitHub ↗︎

Package New capabilities Transitives Size Publisher
npm/[email protected] environment, filesystem 0 79.1 kB motdotla

🚮 Removed packages: npm/[email protected]

View full report↗︎

@tmigone tmigone merged commit f86b12a into horizon Oct 1, 2024
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants