Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,28 @@ concurrency:

jobs:
tests:
name: contracts-confidential-wrapper-foundry-tests/tests (bpr)
name: contracts-confidential-wrapper-foundry-tests/tests (bpr, ${{ matrix.network }})
# The live archive-node run needs the RPC secret, which GitHub provides on push to
# main, manual dispatch, and PRs from branches within this repo. Gate the whole job
# so fork PRs skip it cleanly (a maintainer pushes the branch to this repo to run it,
# main, manual dispatch, and PRs from branches within this repo. Gate the whole job
# so fork PRs skip it cleanly (a maintainer pushes the branch to this repo to run it,
# matching security-analysis-gate.yml).
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: 'read' # Required to checkout repository code
strategy:
# One chain failing says nothing about the others, so let every leg report.
fail-fast: false
matrix:
# Each network needs a config/fork.json entry and its RPC secret below. Steps with no
# config for a network skip themselves, so no per-network `if:` is needed here.
network: [ethereum, polygon]
env:
NETWORK: ${{ matrix.network }}
ETHEREUM_MAINNET_FORK_RPC_URL: ${{ secrets.ETHEREUM_MAINNET_FORK_RPC_URL }}
POLYGON_MAINNET_FORK_RPC_URL: ${{ secrets.POLYGON_MAINNET_FORK_RPC_URL }}
steps:
# TODO: remove once GitHub runner images ship the CVE-2026-31431 kernel fix
- name: Workaround CVE-2026-31431 (copy.fail)
Expand Down Expand Up @@ -70,8 +81,9 @@ jobs:
working-directory: contracts/confidential-wrapper/test/foundry
run: make build

# The batcher suite drives the deployed bytecode at the addresses in config/batchers.json, so
# a redeploy upstream would leave it testing dead contracts. Skips itself without a token.
# The batcher suite drives the deployed bytecode at the addresses in
# config/<network>/batchers.json, so a redeploy upstream would leave it testing dead
# contracts. Skips itself without a token, or on a network with no batchers.
- name: Check batcher deployment manifest is current
working-directory: contracts/confidential-wrapper/test/foundry
env:
Expand All @@ -80,12 +92,8 @@ jobs:

- name: Run live fork tests against archive node
working-directory: contracts/confidential-wrapper/test/foundry
env:
ETHEREUM_MAINNET_FORK_RPC_URL: ${{ secrets.ETHEREUM_MAINNET_FORK_RPC_URL }}
run: make fork-test

- name: Run live fork tests against the deployed batchers
working-directory: contracts/confidential-wrapper/test/foundry
env:
ETHEREUM_MAINNET_FORK_RPC_URL: ${{ secrets.ETHEREUM_MAINNET_FORK_RPC_URL }}
run: make fork-test-batcher
3 changes: 2 additions & 1 deletion contracts/confidential-wrapper/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ CONFIDENTIAL_WRAPPER_PAUSER_ADDRESS_1=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
# ConfidentialWrapper upgrade version label used by upgrade tasks
CONFIDENTIAL_WRAPPER_UPGRADE_VERSION_LABEL=v2

# Archive RPC used by the Foundry live mainnet-fork tests (make fork-test)
# Archive RPCs for the Foundry live fork tests; `make fork-test` picks one by NETWORK (default ethereum)
ETHEREUM_MAINNET_FORK_RPC_URL=https://ethereum-rpc.publicnode.com/
POLYGON_MAINNET_FORK_RPC_URL=https://polygon-bor-rpc.publicnode.com/
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,26 @@ describe('ConfidentialWrapperV3 DenyList', function () {
expect(await wrapper.isBlocked(ethers.ZeroAddress)).to.be.false;
});

it('allows wrap and unwrap when the underlying denies the zero address', async function () {
const token: any = await ethers.deployContract('ERC20MockCUSDC');
const wrapper: any = await deployV3(token.target as string, SELECTOR_CUSDC);
await token.setDenyListed(ethers.ZeroAddress, true);

// Sanity: underlying denies zero, wrapper exempts it
expect(await token.isBlacklisted(ethers.ZeroAddress)).to.be.true;
expect(await wrapper.isBlocked(ethers.ZeroAddress)).to.be.false;

await token.mint(holder.address, ethers.parseUnits('100', 6));
await token.connect(holder).approve(wrapper.target, ethers.MaxUint256);

// Mint path: wrap → _update(0, holder, ...)
await expect(wrapper.connect(holder).wrap(holder.address, ethers.parseUnits('100', 6))).not.to.be.reverted;

// Burn path: unwrap → _update(holder, 0, ...)
const balance = await wrapper.confidentialBalanceOf(holder.address);
await expect(wrapper.connect(holder).unwrap(holder.address, holder.address, balance)).not.to.be.reverted;
});

