diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml new file mode 100644 index 00000000..f406e190 --- /dev/null +++ b/.github/workflows/foundry.yml @@ -0,0 +1,103 @@ +# Tests compilation and E2E integration with foundry-polkadot, including basic +# runtime behavior, but not differential runtime correctness. + +name: Foundry Integration + +on: + workflow_call: + +env: + CARGO_TERM_COLOR: always + # Workflow-internal foundry envs are prefixed with `CI_` to not conflict with the + # `FOUNDRY_*` namespace, which foundry itself will interpret for its configuration. + CI_FOUNDRY_PROJECT_ROOT: tooling-projects/foundry/erc20 + # This is currently the commit that adds support for resolc ">=0.6.0, <2.0.0". + # The latest release as of Apr. 28, 2026 supports ">=0.6.0, <0.7.0". + # Once a release includes the updated supported versions, this can be changed to a + # tag in order to download the prebuilt binary. + CI_FOUNDRY_POLKADOT_COMMIT: b3173d0584382687cc96b2af072d8cb2addf23d3 + FORGE_STD_VERSION: v1.15.0 + OZ_CONTRACTS_VERSION: v5.3.0 + +jobs: + build: + uses: ./.github/workflows/reusable-build-linux.yml + with: + retention_days: 1 + + use-foundry: + needs: build + runs-on: ubuntu-24.04 + steps: + - name: Checkout revive + uses: actions/checkout@v6 + + # TODO: Remove once the foundry install step replaces `--commit` with `--install`. + # Needed for building foundry-polkadot. + - name: Set Up Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + rustflags: "" + toolchain: "1.89.0" + target: wasm32-unknown-unknown + components: rust-src + + # TODO: Remove once the foundry install step replaces `--commit` with `--install`. + - name: Set Default Rust Toolchain + run: rustup default 1.89.0 + + - name: Download resolc Binary + uses: actions/download-artifact@v7 + with: + name: resolc-x86_64-unknown-linux-musl + path: resolc-bin + + - name: Export resolc Path + run: | + chmod +x resolc-bin/resolc-x86_64-unknown-linux-musl + echo "RESOLC_PATH=$(pwd)/resolc-bin/resolc-x86_64-unknown-linux-musl" >> $GITHUB_ENV + + # TODO: Remove once the foundry install step replaces `--commit` with `--install`. + - name: Install Build Dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang libclang-dev build-essential protobuf-compiler + + # NOTE: ~/.foundry/bin is not cached (which would be very useful when using `--commit`) + # due to forge sometimes crashing with `Illegal instruction` when using the cached + # binaries, seemingly due to the CPU being different when building forge vs running it. + - name: Install foundryup-polkadot and the Foundry Toolchain + run: | + # TODO: Replace `--commit` with `--install` once a release with this commit exists: https://github.com/paritytech/foundry-polkadot/commit/b3173d0584382687cc96b2af072d8cb2addf23d3 + + # Pin install path with `FOUNDRY_DIR` to prevent the pre-existing `XDG_CONFIG_HOME` from being used. + export FOUNDRY_DIR="$HOME/.foundry" + curl -fsSL https://raw.githubusercontent.com/paritytech/foundry-polkadot/refs/heads/master/foundryup/install | bash + "$FOUNDRY_DIR/bin/foundryup-polkadot" --commit "$CI_FOUNDRY_POLKADOT_COMMIT" + + - name: Add Foundry to PATH + run: echo "$HOME/.foundry/bin" >> "$GITHUB_PATH" + + - name: Check Tool Versions + run: | + foundryup-polkadot --version + forge --version + + - name: Fetch Project Dependencies + working-directory: ${{ env.CI_FOUNDRY_PROJECT_ROOT }} + run: | + mkdir -p lib + git clone --depth 1 --branch "$FORGE_STD_VERSION" https://github.com/foundry-rs/forge-std.git lib/forge-std + git clone --depth 1 --branch "$OZ_CONTRACTS_VERSION" https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + + - name: Compile Project + working-directory: ${{ env.CI_FOUNDRY_PROJECT_ROOT }} + run: forge build --use-resolc "$RESOLC_PATH" --optimize -Oz + + - name: Verify Output + working-directory: ${{ env.CI_FOUNDRY_PROJECT_ROOT }} + run: bash verify-compiler-output.sh "$RESOLC_PATH" + + - name: Test Project + working-directory: ${{ env.CI_FOUNDRY_PROJECT_ROOT }} + run: forge test --polkadot=pvm --use-resolc "$RESOLC_PATH" --optimize -Oz -vvvv diff --git a/.github/workflows/reusable-build-linux.yml b/.github/workflows/reusable-build-linux.yml index a6ee9930..2ca06a77 100644 --- a/.github/workflows/reusable-build-linux.yml +++ b/.github/workflows/reusable-build-linux.yml @@ -28,6 +28,7 @@ jobs: # Use `reusable-build` and not `${{ github.workflow }}` here so that callers share the same concurrency group. group: reusable-build-x86_64-unknown-linux-musl-${{ github.sha }} cancel-in-progress: false + queue: max steps: - name: Checkout revive uses: actions/checkout@v6 diff --git a/.github/workflows/reusable-build-macos.yml b/.github/workflows/reusable-build-macos.yml index 435f6222..cde9fc8d 100644 --- a/.github/workflows/reusable-build-macos.yml +++ b/.github/workflows/reusable-build-macos.yml @@ -50,6 +50,7 @@ jobs: # Use `reusable-build` and not `${{ github.workflow }}` here so that callers share the same concurrency group. group: reusable-build-${{ matrix.target }}-${{ github.sha }} cancel-in-progress: false + queue: max steps: - name: Checkout revive uses: actions/checkout@v6 @@ -80,6 +81,7 @@ jobs: # Use `reusable-build` and not `${{ github.workflow }}` here so that callers share the same concurrency group. group: reusable-build-universal-apple-darwin-${{ github.sha }} cancel-in-progress: false + queue: max steps: - name: Check Build Cache id: cache diff --git a/.github/workflows/reusable-build-wasm.yml b/.github/workflows/reusable-build-wasm.yml index 3348bdab..6fa4ef06 100644 --- a/.github/workflows/reusable-build-wasm.yml +++ b/.github/workflows/reusable-build-wasm.yml @@ -34,6 +34,7 @@ jobs: # Use `reusable-build` and not `${{ github.workflow }}` here so that callers share the same concurrency group. group: reusable-build-wasm32-unknown-emscripten-${{ github.sha }} cancel-in-progress: false + queue: max steps: - name: Checkout revive uses: actions/checkout@v6 diff --git a/.github/workflows/reusable-build-windows.yml b/.github/workflows/reusable-build-windows.yml index b6161625..4a4a393e 100644 --- a/.github/workflows/reusable-build-windows.yml +++ b/.github/workflows/reusable-build-windows.yml @@ -28,6 +28,7 @@ jobs: # Use `reusable-build` and not `${{ github.workflow }}` here so that callers share the same concurrency group. group: reusable-build-x86_64-pc-windows-msvc-${{ github.sha }} cancel-in-progress: false + queue: max steps: - name: Enable Long Paths run: git config --system core.longpaths true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e91248c..4d9f8bc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,6 +64,7 @@ jobs: outputs: should-run-differential-tests: ${{ steps.filter.outputs.should-run-differential-tests }} should-run-hardhat-tests: ${{ steps.filter.outputs.should-run-hardhat-tests }} + should-run-foundry-tests: ${{ steps.filter.outputs.should-run-foundry-tests }} steps: - name: Checkout uses: actions/checkout@v6 @@ -95,23 +96,28 @@ jobs: print_changed_files + common_ignored=( + '\.md$' + '^book/' + '^docs/' + '^js/' + '^package\.json$' + '^package-lock\.json$' + ) + echo "should-run-differential-tests=$(has_changes_other_than \ - '\.md$' \ - '^book/' \ - '^docs/' \ - '^js/' \ - '^package\.json$' \ - '^package-lock\.json$' \ + "${common_ignored[@]}" \ '^tooling-projects/' \ )" | tee -a "$GITHUB_OUTPUT" echo "should-run-hardhat-tests=$(has_changes_other_than \ - '\.md$' \ - '^book/' \ - '^docs/' \ - '^js/' \ - '^package\.json$' \ - '^package-lock\.json$' \ + "${common_ignored[@]}" \ + '^tooling-projects/foundry' \ + )" | tee -a "$GITHUB_OUTPUT" + + echo "should-run-foundry-tests=$(has_changes_other_than \ + "${common_ignored[@]}" \ + '^tooling-projects/hardhat' \ )" | tee -a "$GITHUB_OUTPUT" run-differential-tests: @@ -123,3 +129,8 @@ jobs: needs: [test, check-changes] if: ${{ needs.check-changes.outputs.should-run-hardhat-tests == 'true' }} uses: ./.github/workflows/hardhat.yml + + run-foundry-tests: + needs: [test, check-changes] + if: ${{ needs.check-changes.outputs.should-run-foundry-tests == 'true' }} + uses: ./.github/workflows/foundry.yml diff --git a/tooling-projects/foundry/erc20/foundry.toml b/tooling-projects/foundry/erc20/foundry.toml new file mode 100644 index 00000000..59ce13da --- /dev/null +++ b/tooling-projects/foundry/erc20/foundry.toml @@ -0,0 +1,25 @@ +[profile.default] +src = "src" +test = "test" +libs = ["lib"] +solc = "0.8.35" +remappings = [ + "forge-std/=lib/forge-std/src/", + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", +] +extra_output = [ + "abi", + "metadata", + "devdoc", + "userdoc", + "storageLayout", + "ir", + "irOptimized", + "evm.bytecode", + "evm.deployedBytecode", + "evm.assembly", + "evm.methodIdentifiers", +] + +[profile.default.polkadot] +resolc_compile = true diff --git a/tooling-projects/foundry/erc20/src/MyToken.sol b/tooling-projects/foundry/erc20/src/MyToken.sol new file mode 100644 index 00000000..da68dc5c --- /dev/null +++ b/tooling-projects/foundry/erc20/src/MyToken.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.22; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +contract MyToken is ERC20, Ownable { + constructor(address initialOwner) + ERC20("MyToken", "MTK") + Ownable(initialOwner) + {} + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} diff --git a/tooling-projects/foundry/erc20/test/MyToken.t.sol b/tooling-projects/foundry/erc20/test/MyToken.t.sol new file mode 100644 index 00000000..ed668f60 --- /dev/null +++ b/tooling-projects/foundry/erc20/test/MyToken.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import { Test } from "forge-std/Test.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { MyToken } from "../src/MyToken.sol"; + +contract MyTokenTest is Test { + MyToken internal token; + address internal owner = address(this); + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + + function setUp() public { + token = new MyToken(owner); + } + + function test_NameAndSymbol() public view { + assertEq(token.name(), "MyToken"); + assertEq(token.symbol(), "MTK"); + } + + function test_Owner() public view { + assertEq(token.owner(), owner); + } + + function test_OwnerCanMint() public { + token.mint(alice, 1000); + assertEq(token.balanceOf(alice), 1000); + } + + function test_NonOwnerCannotMint() public { + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice) + ); + token.mint(alice, 1000); + } + + function test_TotalSupplyIncreases() public { + uint256 before = token.totalSupply(); + token.mint(alice, 500); + assertEq(token.totalSupply() - before, 500); + } + + function test_Transfer() public { + token.mint(owner, 1000); + assertTrue(token.transfer(alice, 100)); + assertEq(token.balanceOf(alice), 100); + assertEq(token.balanceOf(owner), 900); + } + + function test_TransferFrom() public { + token.mint(owner, 1000); + token.approve(alice, 50); + vm.prank(alice); + assertTrue(token.transferFrom(owner, bob, 50)); + assertEq(token.balanceOf(bob), 50); + assertEq(token.allowance(owner, alice), 0); + } +} diff --git a/tooling-projects/foundry/erc20/verify-compiler-output.sh b/tooling-projects/foundry/erc20/verify-compiler-output.sh new file mode 100644 index 00000000..89530f2b --- /dev/null +++ b/tooling-projects/foundry/erc20/verify-compiler-output.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# Asserts that the compiled project contains the expected compiler output. +# Requires `forge` in PATH. Run from the project's root. +# +# Usage: verify-compiler-output.sh + +set -euxo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +resolc=$1 + +inspect() { + forge inspect --use-resolc "$resolc" MyToken "$@" +} + +inspect bytecode | grep '^0x50564d' > /dev/null +inspect deployedBytecode | grep '^0x50564d' > /dev/null +inspect irOptimized | grep . > /dev/null +inspect ir | grep . > /dev/null +inspect assembly | grep . > /dev/null +inspect abi --json | jq -e 'length > 0' > /dev/null +inspect methodIdentifiers --json | jq -e 'length > 0' > /dev/null +inspect storageLayout --json | jq -e '.storage | length > 0' > /dev/null +inspect metadata --json | jq -e 'length > 0' > /dev/null +inspect devdoc --json | jq -e 'length > 0' > /dev/null +inspect userdoc --json | jq -e 'length > 0' > /dev/null + +echo "all checks passed"