diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 78ad66f58..5a228b2ac 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -22,9 +22,6 @@ on: jobs: test-foundry: - strategy: - fail-fast: true - name: Foundry tests runs-on: ubuntu-latest steps: @@ -48,6 +45,40 @@ jobs: forge test -vvv id: test + deploy: + name: Deploy contracts to Liquity v2 Testnet + runs-on: ubuntu-latest + steps: + - name: Git checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install pnpm + uses: pnpm/action-setup@v3.0.0 + with: + version: 8 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.node-version' + cache: 'pnpm' + cache-dependency-path: 'contracts/pnpm-lock.yaml' + + - name: Install dependencies + run: pnpm install + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Run deployment tool + run: ./deploy liquity-testnet --verify + env: + DEPLOYER: ${{ secrets.DEPLOYER }} + console-logs: name: Check we didn’t forget to remove console imports runs-on: ubuntu-latest diff --git a/contracts/.gitignore b/contracts/.gitignore index 842f3e850..26e798295 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -5,6 +5,7 @@ out/ # Ignores development broadcast logs !/broadcast /broadcast/*/31337/ +/broadcast/*/1337/ /broadcast/**/dry-run/ # Docs diff --git a/contracts/deploy b/contracts/deploy new file mode 100755 index 000000000..b48ee53fe --- /dev/null +++ b/contracts/deploy @@ -0,0 +1,8 @@ +#!/usr/bin/env -S npx tsx + +require("./utils/deploy-cli").main().catch(({ message }) => { + console.error(""); + console.error(` Error: ${message}`); + console.error(""); + process.exit(1); +}); diff --git a/contracts/package.json b/contracts/package.json index bdab8073e..d3af97c8f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -37,7 +37,9 @@ "hardhat-gas-reporter": "^1.0.8", "solidity-coverage": "^0.8.8", "ts-node": ">=8.0.0", + "tsx": "^4.7.1", "typechain": "^8.1.0", - "typescript": ">=4.5.0" + "typescript": ">=4.5.0", + "zx": "^7.2.3" } } diff --git a/contracts/src/deployment.sol b/contracts/src/deployment.sol new file mode 100644 index 000000000..a25a52dc6 --- /dev/null +++ b/contracts/src/deployment.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; + +import "./ActivePool.sol"; +import "./BoldToken.sol"; +import "./BorrowerOperations.sol"; +import "./CollSurplusPool.sol"; +import "./DefaultPool.sol"; +import "./GasPool.sol"; +import "./HintHelpers.sol"; +import "./MultiTroveGetter.sol"; +import "./SortedTroves.sol"; +import "./StabilityPool.sol"; +import "./TroveManager.sol"; +import "./MockInterestRouter.sol"; +import "./test/TestContracts/PriceFeedTestnet.sol"; + +struct LiquityContracts { + IActivePool activePool; + IBorrowerOperations borrowerOperations; + ICollSurplusPool collSurplusPool; + IDefaultPool defaultPool; + ISortedTroves sortedTroves; + IStabilityPool stabilityPool; + ITroveManager troveManager; + IBoldToken boldToken; + IPriceFeedTestnet priceFeed; + GasPool gasPool; + IInterestRouter interestRouter; + IERC20 WETH; +} + +function _deployAndConnectContracts() returns (LiquityContracts memory contracts) { + contracts.WETH = new ERC20("Wrapped ETH", "WETH"); + + // TODO: optimize deployment order & constructor args & connector functions + + // Deploy all contracts + contracts.activePool = new ActivePool(address(contracts.WETH)); + contracts.borrowerOperations = new BorrowerOperations(address(contracts.WETH)); + contracts.collSurplusPool = new CollSurplusPool(address(contracts.WETH)); + contracts.defaultPool = new DefaultPool(address(contracts.WETH)); + contracts.gasPool = new GasPool(); + contracts.priceFeed = new PriceFeedTestnet(); + contracts.sortedTroves = new SortedTroves(); + contracts.stabilityPool = new StabilityPool(address(contracts.WETH)); + contracts.troveManager = new TroveManager(); + contracts.interestRouter = new MockInterestRouter(); + + contracts.boldToken = new BoldToken( + address(contracts.troveManager), + address(contracts.stabilityPool), + address(contracts.borrowerOperations), + address(contracts.activePool) + ); + + // Connect contracts + contracts.sortedTroves.setParams( + type(uint256).max, address(contracts.troveManager), address(contracts.borrowerOperations) + ); + + // set contracts in the Trove Manager + contracts.troveManager.setAddresses( + address(contracts.borrowerOperations), + address(contracts.activePool), + address(contracts.defaultPool), + address(contracts.stabilityPool), + address(contracts.gasPool), + address(contracts.collSurplusPool), + address(contracts.priceFeed), + address(contracts.boldToken), + address(contracts.sortedTroves) + ); + + // set contracts in BorrowerOperations + contracts.borrowerOperations.setAddresses( + address(contracts.troveManager), + address(contracts.activePool), + address(contracts.defaultPool), + address(contracts.stabilityPool), + address(contracts.gasPool), + address(contracts.collSurplusPool), + address(contracts.priceFeed), + address(contracts.sortedTroves), + address(contracts.boldToken) + ); + + // set contracts in the Pools + contracts.stabilityPool.setAddresses( + address(contracts.borrowerOperations), + address(contracts.troveManager), + address(contracts.activePool), + address(contracts.boldToken), + address(contracts.sortedTroves), + address(contracts.priceFeed) + ); + + contracts.activePool.setAddresses( + address(contracts.borrowerOperations), + address(contracts.troveManager), + address(contracts.stabilityPool), + address(contracts.defaultPool), + address(contracts.boldToken), + address(contracts.interestRouter) + ); + + contracts.defaultPool.setAddresses(address(contracts.troveManager), address(contracts.activePool)); + + contracts.collSurplusPool.setAddresses( + address(contracts.borrowerOperations), address(contracts.troveManager), address(contracts.activePool) + ); +} diff --git a/contracts/src/scripts/DeployLiquity2.s.sol b/contracts/src/scripts/DeployLiquity2.s.sol new file mode 100644 index 000000000..faed6ab2e --- /dev/null +++ b/contracts/src/scripts/DeployLiquity2.s.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.18; + +import {Script} from "forge-std/Script.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import "../deployment.sol"; +import {Accounts} from "../test/TestContracts/Accounts.sol"; + +contract DeployLiquity2Script is Script, StdCheats { + struct TroveParams { + uint256 coll; + uint256 debt; + } + + function run() external { + if (vm.envBytes("DEPLOYER").length == 20) { + // address + vm.startBroadcast(vm.envAddress("DEPLOYER")); + } else { + // private key + vm.startBroadcast(vm.envUint("DEPLOYER")); + } + + LiquityContracts memory contracts = _deployAndConnectContracts(); + vm.stopBroadcast(); + + if (vm.envOr("OPEN_DEMO_TROVES", false)) { + openDemoTroves(contracts.WETH, contracts.borrowerOperations); + } + } + + function openDemoTroves(IERC20 WETH, IBorrowerOperations borrowerOperations) internal { + address[10] memory accounts = createAccounts(WETH, borrowerOperations); + + uint256 eth = 1e18; + uint256 bold = 1e18; + + TroveParams[8] memory troves = [ + TroveParams(20 * eth, 1800 * bold), + TroveParams(32 * eth, 2800 * bold), + TroveParams(30 * eth, 4000 * bold), + TroveParams(65 * eth, 6000 * bold), + TroveParams(50 * eth, 5000 * bold), + TroveParams(37 * eth, 2400 * bold), + TroveParams(37 * eth, 2800 * bold), + TroveParams(36 * eth, 2222 * bold) + ]; + + for (uint256 i = 0; i < troves.length; i++) { + vm.startPrank(accounts[i]); + + borrowerOperations.openTrove( + accounts[i], // _owner + 1, // _ownerIndex + 1e18, // _maxFeePercentage + troves[i].coll, // _ETHAmount + troves[i].debt, // _boldAmount + 0, // _upperHint + 0, // _lowerHint + 0.05e18 // _annualInterestRate + ); + + vm.stopPrank(); + } + } + + function createAccounts(IERC20 WETH, IBorrowerOperations borrowerOperations) + internal + returns (address[10] memory accountsList) + { + Accounts accounts = new Accounts(); + + for (uint256 i = 0; i < accounts.getAccountsCount(); i++) { + accountsList[i] = vm.addr(uint256(accounts.accountsPks(i))); + deal(address(WETH), accountsList[i], 1000e18); + + // Approve infinite WETH to BorrowerOperations + vm.startPrank(accountsList[i]); + WETH.approve(address(borrowerOperations), type(uint256).max); + vm.stopPrank(); + } + } +} diff --git a/contracts/src/test/TestContracts/DevTestSetup.sol b/contracts/src/test/TestContracts/DevTestSetup.sol index 2bd6f48cc..25add719d 100644 --- a/contracts/src/test/TestContracts/DevTestSetup.sol +++ b/contracts/src/test/TestContracts/DevTestSetup.sol @@ -2,24 +2,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.18; -import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; - -import "./Interfaces/IPriceFeedTestnet.sol"; - -import "../../ActivePool.sol"; -import "../../BoldToken.sol"; -import "../../BorrowerOperations.sol"; -import "../../CollSurplusPool.sol"; -import "../../DefaultPool.sol"; -import "../../GasPool.sol"; -import "../../HintHelpers.sol"; -import "../../MultiTroveGetter.sol"; -import "../../SortedTroves.sol"; -import "../../StabilityPool.sol"; -import "../../TroveManager.sol"; -import "../../MockInterestRouter.sol"; - import "./BaseTest.sol"; +import "../../deployment.sol"; contract DevTestSetup is BaseTest { @@ -51,85 +35,19 @@ contract DevTestSetup is BaseTest { (A, B, C, D, E, F, G) = (accountsList[0], accountsList[1], accountsList[2], accountsList[3], accountsList[4], accountsList[5], accountsList[6]); - WETH = new ERC20("Wrapped ETH", "WETH"); - - // TODO: optimize deployment order & constructor args & connector functions - - // Deploy all contracts - activePool = new ActivePool(address(WETH)); - borrowerOperations = new BorrowerOperations(address(WETH)); - collSurplusPool = new CollSurplusPool(address(WETH)); - defaultPool = new DefaultPool(address(WETH)); - gasPool = new GasPool(); - priceFeed = new PriceFeedTestnet(); - sortedTroves = new SortedTroves(); - stabilityPool = new StabilityPool(address(WETH)); - troveManager = new TroveManager(); - boldToken = new BoldToken(address(troveManager), address(stabilityPool), address(borrowerOperations), address(activePool)); - mockInterestRouter = new MockInterestRouter(); - - // Connect contracts - sortedTroves.setParams( - MAX_UINT256, - address(troveManager), - address(borrowerOperations) - ); - - // set contracts in the Trove Manager - troveManager.setAddresses( - address(borrowerOperations), - address(activePool), - address(defaultPool), - address(stabilityPool), - address(gasPool), - address(collSurplusPool), - address(priceFeed), - address(boldToken), - address(sortedTroves) - ); - - // set contracts in BorrowerOperations - borrowerOperations.setAddresses( - address(troveManager), - address(activePool), - address(defaultPool), - address(stabilityPool), - address(gasPool), - address(collSurplusPool), - address(priceFeed), - address(sortedTroves), - address(boldToken) - ); - - // set contracts in the Pools - stabilityPool.setAddresses( - address(borrowerOperations), - address(troveManager), - address(activePool), - address(boldToken), - address(sortedTroves), - address(priceFeed) - ); - - activePool.setAddresses( - address(borrowerOperations), - address(troveManager), - address(stabilityPool), - address(defaultPool), - address(boldToken), - address(mockInterestRouter) - ); - - defaultPool.setAddresses( - address(troveManager), - address(activePool) - ); - - collSurplusPool.setAddresses( - address(borrowerOperations), - address(troveManager), - address(activePool) - ); + LiquityContracts memory contracts = _deployAndConnectContracts(); + WETH = contracts.WETH; + activePool = contracts.activePool; + borrowerOperations = contracts.borrowerOperations; + collSurplusPool = contracts.collSurplusPool; + defaultPool = contracts.defaultPool; + gasPool = contracts.gasPool; + priceFeed = contracts.priceFeed; + sortedTroves = contracts.sortedTroves; + stabilityPool = contracts.stabilityPool; + troveManager = contracts.troveManager; + boldToken = contracts.boldToken; + mockInterestRouter = contracts.interestRouter; // Give some ETH to test accounts, and approve it to BorrowerOperations uint256 initialETHAmount = 10_000e18; diff --git a/contracts/utils/deploy-cli.ts b/contracts/utils/deploy-cli.ts new file mode 100644 index 000000000..79344f66f --- /dev/null +++ b/contracts/utils/deploy-cli.ts @@ -0,0 +1,269 @@ +import { $, argv, echo, fs } from "zx"; + +const HELP = ` +deploy - deploy the Liquity contracts. + +Usage: + ./deploy [NETWORK_PRESET] [OPTIONS] + +Arguments: + NETWORK_PRESET A network preset, which is a shorthand for setting certain options + such as the chain ID and RPC URL. Options take precedence over + network presets. Available presets: + - local: Deploy to a local network + - mainnet: Deploy to the Ethereum mainnet + - tenderly-devnet: Deploy to a Tenderly devnet + - liquity-testnet: Deploy to the Liquity v2 testnet + + +Options: + --chain-id Chain ID to deploy to. + --deployer Address or private key to deploy with. + Requires a Ledger if an address is used. + --ledger-path HD path to use with the Ledger (only used + when DEPLOYER is an address). + --etherscan-api-key Etherscan API key to verify the contracts + (required when verifying with Etherscan). + --help, -h Show this help message. + --open-demo-troves Open demo troves after deployment (local + only). + --rpc-url RPC URL to use. + --verify Verify contracts after deployment. + --verifier Verification provider to use. + Possible values: etherscan, sourcify. + --verifier-url The verifier URL, if using a custom + provider. + +Note: options can also be set via corresponding environment variables, +e.g. --chain-id can be set via CHAIN_ID instead. Parameters take precedence over variables. +`; + +const ANVIL_FIRST_ACCOUNT = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + +export async function main() { + const { networkPreset, options } = await parseArgs(); + + if (options.help) { + echo`${HELP}`; + process.exit(0); + } + + // network preset: local + if (networkPreset === "local") { + options.chainId ??= 31337; + options.deployer ??= ANVIL_FIRST_ACCOUNT; + options.rpcUrl ??= "http://localhost:8545"; + } + + // network preset: liquity-testnet + if (networkPreset === "liquity-testnet") { + options.chainId ??= 1337; + options.rpcUrl ??= "https://testnet.liquity.org/rpc"; + options.verifier ??= "sourcify"; + options.verifierUrl ??= "https://testnet.liquity.org/sourcify/server"; + } + + // network preset: tenderly-devnet + if (networkPreset === "tenderly-devnet") { + options.chainId ??= 1; + options.rpcUrl ??= ( + await $`tenderly devnet spawn-rpc ${[ + "--project", + "project", + "--template", + "liquity2", + ]} 2>&1`.quiet() + ).stdout.trim(); + } + + // network preset: mainnet + if (networkPreset === "mainnet") { + options.chainId ??= 1; + } + + options.verifier ??= "etherscan"; + + // handle missing options + if (!options.chainId) { + throw new Error("--chain-id is required"); + } + if (!options.rpcUrl) { + throw new Error("--rpc-url is required"); + } + if (!options.deployer) { + throw new Error("--deployer is required"); + } + if (options.verify && options.verifier === "etherscan" && !options.etherscanApiKey) { + throw new Error( + "Verifying with Etherscan requires --etherscan-api-key ", + ); + } + + const forgeArgs: string[] = [ + "script", + "src/scripts/DeployLiquity2.s.sol", + "--chain-id", + String(options.chainId), + "--rpc-url", + options.rpcUrl, + "--broadcast", + ]; + + // verify + if (options.verify) { + forgeArgs.push("--verify"); + + // Etherscan API key + if (options.etherscanApiKey) { + forgeArgs.push("--etherscan-api-key"); + forgeArgs.push(options.etherscanApiKey); + } + + // verifier + if (options.verifier) { + forgeArgs.push("--verifier"); + forgeArgs.push(options.verifier); + } + + // verifier URL + if (options.verifierUrl) { + forgeArgs.push("--verifier-url"); + forgeArgs.push(options.verifierUrl); + } + } + + // Ledger signing + if (options.deployer.startsWith("0x") && options.deployer.length === 42) { + forgeArgs.push("--ledger"); + if (options.ledgerPath) { + forgeArgs.push("--hd-paths"); + forgeArgs.push(options.ledgerPath); + } + } + + echo` +Deploying Liquity contracts with the following settings: + + CHAIN_ID: ${options.chainId} + DEPLOYER: ${options.deployer} + LEDGER_PATH: ${options.ledgerPath} + ETHERSCAN_API_KEY: ${options.etherscanApiKey && "(secret)"} + OPEN_DEMO_TROVES: ${options.openDemoTroves ? "yes" : "no"} + RPC_URL: ${options.rpcUrl} + VERIFY: ${options.verify ? "yes" : "no"} + VERIFIER: ${options.verifier} + VERIFIER_URL: ${options.verifierUrl} +`; + + const envVars = [ + `DEPLOYER=${options.deployer}`, + ]; + + if (options.openDemoTroves) { + envVars.push("OPEN_DEMO_TROVES=true"); + } + + // deploy + await $`${envVars} forge ${forgeArgs}`; + + const deployedContracts = await getDeployedContracts( + `broadcast/DeployLiquity2.s.sol/${options.chainId}/run-latest.json`, + ); + + // format deployed contracts + const longestContractName = Math.max( + ...deployedContracts.map(([name]) => name.length), + ); + const deployedContractsFormatted = deployedContracts + .map(([name, address]) => `${name.padEnd(longestContractName)} ${address}`) + .join("\n"); + + echo("Contract deployment complete."); + echo(""); + echo(deployedContractsFormatted); + echo(""); +} + +function isDeploymentLog(log: unknown): log is { + transactions: Array<{ + transactionType: "CREATE"; + contractName: string; + contractAddress: string; + }>; +} { + return ( + typeof log === "object" + && log !== null + && "transactions" in log + && Array.isArray(log.transactions) + && log.transactions + .filter(tx => ( + typeof tx === "object" + && tx !== null + && tx.transactionType === "CREATE" + )) + .every(tx => ( + typeof tx.contractName === "string" + && typeof tx.contractAddress === "string" + )) + ); +} + +async function getDeployedContracts(jsonPath: string) { + const latestRun = await fs.readJson(jsonPath); + + if (isDeploymentLog(latestRun)) { + return latestRun.transactions + .filter((tx) => tx.transactionType === "CREATE") + .map((tx) => [tx.contractName, tx.contractAddress]); + } + + throw new Error("Invalid deployment log: " + JSON.stringify(latestRun)); +} + +function argInt(name: string) { + return typeof argv[name] === "number" ? parseInt(argv[name], 10) : undefined; +} + +function argBoolean(name: string) { + // allow "false" + return argv[name] === "false" ? false : Boolean(argv[name]); +} + +async function parseArgs() { + const options = { + chainId: argInt("chain-id"), + deployer: argv["deployer"], + etherscanApiKey: argv["etherscan-api-key"], + help: "help" in argv || "h" in argv, + ledgerPath: argv["ledger-path"], + openDemoTroves: argBoolean("open-demo-troves"), + rpcUrl: argv["rpc-url"], + verify: argBoolean("verify"), + verifier: argv["verifier"], + verifierUrl: argv["verifier-url"] + }; + + const [networkPreset] = argv._; + + if (options.chainId === undefined) { + const chainIdEnv = parseInt(process.env.CHAIN_ID ?? "", 10); + if (chainIdEnv && isNaN(chainIdEnv)) { + options.chainId = chainIdEnv; + } + } + options.deployer ??= process.env.DEPLOYER; + options.etherscanApiKey ??= process.env.ETHERSCAN_API_KEY; + options.ledgerPath ??= process.env.LEDGER_PATH; + options.openDemoTroves ??= Boolean( + process.env.OPEN_DEMO_TROVES && process.env.OPEN_DEMO_TROVES !== "false", + ); + options.rpcUrl ??= process.env.RPC_URL; + options.verify ??= Boolean( + process.env.VERIFY && process.env.VERIFY !== "false", + ); + options.verifier ??= process.env.VERIFIER; + options.verifierUrl ??= process.env.VERIFIER_URL; + + return { options, networkPreset }; +}