it('reverts with UnderlyingDenyListCallFailed when the underlying call reverts', async function () {
const token: any = await ethers.deployContract('ERC20MockRevertingDenyList');
const wrapper = await deployV3(token.target as string, SELECTOR_CUSDC);
Expand Down
21 changes: 12 additions & 9 deletions contracts/confidential-wrapper/test/foundry/Makefile
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
.PHONY: setup fork-test fork-test-batcher build clean

# The chain to fork: a config/fork.json key, which also names its config/<network>/ directory.
# Exported so fork-test.sh and the tests (vm.envOr("NETWORK", ...)) read the same value.
NETWORK ?= ethereum
export NETWORK

# Installs the soldeer dependencies pinned in soldeer.toml (incl. forge-fhevm).
setup:
forge soldeer install --config-location soldeer

build:
forge build

# Network-bound test run against a live mainnet fork. Reads
# ETHEREUM_MAINNET_FORK_RPC_URL from the environment (CI secret) or
# contracts/confidential-wrapper/.env (see .env.example). The fork block comes from
# config/fork.json, which is unpinned by default so runs cover latest - 50;
# FORK_BLOCK pins a run ad hoc.
# Network-bound test run against a live fork of $(NETWORK). fork-test.sh makes that network's RPC
# variable visible to Foundry (from the environment in CI, or contracts/confidential-wrapper/.env
# locally — see .env.example) and resolves the fork block from config/fork.json, which is unpinned
# by default so runs cover latest - 50; FORK_BLOCK pins a run ad hoc. Call the script directly to
# forward extra forge arguments, e.g. `./script/utils/fork-test.sh --match-test test_Foo`.
fork-test:
@FORK_URL="$$(./script/utils/resolve-fork.sh)" || exit 1; \
forge test --fork-url "$${FORK_URL}" -vvv
@./script/utils/fork-test.sh

# Same fork, but the deployed-batcher suite under the `batcher` profile.
fork-test-batcher:
@FORK_URL="$$(./script/utils/resolve-fork.sh)" || exit 1; \
FOUNDRY_PROFILE=batcher forge test --fork-url "$${FORK_URL}" -vvv
@FOUNDRY_PROFILE=batcher ./script/utils/fork-test.sh

clean:
forge clean
96 changes: 54 additions & 42 deletions contracts/confidential-wrapper/test/foundry/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# Foundry Mainnet-Fork Tests: Confidential Wrappers
# Foundry Fork Tests: Confidential Wrappers

Foundry tests that exercise the **live** Confidential Wrappers deployed on Ethereum mainnet.
Foundry tests that exercise the **live** Confidential Wrappers deployed on a supported chain.
`BaseForkTest` enumerates every valid wrapper from the on-chain
`ConfidentialTokenWrappersRegistry`, and the suite checks:

- direct wrap, confidential transfer, unwrap, finalize, and ERC-1363 receiver flows;
- per-wrapper deny-list behavior (owner gating, block/unblock, blocked-wrap guard);
- configured underlying-token deny-list selectors against the deployed underlying token code;
- underlying-token deny-list gating against real mainnet state, including known
blacklisted mainnet addresses.
- underlying-token deny-list gating against real chain state, including known
blacklisted addresses.

A second suite under `test/batcher` drives the **deployed** Confidential DeFi Gateway batchers
against the same candidate implementation, so a wrapper upgrade that breaks the batchers fails
here. See [Deployed-batcher suite](#deployed-batcher-suite).

Tests run against a **live mainnet fork**: `forge test --fork-url <archive RPC>` reads the code
and storage the tests touch directly from the archive node.
Tests run against a **live fork**: `forge test --fork-url <archive RPC>` reads the code
and storage the tests touch directly from the archive node. The chain is selected with
`NETWORK` (default `ethereum`); see [Networks](#networks).

## Setup

Expand All @@ -31,43 +32,57 @@ make build # forge build

| Task | Command | Notes |
| ---- | ------- | ----- |
| Live fork run | `make fork-test` | Forks mainnet at latest - 50 by default (see [Fork block](#fork-block)). Reads the RPC (see below). |
| Live fork run | `make fork-test` | Forks the network at latest - 50 by default (see [Fork block](#fork-block)). Reads the RPC (see below). |
| Deployed-batcher run | `make fork-test-batcher` | Same fork, `test/batcher` under the `batcher` profile. |
| Another network | `make fork-test NETWORK=polygon` | Any key in `config/fork.json`. |
| Ad-hoc block | `FORK_BLOCK=<n> make fork-test` | Pins one run to a specific block. |

Test cases are isolated: each `test_*` starts from its own `setUp()` state; mutations do not
leak across tests or files.

`make fork-test` resolves `ETHEREUM_MAINNET_FORK_RPC_URL` via
`script/utils/resolve-fork.sh`: the process environment first (CI injects it from a GitHub
secret), then `contracts/confidential-wrapper/.env` for local dev (see `.env.example`). CI runs
`make fork-test` against the archive node on pushes to `main`, manual dispatch, and PRs from
branches in this repo; fork PRs skip the whole job, since GitHub withholds the secret from them.
Each network is an `[rpc_endpoints]` alias in `foundry.toml` pointing at its RPC variable, e.g.
`ETHEREUM_MAINNET_FORK_RPC_URL`, which `forge` and `cast` resolve from the environment. CI sets
those variables from GitHub secrets; locally `script/utils/fork-test.sh` loads them from
`contracts/confidential-wrapper/.env` (see `.env.example`).

## Networks

`NETWORK` (default `ethereum`) selects the chain. It names an entry in `config/fork.json`, the
`[rpc_endpoints]` alias in `foundry.toml` that resolves its RPC URL, and the `config/<network>/`
directory holding that chain's deny-list and batcher files.

To add a network:

1. Add its entry to `config/fork.json` and its alias to `foundry.toml`'s `[rpc_endpoints]`.
2. Add a `config/<network>/` directory for the deny-list tokens and batchers it has, if any.
3. Add it to the matrix in `.github/workflows/contracts-confidential-wrapper-foundry-tests.yml`,
with its RPC variable in the job `env` and the matching repository secret.

## Fork block

The fork block is optional and resolved by `script/utils/resolve-fork.sh`.
The fork block is optional and resolved by `script/utils/fork-test.sh`.

Precedence: `FORK_BLOCK` (ad-hoc override) → `config/fork.json` → latest - 50 when
`ethereumMainnet.block` is `null`, which is the committed default. Set `ethereumMainnet.block` to an integer, or export `FORK_BLOCK`, to pin a run while reproducing a failure.
Precedence: `FORK_BLOCK` (ad-hoc override) → `config/fork.json` → latest - 50 when the selected
network's `block` is `null`, which is the committed default. Set `<network>.block` to an integer, or
export `FORK_BLOCK`, to pin a run while reproducing a failure.

## Deny-list config

USDC, USDT, XAUT, and TGBP carry on-chain deny lists. Two small committed files drive the
deny-list tests:
One committed file per network, `config/<network>/blacklist-interfaces.json`, drives the deny-list tests.

Each token entry carries:

- `config/blacklist-interfaces.json` — the bool-returning `getter` selector per token
(USDC `isBlacklisted(address)`, USDT `isBlackListed(address)`, XAUT `isBlocked(address)`,
TGBP `isBanned(address)`). Read by `test/BaseForkTest.t.sol`.
- `config/blacklist-seeds.json` — a handful of known-denied addresses per token, used as test
vectors. The suite reads each seed's deny-list slot from the live fork and asserts the token
reports it denied. These are real addresses that must still be denied at the forked block. Adding a token is a
one-entry edit to each file.
- `getter` — the bool-returning selector the wrapper staticcalls (USDC `isBlacklisted(address)`,
USDT `isBlackListed(address)`, etc.).
- `setter` / `authority` — used to freshly deny an address by pranking the token's own admin.
- `blacklisted` — a handful of real already-denied addresses used as test vectors. The suite reads
each one's deny-list slot from the live fork and asserts the token still reports it denied, so
they must remain denied at the forked block.

## Deployed-batcher suite

`test/batcher` runs the deployed Confidential DeFi batchers against the candidate wrapper
implementation. The batchers are read from mainnet, not deployed by the tests, so the suite checks
implementation. The batchers are read from the chain, not deployed by the tests, so the suite checks
the exact non-upgradeable bytecode a wrapper upgrade must support.

Run it with:
Expand All @@ -79,10 +94,10 @@ make fork-test-batcher
It uses the `batcher` Foundry profile, which enables
`isolate = true` and keeps the regular `make fork-test` target scoped to the wrapper suite.

Addresses live in `config/batchers.json`.
Addresses live in `config/<network>/batchers.json`.

The harness mutates fork storage to repoint the deployed batchers at the local fhEVM host and clear
mainnet ciphertext handles that cannot be decoded locally. If a storage-layout guard fails, rederive
live ciphertext handles that cannot be decoded locally. If a storage-layout guard fails, rederive
the deployed layout before changing any `vm.store` slot.

## Layout
Expand All @@ -92,30 +107,27 @@ the deployed layout before changing any `vm.store` slot.
| `test/BaseForkTest.t.sol` | `FhevmTest` harness: enumerate registry wrappers, repoint FHE config at the local host, shared token/KMS helpers |
| `test/WrapperFlows.t.sol` | Per-wrapper wrap, confidential transfer, unwrap/finalize, ERC-1363 receiver path |
| `test/DenyList.t.sol` | Local block/unblock, owner gating, blocked wrap guard |
| `test/UnderlyingDenyList.t.sol` | Underlying deny-list selectors vs. token code and known blacklisted mainnet addresses |
| `test/UnderlyingDenyList.t.sol` | Underlying deny-list selectors vs. token code and known blacklisted addresses |
| `test/Upgrade.t.sol` | Upgrades every live proxy onto the HEAD impl and asserts storage, enablement and initializer-version invariants |
| `test/batcher/IVaultBatcher.sol` | Slice of the deployed batchers' ABI these tests drive |
| `test/batcher/BatcherForkBase.t.sol` | Harness for the deployed batchers |
| `test/batcher/BatcherFlows.t.sol` | Wiring guard, deposit/redeem round trip, operator join and quit, empty-batch dispatch |
| `test/batcher/BatcherDenyList.t.sol` | Deny-list and pause behavior seen through a batcher |
| `script/utils/resolve-fork.sh` | Resolves the fork target: RPC URL from the environment or `.env`, block from `FORK_BLOCK` or `config/fork.json` |
| `script/utils/check-batcher-manifest.sh` | Fails when `config/batchers.json` drifts from the upstream deployment manifest |
| `config/fork.json` | Optional mainnet fork block pin (`null` = chain tip) |
| `config/blacklist-interfaces.json` | Per-token deny-list getter selectors |
| `config/blacklist-seeds.json` | Per-token known-denied test-vector addresses |
| `config/batchers.json` | Deployed batcher, wrapper and vault addresses |
| `script/utils/fork-test.sh` | Runs `forge test` against a fork of `NETWORK`: loads its RPC variable from `.env` when unset, resolves the block from `FORK_BLOCK` or `config/fork.json` |
| `script/utils/check-batcher-manifest.sh` | Fails when `config/<network>/batchers.json` drifts from the upstream deployment manifest |
| `config/fork.json` | Per-network registry address and optional fork block pin (`null` = chain tip) |
| `config/<network>/blacklist-interfaces.json` | Per-token deny-list selectors and known-denied test-vector addresses |
| `config/<network>/batchers.json` | Deployed batcher, wrapper and vault addresses |

## Troubleshooting

- `ETHEREUM_MAINNET_FORK_RPC_URL is not set`: export the archive RPC or set it in
`contracts/confidential-wrapper/.env` (see `.env.example`).
- `missing underlying token code`: the archive node did not return code for that address at the
forked block; check the RPC and the pinned `FORK_BLOCK`.
- `seeded address not denied by real token state`: a `config/blacklist-seeds.json` address is no
longer denied at the forked block; refresh the seed. Runs default to the chain tip, so this
tracks live mainnet state.
- `seeded address not denied by real token state`: a `blacklisted` address in the deny-list config
is no longer denied at the forked block; refresh the seed. Runs default to the chain tip, so this
tracks live chain state.
- `MISMATCH <key>` from `check-batcher-manifest.sh`: the batchers were redeployed upstream; copy the
new addresses into `config/batchers.json` and re-run `make fork-test-batcher`.
new addresses into `config/<network>/batchers.json` and re-run `make fork-test-batcher`.
- `batcher: unexpected ACL` / `unexpected batcher layout`: the deployed batcher no longer matches
what the harness assumes (FHE config or `BatcherConfidential` storage slots). Re-derive it (see
below) rather than relaxing the guard — it is the only thing keeping the `vm.store` writes honest.
Expand All @@ -140,14 +152,14 @@ drift from the mainnet deployment.

### FHE on a live fork

The deployed wrappers point their FHE config at the real Zama mainnet coprocessor (compute
The deployed wrappers point their FHE config at the real Zama coprocessor for their chain (compute
happens off-chain), so a bare fork can't produce usable ciphertext/decryptions. Zama's
[`forge-fhevm`](https://github.com/zama-ai/forge-fhevm) closes the gap:

- The inherited `FhevmTest.setUp()` deploys the local fhEVM host stack (at canonical addresses)
and records executor logs into an in-memory plaintext DB.
- `BaseForkTest.setUp()` then repoints each wrapper's three FHE config slots at those local host
addresses and **zeroes the cached total-supply handle** (a mainnet handle has no entry in the
addresses and **zeroes the cached total-supply handle** (a live handle has no entry in the
local plaintext DB, so the first local mint/burn rebuilds it against the local executor).
- `finalizeUnwrap` verifies a scalar `abi.encode(uint64)` payload, so tests use
`buildDecryptionProof(handle, abi.encode(cleartext))` rather than the generic
Expand Down

This file was deleted.

Loading
Loading