diff --git a/crates/forge/assets/.gitignoreTemplate b/crates/forge/assets/solidity/.gitignoreTemplate similarity index 100% rename from crates/forge/assets/.gitignoreTemplate rename to crates/forge/assets/solidity/.gitignoreTemplate diff --git a/crates/forge/assets/CounterTemplate.s.sol b/crates/forge/assets/solidity/CounterTemplate.s.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.s.sol rename to crates/forge/assets/solidity/CounterTemplate.s.sol diff --git a/crates/forge/assets/CounterTemplate.sol b/crates/forge/assets/solidity/CounterTemplate.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.sol rename to crates/forge/assets/solidity/CounterTemplate.sol diff --git a/crates/forge/assets/CounterTemplate.t.sol b/crates/forge/assets/solidity/CounterTemplate.t.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.t.sol rename to crates/forge/assets/solidity/CounterTemplate.t.sol diff --git a/crates/forge/assets/README.md b/crates/forge/assets/solidity/README.md similarity index 100% rename from crates/forge/assets/README.md rename to crates/forge/assets/solidity/README.md diff --git a/crates/forge/assets/workflowTemplate.yml b/crates/forge/assets/solidity/workflowTemplate.yml similarity index 100% rename from crates/forge/assets/workflowTemplate.yml rename to crates/forge/assets/solidity/workflowTemplate.yml diff --git a/crates/forge/assets/vyper/CounterTemplate.s.sol b/crates/forge/assets/vyper/CounterTemplate.s.sol new file mode 100644 index 0000000000000..5ac11b8860858 --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.s.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {VyperDeployer} from "../src/utils/VyperDeployer.sol"; + +contract CounterScript is Script { + uint256 public constant INITIAL_COUNTER = 42; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + VyperDeployer vyperDeployer = new VyperDeployer(); + + vm.startBroadcast(deployerPrivateKey); + + address deployedAddress = vyperDeployer.deployContract("Counter", abi.encode(INITIAL_COUNTER)); + + require(deployedAddress != address(0), "Could not deploy contract"); + + vm.stopBroadcast(); + } +} diff --git a/crates/forge/assets/vyper/CounterTemplate.t.sol b/crates/forge/assets/vyper/CounterTemplate.t.sol new file mode 100644 index 0000000000000..dd77c928e9edb --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.t.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {ICounter} from "../src/interface/ICounter.sol"; +import {VyperDeployer} from "../src/utils/VyperDeployer.sol"; + +contract CounterTest is Test { + VyperDeployer public vyperDeployer; + ICounter public counterContract; + uint256 public constant INITIAL_COUNTER = 42; + + function setUp() public { + vyperDeployer = new VyperDeployer(); + counterContract = ICounter(vyperDeployer.deployContract("Counter", abi.encode(INITIAL_COUNTER))); + } + + function test_getCounter() public view { + uint256 counter = counterContract.counter(); + assertEq(counter, INITIAL_COUNTER); + } + + function test_setCounter() public { + uint256 newCounter = 100; + counterContract.set_counter(newCounter); + uint256 counter = counterContract.counter(); + assertEq(counter, newCounter); + } + + function test_increment() public { + uint256 counterBefore = counterContract.counter(); + counterContract.increment(); + uint256 counterAfter = counterContract.counter(); + assertEq(counterAfter, counterBefore + 1); + } +} diff --git a/crates/forge/assets/vyper/CounterTemplate.vy b/crates/forge/assets/vyper/CounterTemplate.vy new file mode 100644 index 0000000000000..acad465cfa0a7 --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.vy @@ -0,0 +1,14 @@ +counter: public(uint256) + +@deploy +@payable +def __init__(initial_counter: uint256): + self.counter = initial_counter + +@external +def set_counter(new_counter: uint256): + self.counter = new_counter + +@external +def increment(): + self.counter += 1 diff --git a/crates/forge/assets/vyper/ICounterTemplate.sol b/crates/forge/assets/vyper/ICounterTemplate.sol new file mode 100644 index 0000000000000..33bcbc77ab7d1 --- /dev/null +++ b/crates/forge/assets/vyper/ICounterTemplate.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +interface ICounter { + function counter() external view returns (uint256); + function set_counter(uint256 new_counter) external; + function increment() external; +} \ No newline at end of file diff --git a/crates/forge/assets/vyper/README.md b/crates/forge/assets/vyper/README.md new file mode 100644 index 0000000000000..14f8d38c321bb --- /dev/null +++ b/crates/forge/assets/vyper/README.md @@ -0,0 +1,67 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Getting Started + +### Prerequisites + +- [Vyper](https://vyper.readthedocs.io/) + + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/crates/forge/assets/vyper/VyperDeployerTemplate.sol b/crates/forge/assets/vyper/VyperDeployerTemplate.sol new file mode 100644 index 0000000000000..83e1b399338dd --- /dev/null +++ b/crates/forge/assets/vyper/VyperDeployerTemplate.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Vm} from "forge-std/Vm.sol"; + +contract VyperDeployer { + address private constant HEVM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); + Vm private constant vm = Vm(HEVM_ADDRESS); + // Base directory for Vyper contracts + string private constant BASE_PATH = "src/"; + + /** + * Compiles a Vyper contract and returns the `CREATE` address. + * @param fileName The file name of the Vyper contract. + * @return deployedAddress The address calculated through create operation. + */ + function deployContract(string memory fileName) public returns (address) { + return deployContract(BASE_PATH, fileName, ""); + } + + /** + * Compiles a Vyper contract and returns the `CREATE` address. + * @param fileName The file name of the Vyper contract. + * @param args The constructor arguments for the contract + * @return deployedAddress The address calculated through create operation. + */ + function deployContract(string memory fileName, bytes memory args) public returns (address) { + return deployContract(BASE_PATH, fileName, args); + } + + /** + * Compiles a Vyper contract with constructor arguments and returns the `CREATE` address. + * @param basePath The base directory path where the Vyper contract is located + * @param fileName The file name of the Vyper contract. + * @param args The constructor arguments for the contract + * @return deployedAddress The address calculated through create operation. + */ + function deployContract(string memory basePath, string memory fileName, bytes memory args) + public + returns (address) + { + // Compile the Vyper contract + bytes memory bytecode = compileVyperContract(basePath, fileName); + + // Add constructor arguments if provided + if (args.length > 0) { + bytecode = abi.encodePacked(bytecode, args); + } + + // Deploy the contract + address deployedAddress = deployBytecode(bytecode); + + // Return the deployed address + return deployedAddress; + } + + /** + * Compiles a Vyper contract and returns the bytecode + * @param basePath The base directory path where the Vyper contract is located + * @param fileName The file name of the Vyper contract + * @return The compiled bytecode of the contract + */ + function compileVyperContract(string memory basePath, string memory fileName) internal returns (bytes memory) { + // create a list of strings with the commands necessary to compile Vyper contracts + string[] memory cmds = new string[](2); + cmds[0] = "vyper"; + cmds[1] = string.concat(basePath, fileName, ".vy"); + + // compile the Vyper contract and return the bytecode + return vm.ffi(cmds); + } + + /** + * Deploys bytecode using the create instruction + * @param bytecode - The bytecode to deploy + * @return deployedAddress The address calculated through create operation. + */ + function deployBytecode(bytes memory bytecode) internal returns (address deployedAddress) { + // deploy the bytecode with the create instruction + assembly { + deployedAddress := create(0, add(bytecode, 0x20), mload(bytecode)) + } + + // check that the deployment was successful + require(deployedAddress != address(0), "VyperDeployer could not deploy contract"); + } +} diff --git a/crates/forge/assets/vyper/workflowTemplate.yml b/crates/forge/assets/vyper/workflowTemplate.yml new file mode 100644 index 0000000000000..a6ff8588a7edd --- /dev/null +++ b/crates/forge/assets/vyper/workflowTemplate.yml @@ -0,0 +1,79 @@ +name: test + +on: [push, pull_request, workflow_dispatch] + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + python_version: + - 3.12 + architecture: + - x64 + node_version: + - 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python_version }} + architecture: ${{ matrix.architecture }} + + - name: Install latest Vyper + run: pip install --force-reinstall vyper + + - name: Show the Vyper version + run: vyper --version + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: latest + run_install: false + + - name: Get pnpm cache directory path + id: pnpm-cache-dir-path + run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Restore pnpm cache + uses: actions/cache@v4 + id: pnpm-cache + with: + path: ${{ steps.pnpm-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm-store- + + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node_version }} + + - name: Install pnpm project with a clean slate + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show the Foundry CI config + run: forge config + env: + FOUNDRY_PROFILE: ci + + - name: Foundry tests + run: forge test + env: + FOUNDRY_PROFILE: ci diff --git a/crates/forge/src/cmd/init.rs b/crates/forge/src/cmd/init.rs index 7866f14d63908..96cf0e87decec 100644 --- a/crates/forge/src/cmd/init.rs +++ b/crates/forge/src/cmd/init.rs @@ -37,13 +37,17 @@ pub struct InitArgs { #[arg(long, conflicts_with = "template")] pub vscode: bool, + /// Initialize a Vyper project template + #[arg(long, conflicts_with = "template")] + pub vyper: bool, + #[command(flatten)] pub install: DependencyInstallOpts, } impl InitArgs { pub fn run(self) -> Result<()> { - let Self { root, template, branch, install, offline, force, vscode } = self; + let Self { root, template, branch, install, offline, force, vscode, vyper } = self; let DependencyInstallOpts { shallow, no_git, commit } = install; // create the root dir if it does not exist @@ -118,30 +122,71 @@ impl InitArgs { let script = root.join("script"); fs::create_dir_all(&script)?; - // write the contract file - let contract_path = src.join("Counter.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.sol"))?; - // write the tests - let contract_path = test.join("Counter.t.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.t.sol"))?; - // write the script - let contract_path = script.join("Counter.s.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.s.sol"))?; - // Write the default README file - let readme_path = root.join("README.md"); - fs::write(readme_path, include_str!("../../assets/README.md"))?; + // Determine file paths and content based on vyper flag + if vyper { + // Vyper template files + let interface_path = src.join("interface"); + fs::create_dir_all(&interface_path)?; + let utils_path = src.join("utils"); + fs::create_dir_all(&utils_path)?; + let readme_path = root.join("README.md"); + let test_path = test.join("Counter.t.sol"); + let script_path = script.join("Counter.s.sol"); + + let contract_path = src.join("Counter.vy"); + let contract_interface_path = interface_path.join("ICounter.sol"); + let vyper_deployer_path = utils_path.join("VyperDeployer.sol"); + + fs::write(test_path, include_str!("../../assets/vyper/CounterTemplate.t.sol"))?; + fs::write(script_path, include_str!("../../assets/vyper/CounterTemplate.s.sol"))?; + fs::write(readme_path, include_str!("../../assets/vyper/README.md"))?; + + fs::write(contract_path, include_str!("../../assets/vyper/CounterTemplate.vy"))?; + fs::write( + contract_interface_path, + include_str!("../../assets/vyper/ICounterTemplate.sol"), + )?; + fs::write( + vyper_deployer_path, + include_str!("../../assets/vyper/VyperDeployerTemplate.sol"), + )?; + } else { + // Solidity template files + let contract_path = src.join("Counter.sol"); + let readme_path = root.join("README.md"); + let test_path = test.join("Counter.t.sol"); + let script_path = script.join("Counter.s.sol"); + + fs::write(test_path, include_str!("../../assets/solidity/CounterTemplate.t.sol"))?; + fs::write( + script_path, + include_str!("../../assets/solidity/CounterTemplate.s.sol"), + )?; + fs::write(readme_path, include_str!("../../assets/solidity/README.md"))?; + + fs::write( + contract_path, + include_str!("../../assets/solidity/CounterTemplate.sol"), + )?; + } // write foundry.toml, if it doesn't exist already let dest = root.join(Config::FILE_NAME); let mut config = Config::load_with_root(&root)?; - if !dest.exists() { + if vyper { + // Write the full config with FFI enabled to foundry.toml + if !dest.exists() { + let toml_content = "[profile.default]\nsrc = \"src\"\nout = \"out\"\nlibs = [\"lib\"]\nffi = true\n\n# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options".to_string(); + fs::write(dest, toml_content)?; + } + } else if !dest.exists() { fs::write(dest, config.clone().into_basic().to_string_pretty()?)?; } let git = self.install.git(&config); // set up the repo if !no_git { - init_git_repo(git, commit)?; + init_git_repo(git, commit, vyper)?; } // install forge-std @@ -171,7 +216,7 @@ impl InitArgs { /// Creates `.gitignore` and `.github/workflows/test.yml`, if they don't exist already. /// /// Commits everything in `root` if `commit` is true. -fn init_git_repo(git: Git<'_>, commit: bool) -> Result<()> { +fn init_git_repo(git: Git<'_>, commit: bool, vyper: bool) -> Result<()> { // git init if !git.is_in_repo()? { git.init()?; @@ -180,14 +225,18 @@ fn init_git_repo(git: Git<'_>, commit: bool) -> Result<()> { // .gitignore let gitignore = git.root.join(".gitignore"); if !gitignore.exists() { - fs::write(gitignore, include_str!("../../assets/.gitignoreTemplate"))?; + fs::write(gitignore, include_str!("../../assets/solidity/.gitignoreTemplate"))?; } // github workflow let workflow = git.root.join(".github/workflows/test.yml"); if !workflow.exists() { fs::create_dir_all(workflow.parent().unwrap())?; - fs::write(workflow, include_str!("../../assets/workflowTemplate.yml"))?; + if vyper { + fs::write(workflow, include_str!("../../assets/vyper/workflowTemplate.yml"))?; + } else { + fs::write(workflow, include_str!("../../assets/solidity/workflowTemplate.yml"))?; + } } // commit everything diff --git a/crates/forge/tests/cli/cmd.rs b/crates/forge/tests/cli/cmd.rs index f845981bda592..a0e67d76786cd 100644 --- a/crates/forge/tests/cli/cmd.rs +++ b/crates/forge/tests/cli/cmd.rs @@ -281,6 +281,32 @@ Warning: Target directory is not empty, but `--force` was specified let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; }); +// checks that init works with vyper flag +forgetest!(can_init_vyper_project, |prj, cmd| { + prj.wipe(); + + cmd.args(["init", "--vyper"]).arg(prj.root()).assert_success().stdout_eq(str![[r#" +Initializing [..]... +Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + Installed forge-std[..] + Initialized forge project + +"#]]); + + // Check that the Vyper project was initialized correctly + assert!(prj.root().join("foundry.toml").exists()); + assert!(prj.root().join("lib/forge-std").exists()); + assert!(prj.root().join("src").exists()); + assert!(prj.root().join("test").exists()); + assert!(prj.root().join("script").exists()); + assert!(prj.root().join("src/interface").exists()); + assert!(prj.root().join("src/utils").exists()); + assert!(prj.root().join("src").read_dir().unwrap().any(|entry| { + let path = entry.unwrap().path(); + path.to_string_lossy().ends_with(".vy") + })); +}); + // Checks that a forge project fails to initialise if dir is already git repo and dirty forgetest!(can_detect_dirty_git_status_on_init, |prj, cmd| { prj.wipe();