diff --git a/.github/workflows/ccip-load-command.yml b/.github/workflows/ccip-load-command.yml index 1016f0022..e32f65d43 100644 --- a/.github/workflows/ccip-load-command.yml +++ b/.github/workflows/ccip-load-command.yml @@ -65,15 +65,21 @@ jobs: echo "message_rate=${NAMED_MESSAGE_RATE:-1/45s}" >> "$GITHUB_OUTPUT" echo "load_duration=${NAMED_LOAD_DURATION:-2m}" >> "$GITHUB_OUTPUT" echo "config_file=${NAMED_CONFIG_FILE:-env-prod-testnet.ci.toml}" >> "$GITHUB_OUTPUT" - echo "skip_exec_confirm=${NAMED_SKIP_EXEC_CONFIRM:-true}" >> "$GITHUB_OUTPUT" echo "confirm_exec_timeout=${NAMED_CONFIRM_EXEC_TIMEOUT:-10m}" >> "$GITHUB_OUTPUT" echo "party_id=${NAMED_PARTY_ID:-u_d53a15c42af6::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7}" >> "$GITHUB_OUTPUT" echo "grpc_url=${NAMED_GRPC_URL:-testnet.cv1.bcy-v.metalhosts.com:443}" >> "$GITHUB_OUTPUT" direction="${NAMED_DIRECTION:-canton2evm}" + if [ -n "${NAMED_SKIP_EXEC_CONFIRM}" ]; then + echo "skip_exec_confirm=${NAMED_SKIP_EXEC_CONFIRM}" >> "$GITHUB_OUTPUT" + elif [ "$direction" = "canton2evm" ]; then + echo "skip_exec_confirm=true" >> "$GITHUB_OUTPUT" + else + echo "skip_exec_confirm=false" >> "$GITHUB_OUTPUT" + fi if [ -n "${NAMED_TEST_TIMEOUT}" ]; then echo "test_timeout=${NAMED_TEST_TIMEOUT}" >> "$GITHUB_OUTPUT" - elif [ "$direction" = "evm2canton" ]; then + elif [ "$direction" = "evm2canton" ] || [ "$direction" = "evm2canton-token" ]; then echo "test_timeout=45m" >> "$GITHUB_OUTPUT" else echo "test_timeout=30m" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ccip-load-tests.yml b/.github/workflows/ccip-load-tests.yml index 77e9b655b..18a1e00b0 100644 --- a/.github/workflows/ccip-load-tests.yml +++ b/.github/workflows/ccip-load-tests.yml @@ -89,7 +89,7 @@ jobs: echo "load_duration=${{ inputs.load_duration }}" >> "$GITHUB_OUTPUT" fi if [ "${{ inputs.test_timeout }}" = "40m" ]; then - if [ "${{ inputs.direction }}" = "evm2canton" ]; then + if [ "${{ inputs.direction }}" = "evm2canton" ] || [ "${{ inputs.direction }}" = "evm2canton-token" ]; then echo "test_timeout=45m" >> "$GITHUB_OUTPUT" else echo "test_timeout=30m" >> "$GITHUB_OUTPUT" @@ -97,6 +97,12 @@ jobs: else echo "test_timeout=${{ inputs.test_timeout }}" >> "$GITHUB_OUTPUT" fi + direction="${{ inputs.direction }}" + if [ "$direction" = "canton2evm" ]; then + echo "skip_exec_confirm=true" >> "$GITHUB_OUTPUT" + else + echo "skip_exec_confirm=false" >> "$GITHUB_OUTPUT" + fi else echo "canton_path=chainlink-canton" >> "$GITHUB_OUTPUT" echo "message_rate=${{ inputs.message_rate }}" >> "$GITHUB_OUTPUT" @@ -115,7 +121,7 @@ jobs: config_file: ${{ inputs.config_file }} canton_path: ${{ steps.prod_defaults.outputs.canton_path }} canton_ref: ${{ inputs.canton_ref }} - skip_exec_confirm: ${{ inputs.skip_exec_confirm }} + skip_exec_confirm: ${{ inputs.ccip_env == 'prod-testnet' && steps.prod_defaults.outputs.skip_exec_confirm || inputs.skip_exec_confirm }} confirm_exec_timeout: ${{ inputs.confirm_exec_timeout }} CANTON_OKTA_AUTHORIZER_TESTNET: ${{ secrets.CANTON_OKTA_AUTHORIZER_TESTNET }} CANTON_OKTA_CLIENT_ID_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_ID_TESTNET }} diff --git a/.gitignore b/.gitignore index 128048c90..0a0d9affe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ scripts/prod_testnet/.env eds/staging_testnet_canton.local.toml eds/prod_testnet_canton.local.toml eds/prod_testnet_canton.local.secrets.toml +ccip/devenv/.env.staging.local +ccip/devenv/.env.prod-testnet.local scripts/prod_mainnet/.env scripts/prod_mainnet/.env.example .env.prod-testnet.local diff --git a/Makefile b/Makefile index 1719a2d96..b990389f5 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,15 @@ run-canton2evm-load: ## Canton→EVM WASP load (requires running devenv + env-ca run-evm2canton-load: ## EVM→Canton WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$$' +.PHONY: run-evm2canton-load-prod +run-evm2canton-load-prod: ## EVM→Canton WASP load on prod-testnet (Canton TestNet + Sepolia; set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && go test -timeout 45m -v -count 1 -ccip-env=prod-testnet -run '^TestEVM2Canton_Load$$' + +.PHONY: run-canton2evm-load-prod +run-canton2evm-load-prod: ## Canton→EVM WASP load on prod-testnet (send-only; set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && CANTON_LOAD_SKIP_EXEC_CONFIRM=true go test -timeout 30m -v -count=1 \ + -ccip-env=prod-testnet -run '^TestCanton2EVM_Load$$' + .PHONY: run-canton2evm-token-load run-canton2evm-token-load: ## Canton→EVM token WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$$' @@ -116,6 +125,28 @@ run-canton2evm-token-load: ## Canton→EVM token WASP load (requires running dev run-evm2canton-token-load: ## EVM→Canton token WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestEVM2Canton_TokenLoad$$' +.PHONY: run-canton2evm-token-load-prod +run-canton2evm-token-load-prod: ## Canton→EVM token WASP load on prod-testnet (set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && go test -timeout 30m -v -count=1 \ + -ccip-env=prod-testnet -run '^TestCanton2EVM_TokenLoad$$' + +.PHONY: run-evm2canton-token-load-prod +run-evm2canton-token-load-prod: ## EVM→Canton token WASP load on prod-testnet (set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && go test -timeout 45m -v -count=1 \ + -ccip-env=prod-testnet -run '^TestEVM2Canton_TokenLoad$$' + +.PHONY: run-evm2canton-token-e2e-prod +run-evm2canton-token-e2e-prod: ## EVM→Canton token e2e on prod-testnet. + cd ccip/devenv/tests/e2e && go test -timeout 15m -v -count=1 \ + -ccip-env=prod-testnet \ + -run '^TestEVM2Canton_Basic$/^token_transfer$$' + +.PHONY: run-canton2evm-token-e2e-prod +run-canton2evm-token-e2e-prod: ## Canton→EVM token e2e on prod-testnet. + cd ccip/devenv/tests/e2e && go test -timeout 15m -v -count=1 \ + -ccip-env=prod-testnet \ + -run '^TestCanton2EVM_Basic$/^EOA receiver and default committee verifier token transfer$$' + .PHONY: build-run-e2e-tests build-run-e2e-tests: start-devenv run-e2e-tests diff --git a/ccip/devenv/README.md b/ccip/devenv/README.md index 05e11518c..833a14a27 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -30,13 +30,54 @@ After the environment is spun up, you can run a test like: make run-e2e-tests ``` +### E2E environment selection (`-ccip-env` / `CCIP_ENV`) + +Message e2e tests run against **local devenv** by default or **Canton TestNet + Sepolia** when prod-testnet is selected. Set the environment by name (not TOML path): + +| Value | Config file | Remote | +|---|---|---| +| `devenv` (default) | [`env-canton-evm-out.toml`](./env-canton-evm-out.toml) | no | +| `prod-testnet` | [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) | yes | + +Use the `-ccip-env` flag or `CCIP_ENV` env var (flag wins if both are set): + +```bash +# Local devenv (default) +cd ccip/devenv/tests/e2e && go test -v -run 'TestCanton2EVM_Basic/EOA' -count=1 + +# Prod testnet +CCIP_ENV=prod-testnet \ + CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_*=... \ + PRIVATE_KEY=... \ + go test -timeout 8m -v -count=1 -ccip-env=prod-testnet \ + -run 'TestEVM2Canton_Basic/message|TestCanton2EVM_Basic/EOA' +``` + +**Prod prerequisites** + +- Canton party wallet funded with at least **50 Amulet units** (message fee) +- Canton auth env vars (`CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`) — see [Prod testnet connection smoke test](#prod-testnet-connection-smoke-test) +- Sepolia gas via `PRIVATE_KEY` (EVM sender / receiver for prod runs) + +**Optional instance ID overrides** (defaults: `test-router`, `e2e-ccipsender`, `e2e-receiver`): + +| Env var | Default | +|---|---| +| `CANTON_ROUTER_INSTANCE_ID` | `test-router` | +| `CANTON_SENDER_INSTANCE_ID` | `e2e-ccipsender` | +| `CANTON_RECEIVER_INSTANCE_ID` | `e2e-receiver` | + +Token e2e: **EVM→Canton token** and **Canton→EVM token** are supported on prod-testnet (see [EVM→Canton token e2e (prod-testnet)](#evm→canton-token-e2e-prod-testnet) and [Canton→EVM token e2e (prod-testnet)](#canton→evm-token-e2e-prod-testnet)). A second prod run reuses existing router/sender/receiver contracts on ledger when instance IDs match. + ## Load tests Load tests live in `ccip/devenv/tests/load`. They use [WASP](https://pkg.go.dev/github.com/smartcontractkit/chainlink-testing-framework/wasp) and run sequentially (RPS=1) because Canton holdings are single-flight. -### Canton → EVM load (requires devenv) +### Canton → EVM load -Sequential Canton→EVM messages round-robined across every EVM destination in the env file. Requires `make start-devenv` so `ccip/devenv/env-canton-evm-out.toml` exists. +Sequential Canton→EVM messages round-robined across every EVM destination in the env file. + +**Devenv** (requires `make start-devenv` so `ccip/devenv/env-canton-evm-out.toml` exists): pre-mints fee holdings and calls `SetupSend` once before WASP starts. Full send + EVM exec confirmation per message. Schedule is configured via env vars (defaults are `1/1s` for 90s): @@ -64,9 +105,32 @@ cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestCanton2 If the out file is missing the test skips with a hint. -### Canton → EVM token load (requires devenv) +**Prod-testnet** (Canton TestNet + Sepolia): send-only message load — Canton send + confirm send, no `ConfirmExecOnDest` on EVM (executor not available on prod). Set `CANTON_LOAD_SKIP_EXEC_CONFIRM=true`. Verify delivery via indexer/CCIP ops; the test does not assert EVM execution. -Separate test from message-only load: `TestCanton2EVM_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)) against the source chain's `GetTokenTransferConfigs`, validating every destination has the lane. Pre-mints Canton fee + transfer holdings, runs WASP, then asserts EVM receiver token balance delta. +Prerequisites: `CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`, `PRIVATE_KEY` (EVM message receiver wallet), and a pre-funded Canton party (~50 Amulet per message at `CantonToEVMFeeAmount`). + +```bash +CCIP_ENV=prod-testnet \ +CANTON_LOAD_SKIP_EXEC_CONFIRM=true \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/10s \ +CANTON_LOAD_DURATION=5m \ +go test -timeout 30m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestCanton2EVM_Load$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-canton2evm-load-prod +``` + +### Canton → EVM token load + +Separate test from message-only load: `TestCanton2EVM_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)) against the source chain's `GetTokenTransferConfigs`, validating every destination has the lane. Runs WASP with full exec confirm; on devenv, asserts EVM receiver token balance delta. + +**Devenv** (requires running devenv + `env-canton-evm-out.toml`): pre-mints Canton fee + transfer holdings via `SetupCantonTokenSend`. ```bash make run-canton2evm-token-load @@ -78,9 +142,35 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$' ``` -### EVM → Canton load (requires devenv) +**Prod-testnet** (Canton TestNet + Sepolia): full per-message confirmation (send → receipt on EVM → `ConfirmExecOnDest`). Pre-fund the Canton party before the run: + +- **Amulet** for CCIP fees: `estimatedMessages × 130` (see `CantonToEVMTokenTransferFeeAmount`) +- **LINK** (`link-token`): `estimatedMessages × 100` fixed-point per send (from `[prod-testnet.canton_to_evm]` `transfer_amount`) + +Also set `PRIVATE_KEY` to a Sepolia wallet with ETH for execution gas on the EVM receiver. The test logs EVM receiver balance before/after but does **not** assert balance on prod — verify delivery via indexer/CCIP ops. + +```bash +CCIP_ENV=prod-testnet \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/30s \ +CANTON_LOAD_DURATION=5m \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +go test -timeout 30m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestCanton2EVM_TokenLoad$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-canton2evm-token-load-prod +``` -Sequential EVM→Canton messages against the Canton destination in the env file. Uses the same schedule env vars as Canton→EVM (`CANTON_LOAD_MESSAGE_RATE`, `CANTON_LOAD_DURATION`). EVM accounts are pre-funded by devenv; no Canton pre-mint. +### EVM → Canton load + +Sequential EVM→Canton messages against the Canton destination in the env file. Uses the same schedule env vars as Canton→EVM (`CANTON_LOAD_MESSAGE_RATE`, `CANTON_LOAD_DURATION`). + +**Devenv** (requires running devenv + `env-canton-evm-out.toml`): EVM accounts are pre-funded by devenv; no Canton pre-mint. Example — 1 message every 20 seconds for 10 minutes: @@ -99,9 +189,32 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$' ``` -### EVM → Canton token load (requires devenv) +**Prod-testnet** (Canton TestNet + Sepolia): message-only load with full per-message confirmation (send → receipt on EVM → `ConfirmExecOnDest` on Canton). Use a conservative rate — each iteration is synchronous (~30–60s end-to-end on prod), so WASP RPS=1 is effectively bounded by confirm latency. Budget for Sepolia gas per send plus Canton execution fees. Token load is also supported on prod (see [Canton → EVM token load](#canton--evm-token-load) and [EVM → Canton token load](#evm--canton-token-load)). + +Prerequisites: `CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`, `PRIVATE_KEY` (Sepolia sender/receiver wallet), and a pre-funded Canton party. + +```bash +CCIP_ENV=prod-testnet \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/30s \ +CANTON_LOAD_DURATION=5m \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +go test -timeout 45m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestEVM2Canton_Load$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-evm2canton-load-prod +``` + +### EVM → Canton token load + +Separate test from message-only load: `TestEVM2Canton_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)), logs EVM sender balance vs estimated transfer need, runs WASP, logs Canton holdings post-run. -Separate test from message-only load: `TestEVM2Canton_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)), logs EVM sender balance vs estimated transfer need (devenv pre-funds sender), runs WASP, logs Canton holdings post-run. +**Devenv** (requires running devenv + `env-canton-evm-out.toml`): EVM sender is pre-funded by devenv. ```bash make run-evm2canton-token-load @@ -113,59 +226,231 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestEVM2Canton_TokenLoad$' ``` +**Prod-testnet** (Canton TestNet + Sepolia): full per-message confirmation. Fund `PRIVATE_KEY` wallet with: + +- **TEST** ERC-20 tokens on Sepolia: `estimatedMessages × transfer_amount` from `[prod-testnet.evm_to_canton]` (default `1000000000000000000` wei per send) +- **Sepolia ETH** for send and possible router approve tx + +Also ensure the Canton party is funded for execution fees. + +```bash +CCIP_ENV=prod-testnet \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/30s \ +CANTON_LOAD_DURATION=5m \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +go test -timeout 45m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestEVM2Canton_TokenLoad$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-evm2canton-token-load-prod +``` + +### EVM→Canton token e2e (prod-testnet) + +Single EVM→Canton token transfer end-to-end on Canton TestNet + Sepolia via `TestEVM2Canton_Basic/token_transfer`. + +**Prerequisites** + +- `CCIP_ENV=prod-testnet` and `CCIP_CONFIG_FILE=env-prod-testnet.ci.toml` (or local `env-prod-testnet-out.toml`) +- Canton auth env vars (`CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`) — see [Prod testnet connection smoke test](#prod-testnet-connection-smoke-test) +- `PRIVATE_KEY` wallet funded with: + - **TEST** ERC-20 tokens on Sepolia (≥ `transfer_amount` from `[prod-testnet.evm_to_canton]` in `token_transfer_config.toml`, default `1000000000000000000`) + - **Sepolia ETH** for send and possible router approve tx +- Canton party wallet funded for execution fees + +```bash +CCIP_ENV=prod-testnet \ +CCIP_CONFIG_FILE=env-prod-testnet.ci.toml \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_*=... \ +PRIVATE_KEY=0x... \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +make run-evm2canton-token-e2e-prod +``` + +Equivalent: + +```bash +cd ccip/devenv/tests/e2e && go test -timeout 15m -v -count=1 \ + -ccip-env=prod-testnet \ + -run '^TestEVM2Canton_Basic$/^token_transfer$' +``` + +### Canton→EVM token e2e (prod-testnet) + +Two sequential Canton→EVM LINK transfers end-to-end on Canton TestNet + Sepolia via `TestCanton2EVM_Basic/EOA receiver and default committee verifier token transfer`. + +**Prerequisites** + +- `CCIP_ENV=prod-testnet` and `CCIP_CONFIG_FILE=env-prod-testnet.ci.toml` (or local `env-prod-testnet-out.toml`) +- Canton auth env vars (`CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`) — see [Prod testnet connection smoke test](#prod-testnet-connection-smoke-test) +- Canton party wallet funded with: + - **Amulet** for CCIP fees (≥ `260` for 2 sends at `CantonToEVMTokenTransferFeeAmount` = 130) + - **LINK** on Canton (`link-token`, ≥ `200` fixed-point for 2 sends at `transfer_amount` = `"100"` from `[prod-testnet.canton_to_evm]`) +- `PRIVATE_KEY` wallet on Sepolia with ETH for execution gas on the EVM receiver + +```bash +CCIP_ENV=prod-testnet \ +CCIP_CONFIG_FILE=env-prod-testnet.ci.toml \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_*=... \ +PRIVATE_KEY=0x... \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +make run-canton2evm-token-e2e-prod +``` + +Equivalent: + +```bash +cd ccip/devenv/tests/e2e && go test -timeout 15m -v -count=1 \ + -ccip-env=prod-testnet \ + -run '^TestCanton2EVM_Basic$/^EOA receiver and default committee verifier token transfer$' +``` + +Expected EVM receiver token delta: `2 × 100000000000` wei TEST (= `2 × 0.0000001` LINK at `transfer_amount` = `"100"` fixed-point). + ### Token lane configuration -Token transfer tests (both e2e and load) declare the token lane to send in [`tests/token_transfer_config.toml`](./tests/token_transfer_config.toml). The test resolves the declared **token pool identity** against the source chain's `GetTokenTransferConfigs`, then validates that every destination chain has that lane configured before running. The committed file holds the devenv defaults. +Token transfer tests (both e2e and load) declare the token lane to send in [`tests/token_transfer_config.toml`](./tests/token_transfer_config.toml). The test resolves the declared **token pool identity** against the source chain's `GetTokenTransferConfigs`, then validates that every destination chain has that lane configured before running. On prod-testnet, when combo discovery fails (simple `TEST`/`LINK` qualifiers), the resolver falls back to direct datastore lookups using `remote_pool_*` fields. -Override the file path with `CANTON_TOKEN_TEST_CONFIG`: +Env selection follows `-ccip-env` / `CCIP_ENV` (same as the CCIP harness). Override the entire file path with `CANTON_TOKEN_TEST_CONFIG`: ```bash CANTON_TOKEN_TEST_CONFIG=/path/to/custom.toml make run-canton2evm-token-load ``` -The file has one block per direction (`[evm_to_canton]`, `[canton_to_evm]`): +The file uses env-keyed sections (`[devenv.*]`, `[prod-testnet.*]`) with one block per direction (`evm_to_canton`, `canton_to_evm`): | Key | Required | Meaning | |---|---|---| | `pool_type` | yes | token pool contract type on the source chain (e.g. `BurnMintTokenPool`, `LockReleaseTokenPool`) | | `pool_version` | yes | semantic version of the token pool (e.g. `2.0.0`) | | `pool_qualifier` | yes | datastore qualifier identifying the exact pool | -| `transfer_amount` | no | per-message token amount (string integer); falls back to a per-direction default | +| `transfer_amount` | no | per-message token amount; **integer wei** for `evm_to_canton`, **integer 10^10 fixed-point** for `canton_to_evm` (see below) | | `execution_gas_limit` | no | per-message execution gas limit; falls back to a per-direction default | | `finality_config` | no | per-message finality config; falls back to a per-direction default | +| `remote_pool_type` | prod fallback | remote pool type for prod datastore fallback | +| `remote_pool_version` | prod fallback | remote pool version for prod datastore fallback | +| `remote_pool_qualifier` | prod fallback | remote pool qualifier for prod datastore fallback | +| `transfer_instrument_id` | Canton→EVM | Canton transfer instrument when Canton is source (e.g. `LINK`; defaults to Amulet) | + +**`transfer_amount` format by direction** + +| Direction | Format | Example | Sent amount | +|---|---|---|---| +| `evm_to_canton` | Integer **wei** | `"100000000000"` | `100000000000` wei on Sepolia TEST | +| `canton_to_evm` | Integer **10^10 fixed-point** | `"1000"` | `0.0000001` LINK (`1000` fixed-point units) | +| `canton_to_evm` send boundary | same fixed-point in `TokenAmount.Amount` | `"1000"` | passed directly to `TokenAmount.Amount` | +| `canton_to_evm` EVM assert | `fixedPoint × 10^8` wei | `1000` → `100000000000` wei | matches reciprocal EVM→Canton | + +Fixed-point scale: `1000000000` = `0.1` LINK, `1000` = `0.0000001` LINK (same 10-decimal scale as Canton NUMERIC). Token **identity** (`pool_type` / `pool_version` / `pool_qualifier`) is always required and is never defaulted; only the numeric send params have code-level fallbacks. If the qualifier matches zero or multiple pools, or a destination lacks the lane, the test fails fast listing the available pool refs / selectors. ```toml -[evm_to_canton] +[devenv.evm_to_canton] pool_type = "BurnMintTokenPool" pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::BurnMintTokenPool 2.0.0 [default]" transfer_amount = "100000000000" execution_gas_limit = 200000 -finality_config = 0 +finality_config = 1 + +[prod-testnet.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1000000000000000000" +execution_gas_limit = 200000 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "LINK" -[canton_to_evm] -pool_type = "LockReleaseTokenPool" +[prod-testnet.canton_to_evm] +pool_type = "BurnMintTokenPool" pool_version = "2.0.0" -pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]" -transfer_amount = "1000" +pool_qualifier = "LINK" +transfer_amount = "100" execution_gas_limit = 500000 finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "TEST" +transfer_instrument_id = "link-token" ``` ### CI (on demand) -The **CCIP Canton Load Tests** workflow (`ccip-load-tests.yml`) can be triggered manually from GitHub Actions -(`workflow_dispatch`). It reuses the same devenv setup as the CCIP E2E workflow and runs one of the load tests depending on the `direction` input. Inputs: +Load tests share one composite action (`.github/actions/ccip-load-test`) used by: + +- **CCIP Canton Load Tests** (`ccip-load-tests.yml`) — manual `workflow_dispatch` +- **CCIP Load (ChatOps)** (`ccip-load-command.yml`) — PR comment `/ccip-load` + +#### Manual workflow (`workflow_dispatch`) + +| Input | Default (devenv) | Default (prod-testnet) | Maps to | +|---|---|---|---| +| `ccip_env` | `devenv` | select `prod-testnet` | `-ccip-env` | +| `direction` | `canton2evm` | same | test `-run` regex | +| `message_rate` | `1/1s` | `1/45s` (when left at devenv default) | `CANTON_LOAD_MESSAGE_RATE` | +| `load_duration` | `90s` | `2m` (when left at devenv default) | `CANTON_LOAD_DURATION` | +| `test_timeout` | `40m` | `30m` / `45m` for evm2canton or evm2canton-token | `go test -timeout` | +| `config_file` | — | `env-prod-testnet.ci.toml` | `CCIP_CONFIG_FILE` | +| `skip_exec_confirm` | — | `true` for canton2evm only; `false` for token + evm2canton | `CANTON_LOAD_SKIP_EXEC_CONFIRM` | +| `confirm_exec_timeout` | — | `10m` | `CANTON_CONFIRM_EXEC_TIMEOUT` | +| `canton_ref` | workflow ref | devenv only | chainlink-canton checkout | + +**Devenv** spins up Docker via `setup-ccip-devenv` (same as CCIP E2E). **Prod-testnet** hits live Canton TestNet + Sepolia with no local devenv. + +#### ChatOps (PR comment) + +On an open PR, comment (all named args optional): -| Input | Default | Maps to | +``` +/ccip-load direction=canton2evm config_file=env-prod-testnet.ci.toml message_rate=1/45s load_duration=2m +``` + +| Named arg | Default | Notes | |---|---|---| -| `direction` | `canton2evm` | `canton2evm` → `TestCanton2EVM_Load`; `evm2canton` → `TestEVM2Canton_Load`; `canton2evm-token` → `TestCanton2EVM_TokenLoad`; `evm2canton-token` → `TestEVM2Canton_TokenLoad` | -| `message_rate` | `1/1s` | `CANTON_LOAD_MESSAGE_RATE` | -| `load_duration` | `90s` | `CANTON_LOAD_DURATION` | -| `test_timeout` | `40m` | `go test -timeout` | -| `canton_ref` | workflow ref | chainlink-canton checkout | +| `ccip_env` | `prod-testnet` | | +| `direction` | `canton2evm` | `evm2canton`, `canton2evm-token`, `evm2canton-token` (token directions supported on prod) | +| `config_file` | `env-prod-testnet.ci.toml` | basename under `ccip/devenv/`; sets `CCIP_CONFIG_FILE` | +| `message_rate` | `1/45s` | | +| `load_duration` | `2m` | | +| `test_timeout` | `30m` / `45m` for evm2canton or evm2canton-token | | +| `skip_exec_confirm` | `true` for canton2evm only | `false` for token directions and evm2canton | +| `confirm_exec_timeout` | `10m` | | +| `party_id` | ccip-app party (see composite action default) | | +| `grpc_url` | `testnet.cv1.bcy-v.metalhosts.com:443` | | +| `auth_type` | `clientCredentials` | CI only; local dev uses `authorizationCode` | +| `auth_url`, `client_id` | from GitHub secrets | override secrets when set | + +Requires write access to the repo (same as `/auto-fix`). + +#### Prod-testnet config file + +`*-out.toml` files are gitignored (local devenv output). CI uses the committed snapshot [`env-prod-testnet.ci.toml`](./env-prod-testnet.ci.toml) instead. Override with `config_file=` or `CCIP_CONFIG_FILE` when running locally: + +```bash +CCIP_CONFIG_FILE=env-prod-testnet.ci.toml CCIP_ENV=prod-testnet go test ... +``` + +#### GitHub secrets (prod-testnet CI) + +Workflows pass these secrets to `.github/actions/ccip-load-test` using the same names (1:1); the composite action maps them to test env vars: + +| Secret (input name) | Env var | +|---|---| +| `CANTON_OKTA_AUTHORIZER_TESTNET` | `CANTON_AUTH_URL` | +| `CANTON_OKTA_CLIENT_ID_TESTNET` | `CANTON_CLIENT_ID` | +| `CANTON_OKTA_CLIENT_SECRET_TESTNET` | `CANTON_CLIENT_SECRET` | +| `CCIP_PROD_TESTNET_PRIVATE_KEY` | `PRIVATE_KEY` | + +Devenv secrets unchanged: `CCV_IAM_ROLE`, `JD_REGISTRY`, `JD_IMAGE`. chainlink-ccv is pinned to **`695181f87033217a197386366e1ab563198f482e`** in `.github/actions/setup-ccip-devenv` and `go.mod` (same as CCIP E2E). @@ -177,3 +462,55 @@ If you want to build docker images, spin up a new env, and run the test in a sin # From repo root make build-run-e2e-tests ``` + +## Prod testnet connection smoke test + +Minimal Canton-only connectivity check against real testnet infrastructure. Uses [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) (Canton TestNet ↔ Sepolia message lane: contract refs, indexer URLs, EDS URL, verifier issuers); auth secrets and party identity come from environment variables. + +The test is opt-in: it skips unless `CANTON_GRPC_URL` is set (even though the TOML file includes default URLs). This keeps CI green while allowing manual or workflow-triggered runs. + +### Environment variables + +| Variable | Required | Notes | +|---|---|---| +| `CANTON_PARTY_ID` | yes | Ledger party to query; skips `GetUser` when set | +| `CANTON_AUTH_URL` | yes | OIDC issuer | +| `CANTON_CLIENT_ID` | yes | OAuth2 client ID | +| `CANTON_AUTH_TYPE` | no | `authorizationCode` (local), `clientCredentials` (CI), `static`, `insecureStatic` | +| `CANTON_USER_ID` | no | Required for `clientCredentials`; optional for `authorizationCode` (extracted from token `sub` after login) | +| `CANTON_CLIENT_SECRET` | CI only | Required when `CANTON_AUTH_TYPE=clientCredentials` | +| `CANTON_JWT` | static only | For `static` / `insecureStatic` auth | +| `CANTON_GRPC_URL` | opt-in signal | Must be set to run the test; overrides TOML gRPC URL | +| `CANTON_VALIDATOR_API_URL` | no | Overrides TOML validator API URL | + +**Local (browser Okta login):** + +```bash +export CANTON_GRPC_URL='testnet.cv1.bcy-v.metalhosts.com:443' +export CANTON_PARTY_ID='u_0e0328cbbcb7::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7' +export CANTON_AUTH_URL='https://smartcontract.okta.com/oauth2/austsuml9q2WhPBMM5d7' +export CANTON_CLIENT_ID='0oau1l22b1Jv3dcih5d7' +export CANTON_AUTH_TYPE='authorizationCode' +``` + +**CI (`clientCredentials`, no browser):** + +```bash +export CANTON_GRPC_URL='testnet.cv1.bcy-v.metalhosts.com:443' +export CANTON_PARTY_ID='...' +export CANTON_AUTH_URL='https://smartcontract.okta.com/oauth2/austsuml9q2WhPBMM5d7' +export CANTON_CLIENT_ID='0oau1l22b1Jv3dcih5d7' +export CANTON_AUTH_TYPE='clientCredentials' +export CANTON_CLIENT_SECRET='...' +export CANTON_USER_ID='...' +``` + +### Run command + +```bash +cd ccip/devenv/tests/integration && go test -v -run TestIntegration_CantonProdTestnet_Connection -count=1 +``` + +Use `-ccip-env=prod-testnet` (or `CCIP_ENV=prod-testnet`) so the test loads `env-prod-testnet-out.toml` locally, or `CCIP_CONFIG_FILE=env-prod-testnet.ci.toml` for the committed CI snapshot; default devenv config is `env-canton-evm-out.toml` via `-ccip-env=devenv`. + +The test connects via `NewCLDF`, asserts `PartyID` is set, and lists holdings for the party (empty balance is OK). diff --git a/ccip/devenv/cldf.go b/ccip/devenv/cldf.go index 01a6e1bb0..fd0ea59be 100644 --- a/ccip/devenv/cldf.go +++ b/ccip/devenv/cldf.go @@ -4,14 +4,14 @@ import ( "context" "fmt" - adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" chainsel "github.com/smartcontractkit/chain-selectors" - "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "google.golang.org/grpc" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" cldf_canton_provider "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" ) func NewCLDF(ctx context.Context, b *blockchain.Input) (cldf_chain.BlockChain, uint64, error) { @@ -24,46 +24,80 @@ func NewCLDF(ctx context.Context, b *blockchain.Input) (cldf_chain.BlockChain, u Participants: make([]cldf_canton_provider.ParticipantConfig, len(b.Out.NetworkSpecificData.CantonData.ExternalEndpoints.Participants)), } + presetPartyID := resolvePartyID() + internalParticipants := b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants + for i, config := range b.Out.NetworkSpecificData.CantonData.ExternalEndpoints.Participants { - authProvider := authentication.NewInsecureStaticProvider(config.JWT) - // Get Primary Party for user - ledgerApiConn, err := grpc.NewClient( - config.GRPCLedgerAPIURL, - grpc.WithTransportCredentials(authProvider.TransportCredentials()), - grpc.WithPerRPCCredentials(authProvider.PerRPCCredentials()), - ) + authCfg := resolveAuthConfig(config.JWT, config.UserID) + authProvider, err := newAuthProvider(ctx, authCfg) if err != nil { - return nil, 0, fmt.Errorf("failed to create gRPC connection to Ledger API for Canton participant %d: %w", i+1, err) + return nil, 0, fmt.Errorf("create auth provider for Canton participant %d: %w", i+1, err) } - userResp, err := adminv2.NewUserManagementServiceClient(ledgerApiConn).GetUser(context.Background(), &adminv2.GetUserRequest{UserId: config.UserID}) + + userID := resolveUserID(config.UserID) + if userID == "" && authCfg.Type == commonconfig.AuthTypeAuthorizationCode { + userID, err = userIDFromToken(ctx, authProvider) + if err != nil { + return nil, 0, fmt.Errorf("resolve user id for Canton participant %d: %w", i+1, err) + } + } + + grpcURL := resolveGRPCLedgerURL(config.GRPCLedgerAPIURL) + party, err := func() (string, error) { + conn, err := grpc.NewClient( + grpcURL, + grpc.WithTransportCredentials(authProvider.TransportCredentials()), + grpc.WithPerRPCCredentials(authProvider.PerRPCCredentials()), + ) + if err != nil { + return "", fmt.Errorf("create gRPC connection to Ledger API for Canton participant %d: %w", i+1, err) + } + defer conn.Close() + + party, err := resolveParticipantParty(ctx, conn, userID, presetPartyID) + if err != nil { + return "", fmt.Errorf("resolve party for Canton participant %d: %w", i+1, err) + } + + return party, nil + }() if err != nil { - return nil, 0, fmt.Errorf("failed to get user info for user %s for Canton participant %d: %w", config.UserID, i+1, err) + return nil, 0, err } - party := userResp.GetUser().GetPrimaryParty() - if party == "" { - return nil, 0, fmt.Errorf("no primary party found for user %s for Canton participant %d", config.UserID, i+1) + + var internalEndpoints *cldf_canton_provider.Endpoints + if i < len(internalParticipants) { + internal := internalParticipants[i] + if endpointsNonEmpty( + internal.JSONLedgerAPIURL, + internal.GRPCLedgerAPIURL, + internal.AdminAPIURL, + internal.ValidatorAPIURL, + ) { + internalEndpoints = &cldf_canton_provider.Endpoints{ + JSONLedgerAPIURL: internal.JSONLedgerAPIURL, + GRPCLedgerAPIURL: internal.GRPCLedgerAPIURL, + AdminAPIURL: internal.AdminAPIURL, + ValidatorAPIURL: internal.ValidatorAPIURL, + } + } } - _ = ledgerApiConn.Close() providerConfig.Participants[i] = cldf_canton_provider.ParticipantConfig{ Endpoints: cldf_canton_provider.Endpoints{ JSONLedgerAPIURL: config.JSONLedgerAPIURL, - GRPCLedgerAPIURL: config.GRPCLedgerAPIURL, + GRPCLedgerAPIURL: grpcURL, AdminAPIURL: config.AdminAPIURL, - ValidatorAPIURL: config.ValidatorAPIURL, + ValidatorAPIURL: resolveValidatorAPIURL(config.ValidatorAPIURL), }, - InternalEndpoints: &cldf_canton_provider.Endpoints{ - JSONLedgerAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].JSONLedgerAPIURL, - GRPCLedgerAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].GRPCLedgerAPIURL, - AdminAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].AdminAPIURL, - ValidatorAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].ValidatorAPIURL, - }, - UserID: config.UserID, - PartyID: party, - AuthProvider: authProvider, + InternalEndpoints: internalEndpoints, + UserID: userID, + PartyID: party, + AuthProvider: authProvider, } } - p, err := cldf_canton_provider.NewRPCChainProvider(d.ChainSelector, providerConfig).Initialize(context.TODO()) + + p, err := cldf_canton_provider.NewRPCChainProvider(d.ChainSelector, providerConfig).Initialize(ctx) if err != nil { return nil, 0, err } diff --git a/ccip/devenv/cldf_auth.go b/ccip/devenv/cldf_auth.go new file mode 100644 index 000000000..25ac66143 --- /dev/null +++ b/ccip/devenv/cldf_auth.go @@ -0,0 +1,157 @@ +package devenv + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + + adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" + "google.golang.org/grpc" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" + "github.com/smartcontractkit/chainlink-canton/deployment/authentication/authorizationcode" +) + +func resolveAuthConfig(tomlJWT, tomlUserID string) commonconfig.AuthConfig { + jwt := envTrim("CANTON_JWT") + if jwt == "" { + jwt = tomlJWT + } + + authType := envTrim("CANTON_AUTH_TYPE") + switch { + case authType == "" && jwt != "": + authType = commonconfig.AuthTypeInsecureStatic + case authType == "": + authType = commonconfig.AuthTypeAuthorizationCode + } + + authURL := envTrim("CANTON_AUTH_URL") + clientID := envTrim("CANTON_CLIENT_ID") + clientSecret := envTrim("CANTON_CLIENT_SECRET") + if authType != commonconfig.AuthTypeClientCredentials { + clientSecret = "" + } + + return commonconfig.AuthConfig{ + Type: authType, + UserID: resolveUserID(tomlUserID), + JWT: jwt, + AuthURL: authURL, + ClientID: clientID, + ClientSecret: clientSecret, + } +} + +func resolveUserID(tomlUserID string) string { + if userID := envTrim("CANTON_USER_ID"); userID != "" { + return userID + } + + return tomlUserID +} + +func resolvePartyID() string { + return envTrim("CANTON_PARTY_ID") +} + +func resolveGRPCLedgerURL(tomlURL string) string { + if url := envTrim("CANTON_GRPC_URL"); url != "" { + return url + } + + return tomlURL +} + +func resolveValidatorAPIURL(tomlURL string) string { + if url := envTrim("CANTON_VALIDATOR_API_URL"); url != "" { + return url + } + + return tomlURL +} + +func newAuthProvider(ctx context.Context, authCfg commonconfig.AuthConfig) (authentication.Provider, error) { + if authCfg.Type == commonconfig.AuthTypeAuthorizationCode && authCfg.UserID == "" { + return authorizationcode.NewDiscoveryProvider(ctx, authCfg.AuthURL, authCfg.ClientID) + } + + return authCfg.NewProvider(ctx) +} + +func userIDFromToken(ctx context.Context, provider authentication.Provider) (string, error) { + _ = ctx + + token, err := provider.TokenSource().Token() + if err != nil { + return "", fmt.Errorf("get oauth token: %w", err) + } + + sub, err := jwtSubject(token.AccessToken) + if err != nil { + return "", fmt.Errorf("extract user id from token sub: %w", err) + } + if sub == "" { + return "", fmt.Errorf("token sub claim is empty") + } + + return sub, nil +} + +func jwtSubject(accessToken string) (string, error) { + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode JWT payload: %w", err) + } + + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse JWT payload: %w", err) + } + + return claims.Sub, nil +} + +func resolveParticipantParty(ctx context.Context, conn *grpc.ClientConn, userID, partyID string) (string, error) { + if party := strings.TrimSpace(partyID); party != "" { + return party, nil + } + + if strings.TrimSpace(userID) == "" { + return "", fmt.Errorf("party ID not preset and user ID unset") + } + + userResp, err := adminv2.NewUserManagementServiceClient(conn).GetUser(ctx, &adminv2.GetUserRequest{UserId: userID}) + if err != nil { + return "", fmt.Errorf("get user %s: %w", userID, err) + } + + party := userResp.GetUser().GetPrimaryParty() + if party == "" { + return "", fmt.Errorf("no primary party found for user %s", userID) + } + + return party, nil +} + +func endpointsNonEmpty(jsonURL, grpcURL, adminURL, validatorURL string) bool { + return strings.TrimSpace(jsonURL) != "" || + strings.TrimSpace(grpcURL) != "" || + strings.TrimSpace(adminURL) != "" || + strings.TrimSpace(validatorURL) != "" +} + +func envTrim(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} diff --git a/ccip/devenv/cldf_auth_test.go b/ccip/devenv/cldf_auth_test.go new file mode 100644 index 000000000..465c450eb --- /dev/null +++ b/ccip/devenv/cldf_auth_test.go @@ -0,0 +1,203 @@ +package devenv + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/oauth" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" +) + +// validJWT is a well-formed JWT with sub "1234567890". +const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + +func clearCantonEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "CANTON_AUTH_TYPE", + "CANTON_AUTH_URL", + "CANTON_CLIENT_ID", + "CANTON_CLIENT_SECRET", + "CANTON_JWT", + "CANTON_USER_ID", + "CANTON_PARTY_ID", + "CANTON_GRPC_URL", + "CANTON_VALIDATOR_API_URL", + } { + t.Setenv(key, "") + } +} + +func TestEnvTrim(t *testing.T) { + clearCantonEnv(t) + + require.Empty(t, envTrim("CANTON_PARTY_ID")) + + t.Setenv("CANTON_PARTY_ID", "party-1") + require.Equal(t, "party-1", envTrim("CANTON_PARTY_ID")) + + t.Setenv("CANTON_PARTY_ID", " party-2 ") + require.Equal(t, "party-2", envTrim("CANTON_PARTY_ID")) +} + +func TestResolveAuthConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + tomlJWT string + tomlUserID string + want commonconfig.AuthConfig + }{ + { + name: "toml jwt defaults to insecureStatic", + tomlJWT: validJWT, + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeInsecureStatic, + JWT: validJWT, + }, + }, + { + name: "no jwt defaults to authorizationCode", + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeAuthorizationCode, + }, + }, + { + name: "env overrides toml", + env: map[string]string{ + "CANTON_AUTH_TYPE": commonconfig.AuthTypeClientCredentials, + "CANTON_JWT": "env-jwt", + "CANTON_USER_ID": "env-user", + "CANTON_AUTH_URL": "https://auth.example.com/", + "CANTON_CLIENT_ID": "env-client", + "CANTON_CLIENT_SECRET": "env-secret", + }, + tomlJWT: validJWT, + tomlUserID: "toml-user", + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeClientCredentials, + UserID: "env-user", + JWT: "env-jwt", + AuthURL: "https://auth.example.com/", + ClientID: "env-client", + ClientSecret: "env-secret", + }, + }, + { + name: "client secret cleared for authorizationCode", + env: map[string]string{ + "CANTON_AUTH_TYPE": commonconfig.AuthTypeAuthorizationCode, + "CANTON_AUTH_URL": "https://auth.example.com/", + "CANTON_CLIENT_ID": "env-client", + "CANTON_CLIENT_SECRET": "should-not-apply", + }, + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeAuthorizationCode, + AuthURL: "https://auth.example.com/", + ClientID: "env-client", + ClientSecret: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearCantonEnv(t) + for key, value := range tt.env { + t.Setenv(key, value) + } + + got := resolveAuthConfig(tt.tomlJWT, tt.tomlUserID) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResolveUserID(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "toml-user", resolveUserID("toml-user")) + + t.Setenv("CANTON_USER_ID", "env-user") + require.Equal(t, "env-user", resolveUserID("toml-user")) +} + +func TestResolvePartyID(t *testing.T) { + clearCantonEnv(t) + + require.Empty(t, resolvePartyID()) + + t.Setenv("CANTON_PARTY_ID", "party::1220abc") + require.Equal(t, "party::1220abc", resolvePartyID()) +} + +func TestResolveGRPCLedgerURL(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "toml-host:443", resolveGRPCLedgerURL("toml-host:443")) + + t.Setenv("CANTON_GRPC_URL", "env-host:443") + require.Equal(t, "env-host:443", resolveGRPCLedgerURL("toml-host:443")) +} + +func TestResolveValidatorAPIURL(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "https://toml.example/validator/", resolveValidatorAPIURL("https://toml.example/validator/")) + + t.Setenv("CANTON_VALIDATOR_API_URL", "https://env.example/validator/") + require.Equal(t, "https://env.example/validator/", resolveValidatorAPIURL("https://toml.example/validator/")) +} + +func TestJWTSubject(t *testing.T) { + t.Parallel() + + sub, err := jwtSubject(validJWT) + require.NoError(t, err) + require.Equal(t, "1234567890", sub) +} + +func TestUserIDFromToken(t *testing.T) { + t.Parallel() + + userID, err := userIDFromToken(context.Background(), testAuthProvider{token: validJWT}) + require.NoError(t, err) + require.Equal(t, "1234567890", userID) +} + +func TestEndpointsNonEmpty(t *testing.T) { + t.Parallel() + + require.False(t, endpointsNonEmpty("", "", "", "")) + require.True(t, endpointsNonEmpty("", "grpc:443", "", "")) +} + +func TestResolveAuthConfig_envJWTOverridesToml(t *testing.T) { + clearCantonEnv(t) + + t.Setenv("CANTON_JWT", validJWT) + got := resolveAuthConfig("toml-jwt-should-lose", "") + require.Equal(t, validJWT, got.JWT) + require.Equal(t, commonconfig.AuthTypeInsecureStatic, got.Type) +} + +type testAuthProvider struct { + token string +} + +func (p testAuthProvider) TokenSource() oauth2.TokenSource { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: p.token}) +} + +func (p testAuthProvider) TransportCredentials() credentials.TransportCredentials { + return authentication.NewInsecureStaticProvider(p.token).TransportCredentials() +} + +func (p testAuthProvider) PerRPCCredentials() credentials.PerRPCCredentials { + return oauth.TokenSource{TokenSource: p.TokenSource()} +} diff --git a/ccip/devenv/constants.go b/ccip/devenv/constants.go new file mode 100644 index 000000000..c0234b457 --- /dev/null +++ b/ccip/devenv/constants.go @@ -0,0 +1,26 @@ +package devenv + +import "github.com/smartcontractkit/chainlink-ccv/protocol" + +// Shared devenv test constants (message and token paths). + +const ( + // EVMToCantonFinalityConfig is the minimum block-depth FTF (1 confirmation). + EVMToCantonFinalityConfig = protocol.Finality(1) + + // CantonToEVMFeeAmount is the per-message CCIP fee budget in Amulet units for + // message-only sends (200k gas). Kept low for prod-testnet compatibility. + CantonToEVMFeeAmount int64 = 50 + + // CantonToEVMTokenTransferFeeAmount is the per-message fee budget for token transfers + // (500k execution gas), which quote ~127 Amulet per send in devenv. Used only by + // token e2e/load tests; must cover one send and leave enough change for sequential sends. + CantonToEVMTokenTransferFeeAmount int64 = 130 + + // Canton token amounts use 10-decimal fixed point (e.g. 1_000_000_000 = 0.1). + CantonFixedPointScale int64 = 10_000_000_000 + CantonFixedPointToEVMScale int64 = 100_000_000 // fixedPoint * this = EVM wei + + // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. + CantonToEVMTokenSequentialSends = 2 +) diff --git a/ccip/devenv/env-prod-testnet.ci.toml b/ccip/devenv/env-prod-testnet.ci.toml new file mode 100644 index 000000000..0d35777df --- /dev/null +++ b/ccip/devenv/env-prod-testnet.ci.toml @@ -0,0 +1,52 @@ +version = 0 +indexer_endpoints = [ + "https://indexer-1.testnet.ccip.chain.link", + "https://indexer-2.testnet.ccip.chain.link", +] + +[cldf] +addresses = [ + '[{"address":"0x181Ac7dC295f1C8C87342d07CFaBA90bC477DB5d","chainSelector":16015286601757825753,"labels":[],"qualifier":"","type":"OnRamp","version":"2.0.0"},{"address":"0xc6A246A9AcdAaE651708706494720F79C3E5d0A1","chainSelector":16015286601757825753,"labels":[],"qualifier":"","type":"OffRamp","version":"2.0.0"},{"address":"0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59","chainSelector":16015286601757825753,"labels":[],"qualifier":"0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59-Router","type":"Router","version":"1.2.0"},{"address":"0x8632C3025FAFdD85A299211FD5838b5fBE2df816","chainSelector":16015286601757825753,"labels":[],"qualifier":"","type":"FeeQuoter","version":"2.0.0"},{"address":"0x8f3ee3c77D2B27c32306a89D367654F959Db223D","chainSelector":16015286601757825753,"labels":[],"qualifier":"default","type":"CommitteeVerifierResolver","version":"2.0.0"},{"address":"0x66f9E0738a4a6fe54aE62DEd00Ca1F72bDecc092","chainSelector":16015286601757825753,"labels":[],"qualifier":"default","type":"ExecutorProxy","version":"2.0.0"},{"address":"0x78874Df86F55b207d5584093C17B59220E8c6B15","chainSelector":16015286601757825753,"labels":[],"qualifier":"default","type":"Executor","version":"2.0.0"},{"address":"0x097D90c9d3E0B50Ca60e1ae45F6A81010f9FB534","chainSelector":16015286601757825753,"labels":[],"qualifier":"","type":"WETH9","version":"1.0.0"},{"address":"0x779877A7B0D9E8603169DdbD7836e478b4624789","chainSelector":16015286601757825753,"labels":[],"qualifier":"","type":"LinkToken","version":"1.0.0"},{"address":"0x5185b41F1588FC8C541360709C992794925D484C","chainSelector":16015286601757825753,"labels":[],"qualifier":"TEST","type":"BurnMintTokenPool","version":"2.0.0"},{"address":"0xeEe6675b20fE5950eb51361b93021D076289F612","chainSelector":16015286601757825753,"labels":[],"qualifier":"TEST","type":"BurnMintERC20WithDrip","version":"1.5.0"},{"address":"0xA0a507CE0709D3D40F71166c730a860aa29f3491","chainSelector":16015286601757825753,"labels":[],"qualifier":"TEST","type":"AdvancedPoolHooks","version":"2.0.0"}]', + '[{"address":"0x03519eac48d545c4d0ecdc3e3022e443d9e878867827eecb37d5e5a60ae0c989","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"OnRamp","version":"2.0.0"},{"address":"0xd03375cb15a7179bfbfa7cfb843250a6b26e4ddc7a4c30e7bc1777cf3afbf580","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"OffRamp","version":"2.0.0"},{"address":"0x18a00775f6781bfe3032d1b9ceeee225416fb36143218969818df860cb1e29c6","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"FeeQuoter","version":"2.0.0"},{"address":"0x45f673fb23aa33fdaa07e7ae7ea0d37218bcff8e63575a5582a2356cbfaa8883","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"CantonGlobalConfig","version":"2.0.0"},{"address":"0x665cfb57b33b2b74383e99af880ff7d967ad85ef5966ac912f9a3cd4faaee5f9","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"CantonPerPartyRouterFactory","version":"2.0.0"},{"address":"0xe8df5a00cc82df74bae0ca2a032e4d5a7fb5424c1194c4036caabe5fd9f40f81","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"TokenAdminRegistry","version":"2.0.0"},{"address":"0xd4678dbaa95efbf0631162b4fbbae08bf42bbde19038692fec94b6f6b5528fec","chainSelector":9268731218649498074,"labels":[],"qualifier":"","type":"RMNRemote","version":"2.0.0"},{"address":"0xec1e288bcf8bbf034ac2d31b67f9b15a3f1f828d086c5b9d8fc2866129cd02fe","chainSelector":9268731218649498074,"labels":[],"qualifier":"default","type":"CommitteeVerifier","version":"2.0.0"},{"address":"0x4b19a247edd769630a136840ef703bd55ca3eda382317ce4384f2aadd47ddbaa","chainSelector":9268731218649498074,"labels":[],"qualifier":"default","type":"Executor","version":"2.0.0"},{"address":"0x6ac67a53d53ac425440550d27afeb1da16f6d41c224dcd1ed8e9ab1ae20f7ace","chainSelector":9268731218649498074,"labels":["burnminttokenpool-LINK@ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"],"qualifier":"LINK","type":"BurnMintTokenPool","version":"2.0.0"},{"address":"0x6ac67a53d53ac425440550d27afeb1da16f6d41c224dcd1ed8e9ab1ae20f7ace","chainSelector":9268731218649498074,"labels":["burnminttokenpool-LINK@ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"],"qualifier":"LINK","type":"CantonBurnMintTokenPool","version":"2.0.0"},{"address":"39c6212f2291365bed155b2533abbd7ddf340498ccbbb358cee07b83106986ad","chainSelector":9268731218649498074,"labels":["instrument-admin:ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551","instrument-id:link-token","ccip-owner:ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"],"qualifier":"LINK","type":"Token","version":"2.0.0"},{"address":"0x576182aab988a0804a1aa13081902c076ed6108c1162a04b3e971e871a608527","chainSelector":9268731218649498074,"labels":["linkregistry@ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"],"qualifier":"","type":"LinkRegistry","version":"2.0.0"}]', +] +env_metadata = "{\"metadata\":{\"cantonConfigs\":{\"eds_url\":\"https://eds.testnet.ccip.chain.link\"},\"offchainConfigs\":{\"indexers\":{\"indexer-1\":{\"verifiers\":[{\"issuerAddresses\":[\"0x8f3ee3c77D2B27c32306a89D367654F959Db223D\",\"0xec1e288bcf8bbf034ac2d31b67f9b15a3f1f828d086c5b9d8fc2866129cd02fe\"],\"name\":\"default-verifier\"}]}}}}}" + +[[blockchains]] +container_name = "ethereum-testnet-sepolia" +chain_id = "11155111" +type = "anvil" +out.type = "anvil" +out.use_cache = true +out.chain_id = "11155111" +out.family = "evm" +out.container_name = "ethereum-testnet-sepolia" +out.nodes = [ + { http_url = "https://rpcs.cldev.sh/16015286601757825753", internal_http_url = "https://rpcs.cldev.sh/16015286601757825753", internal_ws_url = "wss://rpcs.cldev.sh/16015286601757825753", ws_url = "wss://rpcs.cldev.sh/16015286601757825753" }, +] + +[[blockchains]] +container_name = "canton-testnet" +chain_id = "TestNet" +type = "canton" +out.type = "canton" +out.use_cache = true +out.chain_id = "TestNet" +out.family = "canton" +out.container_name = "canton-testnet" +out.nodes = [] + +[blockchains.out.network_specific_data.canton_data.external_endpoints] + +[[blockchains.out.network_specific_data.canton_data.external_endpoints.participants]] +grpc_ledger_api_url = "testnet.cv1.bcy-v.metalhosts.com:443" +json_ledger_api_url = "https://canton-testnet.bcy-v.metalhosts.com/api/json/" +validator_api_url = "https://canton-testnet.bcy-v.metalhosts.com/api/validator/" +user_id = "" +jwt = "" + +[[blockchains.out.network_specific_data.canton_data.internal_endpoints.participants]] +grpc_ledger_api_url = "testnet.cv1.bcy-v.metalhosts.com:443" +json_ledger_api_url = "https://canton-testnet.bcy-v.metalhosts.com/api/json/" +validator_api_url = "https://canton-testnet.bcy-v.metalhosts.com/api/validator/" +user_id = "" +jwt = "" diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 612b1abcf..12fc73281 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -44,7 +44,6 @@ import ( "github.com/smartcontractkit/go-daml/pkg/types" "github.com/smartcontractkit/chainlink-canton/bindings" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" @@ -68,8 +67,9 @@ import ( ) // TODO: move this to share between devenv and integration tests -// and define a const for LINK instrument const AMTInstrument = types.TEXT("Amulet") +const LINKInstrument = types.TEXT("LINK") + const amuletTransferPreapprovalTemplateID = "#splice-amulet:Splice.AmuletRules:TransferPreapproval" var _ cciptestinterfaces.CCIP17 = &Chain{} @@ -216,16 +216,19 @@ type Chain struct { // Send setup prerequisites routerAddress contracts.InstanceAddress senderAddress contracts.InstanceAddress + receiverAddress contracts.InstanceAddress registryAdmin string validatorAPIClients validatorAPIClients feeTokenInstrument splice_api_token_holding_v1.InstrumentId + feeAmountPerMessage uint64 // per-message fee budget; used when rotating fee holdings after send nextFeeCID string // holding CID to be used as fee on next message send transferTokenInstrument *splice_api_token_holding_v1.InstrumentId nextTransferCID string // holding CID to be used as transfer on next message send + sendsLeft uint64 // last on-chain seq in current setup batch; 0 = always rotate // verifierObs is injected post-construction by test runners (see SetVerifierObservation). - // Required by ConfirmExecOnDest to fetch verifier results from aggregator/indexer. + // Required by ConfirmExecOnDest to fetch verifier results from indexer (aggregator optional). verifierObs VerifierObservation // partyMutexes serializes manual execution per receiver party so that @@ -549,9 +552,10 @@ func (c *Chain) GetTokenExpansionConfigs( "instrument-id:Amulet", ), }, - PoolType: string(poolRef.Type), - TokenPoolQualifier: poolRef.Qualifier, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + PoolType: string(poolRef.Type), + TokenPoolQualifier: poolRef.Qualifier, + // BlockDepth 1: minimum FTF allowed by pool; messages may request finality>=1 via extra args. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }, }}, nil } @@ -759,8 +763,9 @@ func (c *Chain) GetTokenTransferConfigs( Type: datastore.ContractType(token_admin_registry.ContractType), Version: token_admin_registry.Version, }, - RemoteChains: remoteChains, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + RemoteChains: remoteChains, + // BlockDepth 1: pool must allow message FTF; WaitForFinality-only rejects BlockDepth requests. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }}, nil } @@ -936,9 +941,9 @@ func (c *Chain) ConfirmSendOnSource(ctx context.Context, to uint64, key cciptest // 1. Lock per receiver party (PerPartyRouter CID is consumed on every Execute). // 2. Idempotency: if an ExecutionStateChanged for (from, seqNo, messageID) already // exists on the ledger, parse and return it without re-executing. -// 3. Otherwise fetch the verifier result via verifier observation (aggregator + -// indexer), translate the verifier dest address to its hashed instance address, -// and call ManuallyExecuteMessage. +// 3. Otherwise fetch the verifier result via verifier observation (indexer; +// aggregator optional), translate the verifier dest address to its hashed +// instance address, and call ManuallyExecuteMessage. // // Both SeqNum AND MessageID must be set on key: they key the idempotency lookup and // the verifier-result fetch respectively. EVM-side ConfirmExecOnDest is permissive @@ -1011,12 +1016,14 @@ func (c *Chain) SetupReceive(ctx context.Context) error { // SetupSend sets up Canton sender specific prerequisites for sending a message. // eg: deploy per-party router, deploy ccipsender contract... +// transferInstrument defaults to Amulet under registryAdmin when omitted or Admin is empty. func (c *Chain) SetupSend( ctx context.Context, feeAmountPerMessage uint64, - transferAmountPerMessage uint64, + transferAmountPerMessage *big.Rat, + transferInstrument ...splice_api_token_holding_v1.InstrumentId, ) error { - participant, clientIdx, err := c.ClientParticipant() + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) } @@ -1029,21 +1036,10 @@ func (c *Chain) SetupSend( return fmt.Errorf("failed to deploy per-party router: %w", err) } - // Deploy a sender-owned CCIPSender contract. - senderInstanceID := contracts.MustNewInstanceID("devenv-ccipsender") - out, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ - Qualifier: nil, - ParticipantIndex: clientIdx, - Template: ccipsender.CCIPSender{ - InstanceId: types.TEXT(senderInstanceID), - Owner: types.PARTY(party), - }, - OwnerParty: types.PARTY(party), - }) + senderAddress, err := c.DeployCCIPSender(ctx, participant, party) if err != nil { return fmt.Errorf("failed to deploy ccip sender contract: %w", err) } - senderAddress := contracts.HexToInstanceAddress(out.Output.Address) registryAdmin, err := testhelpers.ResolveRegistryAdmin(ctx, participant) if err != nil { return fmt.Errorf("resolve registry admin: %w", err) @@ -1081,19 +1077,24 @@ func (c *Chain) SetupSend( } c.feeTokenInstrument = feeTokenInstrument + c.feeAmountPerMessage = feeAmountPerMessage c.nextFeeCID = selectedFee[0].ContractID // End setup, no transfer holdings needed - if transferAmountPerMessage == 0 { + if transferAmountPerMessage == nil || transferAmountPerMessage.Sign() <= 0 { + c.transferTokenInstrument = nil + c.nextTransferCID = "" return nil } // Setup transfer holdings - // TODO: add support for LINK instrument transferTokenInstrument := &splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(registryAdmin), Id: AMTInstrument, } + if len(transferInstrument) > 0 && transferInstrument[0].Admin != "" { + transferTokenInstrument = &transferInstrument[0] + } // Select transfer holding using another call here because fee/token might use different instruments filters := append(baseFilters, testhelpers.ExcludeCIDs([]string{c.nextFeeCID})) // this filter is here in case fee and transfer use the same instrument @@ -1101,7 +1102,7 @@ func (c *Chain) SetupSend( if err != nil { return fmt.Errorf("list transfer holdings for setup: %w", err) } - transferMin := new(big.Rat).SetUint64(transferAmountPerMessage) + transferMin := new(big.Rat).Set(transferAmountPerMessage) selectedTransfer, err := testhelpers.SelectHoldingsForInstrument(transferRows, []*big.Rat{transferMin}) if err != nil || len(selectedTransfer) == 0 { return fmt.Errorf("select transfer holding for setup: %w", err) @@ -1113,10 +1114,24 @@ func (c *Chain) SetupSend( return nil } +// SetSequentialSends limits holding rotation to the next sends messages in this setup batch. +// After the send whose on-chain seq equals nextSeq+sends, setNextHoldings is skipped. +// Pass 0 to always rotate (open-ended load). +func (c *Chain) SetSequentialSends(sends int) { + if sends <= 0 { + c.sendsLeft = 0 + return + } + c.sendsLeft = uint64(sends) +} + // MintTokens mint tokens for transfer and fees. To be used on devenv tests only. // this method won't work in staging/prod tests -// TODO: add support for LINK instrument -func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { +func (c *Chain) MintTokens(ctx context.Context, amount *big.Rat) error { + if amount == nil || amount.Sign() <= 0 { + return nil + } + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1135,7 +1150,7 @@ func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { validatorAPIClients.transferClient, validatorAPIClients.scanClient, party, - strconv.FormatUint(amount, 10), + amount.FloatString(10), ) if err != nil { return fmt.Errorf("failed to mint tokens: %w", err) @@ -1250,7 +1265,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if hasTokenTransfer { // TODO: move this to the other line also branching by tokenTransfer outgoingMessage.TokenTransfer = &oapiCommon.TokenTransfer{ - Amount: fields.TokenAmount.Amount.String(), + Amount: new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)).FloatString(10), Token: oapiCommon.InstrumentId{ Admin: oapiCommon.PartyId(c.transferTokenInstrument.Admin), Id: string(c.transferTokenInstrument.Id), @@ -1258,7 +1273,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint } } // TODO come up with a better way of doing this - routerCid, err := contract.FindActiveContractIDByInstanceAddress(ctx, participant.LedgerServices.State, []string{party}, ccipruntime.PerPartyRouter{}.GetTemplateID(), c.routerAddress) + routerCid, err := c.findPerPartyRouterCidByParty(ctx, participant, party) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("find active contract ID for router at address %s: %w", c.routerAddress, err) } @@ -1299,7 +1314,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint var tokenPoolRequiredCCVs []string if hasTokenTransfer { // Get Token Pool - token := contracts.EncodeInstrumentID(c.feeTokenInstrument) + token := contracts.EncodeInstrumentID(*c.transferTokenInstrument) tokenPoolAddress, err := c.GetTokenPoolForToken(ctx, token) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("get token pool for token %s: %w", token.String(), err) @@ -1400,7 +1415,6 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("execute CCIP Send: %w", err) } - c.logger.Info().Str("UpdateID", ccipSendReport.Output.ExecInfo.UpdateID).Msg("CCIP Send executed") update, err := participant.LedgerServices.Update.GetUpdateById(ctx, &apiv2.GetUpdateByIdRequest{ UpdateId: ccipSendReport.Output.ExecInfo.UpdateID, UpdateFormat: &apiv2.UpdateFormat{ @@ -1443,12 +1457,11 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, err } - - // Set next holdings - err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, fields.TokenAmount.Amount) - if err != nil { - return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) - } + c.logger.Info(). + Str("UpdateID", ccipSendReport.Output.ExecInfo.UpdateID). + Str("messageID", protocol.Bytes32(parsedSend.messageID).String()). + Uint64("seqNo", parsedSend.seqNo). + Msg("CCIP Send executed") event := cciptestinterfaces.MessageSentEvent{ MessageID: parsedSend.messageID, @@ -1462,6 +1475,25 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint c.lastSentSeq = parsedSend.seqNo c.lastSentEvent = event + // Case it's the last send + c.sendsLeft-- + if c.sendsLeft == 0 { + c.logger.Info(). + Uint64("sendsLeft", c.sendsLeft). + Msg("Skipping holding rotation after last planned send in batch") + + return event, nil + } + + var tokenAmount *big.Rat + if hasTokenTransfer { + tokenAmount = new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)) + } + err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, tokenAmount) + if err != nil { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) + } + return event, nil } @@ -1564,7 +1596,7 @@ func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSe // Created events from the send transaction. When no qualifying holding appears in those // events, the corresponding next CID is cleared (empty means no next holding available); // the current send already succeeded and a future SendMessage will fail its holding checks. -func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Int) error { +func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Rat) error { participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1590,10 +1622,11 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to testhelpers.ExcludeCIDs(spentCIDs), } + feeMin := new(big.Rat).SetUint64(c.feeAmountPerMessage) nextFeeCID, exhaustion, err := pickNextHolding( events, c.feeTokenInstrument, - big.NewRat(0, 1), // TODO: we'll have to consider real fee here once we go load tests + feeMin, refreshFilters..., ) if err != nil && !exhaustion { @@ -1615,7 +1648,7 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to slices.Clone(refreshFilters), testhelpers.ExcludeCIDs(append(spentCIDs, c.nextFeeCID)), ) - transferValue := new(big.Rat).SetInt(tokenAmount) + transferValue := new(big.Rat).Set(tokenAmount) nextTransferCID, exhaustion, err := pickNextHolding( events, *c.transferTokenInstrument, @@ -1655,7 +1688,7 @@ func pickNextHolding( } picked, err := testhelpers.SelectHoldingsForInstrument(fromEvents, minAmounts) if err != nil || len(picked) == 0 { - return "", true, fmt.Errorf("refresh next fee holding from update: %w", err) + return "", true, fmt.Errorf("select next holding from send events: %w", err) } return picked[0].ContractID, false, nil diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index c4947e642..0dc51fa83 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math/big" + "os" "strings" "sync" "time" @@ -22,40 +23,49 @@ import ( "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" "github.com/smartcontractkit/chainlink-canton/contracts" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/receiver" + "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) -const perPartyRouterInstanceID = "test-router" +func instanceIDFromEnv(key, defaultID string) contracts.InstanceID { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return contracts.InstanceID(v) + } -// DeployPerPartyRouter uses the PerPartyRouterFactory to create a new PerPartyRouter instance for the given party. -// partyOwner (client participant) exercises CreateRouter with factory disclosures from EDS. -// It returns the instance address of the router. If a router already exists for the party, it returns the existing address. -func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant canton.Participant, partyOwner string) (routerAddress contracts.InstanceAddress, err error) { - perPartyRouterFactoryDisclosure, err := c.GetPerPartyRouterFactoryDisclosure(ctx, partyOwner) - if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf("failed to get canton per party router factory disclosure: %w", err) + return contracts.InstanceID(defaultID) +} + +// DeployPerPartyRouter uses the PerPartyRouterFactory to create a PerPartyRouter instance for the given party. +// It returns the partyOwner-based instance address used in CCIP protocol fields, reusing an existing router on ledger when present. +func (c *Chain) DeployPerPartyRouter(ctx context.Context, participant canton.Participant, partyId string) (routerAddress contracts.InstanceAddress, err error) { + var unset contracts.InstanceAddress + routerInstanceID := instanceIDFromEnv("CANTON_ROUTER_INSTANCE_ID", "test-router") + if c.routerAddress != unset { + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("verify cached per-party router: %w", findErr) + } else if ok && found == c.routerAddress { + return c.routerAddress, nil + } + c.routerAddress = unset } - c.logger.Debug().Str("ContractId", perPartyRouterFactoryDisclosure.ContractId).Msg("Resolved per-party router factory disclosure") - routerInstanceID := contracts.InstanceID(perPartyRouterInstanceID) - ccipOwner := perPartyRouterFactoryDisclosure.Address.Owner() - routerAddress = routerInstanceID.RawInstanceAddress(types.PARTY(ccipOwner)).InstanceAddress() + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("find existing per-party router: %w", findErr) + } else if ok { + c.routerAddress = found + return found, nil + } - // Return early only if the router already exists at the expected instance address. - _, err = contract.FindActiveContractIDByInstanceAddress( - ctx, - clientParticipant.LedgerServices.State, - []string{partyOwner}, - ccipruntime.PerPartyRouter{}.GetTemplateID(), - routerAddress, - ) - if err == nil { - return routerAddress, nil + perPartyRouterFactoryDisclosure, err := c.GetPerPartyRouterFactoryDisclosure(ctx, partyId) + if err != nil { + return contracts.InstanceAddress{}, fmt.Errorf("failed to get canton per party router factory disclosure: %w", err) } + c.logger.Debug().Str("ContractId", perPartyRouterFactoryDisclosure.ContractId).Msg("Resolved per-party router factory address") _, err = operations.ExecuteOperation( c.e.OperationsBundle, @@ -67,44 +77,190 @@ func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant cant ParticipantIndex: c.clientParticipantIndex(), DisclosedContracts: contract.DisclosedContractsFromProto(perPartyRouterFactoryDisclosure.DisclosedContracts), Args: ccipruntime.CreateRouter{ - PartyOwner: types.PARTY(partyOwner), + PartyOwner: types.PARTY(partyId), InstanceId: types.TEXT(routerInstanceID.String()), }, }, operations.WithForceExecute[contract.ChoiceInput[ccipruntime.CreateRouter], canton.Chain](), ) if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf("failed to create per-party router: %w", err) + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("create per-party router: %w (find after failure: %w)", err, findErr) + } else if ok { + c.routerAddress = found + return found, nil + } + + return contracts.InstanceAddress{}, fmt.Errorf("create per-party router: %w", err) + } + + found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID) + if findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("find per-party router after create: %w", findErr) + } + if !ok { + return contracts.InstanceAddress{}, fmt.Errorf("per-party router not found after create for party %s", partyId) + } + + c.routerAddress = found + + return found, nil +} + +func (c *Chain) findPerPartyRouterByParty( + ctx context.Context, + participant canton.Participant, + partyId string, + preferredInstanceID contracts.InstanceID, +) (contracts.InstanceAddress, string, bool, error) { + templateID := contracts.TemplateIDFromBinding(ccipruntime.PerPartyRouter{}).ToLedgerIdentifier() + activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) + if err != nil { + return contracts.InstanceAddress{}, "", false, err + } + + var fallback contracts.InstanceAddress + var fallbackCid string + var hasFallback bool + for _, ac := range activeContracts { + created := ac.GetCreatedEvent() + if created == nil { + continue + } + instanceId, partyOwner, ok := perPartyRouterFieldsFromCreated(created) + if !ok { + c.logger.Debug().Msg("Skipping PerPartyRouter active contract missing instanceId or partyOwner") + continue + } + if partyOwner != partyId { + continue + } + addr := contracts.InstanceID(instanceId).RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + cid := created.GetContractId() + if contracts.InstanceID(instanceId) == preferredInstanceID { + return addr, cid, true, nil + } + if !hasFallback { + fallback = addr + fallbackCid = cid + hasFallback = true + } + } + if hasFallback { + return fallback, fallbackCid, true, nil } - _, err = contract.FindActiveContractIDByInstanceAddress( + return contracts.InstanceAddress{}, "", false, nil +} + +func perPartyRouterFieldsFromCreated(created *apiv2.CreatedEvent) (instanceId, partyOwner string, ok bool) { + for _, field := range created.GetCreateArguments().GetFields() { + switch field.GetLabel() { + case "instanceId": + instanceId = field.GetValue().GetText() + case "partyOwner": + partyOwner = field.GetValue().GetParty() + } + } + + return instanceId, partyOwner, instanceId != "" && partyOwner != "" +} + +// findPerPartyRouterCidByParty resolves the ledger contract ID for a PerPartyRouter owned by partyId. +// PerPartyRouter is signed by ccipOwner, so instance-address lookup must use partyOwner (see OnRamp.daml). +func (c *Chain) findPerPartyRouterCidByParty(ctx context.Context, participant canton.Participant, partyId string) (string, error) { + routerInstanceID := instanceIDFromEnv("CANTON_ROUTER_INSTANCE_ID", "test-router") + _, cid, ok, err := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("no active PerPartyRouter found for party %s", partyId) + } + + return cid, nil +} + +// DeployCCIPSender returns a sender-owned CCIPSender instance address, deploying only when missing. +func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Participant, partyId string) (contracts.InstanceAddress, error) { + var unset contracts.InstanceAddress + if c.senderAddress != unset { + return c.senderAddress, nil + } + + instanceID := instanceIDFromEnv("CANTON_SENDER_INSTANCE_ID", "e2e-ccipsender") + senderAddress := instanceID.RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + + if _, err := contract.FindActiveContractIDByInstanceAddress( ctx, - clientParticipant.LedgerServices.State, - []string{partyOwner}, - ccipruntime.PerPartyRouter{}.GetTemplateID(), - routerAddress, - ) + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); err == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + + _, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ + Qualifier: nil, + ParticipantIndex: c.clientParticipantIndex(), + Template: ccipsender.CCIPSender{ + InstanceId: types.TEXT(instanceID), + Owner: types.PARTY(partyId), + }, + OwnerParty: types.PARTY(partyId), + }) if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf( - "per-party router not found at %s for party %s after CreateRouter: %w", - routerAddress, partyOwner, err, - ) + if _, findErr := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); findErr == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + + return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy ccip sender contract: %w", err) } - return routerAddress, nil + c.senderAddress = senderAddress + + return senderAddress, nil } func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Participant, partyId string, receiverFinality int64) (contracts.InstanceAddress, error) { + var unset contracts.InstanceAddress + if c.receiverAddress != unset { + return c.receiverAddress, nil + } + + instanceID := instanceIDFromEnv("CANTON_RECEIVER_INSTANCE_ID", "e2e-receiver") + receiverAddress := instanceID.RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + + if _, err := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipreceiver.CCIPReceiver{}.GetTemplateID(), + receiverAddress, + ); err == nil { + c.receiverAddress = receiverAddress + return receiverAddress, nil + } + finalityConfig, err := encodeReceiverFinalityConfig(receiverFinality) if err != nil { return contracts.InstanceAddress{}, fmt.Errorf("failed to encode receiver finality config: %w", err) } - // Deploy receiver contract - out, err := operations.ExecuteOperation(c.e.OperationsBundle, receiver.Deploy, c.chain, contract.DeployInput[ccipreceiver.CCIPReceiver]{ + _, err = operations.ExecuteOperation(c.e.OperationsBundle, receiver.Deploy, c.chain, contract.DeployInput[ccipreceiver.CCIPReceiver]{ Qualifier: nil, ParticipantIndex: c.clientParticipantIndex(), Template: ccipreceiver.CCIPReceiver{ + InstanceId: types.TEXT(instanceID), Owner: types.PARTY(partyId), RequiredCCVs: nil, OptionalCCVs: nil, @@ -114,9 +270,21 @@ func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Parti OwnerParty: types.PARTY(partyId), }) if err != nil { + if _, findErr := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipreceiver.CCIPReceiver{}.GetTemplateID(), + receiverAddress, + ); findErr == nil { + c.receiverAddress = receiverAddress + return receiverAddress, nil + } + return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy receiver contract: %w", err) } - receiverAddress := contracts.HexToInstanceAddress(out.Output.Address) + + c.receiverAddress = receiverAddress return receiverAddress, nil } @@ -158,12 +326,9 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes } encodedMessageHex := hex.EncodeToString(encodedMessage) - routerCid, err := contract.FindActiveContractIDByInstanceAddress(ctx, participant.LedgerServices.State, []string{participant.PartyID}, ccipruntime.PerPartyRouter{}.GetTemplateID(), routerAddress) + routerCid, err := c.findPerPartyRouterCidByParty(ctx, participant, executingParty) if err != nil { - return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf( - "per-party router not found for party %s at %s; call SetupReceive or SetupSend on the client participant before executing messages: %w", - executingParty, routerAddress, err, - ) + return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to get router contract ID: %w", err) } c.logger.Debug().Str("InstanceAddress", routerAddress.String()).Str("ContractId", routerCid).Msg("Resolved PerPartyRouter contract") @@ -393,10 +558,10 @@ func (c *Chain) lockForParty(party string) func() { func (c *Chain) findExistingExecutionState( ctx context.Context, sourceChainSelector, seqNo uint64, messageID protocol.Bytes32, ) (cciptestinterfaces.ExecutionStateChangedEvent, bool, error) { - participant, _, err := c.ClientParticipant() - if err != nil { - return cciptestinterfaces.ExecutionStateChangedEvent{}, false, fmt.Errorf("findExistingExecutionState: %w", err) + if len(c.chain.Participants) == 0 { + return cciptestinterfaces.ExecutionStateChangedEvent{}, false, fmt.Errorf("findExistingExecutionState: no participants on chain") } + participant := c.chain.Participants[0] templateID := contracts.TemplateIDFromBinding(core.ExecutionStateChanged{}).ToLedgerIdentifier() activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) @@ -440,8 +605,8 @@ func verifierAssertTimeout(timeout time.Duration) time.Duration { return timeout } -// fetchVerifierResult queries the aggregator + indexer for the verifier output for -// messageID. Caller must have wired [VerifierObservation] on the chain first. +// fetchVerifierResult queries the indexer (aggregator optional) for the verifier +// output for messageID. Caller must have wired [VerifierObservation] on the chain first. func (c *Chain) fetchVerifierResult(ctx context.Context, messageID protocol.Bytes32, timeout time.Duration) (verifierResult, error) { if !c.verifierObs.wired() { return verifierResult{}, fmt.Errorf("verifier observation not wired") @@ -457,9 +622,6 @@ func (c *Chain) fetchVerifierResult(ctx context.Context, messageID protocol.Byte if err != nil { return verifierResult{}, fmt.Errorf("assertMessage: %w", err) } - if res.AggregatedResult == nil { - return verifierResult{}, fmt.Errorf("aggregated verifier result missing") - } if len(res.IndexedVerifications.Results) != 1 { return verifierResult{}, fmt.Errorf("expected 1 indexed verifier result, got %d", len(res.IndexedVerifications.Results)) } @@ -492,17 +654,28 @@ func encodeReceiverFinalityConfig(finality int64) (core.FinalityConfig, error) { } } -// hashInstanceAddress decodes a verifier result's VerifierDestAddress on Canton. -// On Canton the verifier result carries the raw instance address as a hex-encoded -// string (bytes); to look up its disclosure from EDS we need the hashed instance -// address. This helper is the non-test counterpart of getHashedInstanceAddress -// previously kept in evm2canton_e2e_test.go. -func hashInstanceAddress(rawInstanceAddressBytes protocol.UnknownAddress) (protocol.UnknownAddress, error) { - rawInstanceAddressStr := string(rawInstanceAddressBytes.Bytes()) - rawInstanceAddress, err := contracts.RawInstanceAddressFromString(rawInstanceAddressStr) - if err != nil { - return nil, fmt.Errorf("hashInstanceAddress: %w", err) +// hashInstanceAddress resolves a verifier result's VerifierDestAddress to the hashed +// Canton instance address used for EDS disclosure lookup. +// +// The indexer may return either format depending on environment: +// - devenv: raw instance address string bytes ("instanceId@owner") +// - prod-testnet: already-hashed 32-byte InstanceAddress (from verifier config) +func hashInstanceAddress(addr protocol.UnknownAddress) (protocol.UnknownAddress, error) { + if len(addr) == 0 { + return nil, fmt.Errorf("empty verifier dest address") + } + + if raw, err := contracts.RawInstanceAddressFromString(string(addr)); err == nil { + return protocol.UnknownAddress(raw.InstanceAddress().Bytes()), nil + } + + if len(addr) == contracts.InstanceAddressLength { + return addr, nil + } + + if hexAddr, err := protocol.NewUnknownAddressFromHex(string(addr)); err == nil && len(hexAddr) == contracts.InstanceAddressLength { + return hexAddr, nil } - return protocol.UnknownAddress(rawInstanceAddress.InstanceAddress().Bytes()), nil + return nil, fmt.Errorf("unrecognized verifier dest address format (len=%d)", len(addr)) } diff --git a/ccip/devenv/manual_execution_test.go b/ccip/devenv/manual_execution_test.go new file mode 100644 index 000000000..8875f8cef --- /dev/null +++ b/ccip/devenv/manual_execution_test.go @@ -0,0 +1,38 @@ +package devenv + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-ccv/protocol" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/contracts" +) + +func TestHashInstanceAddress_rawInstanceAddress(t *testing.T) { + t.Parallel() + + raw := "test-verifier@ccvOwner::1220abcd" + addr, err := hashInstanceAddress(protocol.UnknownAddress(raw)) + require.NoError(t, err) + + expectedRaw, err := contracts.RawInstanceAddressFromString(raw) + require.NoError(t, err) + require.Equal(t, protocol.UnknownAddress(expectedRaw.InstanceAddress().Bytes()), addr) +} + +func TestHashInstanceAddress_alreadyHashed(t *testing.T) { + t.Parallel() + + hashed := protocol.UnknownAddress(contracts.HexToInstanceAddress("0xec1e288bcf8bbf034ac2d31b67f9b15a3f1f828d086c5b9d8fc2866129cd02fe").Bytes()) + addr, err := hashInstanceAddress(hashed) + require.NoError(t, err) + require.Equal(t, hashed, addr) +} + +func TestHashInstanceAddress_empty(t *testing.T) { + t.Parallel() + + _, err := hashInstanceAddress(nil) + require.Error(t, err) +} diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go deleted file mode 100644 index 4d16a64a8..000000000 --- a/ccip/devenv/tests/constants.go +++ /dev/null @@ -1,14 +0,0 @@ -package tests - -// Shared devenv test constants (message and token paths). - -const ( - // CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units. - CantonToEVMFeeAmount int64 = 2_000 - - // EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units. - EVMDecimalsScale int64 = 1_000_000_000_000_000_000 - - // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. - CantonToEVMTokenSequentialSends = 2 -) diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 28e2e1a3b..82c618caa 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -1,10 +1,8 @@ package canton import ( - "fmt" "math/big" "testing" - "time" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" @@ -13,8 +11,6 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,48 +26,26 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Skip("skipping Canton2EVM_Basic test in short mode") } - configPath := "../../env-canton-evm-out.toml" - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) - require.NoError(t, err) + boot := devenvtests.BootstrapE2E(t, devenvtests.ParseEnvFromFlag(t)) ctx := ccv.Plog.WithContext(t.Context()) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain cantonImpl must be *devenv.Chain") - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - // Setup message send - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + boot.SetupCantonSend(t, ctx, 0) + receiver := boot.ResolveEVMReceiver(t) - ds, err := lib.DataStore() - require.NoError(t, err) - receiver, err := evmChain.GetEOAReceiverAddress() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(canton_committee_verifier.ContractType), canton_committee_verifier.Version.String(), devenvcommon.DefaultCommitteeVerifierQualifier, "canton committee verifier", ) executorAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(executor.ContractType), executor.Version.String(), devenvcommon.DefaultExecutorQualifier, @@ -82,14 +56,14 @@ func TestCanton2EVM_Basic(t *testing.T) { receiver, ccvAddr, executorAddr, - cantonChain.ChainSelector(), - evmChain.ChainSelector(), + boot.Canton.ChainSelector(), + boot.EVM.ChainSelector(), ) t.Logf("Sending Canton -> EVM message") - sendMessageResult, err := cantonChain.SendMessage( + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm tcapi test"), @@ -110,29 +84,24 @@ func TestCanton2EVM_Basic(t *testing.T) { ) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) - // require.NotEmpty(t, sendMessageResult.ReceiptIssuers) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - t.Logf( - "SendMessage accepted: seqNo=%d", - seqNo, - // len(sendMessageResult.ReceiptIssuers), - ) + t.Logf("SendMessage accepted: seqNo=%d", seqNo) - t.Logf("Waiting for CCIPMessageSent event: from=%d to=%d seq=%d", cantonChain.ChainSelector(), evmChain.ChainSelector(), seqNo) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 30*time.Second) + t.Logf("Waiting for CCIPMessageSent event: from=%d to=%d seq=%d", boot.Canton.ChainSelector(), boot.EVM.ChainSelector(), seqNo) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) t.Logf("CCIPMessageSent event: %+v", sentEvent) t.Logf("Asserting message propagated through aggregator/indexer: messageID=%x", sentEvent.MessageID[:]) - result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, lib, sentEvent.MessageID) + result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, boot.Lib, sentEvent.MessageID) t.Logf( "Message assertion succeeded: aggregated=true indexerResults=%+v", result.IndexedVerifications.Results, ) - t.Logf("Waiting for execution event on EVM: from=%d seq=%d", cantonChain.ChainSelector(), seqNo) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + t.Logf("Waiting for execution event on EVM: from=%d seq=%d", boot.Canton.ChainSelector(), seqNo) + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) require.NoError(t, err) assert.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) t.Logf("Execution event: %+v", ev) @@ -141,48 +110,37 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("EOA receiver and default committee verifier token transfer", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), []uint64{boot.EVM.ChainSelector()}) + boot.SetupCantonTokenSend(t, ctx, lane, cantondevenv.CantonToEVMTokenSequentialSends) - // Setup message send - require.NoError(t, cantonImpl.MintTokens(ctx, - devenvtests.CantonToEVMTokenSequentialSends*uint64(devenvtests.CantonToEVMFeeAmount), - )) // Holdings for fee - require.NoError(t, cantonImpl.MintTokens(ctx, - devenvtests.CantonToEVMTokenSequentialSends*tokenTransferAmount, - )) // Holdings for token transfer - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + receiver := boot.ResolveEVMReceiver(t) - ds, err := lib.DataStore() - require.NoError(t, err) - receiver, err := evmChain.GetEOAReceiverAddress() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(canton_committee_verifier.ContractType), canton_committee_verifier.Version.String(), devenvcommon.DefaultCommitteeVerifierQualifier, "canton committee verifier", ) executorAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(executor.ContractType), executor.Version.String(), devenvcommon.DefaultExecutorQualifier, "source executor", ) - require.NoError(t, err) - destTokenAddress := lane.DestTokenBySelector[evmChain.ChainSelector()] - receiverBalanceBefore, err := evmChain.GetTokenBalance(subtestCtx, receiver, destTokenAddress) + destTokenAddress := lane.DestTokenBySelector[boot.EVM.ChainSelector()] + receiverBalanceBefore, err := boot.EVM.GetTokenBalance(subtestCtx, receiver, destTokenAddress) require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) - for sendIdx := range devenvtests.CantonToEVMTokenSequentialSends { - t.Logf("Token transfer send %d/%d", sendIdx+1, devenvtests.CantonToEVMTokenSequentialSends) - sendMessageResult, err := cantonChain.SendMessage( + for sendIdx := range cantondevenv.CantonToEVMTokenSequentialSends { + t.Logf("Token transfer send %d/%d", sendIdx+1, cantondevenv.CantonToEVMTokenSequentialSends) + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm token transfer"), @@ -209,22 +167,29 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message.TokenTransfer) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + t.Logf("Asserting message propagated through aggregator/indexer: messageID=%x", sentEvent.MessageID[:]) + result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, boot.Lib, sentEvent.MessageID) + t.Logf( + "Message assertion succeeded: aggregated=true indexerResults=%+v", + result.IndexedVerifications.Results, + ) + + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) } - receiverBalanceAfter, err := evmChain.GetTokenBalance(subtestCtx, receiver, destTokenAddress) + receiverBalanceAfter, err := boot.EVM.GetTokenBalance(subtestCtx, receiver, destTokenAddress) require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) - totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(devenvtests.CantonToEVMTokenSequentialSends)) + expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointToEVMScale)) + totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(cantondevenv.CantonToEVMTokenSequentialSends)) expectedReceiverBalanceAfter := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), totalExpectedTransfer) t.Logf("EVM receiver token balance: before=%s after=%s totalExpectedTransfer=%s", receiverBalanceBefore.String(), receiverBalanceAfter.String(), totalExpectedTransfer.String()) require.Equal(t, expectedReceiverBalanceAfter, receiverBalanceAfter) @@ -234,19 +199,14 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with default extraArgs", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() - - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), []uint64{boot.EVM.ChainSelector()}) + boot.SetupCantonTokenSend(t, ctx, lane, 1) - receiver, err := evmChain.GetEOAReceiverAddress() - require.NoError(t, err) + receiver := boot.ResolveEVMReceiver(t) - sendMessageResult, err := cantonChain.SendMessage( + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm token transfer default extraArgs"), @@ -265,10 +225,10 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message.TokenTransfer) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) }) @@ -278,35 +238,31 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with zero gas limit", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), []uint64{boot.EVM.ChainSelector()}) + boot.SetupCantonTokenSend(t, ctx, lane, 1) - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + receiver := boot.ResolveEVMReceiver(t) - ds, err := lib.DataStore() - require.NoError(t, err) - receiver, err := evmChain.GetEOAReceiverAddress() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(canton_committee_verifier.ContractType), canton_committee_verifier.Version.String(), devenvcommon.DefaultCommitteeVerifierQualifier, "canton committee verifier", ) executorAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(executor.ContractType), executor.Version.String(), devenvcommon.DefaultExecutorQualifier, "source executor", ) - sendMessageResult, err := cantonChain.SendMessage( + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm token transfer zero gas"), @@ -332,10 +288,10 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) }) diff --git a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go index 60d57ba07..9a22cc1ee 100644 --- a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go @@ -59,8 +59,8 @@ func setupCanton2EVMSendFixtureWithHoldings(t *testing.T, withFeeHoldings bool) require.True(t, ok) if withFeeHoldings { - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) } ds, err := lib.DataStore() @@ -229,8 +229,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Unsupported token on lane", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, new(big.Rat).SetUint64(1000))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), new(big.Rat).SetUint64(1000))) f.cantonImpl.SetTransferTokenInstrumentForTest(splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY("unsupported-token-admin"), Id: types.TEXT("UnsupportedToken"), @@ -252,8 +252,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Zero token amount", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, new(big.Rat).SetUint64(1000))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), new(big.Rat).SetUint64(1000))) err := f.send( cciptestinterfaces.MessageFields{ diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index 69b45eb03..da0a4a118 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -1,27 +1,23 @@ package canton import ( - "fmt" "math/big" "testing" - "time" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/proxy" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/versioned_verifier_resolver" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + ccldf "github.com/smartcontractkit/chainlink-ccv/build/devenv/cldf" "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory "github.com/smartcontractkit/chainlink-ccv/protocol" - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // register Canton ImplFactory - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) @@ -32,38 +28,24 @@ func TestEVM2Canton_Basic(t *testing.T) { t.Skip("skipping EVM2Canton_Basic test in short mode") } - configPath := "../../env-canton-evm-out.toml" - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) - require.NoError(t, err) + boot := devenvtests.BootstrapE2E(t, devenvtests.ParseEnvFromFlag(t)) + ctx := ccv.Plog.WithContext(t.Context()) + boot.SetupCantonReceive(t, ctx) - chainMap, err := lib.ChainsMap(t.Context()) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - srcChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - dstChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonDest, ok := dstChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton dest chain must be *devenv.Chain") - require.NoError(t, cantonDest.SetupReceive(ccv.Plog.WithContext(t.Context()))) - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - srcSelector := srcChain.ChainSelector() - dstSelector := dstChain.ChainSelector() - receiverParticipant, _, err := cantonDest.ClientParticipant() + srcSelector := boot.EVM.ChainSelector() + dstSelector := boot.Canton.ChainSelector() + _, opsEnv, err := ccldf.NewCLDFOperationsEnvironment(boot.Cfg.Blockchains, boot.Cfg.CLDF.DataStore) require.NoError(t, err) + var receiverParticipant canton.Participant + if chains := opsEnv.BlockChains.CantonChains(); len(chains[dstSelector].Participants) > 0 { + receiverParticipant = chains[dstSelector].Participants[0] + } require.NotEmpty(t, receiverParticipant.PartyID) - receiver, err := cantonDest.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - ds, err := lib.DataStore() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( t, ds, srcSelector, @@ -83,39 +65,26 @@ func TestEVM2Canton_Basic(t *testing.T) { t.Run("message transfer", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - seqNo, err := srcChain.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) + seqNo, err := boot.EVM.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) require.NoError(t, err) - sendMessageResult, err := srcChain.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ + sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello message transfer from EVM!"), - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: 200_000, - FinalityConfig: 0, - Executor: executorAddress, - CCVs: []protocol.CCV{ - { - CCVAddress: ccvAddr, - Args: []byte{}, - ArgsLen: 0, - }, - }, - }, 3) + }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddress, ccvAddr), 3) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) - sentEvent, err := srcChain.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.ConfirmEVMSendOnSource(t, subtestCtx, dstSelector, seqNo, sendMessageResult) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.Nil(t, sentEvent.Message.TokenTransfer) execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) - // testing idempotency of ConfirmExecOnDest: a second call - // must return the same event without re-executing. - idempotentEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + idempotentEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, executionStateChangedEvent.State, idempotentEvent.State) require.Equal(t, executionStateChangedEvent.MessageNumber, idempotentEvent.MessageNumber) @@ -125,14 +94,25 @@ func TestEVM2Canton_Basic(t *testing.T) { t.Run("token transfer", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, srcSelector, []uint64{dstSelector}) + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, srcSelector, []uint64{dstSelector}) + t.Logf("Token lane: pool=%s srcToken=%x transfer=%s", + lane.PoolRef.Qualifier, lane.SrcToken, lane.TransferAmount.String()) + srcToken := lane.SrcToken - srcSender, err := srcChain.GetEOAReceiverAddress() - require.NoError(t, err) - seqNo, err := srcChain.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) + srcSender := boot.ResolveEVMReceiver(t) + + if boot.Env.IsRemote() { + senderBalance, err := boot.EVM.GetTokenBalance(subtestCtx, srcSender, srcToken) + require.NoError(t, err) + require.NotNil(t, senderBalance) + require.GreaterOrEqual(t, senderBalance.Cmp(lane.TransferAmount), 0, + "EVM sender token balance %s is below transfer amount %s; fund PRIVATE_KEY wallet with TEST tokens on Sepolia", + senderBalance.String(), lane.TransferAmount.String()) + t.Log("prod-testnet: ensure PRIVATE_KEY wallet has Sepolia ETH for send and possible router approve tx") + } + seqNo, err := boot.EVM.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) require.NoError(t, err) - sendMessageResult, err := srcChain.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ + sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello token transfer from EVM!"), TokenAmount: cciptestinterfaces.TokenAmount{ @@ -155,15 +135,12 @@ func TestEVM2Canton_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message) require.NotNil(t, sendMessageResult.Message.TokenTransfer) - sentEvent, err := srcChain.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.ConfirmEVMSendOnSource(t, subtestCtx, dstSelector, seqNo, sendMessageResult) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) - // Pre-exec assertions on the verifier result are kept here (cheap aggregator/indexer - // re-read) so the token transfer assertions stay co-located with the test body. - // ConfirmExecOnDest below performs its own fetch internally for execution. - result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, lib, sentEvent.MessageID) + result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, boot.Lib, sentEvent.MessageID) vr := result.IndexedVerifications.Results[0].VerifierResult require.NotNil(t, vr.Message.TokenTransfer) require.NotNil(t, vr.Message.TokenTransfer.Amount) @@ -171,7 +148,7 @@ func TestEVM2Canton_Basic(t *testing.T) { require.Positive(t, vr.Message.TokenTransfer.Amount.Cmp(big.NewInt(0)), "token transfer amount must be positive") execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) @@ -180,10 +157,10 @@ func TestEVM2Canton_Basic(t *testing.T) { totalHoldingsFloat, _ := new(big.Float).SetRat(totalHoldingsRat).Float64() t.Logf("Canton receiver total holdings after execute: %.10f", totalHoldingsFloat) - srcBalanceAfter, err := srcChain.GetTokenBalance(subtestCtx, srcSender, srcToken) + srcBalanceAfter, err := boot.EVM.GetTokenBalance(subtestCtx, srcSender, srcToken) require.NoError(t, err) require.NotNil(t, srcBalanceAfter) - dstBalanceAfter, err := cantonDest.GetTokenBalance(subtestCtx, receiver, nil) + dstBalanceAfter, err := boot.Canton.GetTokenBalance(subtestCtx, receiver, nil) require.NoError(t, err) require.NotNil(t, dstBalanceAfter) t.Logf("Token balances after execute: evm_sender=%s canton_receiver=%s", srcBalanceAfter.String(), dstBalanceAfter.String()) diff --git a/ccip/devenv/tests/e2e/main_test.go b/ccip/devenv/tests/e2e/main_test.go new file mode 100644 index 000000000..bf115df39 --- /dev/null +++ b/ccip/devenv/tests/e2e/main_test.go @@ -0,0 +1,12 @@ +package canton + +import ( + "flag" + "os" + "testing" +) + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} diff --git a/ccip/devenv/tests/env.go b/ccip/devenv/tests/env.go new file mode 100644 index 000000000..458b6457a --- /dev/null +++ b/ccip/devenv/tests/env.go @@ -0,0 +1,92 @@ +package tests + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const envConfigFile = "CCIP_CONFIG_FILE" + +// CCIPEnv names a CCIP e2e target environment. +type CCIPEnv string + +const ( + EnvDevenv CCIPEnv = "devenv" + EnvProdTestnet CCIPEnv = "prod-testnet" +) + +var ccipEnvFlag = flag.String( + "ccip-env", + defaultFromEnv("CCIP_ENV", string(EnvDevenv)), + "CCIP e2e environment: devenv (default) or prod-testnet", +) + +func defaultFromEnv(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + + return fallback +} + +// ParseCCIPEnv validates and returns a CCIPEnv from its string form. +func ParseCCIPEnv(s string) (CCIPEnv, error) { + switch CCIPEnv(strings.TrimSpace(s)) { + case EnvDevenv, EnvProdTestnet: + return CCIPEnv(strings.TrimSpace(s)), nil + case "staging": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + case "mainnet": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + default: + return "", fmt.Errorf("unknown ccip env %q: want devenv or prod-testnet", s) + } +} + +// ConfigPath returns the CCV env output TOML filename under ccip/devenv. +func (e CCIPEnv) ConfigPath() string { + switch e { + case EnvDevenv: + return "env-canton-evm-out.toml" + case EnvProdTestnet: + return "env-prod-testnet-out.toml" + default: + return "" + } +} + +// ResolveConfigPath returns the CCV env output TOML filename under ccip/devenv. +// When CCIP_CONFIG_FILE is set, its basename is used instead of the default for env. +func ResolveConfigPath(env CCIPEnv) string { + if override := strings.TrimSpace(os.Getenv(envConfigFile)); override != "" { + return filepath.Base(override) + } + + return env.ConfigPath() +} + +// IsRemote reports whether the environment targets live testnet infrastructure. +func (e CCIPEnv) IsRemote() bool { + return e == EnvProdTestnet +} + +// ParseEnvFromFlag reads the -ccip-env flag (defaulting from CCIP_ENV). +func ParseEnvFromFlag(t *testing.T) CCIPEnv { + t.Helper() + + t.Logf("CCIP_ENV env=%q ccip-env flag=%q", + os.Getenv("CCIP_ENV"), + *ccipEnvFlag, + ) + + env, err := ParseCCIPEnv(*ccipEnvFlag) + require.NoError(t, err) + + return env +} diff --git a/ccip/devenv/tests/env_test.go b/ccip/devenv/tests/env_test.go new file mode 100644 index 000000000..5ebbbd6e0 --- /dev/null +++ b/ccip/devenv/tests/env_test.go @@ -0,0 +1,35 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveConfigPath(t *testing.T) { + t.Setenv(envConfigFile, "") + + require.Equal(t, "env-canton-evm-out.toml", ResolveConfigPath(EnvDevenv)) + require.Equal(t, "env-prod-testnet-out.toml", ResolveConfigPath(EnvProdTestnet)) + + t.Setenv(envConfigFile, "env-prod-testnet.ci.toml") + require.Equal(t, "env-prod-testnet.ci.toml", ResolveConfigPath(EnvProdTestnet)) + + t.Setenv(envConfigFile, "ccip/devenv/custom.toml") + require.Equal(t, "custom.toml", ResolveConfigPath(EnvDevenv)) +} + +func TestParseCCIPEnv(t *testing.T) { + t.Parallel() + + env, err := ParseCCIPEnv("devenv") + require.NoError(t, err) + require.Equal(t, EnvDevenv, env) + + env, err = ParseCCIPEnv("prod-testnet") + require.NoError(t, err) + require.Equal(t, EnvProdTestnet, env) + + _, err = ParseCCIPEnv("unknown") + require.Error(t, err) +} diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 8d6e77795..bd106c764 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -2,23 +2,278 @@ package tests import ( "context" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" "testing" "time" "github.com/Masterminds/semver/v3" + gethcrypto "github.com/ethereum/go-ethereum/crypto" chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/tcapi" "github.com/smartcontractkit/chainlink-ccv/protocol" - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" + "github.com/smartcontractkit/chainlink-canton/testhelpers" ) +const ( + envConfirmExecTimeout = "CANTON_CONFIRM_EXEC_TIMEOUT" + defaultConfirmExecTimeout = 5 * time.Minute + + envConfirmSendTimeout = "CANTON_CONFIRM_SEND_TIMEOUT" +) + +// ConfirmExecTimeout returns the timeout for Canton ConfirmExecOnDest polling. +// Default is 5 minutes; override with CANTON_CONFIRM_EXEC_TIMEOUT (e.g. "10m"). +func ConfirmExecTimeout(t *testing.T) time.Duration { + t.Helper() + + timeout := defaultConfirmExecTimeout + if d := os.Getenv(envConfirmExecTimeout); d != "" { + parsed, err := time.ParseDuration(d) + require.NoError(t, err, "%s=%q invalid", envConfirmExecTimeout, d) + timeout = parsed + } + + return timeout +} + +// ConfirmSendTimeout returns the timeout for ConfirmSendOnSource polling. +// Defaults: devenv 15s, prod-testnet 10m; override with CANTON_CONFIRM_SEND_TIMEOUT (e.g. "30s"). +func ConfirmSendTimeout(t *testing.T, env CCIPEnv) time.Duration { + t.Helper() + + var timeout time.Duration + switch env { + case EnvDevenv: + timeout = 15 * time.Second + case EnvProdTestnet: + timeout = 10 * time.Minute + default: + timeout = 15 * time.Second + } + + if d := os.Getenv(envConfirmSendTimeout); d != "" { + parsed, err := time.ParseDuration(d) + require.NoError(t, err, "%s=%q invalid", envConfirmSendTimeout, d) + timeout = parsed + } + + return timeout +} + +// EVMToCantonMessageOptions returns standard message options for EVM→Canton sends with FTF. +func EVMToCantonMessageOptions(gasLimit uint32, executor, ccvAddr protocol.UnknownAddress) cciptestinterfaces.MessageOptions { + return cciptestinterfaces.MessageOptions{ + ExecutionGasLimit: gasLimit, + FinalityConfig: cantondevenv.EVMToCantonFinalityConfig, + Executor: executor, + CCVs: []protocol.CCV{ + {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, + }, + } +} + +// E2EBootstrap holds shared CCIP e2e setup for a selected environment. +type E2EBootstrap struct { + Env CCIPEnv + Cfg *ccv.Cfg + Lib ccv.Lib + ChainMap map[uint64]cciptestinterfaces.CCIP17 + Canton *cantondevenv.Chain + EVM cciptestinterfaces.CCIP17 +} + +// BootstrapE2E loads config, wires verifier observation, and resolves Canton + EVM chains. +func BootstrapE2E(t *testing.T, env CCIPEnv) E2EBootstrap { + t.Helper() + + if env.IsRemote() && os.Getenv("CANTON_GRPC_URL") == "" { + t.Skip("CANTON_GRPC_URL unset: not configured for remote Canton") + } + + configPath := filepath.Join("..", "..", ResolveConfigPath(env)) + in, err := ccv.LoadOutput[ccv.Cfg](configPath) + require.NoError(t, err) + + lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) + require.NoError(t, err) + + ctx := t.Context() + chainMap, err := lib.ChainsMap(ctx) + require.NoError(t, err) + require.NoError(t, WireVerifierObservationFromLib(lib, chainMap)) + + evmChain := GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) + cantonChain := GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) + cantonImpl, ok := cantonChain.(*cantondevenv.Chain) + require.True(t, ok, "Canton chain must be *devenv.Chain") + + if !env.IsRemote() { + t.Cleanup(func() { + _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) + require.NoError(t, err) + }) + } + + return E2EBootstrap{ + Env: env, + Cfg: in, + Lib: lib, + ChainMap: chainMap, + Canton: cantonImpl, + EVM: evmChain, + } +} + +// SetupCantonSend prepares Canton for send in both envs. +// devenv mints fee Amulet before SetupSend; prod-testnet assumes the party is already funded. +func (b E2EBootstrap) SetupCantonSend(t *testing.T, ctx context.Context, transferAmount uint64) { + t.Helper() + + fee := uint64(cantondevenv.CantonToEVMFeeAmount) + if !b.Env.IsRemote() { + require.NoError(t, b.Canton.MintTokens(ctx, new(big.Rat).SetUint64(fee))) + } + require.NoError(t, b.Canton.SetupSend(ctx, fee, new(big.Rat).SetUint64(transferAmount))) + b.Canton.SetSequentialSends(1) +} + +// SetupCantonTokenSend prepares Canton for token sends (fee + transfer holdings). +// Devenv mints Amulet; prod-testnet logs required Amulet and transfer instrument balances. +func (b E2EBootstrap) SetupCantonTokenSend(t *testing.T, ctx context.Context, lane TokenLane, sends int) { + t.Helper() + + require.Positive(t, sends) + + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + feeTotal := new(big.Rat).SetInt64(int64(sends) * cantondevenv.CantonToEVMTokenTransferFeeAmount) + transferTotalFP := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(sends+1000))) // add a buffer to avoid flakyness + transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + + if !b.Env.IsRemote() { + require.NoError(t, b.Canton.MintTokens(ctx, feeTotal)) + require.NoError(t, b.Canton.MintTokens(ctx, transferTotal)) + } else { + instrumentLabel := lane.TransferInstrumentID + if instrumentLabel == "" { + instrumentLabel = string(cantondevenv.AMTInstrument) + } + t.Logf("prod-testnet: ensure Canton party holds at least %s Amulet for fees (%d sends × %d)", + feeTotal.FloatString(10), sends, cantondevenv.CantonToEVMTokenTransferFeeAmount) + t.Logf("prod-testnet: ensure Canton party holds at least %s %s for transfers (%d sends × %s)", + transferTotal.FloatString(10), instrumentLabel, sends, transferPerSend.FloatString(10)) + if lane.TransferInstrument.Admin != "" { + participant, _, err := b.Canton.ClientParticipant() + require.NoError(t, err) + inst := &lane.TransferInstrument + filters := []testhelpers.Filter{ + testhelpers.WithHoldingOwner(participant.PartyID), + testhelpers.WithUnlockedHoldingsOnly(), + } + rows, err := testhelpers.ListHoldingsForInstrument(ctx, participant, inst, filters...) + require.NoError(t, err) + balance, err := testhelpers.GetHoldingsBalance(ctx, participant, inst, filters...) + require.NoError(t, err) + t.Logf("prod-testnet: unlocked holdings for instrument admin=%s id=%s: count=%d total=%s", + lane.TransferInstrument.Admin, lane.TransferInstrument.Id, len(rows), balance.FloatString(10)) + } + } + + if lane.TransferInstrument.Admin != "" { + require.NoError(t, b.Canton.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, b.Canton.SetupSend(ctx, fee, transferPerSend)) + } + b.Canton.SetSequentialSends(sends) +} + +// SetupCantonReceive deploys the client party's PerPartyRouter before inbound messages +// are executed on Canton (e.g. EVM→Canton). +func (b E2EBootstrap) SetupCantonReceive(t *testing.T, ctx context.Context) { + t.Helper() + require.NoError(t, b.Canton.SetupReceive(ctx)) +} + +// ResolveEVMReceiver returns the EVM wallet address derived from PRIVATE_KEY on prod-testnet, +// or the devenv EOA otherwise. Used as the Canton→EVM message receiver, the EVM→Canton sender, +// and in load tests for EVM-side balance checks. +func (b E2EBootstrap) ResolveEVMReceiver(t *testing.T) protocol.UnknownAddress { + t.Helper() + + if b.Env.IsRemote() { + pkHex := strings.TrimSpace(os.Getenv("PRIVATE_KEY")) + require.NotEmpty(t, pkHex, "PRIVATE_KEY required for prod-testnet EVM receiver") + pkHex = strings.TrimPrefix(pkHex, "0x") + pk, err := gethcrypto.HexToECDSA(pkHex) + require.NoError(t, err) + addr := gethcrypto.PubkeyToAddress(pk.PublicKey) + + return protocol.UnknownAddress(addr.Bytes()) + } + + receiver, err := b.EVM.GetEOAReceiverAddress() + require.NoError(t, err) + + return receiver +} + +// ConfirmEVMSendOnSource confirms an EVM-side CCIP send after SendMessage. +// +// On devenv we poll CCIPMessageSent via ConfirmSendOnSource (local Anvil, small block range). +// On prod-testnet we use sendResult from SendMessage (tx receipt) only: ConfirmSendOnSource +// should be used here but is broken in chainlink-ccv devenv — the event poller scans from +// block 1 to latest on public RPCs and times out with 504 Gateway Timeout. +func (b E2EBootstrap) ConfirmEVMSendOnSource( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, +) (cciptestinterfaces.MessageSentEvent, error) { + t.Helper() + + if b.Env.IsRemote() { + return sendResult, nil + } + + return b.EVM.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + ConfirmSendTimeout(t, b.Env), + ) +} + +// ConfirmCantonSendOnSource confirms a Canton-side CCIP send after SendMessage. +// Canton tracks the last sent event in memory and polls by sequence number when needed. +func (b E2EBootstrap) ConfirmCantonSendOnSource( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, +) (cciptestinterfaces.MessageSentEvent, error) { + t.Helper() + + return b.Canton.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + ConfirmSendTimeout(t, b.Env), + ) +} + func GetContractAddress( t *testing.T, ds datastore.DataStore, @@ -129,13 +384,12 @@ func AssertSingleVerifierResult( result, err := cantondevenv.AssertMessageWithVerifierObservation(ctx, obs, messageID, tcapi.AssertMessageOptions{ TickInterval: time.Second, - Timeout: utilstests.WaitTimeout(t), + Timeout: ConfirmExecTimeout(t), ExpectedVerifierResults: 1, AssertVerifierLogs: false, AssertExecutorLogs: false, }) require.NoError(t, err) - require.NotNil(t, result.AggregatedResult) require.Len(t, result.IndexedVerifications.Results, 1) return result diff --git a/ccip/devenv/tests/load/gun.go b/ccip/devenv/tests/load/gun.go index afddfb8df..8b4331a27 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -1,13 +1,14 @@ package load import ( + "context" "fmt" "sync" "sync/atomic" + "testing" "time" - "github.com/stretchr/testify/require" - + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-testing-framework/wasp" @@ -15,6 +16,22 @@ import ( devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) +// ConfirmSendFunc confirms a CCIP send on the source chain after SendMessage returns. +type ConfirmSendFunc func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, +) (cciptestinterfaces.MessageSentEvent, error) + +// LoadGunOptions configures send confirmation and exec timeout for CCIPLoadGun. +type LoadGunOptions struct { + ConfirmSend ConfirmSendFunc + ConfirmExecTimeout time.Duration + SkipExecConfirm bool +} + type loadMessageBuilder func( source cciptestinterfaces.CCIP17, callNum int64, @@ -22,7 +39,7 @@ type loadMessageBuilder func( ) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) // Destination identifies one load target chain, its receiver, and how to build load messages for it. -// buildMessage is set by discoverEVMDestinations or discoverCantonDest. +// buildMessage is set by discoverEVMDestinationsFromBoot or discoverCantonDest. type Destination struct { Chain cciptestinterfaces.CCIP17 Receiver protocol.UnknownAddress @@ -52,9 +69,11 @@ func (d Destination) BuildMessage( // required; staging/prod runners rely on pre-existing funded accounts. type CCIPLoadGun struct { mu sync.Mutex + flightReady sync.Cond inFlight int32 maxConcurrent int32 calls atomic.Int64 + messageIDs []protocol.Bytes32 source cciptestinterfaces.CCIP17 destinations []Destination @@ -63,8 +82,11 @@ type CCIPLoadGun struct { ccvAddr protocol.UnknownAddress executorAddr protocol.UnknownAddress - confirmSendTimeout time.Duration + confirmSend ConfirmSendFunc confirmExecTimeout time.Duration + skipExecConfirm bool + + metricsCollector *LoadMetricsCollector } // NewCCIPLoadGun wires a CCIP source with one or more destinations for load testing. @@ -72,11 +94,14 @@ func NewCCIPLoadGun( source cciptestinterfaces.CCIP17, destinations []Destination, ccvAddr, executorAddr protocol.UnknownAddress, - confirmExecTimeout time.Duration, + opts LoadGunOptions, ) (*CCIPLoadGun, error) { if source == nil { return nil, fmt.Errorf("CCIPLoadGun: source is nil") } + if opts.ConfirmSend == nil { + return nil, fmt.Errorf("CCIPLoadGun: ConfirmSend is nil") + } if len(destinations) == 0 { return nil, fmt.Errorf("CCIPLoadGun: at least one destination is required") } @@ -95,18 +120,50 @@ func NewCCIPLoadGun( return nil, fmt.Errorf("CCIPLoadGun: destination[%d] mixes token and message-only destinations", i) } } + confirmExecTimeout := opts.ConfirmExecTimeout if confirmExecTimeout <= 0 { confirmExecTimeout = 5 * time.Minute } - return &CCIPLoadGun{ + g := &CCIPLoadGun{ source: source, destinations: destinations, ccvAddr: ccvAddr, executorAddr: executorAddr, - confirmSendTimeout: 30 * time.Second, + confirmSend: opts.ConfirmSend, confirmExecTimeout: confirmExecTimeout, - }, nil + skipExecConfirm: opts.SkipExecConfirm, + metricsCollector: &LoadMetricsCollector{}, + } + g.flightReady.L = &g.mu + + return g, nil +} + +func (g *CCIPLoadGun) acquireSingleFlight() { + g.mu.Lock() + for g.inFlight >= 1 { + g.flightReady.Wait() + } + g.inFlight++ + if g.inFlight > g.maxConcurrent { + g.maxConcurrent = g.inFlight + } + g.mu.Unlock() +} + +func (g *CCIPLoadGun) releaseSingleFlight() { + g.mu.Lock() + g.inFlight-- + if g.inFlight == 0 { + g.flightReady.Broadcast() + } + g.mu.Unlock() +} + +// ConfirmExecTimeout returns the exec confirmation timeout configured for this gun. +func (g *CCIPLoadGun) ConfirmExecTimeout() time.Duration { + return g.confirmExecTimeout } func (g *CCIPLoadGun) nextDestination() Destination { @@ -124,26 +181,10 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { } t := gen.Cfg.T - var depth int32 - g.mu.Lock() - g.inFlight++ - depth = g.inFlight - if depth > g.maxConcurrent { - g.maxConcurrent = depth - } - g.mu.Unlock() + g.acquireSingleFlight() + defer g.releaseSingleFlight() g.calls.Add(1) - defer func() { - g.mu.Lock() - g.inFlight-- - g.mu.Unlock() - }() - - if depth > 1 { - require.FailNow(t, "overlapping CCIPLoadGun.Call", - "expected single-flight; concurrent depth=%d", depth) - } dest := g.nextDestination() destSelector := dest.Chain.ChainSelector() @@ -151,9 +192,11 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { callNum := g.calls.Load() fields, opts, err := dest.BuildMessage(g.source, callNum, g.ccvAddr, g.executorAddr) if err != nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("BuildMessage (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } + sentTime := time.Now() sendRes, err := g.source.SendMessage( subtestCtx, destSelector, @@ -161,34 +204,70 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { opts, 3, ) + sendDuration := time.Since(sentTime) if err != nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("SendMessage (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } if sendRes.Message == nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: "SendMessage returned nil message", Duration: time.Since(start)} } seqNo := uint64(sendRes.Message.SequenceNumber) - sentEvent, err := g.source.ConfirmSendOnSource( - subtestCtx, - destSelector, - cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, - g.confirmSendTimeout, - ) + confirmSendStart := time.Now() + sentEvent, err := g.confirmSend(t, subtestCtx, destSelector, seqNo, sendRes) + confirmSendDuration := time.Since(confirmSendStart) if err != nil { - return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSendOnSource (dest=%d): %v", destSelector, err), Duration: time.Since(start)} + g.metricsCollector.incrementConfirmSendFailure() + return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSend (dest=%d): %v", destSelector, err), Duration: time.Since(start)} + } + + g.mu.Lock() + g.messageIDs = append(g.messageIDs, sentEvent.MessageID) + g.mu.Unlock() + + ccv.Plog.Info(). + Str("messageID", sentEvent.MessageID.String()). + Uint64("seqNo", seqNo). + Uint64("destSelector", destSelector). + Msg("Load message confirmed on source") + + sourceChain := g.source.ChainSelector() + record := LoadMessageRecord{ + SeqNo: seqNo, + SourceChain: sourceChain, + DestChain: destSelector, + MessageID: sentEvent.MessageID, + SentTime: sentTime, + SendDuration: sendDuration, + ConfirmSendDuration: confirmSendDuration, + } + + if g.skipExecConfirm { + record.TotalDuration = time.Since(sentTime) + g.metricsCollector.appendRecord(record) + return &wasp.Response{ + Failed: false, + StatusCode: "200", + Duration: time.Since(start), + } } + confirmExecStart := time.Now() ev, err := dest.Chain.ConfirmExecOnDest( subtestCtx, g.source.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, g.confirmExecTimeout, ) + confirmExecDuration := time.Since(confirmExecStart) if err != nil { + g.metricsCollector.incrementConfirmExecFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmExecOnDest (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } if ev.State != cciptestinterfaces.ExecutionStateSuccess { + g.metricsCollector.incrementConfirmExecFailure() return &wasp.Response{ Failed: true, Error: fmt.Sprintf("execution state=%s (dest=%d)", ev.State.String(), destSelector), @@ -197,6 +276,11 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { } } + record.ConfirmExecDuration = confirmExecDuration + record.ExecutedTime = time.Now() + record.TotalDuration = record.ExecutedTime.Sub(sentTime) + g.metricsCollector.appendRecord(record) + return &wasp.Response{ Failed: false, StatusCode: "200", @@ -215,3 +299,25 @@ func (g *CCIPLoadGun) MaxConcurrentObserved() int32 { func (g *CCIPLoadGun) CallCount() int64 { return g.calls.Load() } + +// Metrics returns a copy of successful load message timing records. +func (g *CCIPLoadGun) Metrics() []LoadMessageRecord { + records, _ := g.metricsCollector.snapshot() + return records +} + +// FailureCounts returns phase failure counters from failed WASP calls. +func (g *CCIPLoadGun) FailureCounts() LoadFailureCounts { + _, failures := g.metricsCollector.snapshot() + return failures +} + +// MessageIDs returns a copy of message IDs collected after successful ConfirmSend. +func (g *CCIPLoadGun) MessageIDs() []protocol.Bytes32 { + g.mu.Lock() + defer g.mu.Unlock() + out := make([]protocol.Bytes32, len(g.messageIDs)) + copy(out, g.messageIDs) + + return out +} diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 0233a7b49..60bdba18f 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -1,19 +1,14 @@ package load import ( - "fmt" - "os" + "math/big" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init + "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) @@ -27,11 +22,13 @@ const ( // TestCanton2EVM_Load runs WASP RPS=1 against the real Canton→EVM path (message-only), // round-robining across every EVM destination found in the env file. // -// Requires a running devenv and ../../env-canton-evm-out.toml (same as the basic e2e test). +// Devenv: requires a running devenv and env-canton-evm-out.toml; pre-mints fee holdings +// and calls SetupSend once before WASP starts. // -// Devenv-specific: this test pre-mints fee holdings and calls SetupSend once before WASP -// starts. The CCIPLoadGun itself is environment-agnostic so the same gun can be reused by -// a future staging/prod runner that assumes pre-funded accounts. +// Prod-testnet: send-only load (Canton send + confirm send, no ConfirmExecOnDest on EVM). +// Set CANTON_GRPC_URL, CANTON_PARTY_ID, CANTON_AUTH_*, PRIVATE_KEY (EVM message receiver), +// CANTON_LOAD_SKIP_EXEC_CONFIRM=true, and pre-fund the Canton party (~50 Amulet per message). +// Verify delivery via indexer/CCIP ops — the test does not assert EVM execution on prod. // //nolint:paralleltest // Canton holdings must stay 1-wide; shares env with e2e. func TestCanton2EVM_Load(t *testing.T) { @@ -39,60 +36,48 @@ func TestCanton2EVM_Load(t *testing.T) { t.Skip("skipping Canton→EVM load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping Canton→EVM load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - + env := devenvtests.ParseEnvFromFlag(t) + t.Logf("env: %s", env) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain must be *cantondevenv.Chain") + skipExec := loadSkipExecConfirm(t) - destinations := discoverEVMDestinations(t, in, chainMap) + destinations := discoverEVMDestinationsFromBoot(t, boot) require.NotEmpty(t, destinations, "need at least one EVM destination in the env file") t.Logf("Canton→EVM load destinations: %d EVM chain(s)", len(destinations)) for _, d := range destinations { t.Logf(" - selector=%d receiver=%x", d.Chain.ChainSelector(), d.Receiver) } - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveCantonSourceAddrs(t, lib, cantonChain.ChainSelector()) + ccvAddr, executorAddr := resolveCantonSourceAddrs(t, boot.Lib, boot.Canton.ChainSelector()) sched := loadSchedule(t) - estimatedMessages := uint64(sched.rate) * uint64(sched.duration/sched.rateUnit) - if estimatedMessages == 0 { - estimatedMessages = 1 + estimatedMessages := estimateMessages(sched) + requiredAmulet := estimatedMessages * uint64(devenv.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator + if boot.Env.IsRemote() { + t.Logf("Prod: ensure Canton party holds at least %d Amulet (estimatedMessages=%d feePerMessage=%d)", + requiredAmulet, estimatedMessages, devenv.CantonToEVMFeeAmount) + } else { + t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", + estimatedMessages, devenv.CantonToEVMFeeAmount, requiredAmulet) + require.NoError(t, boot.Canton.MintTokens(ctx, new(big.Rat).SetUint64(requiredAmulet))) } - mintAmount := estimatedMessages * uint64(devenvtests.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator - t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", - estimatedMessages, devenvtests.CantonToEVMFeeAmount, mintAmount) - require.NoError(t, cantonImpl.MintTokens(ctx, mintAmount)) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + require.NoError(t, boot.Canton.SetupSend(ctx, uint64(devenv.CantonToEVMFeeAmount), nil)) gun, err := NewCCIPLoadGun( - cantonChain, + boot.Canton, destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: CantonSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: skipExec, + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm", sched, "message_only") + runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", skipExec, boot.Cfg.IndexerEndpoints) } diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index df3a80bc9..66edab1d0 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -1,26 +1,25 @@ package load import ( - "fmt" "math/big" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init + "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) // TestCanton2EVM_TokenLoad runs WASP RPS=1 against the Canton→EVM token transfer path. // -// Requires a running devenv and ../../env-canton-evm-out.toml. +// Devenv: requires a running devenv and env-canton-evm-out.toml; pre-mints fee + transfer +// holdings via SetupCantonTokenSend before WASP starts. +// +// Prod-testnet: requires a pre-funded Canton party (Amulet + transfer instrument) and +// PRIVATE_KEY wallet with Sepolia ETH for execution gas. Full exec confirm; no EVM balance +// assert on prod — verify delivery via indexer/CCIP ops. // //nolint:paralleltest // Canton holdings must stay 1-wide; shares env with e2e. func TestCanton2EVM_TokenLoad(t *testing.T) { @@ -28,70 +27,61 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { t.Skip("skipping Canton→EVM token load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping Canton→EVM token load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain must be *cantondevenv.Chain") - - evmSelectors := discoverEVMTokenSelectors(t, in) + evmSelectors := discoverEVMTokenSelectors(t, boot.Cfg) require.NotEmpty(t, evmSelectors, "need at least one EVM token destination in the env file") - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), evmSelectors) + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), evmSelectors) t.Logf("Token lane: pool=%s transfer=%s", lane.PoolRef.Qualifier, lane.TransferAmount.String()) - destinations := discoverEVMTokenDestinations(t, in, chainMap, lane) + destinations := discoverEVMTokenDestinationsFromBoot(t, boot, lane) require.NotEmpty(t, destinations, "need at least one EVM token destination in the env file") t.Logf("Canton→EVM token load destinations: %d EVM chain(s)", len(destinations)) + for _, d := range destinations { + t.Logf(" - selector=%d receiver=%x", d.Chain.ChainSelector(), d.Receiver) + } + evmReceiver := boot.ResolveEVMReceiver(t) firstDest := destinations[0] destToken := lane.DestTokenBySelector[firstDest.Chain.ChainSelector()] - receiverBalanceBefore, err := firstDest.Chain.GetTokenBalance(ctx, firstDest.Receiver, destToken) + receiverBalanceBefore, err := firstDest.Chain.GetTokenBalance(ctx, evmReceiver, destToken) require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveCantonSourceAddrs(t, lib, cantonChain.ChainSelector()) sched := loadSchedule(t) + estimatedMessages := estimateMessages(sched) + boot.SetupCantonTokenSend(t, ctx, lane, int(estimatedMessages)) - setupCantonTokenLoadHoldings(t, ctx, cantonImpl, sched, lane) + ccvAddr, executorAddr := resolveCantonSourceAddrs(t, boot.Lib, boot.Canton.ChainSelector()) gun, err := NewCCIPLoadGun( - cantonChain, + boot.Canton, destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: CantonSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", false, boot.Cfg.IndexerEndpoints) - receiverBalanceAfter, err := firstDest.Chain.GetTokenBalance(ctx, firstDest.Receiver, destToken) + receiverBalanceAfter, err := firstDest.Chain.GetTokenBalance(ctx, evmReceiver, destToken) require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) + expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenv.CantonFixedPointToEVMScale)) expectedDelta := new(big.Int).Mul(expectedPerMessage, big.NewInt(gun.CallCount())) - expectedBalance := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), expectedDelta) t.Logf("EVM receiver token balance: before=%s after=%s expectedDelta=%s calls=%d", receiverBalanceBefore.String(), receiverBalanceAfter.String(), expectedDelta.String(), gun.CallCount()) - require.Equal(t, expectedBalance, receiverBalanceAfter) + if !boot.Env.IsRemote() { + expectedBalance := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), expectedDelta) + require.Equal(t, expectedBalance, receiverBalanceAfter) + } } diff --git a/ccip/devenv/tests/load/gun_evm2canton_test.go b/ccip/devenv/tests/load/gun_evm2canton_test.go index 034633ae5..7d57f5d1f 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -1,27 +1,21 @@ package load import ( - "fmt" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) // TestEVM2Canton_Load runs WASP RPS=1 against the real EVM→Canton path (message-only). // -// Requires a running devenv and ../../env-canton-evm-out.toml (same as the basic e2e test). -// EVM source accounts are pre-funded by devenv; no Canton MintTokens/SetupSend. +// Devenv: requires a running devenv and env-canton-evm-out.toml; EVM accounts are pre-funded. +// Prod-testnet: set CANTON_GRPC_URL, CANTON_PARTY_ID, CANTON_AUTH_*, and PRIVATE_KEY; Canton +// party must already be funded (no MintTokens on this path). // //nolint:paralleltest // single-flight exec on Canton dest; shares env with e2e. func TestEVM2Canton_Load(t *testing.T) { @@ -29,46 +23,29 @@ func TestEVM2Canton_Load(t *testing.T) { t.Skip("skipping EVM→Canton load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping EVM→Canton load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) + boot.SetupCantonReceive(t, ctx) - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton dest chain must be *devenv.Chain") - require.NoError(t, cantonImpl.SetupReceive(ctx)) - cantonDest := discoverCantonDest(t, in, chainMap) + cantonDest := discoverCantonDestFromBoot(t, boot) t.Logf("EVM→Canton load: source=%d dest=%d receiver=%x", - evmChain.ChainSelector(), cantonDest.Chain.ChainSelector(), cantonDest.Receiver) - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) + boot.EVM.ChainSelector(), cantonDest.Chain.ChainSelector(), cantonDest.Receiver) - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, lib, evmChain.ChainSelector()) + ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( - evmChain, + boot.EVM, []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: EVMSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only") + runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", false, boot.Cfg.IndexerEndpoints) } diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 426e29fed..24f608228 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -1,28 +1,24 @@ package load import ( - "fmt" "math/big" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) // TestEVM2Canton_TokenLoad runs WASP RPS=1 against the EVM→Canton token transfer path. // -// Requires a running devenv and ../../env-canton-evm-out.toml. +// Devenv: requires a running devenv and env-canton-evm-out.toml; EVM sender is pre-funded. +// +// Prod-testnet: requires PRIVATE_KEY wallet with TEST tokens and Sepolia ETH on Sepolia, +// plus a pre-funded Canton party for execution fees. // //nolint:paralleltest // single-flight exec on Canton dest; shares env with e2e. func TestEVM2Canton_TokenLoad(t *testing.T) { @@ -30,72 +26,59 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { t.Skip("skipping EVM→Canton token load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping EVM→Canton token load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) + boot.SetupCantonReceive(t, ctx) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton dest chain must be *devenv.Chain") - require.NoError(t, cantonImpl.SetupReceive(ctx)) - - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, evmChain.ChainSelector(), []uint64{cantonChain.ChainSelector()}) + lane := devenvtests.ResolveTokenLane(t, boot.Env, boot.Cfg, boot.Lib, boot.ChainMap, boot.EVM.ChainSelector(), []uint64{boot.Canton.ChainSelector()}) t.Logf("Token lane: pool=%s transfer=%s srcToken=%x", lane.PoolRef.Qualifier, lane.TransferAmount.String(), lane.SrcToken) - receiverParticipant, _, err := cantonImpl.ClientParticipant() + receiverParticipant, _, err := boot.Canton.ClientParticipant() require.NoError(t, err) require.NotEmpty(t, receiverParticipant.PartyID) - receiver, err := cantonChain.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - cantonDest := cantonTokenLoadDestination(cantonChain, receiver, lane) + cantonDest := cantonTokenLoadDestination(boot.Canton, receiver, lane) sched := loadSchedule(t) estimatedMessages := estimateMessages(sched) - evmSender, err := evmChain.GetEOAReceiverAddress() - require.NoError(t, err) - senderBalance, err := evmChain.GetTokenBalance(ctx, evmSender, lane.SrcToken) + evmSender := boot.ResolveEVMReceiver(t) + senderBalance, err := boot.EVM.GetTokenBalance(ctx, evmSender, lane.SrcToken) require.NoError(t, err) requiredBalance := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(estimatedMessages))) t.Logf("EVM sender token balance=%s requiredForRun=%s (estimatedMessages=%d; devenv pre-funds sender)", senderBalance.String(), requiredBalance.String(), estimatedMessages) - if senderBalance.Cmp(requiredBalance) < 0 { + if boot.Env.IsRemote() { + require.GreaterOrEqual(t, senderBalance.Cmp(requiredBalance), 0, + "EVM sender token balance %s is below required %s; fund PRIVATE_KEY wallet with TEST tokens on Sepolia", + senderBalance.String(), requiredBalance.String()) + t.Log("prod-testnet: ensure PRIVATE_KEY wallet has Sepolia ETH for send and possible router approve tx") + } else if senderBalance.Cmp(requiredBalance) < 0 { t.Logf("warning: EVM sender balance may be insufficient for full run") } - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, lib, evmChain.ChainSelector()) + ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( - evmChain, + boot.EVM, []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: EVMSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", false, boot.Cfg.IndexerEndpoints) totalHoldingsRat, err := testhelpers.GetHoldingsBalance(ctx, receiverParticipant, nil) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 4bf0b1399..c77732911 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -3,8 +3,8 @@ package load import ( "context" "fmt" - "math/big" "os" + "strings" "testing" "time" @@ -16,23 +16,27 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" ccvload "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/load" + ccvmetrics "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/metrics" "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/smartcontractkit/chainlink-testing-framework/wasp" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" canton_committee_verifier "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/committee_verifier" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/executor" ) const ( - envMessageRate = "CANTON_LOAD_MESSAGE_RATE" - envLoadDuration = "CANTON_LOAD_DURATION" - defaultMessageRate = "1/1s" - defaultLoadDuration = 90 * time.Second + envMessageRate = "CANTON_LOAD_MESSAGE_RATE" + envLoadDuration = "CANTON_LOAD_DURATION" + envLoadSkipExecConfirm = "CANTON_LOAD_SKIP_EXEC_CONFIRM" + envLoadCallTimeout = "CANTON_LOAD_CALL_TIMEOUT" + defaultMessageRate = "1/1s" + defaultLoadDuration = 90 * time.Second + defaultLoadCallPadding = 2 * time.Minute + defaultSendOnlyCallBudget = 5 * time.Minute ) type scheduleConfig struct { @@ -70,9 +74,89 @@ func loadSchedule(t *testing.T) scheduleConfig { } } -func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string) { +func loadSkipExecConfirm(t *testing.T) bool { t.Helper() + v := strings.TrimSpace(os.Getenv(envLoadSkipExecConfirm)) + switch strings.ToLower(v) { + case "1", "true", "yes": + return true + default: + return false + } +} + +func waspCallTimeout(t *testing.T, gun *CCIPLoadGun, sched scheduleConfig, skipExecConfirm bool) time.Duration { + t.Helper() + + if v := strings.TrimSpace(os.Getenv(envLoadCallTimeout)); v != "" { + parsed, err := time.ParseDuration(v) + require.NoError(t, err, "%s=%q invalid", envLoadCallTimeout, v) + return parsed + } + if skipExecConfirm { + return defaultSendOnlyCallBudget + sched.rateUnit + } + + return gun.ConfirmExecTimeout() + sched.rateUnit + defaultLoadCallPadding +} + +func printLoadMetrics(t *testing.T, gun *CCIPLoadGun, skipExecConfirm bool) { + t.Helper() + + records := gun.Metrics() + failures := gun.FailureCounts() + PrintPhaseMetricsSummary(t, records, failures, skipExecConfirm) + + if !skipExecConfirm { + ccvMetrics := ToCCVMessageMetrics(records) + if len(ccvMetrics) > 0 { + totals := ccvmetrics.MessageTotals{ + Sent: len(ccvMetrics), + Received: len(ccvMetrics), + } + summary := ccvmetrics.CalculateMetricsSummary(ccvMetrics, totals) + ccvmetrics.PrintMetricsSummary(t, summary) + } + } +} + +func logLoadMessageSummary(t *testing.T, gun *CCIPLoadGun, indexerEndpoints []string) { + t.Helper() + + ids := gun.MessageIDs() + lggr := ccv.Plog + lggr.Info().Int("count", len(ids)).Msg("Load message summary") + + var indexerBase string + if len(indexerEndpoints) > 0 { + indexerBase = strings.TrimSuffix(indexerEndpoints[0], "/") + } + + for i, id := range ids { + msgID := id.String() + ev := lggr.Info().Int("index", i+1).Str("messageID", msgID) + if indexerBase != "" { + ev = ev.Str("indexer", fmt.Sprintf("%s/v1/verifierresults/%s", indexerBase, msgID)) + } + ev.Msg("Load message sent") + } +} + +func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string, skipExecConfirm bool, indexerEndpoints []string) { + t.Helper() + defer logLoadMessageSummary(t, gun, indexerEndpoints) + + callTimeout := waspCallTimeout(t, gun, sched, skipExecConfirm) + ccv.Plog.Info(). + Str("messageRate", sched.messageRate). + Dur("rateUnit", sched.rateUnit). + Dur("loadDuration", sched.duration). + Dur("callTimeout", callTimeout). + Dur("confirmExecTimeout", gun.ConfirmExecTimeout()). + Bool("skipExecConfirm", skipExecConfirm). + Msg("WASP load schedule") + labels := map[string]string{ "go_test_name": genName, "branch": "test", @@ -83,6 +167,9 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi if scenario != "" { labels["scenario"] = scenario } + if skipExecConfirm { + labels["skip_exec_confirm"] = "true" + } p := wasp.NewProfile().Add(wasp.NewGenerator(&wasp.Config{ T: t, @@ -92,6 +179,7 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi wasp.Plain(sched.rate, sched.duration), ), RateLimitUnitDuration: sched.rateUnit, + CallTimeout: callTimeout, Gun: gun, Labels: labels, LokiConfig: nil, @@ -104,14 +192,18 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi require.Positive(t, gun.CallCount(), "gun should have completed at least one message") require.LessOrEqual(t, gun.MaxConcurrentObserved(), int32(1), "Gun.Call must not overlap (single-flight)") + + printLoadMetrics(t, gun, skipExecConfirm) } -func discoverEVMDestinations(t *testing.T, in *ccv.Cfg, chainMap map[uint64]cciptestinterfaces.CCIP17) []Destination { +func discoverEVMDestinationsFromBoot(t *testing.T, boot devenvtests.E2EBootstrap) []Destination { t.Helper() + receiver := boot.ResolveEVMReceiver(t) + dests := make([]Destination, 0) seen := make(map[uint64]struct{}) - for _, bc := range in.Blockchains { + for _, bc := range boot.Cfg.Blockchains { if bc.Type != blockchain.TypeAnvil { continue } @@ -120,12 +212,9 @@ func discoverEVMDestinations(t *testing.T, in *ccv.Cfg, chainMap map[uint64]ccip if _, dup := seen[details.ChainSelector]; dup { continue } - chain, ok := chainMap[details.ChainSelector] + chain, ok := boot.ChainMap[details.ChainSelector] require.True(t, ok, "EVM chain %d not in harness chain map", details.ChainSelector) - receiver, err := chain.GetEOAReceiverAddress() - require.NoError(t, err) - dests = append(dests, evmLoadDestination(chain, receiver)) seen[details.ChainSelector] = struct{}{} } @@ -157,24 +246,27 @@ func discoverEVMTokenSelectors(t *testing.T, in *ccv.Cfg) []uint64 { return selectors } -func discoverEVMTokenDestinations( - t *testing.T, - in *ccv.Cfg, - chainMap map[uint64]cciptestinterfaces.CCIP17, - lane devenvtests.TokenLane, -) []Destination { +func discoverEVMTokenDestinationsFromBoot(t *testing.T, boot devenvtests.E2EBootstrap, lane devenvtests.TokenLane) []Destination { t.Helper() - selectors := discoverEVMTokenSelectors(t, in) - dests := make([]Destination, 0, len(selectors)) - for _, selector := range selectors { - chain, ok := chainMap[selector] - require.True(t, ok, "EVM chain %d not in harness chain map", selector) + receiver := boot.ResolveEVMReceiver(t) - receiver, err := chain.GetEOAReceiverAddress() - require.NoError(t, err) + dests := make([]Destination, 0) + seen := make(map[uint64]struct{}) + for _, bc := range boot.Cfg.Blockchains { + if bc.Type != blockchain.TypeAnvil { + continue + } + details, err := chainsel.GetChainDetailsByChainIDAndFamily(bc.ChainID, chainsel.FamilyEVM) + require.NoError(t, err, "resolve chain selector for chainID=%s", bc.ChainID) + if _, dup := seen[details.ChainSelector]; dup { + continue + } + chain, ok := boot.ChainMap[details.ChainSelector] + require.True(t, ok, "EVM chain %d not in harness chain map", details.ChainSelector) dests = append(dests, evmTokenLoadDestination(chain, receiver, lane)) + seen[details.ChainSelector] = struct{}{} } return dests @@ -193,31 +285,6 @@ func estimateMessages(sched scheduleConfig) uint64 { return estimated } -// setupCantonTokenLoadHoldings pre-mints two separate Amulet holdings (fee + transfer) and -// calls SetupSend once, matching the e2e canton2evm token transfer pattern. -func setupCantonTokenLoadHoldings( - t *testing.T, - ctx context.Context, - cantonImpl *cantondevenv.Chain, - sched scheduleConfig, - lane devenvtests.TokenLane, -) { - t.Helper() - - estimated := estimateMessages(sched) - feeMint := estimated * uint64(devenvtests.CantonToEVMFeeAmount) - transferMint := estimated * lane.TransferAmount.Uint64() - t.Logf("Pre-mint: estimatedMessages=%d feeMint=%d transferMint=%d", - estimated, feeMint, transferMint) - require.NoError(t, cantonImpl.MintTokens(ctx, feeMint)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferMint)) - require.NoError(t, cantonImpl.SetupSend( - ctx, - uint64(devenvtests.CantonToEVMFeeAmount), - lane.TransferAmount.Uint64(), - )) -} - func evmLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { destSelector := chain.ChainSelector() return Destination{ @@ -251,7 +318,7 @@ func evmTokenLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol. Receiver: receiver, Data: fmt.Appendf(nil, "canton2evm token load n=%d dest=%d", callNum, destSelector), TokenAmount: cciptestinterfaces.TokenAmount{ - Amount: new(big.Int).Set(lane.TransferAmount), + Amount: lane.TransferAmount, }, }, cciptestinterfaces.MessageOptions{ ExecutionGasLimit: lane.ExecutionGasLimit, @@ -265,14 +332,39 @@ func evmTokenLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol. } } -func discoverCantonDest(t *testing.T, in *ccv.Cfg, chainMap map[uint64]cciptestinterfaces.CCIP17) Destination { +func discoverCantonDestFromBoot(t *testing.T, boot devenvtests.E2EBootstrap) Destination { t.Helper() - chain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - receiver, err := chain.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - return cantonLoadDestination(chain, receiver) + return cantonLoadDestination(boot.Canton, receiver) +} + +// EVMSourceConfirmSend returns a ConfirmSendFunc that delegates to BootstrapE2E.ConfirmEVMSendOnSource. +func EVMSourceConfirmSend(boot devenvtests.E2EBootstrap) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return boot.ConfirmEVMSendOnSource(t, ctx, destSelector, seqNo, sendResult) + } +} + +// CantonSourceConfirmSend returns a ConfirmSendFunc that delegates to BootstrapE2E.ConfirmCantonSendOnSource. +func CantonSourceConfirmSend(boot devenvtests.E2EBootstrap) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + _ cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return boot.ConfirmCantonSendOnSource(t, ctx, destSelector, seqNo) + } } func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { @@ -282,16 +374,9 @@ func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.Un Receiver: receiver, buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, executorAddr protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { return cciptestinterfaces.MessageFields{ - Receiver: receiver, - Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: 200_000, - FinalityConfig: 0, - Executor: executorAddr, - CCVs: []protocol.CCV{ - {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, - }, - }, nil + Receiver: receiver, + Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), + }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddr, ccvAddr), nil }, } } @@ -308,7 +393,7 @@ func cantonTokenLoadDestination(chain cciptestinterfaces.CCIP17, receiver protoc Receiver: receiver, Data: fmt.Appendf(nil, "evm2canton token load n=%d dest=%d", callNum, destSelector), TokenAmount: cciptestinterfaces.TokenAmount{ - Amount: new(big.Int).Set(lane.TransferAmount), + Amount: lane.TransferAmount, TokenAddress: lane.SrcToken, }, }, cciptestinterfaces.MessageOptions{ diff --git a/ccip/devenv/tests/load/metrics.go b/ccip/devenv/tests/load/metrics.go new file mode 100644 index 000000000..38e0d7b80 --- /dev/null +++ b/ccip/devenv/tests/load/metrics.go @@ -0,0 +1,195 @@ +package load + +import ( + "slices" + "sync" + "testing" + "time" + + ccvmetrics "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/metrics" + "github.com/smartcontractkit/chainlink-ccv/protocol" +) + +// LoadMessageRecord captures per-message phase timings for a successful load test call. +type LoadMessageRecord struct { + SeqNo uint64 + SourceChain uint64 + DestChain uint64 + MessageID protocol.Bytes32 + SentTime time.Time + ExecutedTime time.Time + SendDuration time.Duration + ConfirmSendDuration time.Duration + ConfirmExecDuration time.Duration + TotalDuration time.Duration +} + +// LoadFailureCounts tracks failures by load test phase. +type LoadFailureCounts struct { + Send int + ConfirmSend int + ConfirmExec int +} + +// LoadMetricsCollector accumulates successful message records and phase failure counts. +type LoadMetricsCollector struct { + mu sync.Mutex + records []LoadMessageRecord + failures LoadFailureCounts +} + +func (c *LoadMetricsCollector) appendRecord(r LoadMessageRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.records = append(c.records, r) +} + +func (c *LoadMetricsCollector) incrementSendFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.Send++ +} + +func (c *LoadMetricsCollector) incrementConfirmSendFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.ConfirmSend++ +} + +func (c *LoadMetricsCollector) incrementConfirmExecFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.ConfirmExec++ +} + +func (c *LoadMetricsCollector) snapshot() ([]LoadMessageRecord, LoadFailureCounts) { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]LoadMessageRecord, len(c.records)) + copy(out, c.records) + + return out, c.failures +} + +// PercentileStats holds common percentile values for a set of durations. +type PercentileStats struct { + Min time.Duration + Max time.Duration + P90 time.Duration + P95 time.Duration + P99 time.Duration +} + +func calculatePercentiles(durations []time.Duration) PercentileStats { + if len(durations) == 0 { + return PercentileStats{} + } + + slices.Sort(durations) + + p90Index := int(float64(len(durations)) * 0.90) + p95Index := int(float64(len(durations)) * 0.95) + p99Index := int(float64(len(durations)) * 0.99) + + if p90Index >= len(durations) { + p90Index = len(durations) - 1 + } + if p95Index >= len(durations) { + p95Index = len(durations) - 1 + } + if p99Index >= len(durations) { + p99Index = len(durations) - 1 + } + + return PercentileStats{ + Min: durations[0], + Max: durations[len(durations)-1], + P90: durations[p90Index], + P95: durations[p95Index], + P99: durations[p99Index], + } +} + +func logPhasePercentiles(t *testing.T, label string, stats PercentileStats, count int) { + t.Helper() + t.Logf("%s (n=%d):\n"+ + " Min: %v\n"+ + " Max: %v\n"+ + " P90: %v\n"+ + " P95: %v\n"+ + " P99: %v", + label, count, + stats.Min, stats.Max, stats.P90, stats.P95, stats.P99, + ) +} + +// PrintPhaseMetricsSummary prints per-phase timing percentiles for successful load messages. +func PrintPhaseMetricsSummary(t *testing.T, records []LoadMessageRecord, failures LoadFailureCounts, skipExecConfirm bool) { + t.Helper() + + t.Logf("\n" + + "========================================\n" + + " Load Phase Metrics \n" + + "========================================") + + if len(records) == 0 { + t.Logf("No successful load messages recorded") + } else { + sendDurations := make([]time.Duration, len(records)) + confirmSendDurations := make([]time.Duration, len(records)) + for i, r := range records { + sendDurations[i] = r.SendDuration + confirmSendDurations[i] = r.ConfirmSendDuration + } + + logPhasePercentiles(t, "Send", calculatePercentiles(sendDurations), len(records)) + logPhasePercentiles(t, "Confirm Send", calculatePercentiles(confirmSendDurations), len(records)) + + if skipExecConfirm { + sourceConfirmed := make([]time.Duration, len(records)) + for i, r := range records { + sourceConfirmed[i] = r.SendDuration + r.ConfirmSendDuration + } + logPhasePercentiles(t, "Source Confirmed (Send → Confirm Send)", calculatePercentiles(sourceConfirmed), len(records)) + } else { + confirmExecDurations := make([]time.Duration, len(records)) + totalDurations := make([]time.Duration, len(records)) + for i, r := range records { + confirmExecDurations[i] = r.ConfirmExecDuration + totalDurations[i] = r.TotalDuration + } + logPhasePercentiles(t, "Confirm Exec", calculatePercentiles(confirmExecDurations), len(records)) + logPhasePercentiles(t, "Total (E2E)", calculatePercentiles(totalDurations), len(records)) + } + } + + totalFailures := failures.Send + failures.ConfirmSend + failures.ConfirmExec + if totalFailures > 0 { + t.Logf("----------------------------------------\n"+ + "Failures: send=%d confirm_send=%d confirm_exec=%d", + failures.Send, failures.ConfirmSend, failures.ConfirmExec) + } + + t.Logf("========================================") +} + +// ToCCVMessageMetrics maps load records with ExecutedTime set to CCV message metrics. +func ToCCVMessageMetrics(records []LoadMessageRecord) []ccvmetrics.MessageMetrics { + out := make([]ccvmetrics.MessageMetrics, 0, len(records)) + for _, r := range records { + if r.ExecutedTime.IsZero() { + continue + } + out = append(out, ccvmetrics.MessageMetrics{ + SeqNo: r.SeqNo, + MessageID: r.MessageID.String(), + SourceChain: r.SourceChain, + DestChain: r.DestChain, + SentTime: r.SentTime, + ExecutedTime: r.ExecutedTime, + LatencyDuration: r.ExecutedTime.Sub(r.SentTime), + }) + } + + return out +} diff --git a/ccip/devenv/tests/token_lane.go b/ccip/devenv/tests/token_lane.go index 6ca23d332..e43bc689d 100644 --- a/ccip/devenv/tests/token_lane.go +++ b/ccip/devenv/tests/token_lane.go @@ -21,7 +21,10 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/go-daml/pkg/types" "github.com/stretchr/testify/require" + + splice_api_token_holding_v1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" ) const ( @@ -34,8 +37,12 @@ const ( // TokenLane describes a resolved token pool pairing for load or e2e tests. type TokenLane struct { PoolRef datastore.AddressRef - // TransferAmount is the per-message token transfer amount. + // TransferAmount is the per-message token transfer amount (wei for EVM→Canton, 10^10 fixed-point for Canton→EVM). TransferAmount *big.Int + // TransferInstrumentID is a TOML hint only (e.g. Amulet, LINK); prod resolution uses TransferInstrument. + TransferInstrumentID string + // TransferInstrument is the resolved Canton transfer instrument when Canton is the source. + TransferInstrument splice_api_token_holding_v1.InstrumentId // ExecutionGasLimit is the per-message execution gas limit. ExecutionGasLimit uint32 // FinalityConfig is the per-message finality configuration. @@ -49,23 +56,42 @@ type TokenLane struct { } type tokenConfigTOML struct { + Devenv tokenDirectionsTOML `toml:"devenv"` + ProdTestnet tokenDirectionsTOML `toml:"prod-testnet"` +} + +type tokenDirectionsTOML struct { EVMToCanton tokenDirectionTOML `toml:"evm_to_canton"` CantonToEVM tokenDirectionTOML `toml:"canton_to_evm"` } type tokenDirectionTOML struct { - PoolType string `toml:"pool_type"` - PoolVersion string `toml:"pool_version"` - PoolQualifier string `toml:"pool_qualifier"` - TransferAmount string `toml:"transfer_amount"` - ExecutionGasLimit uint32 `toml:"execution_gas_limit"` - FinalityConfig uint32 `toml:"finality_config"` + PoolType string `toml:"pool_type"` + PoolVersion string `toml:"pool_version"` + PoolQualifier string `toml:"pool_qualifier"` + TransferAmount string `toml:"transfer_amount"` + ExecutionGasLimit uint32 `toml:"execution_gas_limit"` + FinalityConfig uint32 `toml:"finality_config"` + RemotePoolType string `toml:"remote_pool_type"` + RemotePoolVersion string `toml:"remote_pool_version"` + RemotePoolQualifier string `toml:"remote_pool_qualifier"` + TransferInstrumentID string `toml:"transfer_instrument_id"` +} + +type tokenDirectionParsed struct { + PoolRef datastore.AddressRef + RemotePoolRef *datastore.AddressRef + TransferAmount *big.Int + TransferInstrumentID string + ExecutionGasLimit uint32 + FinalityConfig protocol.Finality } // ResolveTokenLane loads send params from TOML, matches the pool on the source chain, // and resolves source/destination token addresses for the requested destinations. func ResolveTokenLane( t *testing.T, + env CCIPEnv, in *ccv.Cfg, lib ccv.Lib, chainMap map[uint64]cciptestinterfaces.CCIP17, @@ -75,9 +101,9 @@ func ResolveTokenLane( t.Helper() // Load the CLDF environment and datastore used to resolve on-chain addresses. - env, err := lib.CLDFEnvironment() + cldfEnv, err := lib.CLDFEnvironment() require.NoError(t, err) - require.NotNil(t, env) + require.NotNil(t, cldfEnv) srcChain, ok := chainMap[srcSelector] require.True(t, ok, "source chain %d not in harness chain map", srcSelector) @@ -88,39 +114,48 @@ func ResolveTokenLane( direction = directionCantonToEVM } // Read pool identity and per-message send params from token_transfer_config.toml. - poolRef, transferAmount, gasLimit, finalityConfig := loadTokenDirection(t, direction) + dir := loadTokenDirection(t, env, direction) // List token transfer configs deployed on the source chain for the requested destinations. srcProvider := tokenConfigProvider(srcChain) - cfgs, err := srcProvider.GetTokenTransferConfigs(env, srcSelector, destSelectors, in.EnvironmentTopology) + cfgs, err := srcProvider.GetTokenTransferConfigs(cldfEnv, srcSelector, destSelectors, in.EnvironmentTopology) require.NoError(t, err, "get token transfer configs for source chain %d", srcSelector) - // Require exactly one config whose pool ref matches the TOML declaration. - cfg := selectTokenConfig(t, cfgs, poolRef, srcSelector) + cfg, matched := trySelectTokenConfig(cfgs, dir.PoolRef) + if !matched { + if !env.IsRemote() { + t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", + srcSelector, poolRefString(dir.PoolRef), poolRefsString(cfgs)) + } + + return resolveTokenLaneFromDatastore(t, cldfEnv, dir, srcSelector, destSelectors) + } // Fail fast if any requested destination is missing from that lane's RemoteChains. for _, sel := range destSelectors { if _, present := cfg.RemoteChains[sel]; !present { t.Fatalf("destination %d not configured for pool %s on chain %d (have %v)", - sel, poolRefString(poolRef), srcSelector, sortedRemoteSelectors(cfg)) + sel, poolRefString(dir.PoolRef), srcSelector, sortedRemoteSelectors(cfg)) } } // Assemble the lane with TOML send params; token addresses are filled in below. lane := TokenLane{ - PoolRef: poolRef, - TransferAmount: transferAmount, - ExecutionGasLimit: gasLimit, - FinalityConfig: finalityConfig, - DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), + PoolRef: dir.PoolRef, + TransferAmount: dir.TransferAmount, + TransferInstrumentID: dir.TransferInstrumentID, + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: dir.FinalityConfig, + DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), } // EVM source: resolve the ERC-20 from the matched config's TokenRef in the datastore. - // Canton source: instrument is chosen in SetupSend, so SrcToken stays empty. if !isCantonSelector(srcSelector) { - srcToken, err := resolveTokenRef(env.DataStore, srcSelector, cfg.TokenRef) + srcToken, err := resolveTokenRef(cldfEnv.DataStore, srcSelector, cfg.TokenRef) require.NoError(t, err, "resolve source token on chain %d", srcSelector) lane.SrcToken = srcToken + } else { + lane.TransferInstrument = resolveCantonTransferInstrument(t, cldfEnv.DataStore, srcSelector, cfg.TokenRef, dir.PoolRef, dir.TransferInstrumentID) } // EVM destinations: look up each dest chain's config for the remote pool and resolve TokenRef. @@ -129,13 +164,13 @@ func ResolveTokenLane( if isCantonSelector(sel) { continue } - lane.DestTokenBySelector[sel] = resolveDestToken(t, env, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], poolRef) + lane.DestTokenBySelector[sel] = resolveDestToken(t, cldfEnv, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], dir.PoolRef) } return lane } -func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.AddressRef, transferAmount *big.Int, gasLimit uint32, finalityConfig protocol.Finality) { +func loadTokenDirection(t *testing.T, env CCIPEnv, direction string) tokenDirectionParsed { t.Helper() path := os.Getenv(envTokenTestConfig) @@ -147,40 +182,208 @@ func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.Addre _, err := toml.DecodeFile(path, &cfg) require.NoError(t, err, "decode token transfer config %q (set %s to override)", path, envTokenTestConfig) + dirs, err := tokenDirectionsForEnv(cfg, env) + require.NoError(t, err, "%s: no token transfer config for env %q", path, env) + var dir tokenDirectionTOML switch direction { case directionEVMToCanton: - dir = cfg.EVMToCanton + dir = dirs.EVMToCanton case directionCantonToEVM: - dir = cfg.CantonToEVM + dir = dirs.CantonToEVM default: t.Fatalf("unknown token transfer direction %q (expected %q or %q)", direction, directionEVMToCanton, directionCantonToEVM) } - require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for direction %q", path, direction) + require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for env %q direction %q", path, env, direction) version, err := semver.NewVersion(dir.PoolVersion) - require.NoError(t, err, "%s: invalid pool_version %q for direction %q", path, dir.PoolVersion, direction) + require.NoError(t, err, "%s: invalid pool_version %q for env %q direction %q", path, dir.PoolVersion, env, direction) - amount, ok := new(big.Int).SetString(strings.TrimSpace(dir.TransferAmount), 10) - require.True(t, ok && amount.Sign() > 0, "%s: transfer_amount %q must be a positive integer for direction %q", path, dir.TransferAmount, direction) + amountStr := strings.TrimSpace(dir.TransferAmount) + intAmount, ok := new(big.Int).SetString(amountStr, 10) + switch direction { + case directionEVMToCanton: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer wei for env %q direction %q", path, dir.TransferAmount, env, direction) + case directionCantonToEVM: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer fixed-point (10^10 scale) for env %q direction %q", path, dir.TransferAmount, env, direction) + default: + t.Fatalf("unknown token transfer direction %q", direction) + } - require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for direction %q", path, direction) + require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for env %q direction %q", path, env, direction) + + parsed := tokenDirectionParsed{ + PoolRef: datastore.AddressRef{ + Type: datastore.ContractType(dir.PoolType), + Version: version, + Qualifier: dir.PoolQualifier, + }, + TransferAmount: intAmount, + TransferInstrumentID: strings.TrimSpace(dir.TransferInstrumentID), + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: protocol.Finality(dir.FinalityConfig), + } + + if dir.RemotePoolType != "" || dir.RemotePoolVersion != "" || dir.RemotePoolQualifier != "" { + require.NotEmpty(t, dir.RemotePoolType, "%s: remote_pool_type is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolVersion, "%s: remote_pool_version is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolQualifier, "%s: remote_pool_qualifier is required when remote pool fields are set (env %q direction %q)", path, env, direction) - poolRef = datastore.AddressRef{ - Type: datastore.ContractType(dir.PoolType), - Version: version, - Qualifier: dir.PoolQualifier, + remoteVersion, err := semver.NewVersion(dir.RemotePoolVersion) + require.NoError(t, err, "%s: invalid remote_pool_version %q for env %q direction %q", path, dir.RemotePoolVersion, env, direction) + + remoteRef := datastore.AddressRef{ + Type: datastore.ContractType(dir.RemotePoolType), + Version: remoteVersion, + Qualifier: dir.RemotePoolQualifier, + } + parsed.RemotePoolRef = &remoteRef } - return poolRef, amount, dir.ExecutionGasLimit, protocol.Finality(dir.FinalityConfig) + return parsed } -func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef, srcSelector uint64) tokenscore.TokenTransferConfig { +func tokenDirectionsForEnv(cfg tokenConfigTOML, env CCIPEnv) (tokenDirectionsTOML, error) { + switch env { + case EnvDevenv: + if cfg.Devenv.EVMToCanton.PoolType == "" && cfg.Devenv.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [devenv.*] sections") + } + + return cfg.Devenv, nil + case EnvProdTestnet: + if cfg.ProdTestnet.EVMToCanton.PoolType == "" && cfg.ProdTestnet.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [prod-testnet.*] sections") + } + + return cfg.ProdTestnet, nil + default: + return tokenDirectionsTOML{}, fmt.Errorf("unsupported ccip env %q", env) + } +} + +func resolveTokenLaneFromDatastore( + t *testing.T, + env *deployment.Environment, + dir tokenDirectionParsed, + srcSelector uint64, + destSelectors []uint64, +) TokenLane { t.Helper() + requireAddressRefInDatastore(t, env.DataStore, srcSelector, dir.PoolRef, "source pool") + + require.NotNil(t, dir.RemotePoolRef, "remote_pool_* required for prod datastore fallback") + + for _, sel := range destSelectors { + if isCantonSelector(sel) { + requireAddressRefInDatastore(t, env.DataStore, sel, *dir.RemotePoolRef, "remote pool") + } + } + + lane := TokenLane{ + PoolRef: dir.PoolRef, + TransferAmount: dir.TransferAmount, + TransferInstrumentID: dir.TransferInstrumentID, + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: dir.FinalityConfig, + DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), + } + + if !isCantonSelector(srcSelector) { + srcToken, err := resolveSrcTokenFromDatastore(env.DataStore, srcSelector) + require.NoError(t, err, "resolve source token on chain %d from datastore", srcSelector) + lane.SrcToken = srcToken + } else { + tokenRef := datastore.AddressRef{ + Type: datastore.ContractType("Token"), + Version: semver.MustParse("2.0.0"), + Qualifier: dir.PoolRef.Qualifier, + } + lane.TransferInstrument = resolveCantonTransferInstrument(t, env.DataStore, srcSelector, tokenRef, dir.PoolRef, dir.TransferInstrumentID) + } + + for _, sel := range destSelectors { + if isCantonSelector(sel) { + continue + } + destToken, err := resolveDestTokenFromDatastore(env.DataStore, sel, *dir.RemotePoolRef) + require.NoError(t, err, "resolve destination token on chain %d from datastore", sel) + lane.DestTokenBySelector[sel] = destToken + } + + return lane +} + +func resolveSrcTokenFromDatastore(ds datastore.DataStore, chainSelector uint64) (protocol.UnknownAddress, error) { + candidates := []datastore.AddressRef{ + { + Type: datastore.ContractType("BurnMintERC20WithDrip"), + Version: semver.MustParse("1.5.0"), + Qualifier: "TEST", + }, + { + Type: datastore.ContractType("BurnMintERC20WithDripToken"), + Version: semver.MustParse("1.0.0"), + Qualifier: "", + }, + } + + var lastErr error + for _, ref := range candidates { + token, err := resolveTokenRef(ds, chainSelector, ref) + if err == nil { + return token, nil + } + lastErr = err + } + + return protocol.UnknownAddress{}, fmt.Errorf("no source token candidate in datastore on chain %d: %w", chainSelector, lastErr) +} + +func resolveDestTokenFromDatastore(ds datastore.DataStore, chainSelector uint64, remotePoolRef datastore.AddressRef) (protocol.UnknownAddress, error) { + candidates := []datastore.AddressRef{ + { + Type: datastore.ContractType("BurnMintERC20WithDrip"), + Version: semver.MustParse("1.5.0"), + Qualifier: remotePoolRef.Qualifier, + }, + { + Type: datastore.ContractType("BurnMintERC20WithDripToken"), + Version: semver.MustParse("1.0.0"), + Qualifier: remotePoolRef.Qualifier, + }, + } + + var lastErr error + for _, ref := range candidates { + token, err := resolveTokenRef(ds, chainSelector, ref) + if err == nil { + return token, nil + } + lastErr = err + } + + return protocol.UnknownAddress{}, fmt.Errorf("no destination token candidate in datastore on chain %d: %w", chainSelector, lastErr) +} + +func requireAddressRefInDatastore( + t *testing.T, + ds datastore.DataStore, + chainSelector uint64, + ref datastore.AddressRef, + label string, +) { + t.Helper() + + _, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, ref.Type, ref.Version, ref.Qualifier)) + require.NoError(t, err, "%s %s not found on chain %d", label, poolRefString(ref), chainSelector) +} + +func trySelectTokenConfig(cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef) (tokenscore.TokenTransferConfig, bool) { matches := make([]tokenscore.TokenTransferConfig, 0, 1) for _, cfg := range cfgs { if poolRefEqual(cfg.TokenPoolRef, poolRef) { @@ -189,16 +392,10 @@ func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, pool } switch len(matches) { case 1: - return matches[0] - case 0: - t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", - srcSelector, poolRefString(poolRef), poolRefsString(cfgs)) + return matches[0], true default: - t.Fatalf("pool %s matched %d configs on chain %d (expected one): %s", - poolRefString(poolRef), len(matches), srcSelector, poolRefsString(matches)) + return tokenscore.TokenTransferConfig{}, false } - - return tokenscore.TokenTransferConfig{} } func resolveDestToken( @@ -243,6 +440,102 @@ func tokenConfigProvider(chain cciptestinterfaces.CCIP17) cciptestinterfaces.Tok return evm.NewEmptyCCIP17EVM() } +func resolveCantonTransferInstrument( + t *testing.T, + ds datastore.DataStore, + chainSelector uint64, + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) splice_api_token_holding_v1.InstrumentId { + t.Helper() + + addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, tokenRef.Type, tokenRef.Version, tokenRef.Qualifier)) + require.NoError(t, err, "resolve Canton token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + poolAddrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, poolRef.Type, poolRef.Version, poolRef.Qualifier)) + require.NoError(t, err, "resolve Canton pool ref %s on chain %d", poolRefString(poolRef), chainSelector) + + instrument, err := resolveInstrumentFromTokenRefWithFallback(addrRef, poolAddrRef, transferInstrumentIDHint) + require.NoError(t, err, "parse instrument from token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + return instrument +} + +func resolveInstrumentFromTokenRefWithFallback( + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) (splice_api_token_holding_v1.InstrumentId, error) { + if instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef); err == nil { + return instrument, nil + } + + ccipOwner := ccipOwnerFromRefLabels(poolRef) + if ccipOwner == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + instrumentID := normalizeTransferInstrumentID(transferInstrumentIDHint) + if instrumentID == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "transfer_instrument_id is required when token ref labels are missing", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(ccipOwner), + Id: types.TEXT(instrumentID), + }, nil +} + +func parseInstrumentIDFromTokenRefLabels(tokenRef datastore.AddressRef) (splice_api_token_holding_v1.InstrumentId, error) { + var instrumentAdmin, instrumentIDText string + for _, label := range tokenRef.Labels.List() { + switch { + case strings.HasPrefix(label, "instrument-admin:"): + instrumentAdmin = strings.TrimSpace(strings.TrimPrefix(label, "instrument-admin:")) + case strings.HasPrefix(label, "instrument-id:"): + instrumentIDText = strings.TrimSpace(strings.TrimPrefix(label, "instrument-id:")) + } + } + if instrumentAdmin == "" || instrumentIDText == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(instrumentAdmin), + Id: types.TEXT(instrumentIDText), + }, nil +} + +func ccipOwnerFromRefLabels(ref datastore.AddressRef) string { + for _, label := range ref.Labels.List() { + if party, ok := strings.CutPrefix(label, "ccip-owner:"); ok { + return strings.TrimSpace(party) + } + if idx := strings.LastIndex(label, "@ccipOwner::"); idx >= 0 { + return strings.TrimSpace(label[idx+1:]) + } + } + + return "" +} + +func normalizeTransferInstrumentID(hint string) string { + hint = strings.TrimSpace(hint) + switch strings.ToLower(hint) { + case "link": + return "link-token" + default: + return hint + } +} + func resolveTokenRef(ds datastore.DataStore, chainSelector uint64, ref datastore.AddressRef) (protocol.UnknownAddress, error) { addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, ref.Type, ref.Version, ref.Qualifier)) if err != nil { diff --git a/ccip/devenv/tests/token_lane_test.go b/ccip/devenv/tests/token_lane_test.go new file mode 100644 index 000000000..d76a5e812 --- /dev/null +++ b/ccip/devenv/tests/token_lane_test.go @@ -0,0 +1,185 @@ +package tests + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/BurntSushi/toml" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/ccip/devenv" +) + +func TestTokenDirectionsForEnv(t *testing.T) { + t.Parallel() + + cfg, err := decodeTokenConfigTOML(defaultTokenConfigPath()) + require.NoError(t, err) + + devenvDirs, err := tokenDirectionsForEnv(cfg, EnvDevenv) + require.NoError(t, err) + require.Equal(t, "BurnMintTokenPool", devenvDirs.EVMToCanton.PoolType) + require.Contains(t, devenvDirs.EVMToCanton.PoolQualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Equal(t, "LockReleaseTokenPool", devenvDirs.CantonToEVM.PoolType) + + prodDirs, err := tokenDirectionsForEnv(cfg, EnvProdTestnet) + require.NoError(t, err) + require.Equal(t, "TEST", prodDirs.EVMToCanton.PoolQualifier) + require.Equal(t, "LINK", prodDirs.EVMToCanton.RemotePoolQualifier) + require.Equal(t, "LINK", prodDirs.CantonToEVM.PoolQualifier) + require.Equal(t, "100", prodDirs.CantonToEVM.TransferAmount) + require.Equal(t, "link-token", prodDirs.CantonToEVM.TransferInstrumentID) +} + +func TestLoadTokenDirection_envSelection(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + devenvDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "BurnMintTokenPool", string(devenvDir.PoolRef.Type)) + require.Contains(t, devenvDir.PoolRef.Qualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Nil(t, devenvDir.RemotePoolRef) + + prodDir := loadTokenDirection(t, EnvProdTestnet, directionEVMToCanton) + require.Equal(t, "TEST", prodDir.PoolRef.Qualifier) + require.NotNil(t, prodDir.RemotePoolRef) + require.Equal(t, "LINK", prodDir.RemotePoolRef.Qualifier) +} + +func TestLoadTokenDirection_transferAmountParsing(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + evmDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "100000000000", evmDir.TransferAmount.String()) + + cantonDir := loadTokenDirection(t, EnvDevenv, directionCantonToEVM) + require.Equal(t, big.NewInt(1000), cantonDir.TransferAmount) + + prodCantonDir := loadTokenDirection(t, EnvProdTestnet, directionCantonToEVM) + require.Equal(t, big.NewInt(100), prodCantonDir.TransferAmount) + require.Equal(t, "link-token", prodCantonDir.TransferInstrumentID) + + expectedWei := new(big.Int).Mul(prodCantonDir.TransferAmount, big.NewInt(devenv.CantonFixedPointToEVMScale)) + require.Equal(t, "10000000000", expectedWei.String()) +} + +func TestLoadTokenDirection_missingEnvSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "token_transfer_config.toml") + require.NoError(t, os.WriteFile(path, []byte(` +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1" +execution_gas_limit = 1 +finality_config = 0 +`), 0o600)) + + t.Setenv(envTokenTestConfig, path) + + _, err := tokenDirectionsForEnv(mustDecodeTokenConfig(t, path), EnvProdTestnet) + require.Error(t, err) + require.Contains(t, err.Error(), "prod-testnet") +} + +func mustDecodeTokenConfig(t *testing.T, path string) tokenConfigTOML { + t.Helper() + + cfg, err := decodeTokenConfigTOML(path) + require.NoError(t, err) + + return cfg +} + +func decodeTokenConfigTOML(path string) (tokenConfigTOML, error) { + var cfg tokenConfigTOML + _, err := toml.DecodeFile(path, &cfg) + + return cfg, err +} + +func TestParseInstrumentIDFromTokenRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + tokenRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + + instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef) + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = parseInstrumentIDFromTokenRefLabels(datastore.AddressRef{}) + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") +} + +func TestNormalizeTransferInstrumentID(t *testing.T) { + t.Parallel() + + require.Equal(t, "link-token", normalizeTransferInstrumentID("LINK")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link")) + require.Equal(t, "link-token", normalizeTransferInstrumentID(" link ")) + require.Equal(t, "Amulet", normalizeTransferInstrumentID("Amulet")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link-token")) +} + +func TestCCIPOwnerFromRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + fromPrefix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("ccip-owner:" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromPrefix)) + + fromSuffix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromSuffix)) + + require.Empty(t, ccipOwnerFromRefLabels(datastore.AddressRef{})) +} + +func TestResolveInstrumentFromTokenRefWithFallback(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + tokenWithLabels := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + poolRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + + instrument, err := resolveInstrumentFromTokenRefWithFallback(tokenWithLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + tokenWithoutLabels := datastore.AddressRef{} + instrument, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, datastore.AddressRef{}, "LINK") + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "") + require.Error(t, err) + require.Contains(t, err.Error(), "transfer_instrument_id") +} diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index c3bbfe7ee..3d58d31e6 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -1,24 +1,49 @@ -# Token lane inputs for devenv e2e/load tests. +# Token lane inputs for CCIP e2e/load tests. # +# Env selection follows -ccip-env / CCIP_ENV (same as CCIP harness). # Token identity (pool_type / pool_version / pool_qualifier) is REQUIRED and is # matched against the source chain's GetTokenTransferConfigs to discover the lane. # Numeric send params (transfer_amount / execution_gas_limit / finality_config) # are optional; when omitted, code-level per-direction defaults are used. # -# Override the file path with the CANTON_TOKEN_TEST_CONFIG env var. +# Override the entire file path with CANTON_TOKEN_TEST_CONFIG. -[evm_to_canton] -pool_type = "BurnMintTokenPool" -pool_version = "2.0.0" +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::BurnMintTokenPool 2.0.0 [default]" -transfer_amount = "100000000000" +transfer_amount = "100000000000" execution_gas_limit = 200000 -finality_config = 0 +# Per-message FTF (1-block depth); requires Canton pool remoteChainCfg BlockDepth >= 1 (see impl.go). +finality_config = 1 -[canton_to_evm] -pool_type = "LockReleaseTokenPool" -pool_version = "2.0.0" +[devenv.canton_to_evm] +pool_type = "LockReleaseTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]" -transfer_amount = "1000" +transfer_amount = "1000" execution_gas_limit = 500000 -finality_config = 1 +finality_config = 1 + +[prod-testnet.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1000000000000000" +execution_gas_limit = 200000 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "LINK" + +[prod-testnet.canton_to_evm] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "LINK" +transfer_amount = "100" +execution_gas_limit = 500000 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "TEST" +transfer_instrument_id = "link-token" diff --git a/ccip/devenv/verifier_observation.go b/ccip/devenv/verifier_observation.go index ac12e11ce..76b72960c 100644 --- a/ccip/devenv/verifier_observation.go +++ b/ccip/devenv/verifier_observation.go @@ -11,56 +11,57 @@ import ( ) // VerifierObservation holds off-chain clients used to wait for CCIP verifier -// results (aggregator + indexer). ConfirmExecOnDest needs these; it does not -// need ChainsMap or other Lib methods. +// results (indexer required; aggregator optional). ConfirmExecOnDest needs these; +// it does not need ChainsMap or other Lib methods. // // Build from a CCV env Lib via [VerifierObservationFromLib] (requires -// [ccv.NewLibFromCCVEnv] — CLDF-only Lib backends cannot provide aggregator/indexer). +// [ccv.NewLibFromCCVEnv] — CLDF-only Lib backends cannot provide indexer). type VerifierObservation struct { AggregatorClient *ccv.AggregatorClient IndexerMonitor *ccv.IndexerMonitor } func (o VerifierObservation) wired() bool { - return o.AggregatorClient != nil && o.IndexerMonitor != nil + return o.IndexerMonitor != nil } -// VerifierObservationFromLib extracts aggregator and indexer clients from lib. +// VerifierObservationFromLib extracts indexer (required) and optional aggregator +// clients from lib. func VerifierObservationFromLib(lib ccv.Lib) (VerifierObservation, error) { if lib == nil { return VerifierObservation{}, fmt.Errorf("VerifierObservationFromLib: lib is nil") } - aggregatorClients, err := lib.AllAggregators() + indexerMonitor, err := lib.IndexerMonitor() if err != nil { - return VerifierObservation{}, fmt.Errorf("all aggregators: %w", err) + return VerifierObservation{}, fmt.Errorf("indexer monitor: %w", err) } - aggregatorClient, ok := aggregatorClients[devenvcommon.DefaultCommitteeVerifierQualifier] - if !ok || aggregatorClient == nil { - return VerifierObservation{}, fmt.Errorf("no aggregator client for qualifier %q", devenvcommon.DefaultCommitteeVerifierQualifier) + + obs := VerifierObservation{ + IndexerMonitor: indexerMonitor, } - indexerMonitor, err := lib.IndexerMonitor() - if err != nil { - return VerifierObservation{}, fmt.Errorf("indexer monitor: %w", err) + aggregatorClients, aggErr := lib.AllAggregators() + if aggErr == nil { + aggregatorClient, ok := aggregatorClients[devenvcommon.DefaultCommitteeVerifierQualifier] + if ok && aggregatorClient != nil { + obs.AggregatorClient = aggregatorClient + } } - return VerifierObservation{ - AggregatorClient: aggregatorClient, - IndexerMonitor: indexerMonitor, - }, nil + return obs, nil } // AssertMessageWithVerifierObservation waits for verifier results for messageID -// using aggregator and indexer only (no chain map). +// using indexer (and aggregator when configured). func AssertMessageWithVerifierObservation( ctx context.Context, obs VerifierObservation, messageID protocol.Bytes32, opts tcapi.AssertMessageOptions, ) (tcapi.AssertionResult, error) { - if !obs.wired() { - return tcapi.AssertionResult{}, fmt.Errorf("verifier observation not wired (aggregator and indexer required)") + if obs.IndexerMonitor == nil { + return tcapi.AssertionResult{}, fmt.Errorf("verifier observation not wired (indexer required)") } testCtx, cleanupFn := tcapi.NewTestingContext(ctx, nil, obs.AggregatorClient, obs.IndexerMonitor) diff --git a/ccip/devenv/verifier_observation_test.go b/ccip/devenv/verifier_observation_test.go new file mode 100644 index 000000000..810d8f5cf --- /dev/null +++ b/ccip/devenv/verifier_observation_test.go @@ -0,0 +1,26 @@ +package devenv + +import ( + "testing" + + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" + "github.com/stretchr/testify/require" +) + +func TestVerifierObservation_wired_indexerOnly(t *testing.T) { + t.Parallel() + + obs := VerifierObservation{ + IndexerMonitor: &ccv.IndexerMonitor{}, + } + require.True(t, obs.wired()) +} + +func TestVerifierObservation_wired_indexerNil(t *testing.T) { + t.Parallel() + + obs := VerifierObservation{ + AggregatorClient: &ccv.AggregatorClient{}, + } + require.False(t, obs.wired()) +} diff --git a/deployment/sequences/token_pools.go b/deployment/sequences/token_pools.go index 13939d24a..9fed73ebe 100644 --- a/deployment/sequences/token_pools.go +++ b/deployment/sequences/token_pools.go @@ -591,12 +591,12 @@ var DeployTokenPoolForToken = operations.NewSequence( return ccipsequences.OnChainOutput{}, fmt.Errorf("tokenRef.address is required") } tokenRef := datastore.AddressRef{ - Address: tokenAddress, - Type: datastore.ContractType("Token"), - // TODO: what should this be set to? + Address: tokenAddress, + Type: datastore.ContractType("Token"), Version: input.TokenPoolVersion, Qualifier: qualifier, ChainSelector: input.ChainSelector, + Labels: input.TokenRef.Labels, } if mcmsEnabled && len(proposalOutputs) > 0 {