Commit 28038018 authored by Mark Tyneway's avatar Mark Tyneway Committed by GitHub

Merge pull request #6088 from ethereum-optimism/foundry-deploy

feat: foundry deploy
parents e1571d74 e96c70c3
...@@ -8,6 +8,7 @@ import calendar ...@@ -8,6 +8,7 @@ import calendar
import datetime import datetime
import time import time
import shutil import shutil
import http.client
import devnet.log_setup import devnet.log_setup
from devnet.genesis import GENESIS_TMPL from devnet.genesis import GENESIS_TMPL
...@@ -51,6 +52,10 @@ def main(): ...@@ -51,6 +52,10 @@ def main():
os.makedirs(devnet_dir, exist_ok=True) os.makedirs(devnet_dir, exist_ok=True)
run_command(['docker-compose', 'build', '--progress', 'plain'], cwd=paths.ops_bedrock_dir, env={
'PWD': paths.ops_bedrock_dir
})
if args.deploy: if args.deploy:
log.info('Devnet with upcoming smart contract deployments') log.info('Devnet with upcoming smart contract deployments')
devnet_deploy(paths) devnet_deploy(paths)
...@@ -88,12 +93,14 @@ def devnet_prestate(paths): ...@@ -88,12 +93,14 @@ def devnet_prestate(paths):
'PWD': paths.ops_bedrock_dir 'PWD': paths.ops_bedrock_dir
}) })
wait_up(8545) wait_up(8545)
wait_for_rpc_server('127.0.0.1:8545')
log.info('Bringing up L2.') log.info('Bringing up L2.')
run_command(['docker-compose', 'up', '-d', 'l2'], cwd=paths.ops_bedrock_dir, env={ run_command(['docker-compose', 'up', '-d', 'l2'], cwd=paths.ops_bedrock_dir, env={
'PWD': paths.ops_bedrock_dir 'PWD': paths.ops_bedrock_dir
}) })
wait_up(9545) wait_up(9545)
wait_for_rpc_server('127.0.0.1:9545')
log.info('Bringing up the services.') log.info('Bringing up the services.')
run_command(['docker-compose', 'up', '-d', 'op-proposer', 'op-batcher'], cwd=paths.ops_bedrock_dir, env={ run_command(['docker-compose', 'up', '-d', 'op-proposer', 'op-batcher'], cwd=paths.ops_bedrock_dir, env={
...@@ -114,6 +121,7 @@ def devnet_deploy(paths): ...@@ -114,6 +121,7 @@ def devnet_deploy(paths):
'PWD': paths.ops_bedrock_dir 'PWD': paths.ops_bedrock_dir
}) })
wait_up(8545) wait_up(8545)
wait_for_rpc_server('127.0.0.1:8545')
log.info('Generating network config.') log.info('Generating network config.')
devnet_cfg_orig = pjoin(paths.contracts_bedrock_dir, 'deploy-config', 'devnetL1.json') devnet_cfg_orig = pjoin(paths.contracts_bedrock_dir, 'deploy-config', 'devnetL1.json')
...@@ -124,16 +132,24 @@ def devnet_deploy(paths): ...@@ -124,16 +132,24 @@ def devnet_deploy(paths):
deploy_config['l1StartingBlockTag'] = 'earliest' deploy_config['l1StartingBlockTag'] = 'earliest'
write_json(devnet_cfg_orig, deploy_config) write_json(devnet_cfg_orig, deploy_config)
fqn = 'scripts/Deploy.s.sol:Deploy'
private_key = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
if os.path.exists(paths.addresses_json_path): if os.path.exists(paths.addresses_json_path):
log.info('Contracts already deployed.') log.info('Contracts already deployed.')
addresses = read_json(paths.addresses_json_path) addresses = read_json(paths.addresses_json_path)
else: else:
log.info('Deploying contracts.') log.info('Deploying contracts.')
run_command(['yarn', 'hardhat', '--network', 'devnetL1', 'deploy', '--tags', 'l1'], env={ run_command([
'CHAIN_ID': '900', 'forge', 'script', fqn, '--private-key', private_key,
'L1_RPC': 'http://localhost:8545', '--rpc-url', 'http://127.0.0.1:8545', '--broadcast'
'PRIVATE_KEY_DEPLOYER': 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' ], env={}, cwd=paths.contracts_bedrock_dir)
}, cwd=paths.contracts_bedrock_dir)
run_command([
'forge', 'script', fqn, '--private-key', private_key,
'--sig', 'sync()', '--rpc-url', 'http://127.0.0.1:8545', '--broadcast'
], env={}, cwd=paths.contracts_bedrock_dir)
contracts = os.listdir(paths.deployment_dir) contracts = os.listdir(paths.deployment_dir)
addresses = {} addresses = {}
for c in contracts: for c in contracts:
...@@ -148,12 +164,14 @@ def devnet_deploy(paths): ...@@ -148,12 +164,14 @@ def devnet_deploy(paths):
'CanonicalTransactionChain': '0x0000000000000000000000000000000000000000', 'CanonicalTransactionChain': '0x0000000000000000000000000000000000000000',
'BondManager': '0x0000000000000000000000000000000000000000', 'BondManager': '0x0000000000000000000000000000000000000000',
}) })
sdk_addresses['L1CrossDomainMessenger'] = addresses['Proxy__OVM_L1CrossDomainMessenger']
sdk_addresses['L1StandardBridge'] = addresses['Proxy__OVM_L1StandardBridge'] sdk_addresses['L1CrossDomainMessenger'] = addresses['L1CrossDomainMessengerProxy']
sdk_addresses['L1StandardBridge'] = addresses['L1StandardBridgeProxy']
sdk_addresses['OptimismPortal'] = addresses['OptimismPortalProxy'] sdk_addresses['OptimismPortal'] = addresses['OptimismPortalProxy']
sdk_addresses['L2OutputOracle'] = addresses['L2OutputOracleProxy'] sdk_addresses['L2OutputOracle'] = addresses['L2OutputOracleProxy']
write_json(paths.addresses_json_path, addresses) write_json(paths.addresses_json_path, addresses)
write_json(paths.sdk_addresses_json_path, sdk_addresses) write_json(paths.sdk_addresses_json_path, sdk_addresses)
log.info(f'Wrote sdk addresses to {paths.sdk_addresses_json_path}')
if os.path.exists(paths.genesis_l2_path): if os.path.exists(paths.genesis_l2_path):
log.info('L2 genesis and rollup configs already generated.') log.info('L2 genesis and rollup configs already generated.')
...@@ -178,6 +196,7 @@ def devnet_deploy(paths): ...@@ -178,6 +196,7 @@ def devnet_deploy(paths):
'PWD': paths.ops_bedrock_dir 'PWD': paths.ops_bedrock_dir
}) })
wait_up(9545) wait_up(9545)
wait_for_rpc_server('127.0.0.1:9545')
log.info('Bringing up everything else.') log.info('Bringing up everything else.')
run_command(['docker-compose', 'up', '-d', 'op-node', 'op-proposer', 'op-batcher'], cwd=paths.ops_bedrock_dir, env={ run_command(['docker-compose', 'up', '-d', 'op-node', 'op-proposer', 'op-batcher'], cwd=paths.ops_bedrock_dir, env={
...@@ -189,6 +208,26 @@ def devnet_deploy(paths): ...@@ -189,6 +208,26 @@ def devnet_deploy(paths):
log.info('Devnet ready.') log.info('Devnet ready.')
def wait_for_rpc_server(url):
log.info(f'Waiting for RPC server at {url}')
conn = http.client.HTTPConnection(url)
headers = {'Content-type': 'application/json'}
body = '{"id":1, "jsonrpc":"2.0", "method": "eth_chainId", "params":[]}'
while True:
try:
conn.request('POST', '/', body, headers)
response = conn.getresponse()
conn.close()
if response.status < 300:
log.info(f'RPC server at {url} ready')
return
except Exception as e:
log.info(f'Waiting for RPC server at {url}')
time.sleep(1)
def run_command(args, check=True, shell=False, cwd=None, env=None): def run_command(args, check=True, shell=False, cwd=None, env=None):
env = env if env else {} env = env if env else {}
return subprocess.run( return subprocess.run(
......
...@@ -19,7 +19,7 @@ type Deployment struct { ...@@ -19,7 +19,7 @@ type Deployment struct {
DeployedBytecode hexutil.Bytes `json:"deployedBytecode"` DeployedBytecode hexutil.Bytes `json:"deployedBytecode"`
Devdoc json.RawMessage `json:"devdoc"` Devdoc json.RawMessage `json:"devdoc"`
Metadata string `json:"metadata"` Metadata string `json:"metadata"`
Receipt Receipt `json:"receipt"` Receipt json.RawMessage `json:"receipt"`
SolcInputHash string `json:"solcInputHash"` SolcInputHash string `json:"solcInputHash"`
StorageLayout solc.StorageLayout `json:"storageLayout"` StorageLayout solc.StorageLayout `json:"storageLayout"`
TransactionHash common.Hash `json:"transactionHash"` TransactionHash common.Hash `json:"transactionHash"`
......
...@@ -239,16 +239,16 @@ func BuildL1DeveloperGenesis(config *DeployConfig) (*core.Genesis, error) { ...@@ -239,16 +239,16 @@ func BuildL1DeveloperGenesis(config *DeployConfig) (*core.Genesis, error) {
} }
} }
if config.FundDevAccounts {
FundDevAccounts(memDB)
SetPrecompileBalances(memDB)
}
stateDB, err := backend.Blockchain().State() stateDB, err := backend.Blockchain().State()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if config.FundDevAccounts {
FundDevAccounts(stateDB)
SetPrecompileBalances(stateDB)
}
for _, dep := range deployments { for _, dep := range deployments {
st, err := stateDB.StorageTrie(dep.Address) st, err := stateDB.StorageTrie(dep.Address)
if err != nil { if err != nil {
......
...@@ -13,3 +13,6 @@ deploy-config/mainnet-forked.json ...@@ -13,3 +13,6 @@ deploy-config/mainnet-forked.json
test-case-generator/fuzz test-case-generator/fuzz
.resource-metering.csv .resource-metering.csv
scripts/differential-testing/differential-testing scripts/differential-testing/differential-testing
deployments/900
deployments/901
deployments/hardhat
...@@ -92,19 +92,26 @@ yarn echidna:aliasing ...@@ -92,19 +92,26 @@ yarn echidna:aliasing
### Deployment ### Deployment
The smart contracts are deployed using `foundry` with a `hardhat-deploy` compatibility layer. When the contracts are deployed,
they will write a temp file to disk that can then be formatted into a `hardhat-deploy` style artifact by calling another script.
#### Configuration #### Configuration
1. Create or modify a file `<network-name>.ts` inside of the [`deploy-config`](./deploy-config/) folder. Create or modify a file `<network-name>.json` inside of the [`deploy-config`](./deploy-config/) folder.
2. Fill out this file according to the `deployConfigSpec` located inside of the [`hardhat.config.ts](./hardhat.config.ts) By default, the network name will be selected automatically based on the chainid. Alternatively, the `DEPLOYMENT_CONTEXT` env var can be used to override the network name.
3. Optionally: Run `npx hardhat generate-deploy-config --network <network-name>` to generate the associated JSON The spec for the deploy config is defined by the `deployConfigSpec` located inside of the [`hardhat.config.ts`](./hardhat.config.ts).
file. This is required if using `op-chain-ops`.
#### Execution #### Execution
1. Copy `.env.example` into `.env` 1. Set the env vars `ETH_RPC_URL`, `PRIVATE_KEY` and `ETHERSCAN_API_KEY` if contract verification is desired
2. Fill out the `L1_RPC` and `PRIVATE_KEY_DEPLOYER` environment variables in `.env` 1. Deploy the contracts with `forge script -vvv scripts/Deploy.s.sol:Deploy --rpc-url $ETH_RPC_URL --broadcast --private-key $PRIVATE_KEY`
3. Run `npx hardhat deploy --network <network-name>` to deploy the L1 contracts Pass the `--verify` flag to verify the deployments automatically with Etherscan.
4. Run `npx hardhat etherscan-verify --network <network-name> --sleep` to verify contracts on Etherscan 1. Generate the hardhat deploy artifacts with `forge script -vvv scripts/Deploy.s.sol:Deploy --sig 'sync()' --rpc-url $ETH_RPC_URL --broadcast --private-key $PRIVATE_KEY`
#### Deploying a single contract
All of the functions for deploying a single contract are `public` meaning that the `--sig` argument to `forge script` can be used to
target the deployment of a single contract.
## Tools ## Tools
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
"batchSenderAddress": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", "batchSenderAddress": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"l2OutputOracleSubmissionInterval": 20, "l2OutputOracleSubmissionInterval": 20,
"l2OutputOracleStartingTimestamp": -1, "l2OutputOracleStartingTimestamp": -1,
"l2OutputOracleStartingBlockNumber": 0,
"l2OutputOracleProposer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "l2OutputOracleProposer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"l2OutputOracleChallenger": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", "l2OutputOracleChallenger": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
"l2GenesisBlockGasLimit": "0x1c9c380", "l2GenesisBlockGasLimit": "0x1c9c380",
...@@ -40,7 +41,7 @@ ...@@ -40,7 +41,7 @@
"governanceTokenOwner": "0xBcd4042DE499D14e55001CcbB24a551F3b954096", "governanceTokenOwner": "0xBcd4042DE499D14e55001CcbB24a551F3b954096",
"eip1559Denominator": 8, "eip1559Denominator": 8,
"eip1559Elasticity": 2, "eip1559Elasticity": 2,
"l1GenesisBlockTimestamp": "0x648a0943", "l1GenesisBlockTimestamp": "0x64935846",
"l1StartingBlockTag": "earliest", "l1StartingBlockTag": "earliest",
"l2GenesisRegolithTimeOffset": "0x0" "l2GenesisRegolithTimeOffset": "0x0"
} }
\ No newline at end of file
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
"l2OutputOracleSubmissionInterval": 6, "l2OutputOracleSubmissionInterval": 6,
"l2OutputOracleStartingTimestamp": 0, "l2OutputOracleStartingTimestamp": 0,
"l2OutputOracleStartingBlockNumber": 0, "l2OutputOracleStartingBlockNumber": 0,
"gasPriceOracleOverhead": 2100,
"gasPriceOracleScalar": 1000000,
"l2OutputOracleProposer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "l2OutputOracleProposer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"l2OutputOracleChallenger": "0x6925B8704Ff96DEe942623d6FB5e946EF5884b63", "l2OutputOracleChallenger": "0x6925B8704Ff96DEe942623d6FB5e946EF5884b63",
"l2GenesisBlockBaseFeePerGas": "0x3B9ACA00", "l2GenesisBlockBaseFeePerGas": "0x3B9ACA00",
...@@ -34,5 +36,8 @@ ...@@ -34,5 +36,8 @@
"governanceTokenSymbol": "OP", "governanceTokenSymbol": "OP",
"governanceTokenOwner": "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", "governanceTokenOwner": "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc",
"finalizationPeriodSeconds": 2, "finalizationPeriodSeconds": 2,
"numDeployConfirmations": 1 "numDeployConfirmations": 1,
"eip1559Denominator": 50,
"eip1559Elasticity": 10,
"l2GenesisRegolithTimeOffset": "0x0"
} }
\ No newline at end of file
...@@ -22,7 +22,10 @@ no_match_contract = 'EchidnaFuzz' ...@@ -22,7 +22,10 @@ no_match_contract = 'EchidnaFuzz'
fs_permissions = [ fs_permissions = [
{ 'access'='read-write', 'path'='./.resource-metering.csv' }, { 'access'='read-write', 'path'='./.resource-metering.csv' },
{ 'access'='read', 'path'='./deployments/' }, { 'access'='read-write', 'path'='./deployments/' },
{ 'access'='read', 'path'='./deploy-config/' },
{ 'access'='read', 'path'='./broadcast/' },
{ access = 'read', path = './forge-artifacts/' },
] ]
[profile.ci] [profile.ci]
......
...@@ -26,7 +26,6 @@ ...@@ -26,7 +26,6 @@
"build:fuzz": "(cd test-case-generator && go build ./cmd/fuzz.go)", "build:fuzz": "(cd test-case-generator && go build ./cmd/fuzz.go)",
"autogen:artifacts": "ts-node scripts/generate-artifacts.ts", "autogen:artifacts": "ts-node scripts/generate-artifacts.ts",
"autogen:invariant-docs": "ts-node scripts/invariant-doc-gen.ts", "autogen:invariant-docs": "ts-node scripts/invariant-doc-gen.ts",
"deploy": "hardhat deploy",
"test": "yarn build:differential && yarn build:fuzz && forge test", "test": "yarn build:differential && yarn build:fuzz && forge test",
"coverage": "yarn build:differential && yarn build:fuzz && forge coverage", "coverage": "yarn build:differential && yarn build:fuzz && forge coverage",
"coverage:lcov": "yarn build:differential && yarn build:fuzz && forge coverage --report lcov", "coverage:lcov": "yarn build:differential && yarn build:fuzz && forge coverage --report lcov",
......
This diff is collapsed.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { Script } from "forge-std/Script.sol";
import { console2 as console } from "forge-std/console2.sol";
import { stdJson } from "forge-std/StdJson.sol";
import { Executables } from "./Executables.sol";
/// @title DeployConfig
/// @notice Represents the configuration required to deploy the system. It is expected
/// to read the file from JSON. A future improvement would be to have fallback
/// values if they are not defined in the JSON themselves.
contract DeployConfig is Script {
string internal _json;
address public finalSystemOwner;
address public controller;
address public portalGuardian;
uint256 public l1ChainID;
uint256 public l2ChainID;
uint256 public l2BlockTime;
uint256 public maxSequencerDrift;
uint256 public sequencerWindowSize;
uint256 public channelTimeout;
address public p2pSequencerAddress;
address public batchInboxAddress;
address public batchSenderAddress;
uint256 public l2OutputOracleSubmissionInterval;
int256 internal _l2OutputOracleStartingTimestamp;
uint256 public l2OutputOracleStartingBlockNumber;
address public l2OutputOracleProposer;
address public l2OutputOracleChallenger;
uint256 public finalizationPeriodSeconds;
address public proxyAdminOwner;
address public baseFeeVaultRecipient;
address public l1FeeVaultRecipient;
address public sequencerFeeVaultRecipient;
string public governanceTokenName;
string public governanceTokenSymbol;
address public governanceTokenOwner;
uint256 public l2GenesisBlockGasLimit;
uint256 public l2GenesisBlockBaseFeePerGas;
uint256 public gasPriceOracleOverhead;
uint256 public gasPriceOracleScalar;
uint256 public eip1559Denominator;
uint256 public eip1559Elasticity;
uint256 public l2GenesisRegolithTimeOffset;
constructor(string memory _path) {
console.log("DeployConfig: reading file %s", _path);
_json = vm.readFile(_path);
finalSystemOwner = stdJson.readAddress(_json, "$.finalSystemOwner");
controller = stdJson.readAddress(_json, "$.controller");
portalGuardian = stdJson.readAddress(_json, "$.portalGuardian");
l1ChainID = stdJson.readUint(_json, "$.l1ChainID");
l2ChainID = stdJson.readUint(_json, "$.l2ChainID");
l2BlockTime = stdJson.readUint(_json, "$.l2BlockTime");
maxSequencerDrift = stdJson.readUint(_json, "$.maxSequencerDrift");
sequencerWindowSize = stdJson.readUint(_json, "$.sequencerWindowSize");
channelTimeout = stdJson.readUint(_json, "$.channelTimeout");
p2pSequencerAddress = stdJson.readAddress(_json, "$.p2pSequencerAddress");
batchInboxAddress = stdJson.readAddress(_json, "$.batchInboxAddress");
batchSenderAddress = stdJson.readAddress(_json, "$.batchSenderAddress");
l2OutputOracleSubmissionInterval = stdJson.readUint(_json, "$.l2OutputOracleSubmissionInterval");
_l2OutputOracleStartingTimestamp = stdJson.readInt(_json, "$.l2OutputOracleStartingTimestamp");
l2OutputOracleStartingBlockNumber = stdJson.readUint(_json, "$.l2OutputOracleStartingBlockNumber");
l2OutputOracleProposer = stdJson.readAddress(_json, "$.l2OutputOracleProposer");
l2OutputOracleChallenger = stdJson.readAddress(_json, "$.l2OutputOracleChallenger");
finalizationPeriodSeconds = stdJson.readUint(_json, "$.finalizationPeriodSeconds");
proxyAdminOwner = stdJson.readAddress(_json, "$.proxyAdminOwner");
baseFeeVaultRecipient = stdJson.readAddress(_json, "$.baseFeeVaultRecipient");
l1FeeVaultRecipient = stdJson.readAddress(_json, "$.l1FeeVaultRecipient");
sequencerFeeVaultRecipient = stdJson.readAddress(_json, "$.sequencerFeeVaultRecipient");
governanceTokenName = stdJson.readString(_json, "$.governanceTokenName");
governanceTokenSymbol = stdJson.readString(_json, "$.governanceTokenSymbol");
governanceTokenOwner = stdJson.readAddress(_json, "$.governanceTokenOwner");
l2GenesisBlockGasLimit = stdJson.readUint(_json, "$.l2GenesisBlockGasLimit");
l2GenesisBlockBaseFeePerGas = stdJson.readUint(_json, "$.l2GenesisBlockBaseFeePerGas");
gasPriceOracleOverhead = stdJson.readUint(_json, "$.gasPriceOracleOverhead");
gasPriceOracleScalar = stdJson.readUint(_json, "$.gasPriceOracleScalar");
eip1559Denominator = stdJson.readUint(_json, "$.eip1559Denominator");
eip1559Elasticity = stdJson.readUint(_json, "$.eip1559Elasticity");
l2GenesisRegolithTimeOffset = stdJson.readUint(_json, "$.l2GenesisRegolithTimeOffset");
}
function l1StartingBlockTag() public returns (bytes32) {
try vm.parseJsonBytes32(_json, "$.l1StartingBlockTag") returns (bytes32 tag) {
return tag;
} catch {
try vm.parseJsonString(_json, "$.l1StartingBlockTag") returns (string memory tag) {
return _getBlockByTag(tag);
} catch {
try vm.parseJsonUint(_json, "$.l1StartingBlockTag") returns (uint256 tag) {
return _getBlockByTag(vm.toString(tag));
} catch {}
}
}
revert("l1StartingBlockTag must be a bytes32, string or uint256 or cannot fetch l1StartingBlockTag");
}
function l2OutputOracleStartingTimestamp() public returns (uint256) {
if (_l2OutputOracleStartingTimestamp < 0) {
bytes32 tag = l1StartingBlockTag();
string[] memory cmd = new string[](3);
cmd[0] = Executables.bash;
cmd[1] = "-c";
cmd[2] = string.concat("cast block ", vm.toString(tag), " --json | ", Executables.jq, " .timestamp");
bytes memory res = vm.ffi(cmd);
return stdJson.readUint(string(res), "");
}
return uint256(_l2OutputOracleStartingTimestamp);
}
function _getBlockByTag(string memory _tag) internal returns (bytes32) {
string[] memory cmd = new string[](3);
cmd[0] = Executables.bash;
cmd[1] = "-c";
cmd[2] = string.concat("cast block ", _tag, " --json | ", Executables.jq, " -r .hash");
bytes memory res = vm.ffi(cmd);
return abi.decode(res, (bytes32));
}
}
This diff is collapsed.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @notice The executables used in ffi commands. These are set here
/// to have a single source of truth in case absolute paths
/// need to be used.
library Executables {
string internal constant bash = "bash";
string internal constant jq = "jq";
string internal constant forge = "forge";
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment