diff --git a/.github/actions/ccip-load-test/action.yml b/.github/actions/ccip-load-test/action.yml index d85c38c4b..d8a2b20c8 100644 --- a/.github/actions/ccip-load-test/action.yml +++ b/.github/actions/ccip-load-test/action.yml @@ -16,7 +16,7 @@ inputs: message_rate: description: "CANTON_LOAD_MESSAGE_RATE" required: false - default: "1/45s" + default: "1/10s" load_duration: description: "CANTON_LOAD_DURATION" required: false @@ -33,10 +33,6 @@ inputs: description: "Checkout path for chainlink-canton (`.` for PR root, `chainlink-canton` for nested devenv checkout)" required: false default: . - canton_ref: - description: "chainlink-canton git ref for devenv setup (empty = workflow SHA)" - required: false - default: "" skip_exec_confirm: description: "CANTON_LOAD_SKIP_EXEC_CONFIRM" required: false @@ -49,10 +45,26 @@ inputs: description: "CANTON_PARTY_ID" required: false default: "u_d53a15c42af6::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7" + user_id: + description: "CANTON_USER_ID" + required: false + default: "0oau1l03xrG9NQw9z5d7" grpc_url: description: "CANTON_GRPC_URL" required: false default: "testnet.cv1.bcy-v.metalhosts.com:443" + router_instance_id: + description: "CANTON_ROUTER_INSTANCE_ID (prod-testnet)" + required: false + default: "gha-load-router" + sender_instance_id: + description: "CANTON_SENDER_INSTANCE_ID (prod-testnet)" + required: false + default: "gha-load-sender" + receiver_instance_id: + description: "CANTON_RECEIVER_INSTANCE_ID (prod-testnet)" + required: false + default: "gha-load-receiver" CANTON_OKTA_AUTHORIZER_TESTNET: description: "GitHub secret CANTON_OKTA_AUTHORIZER_TESTNET" required: false @@ -65,6 +77,12 @@ inputs: CCIP_PROD_TESTNET_PRIVATE_KEY: description: "GitHub secret CCIP_PROD_TESTNET_PRIVATE_KEY" required: false + TS_OAUTH_CLIENT_ID_CANTON: + description: "GitHub secret TS_OAUTH_CLIENT_ID_CANTON (prod-testnet Tailscale)" + required: false + TS_AUDIENCE_CANTON: + description: "GitHub secret TS_AUDIENCE_CANTON (prod-testnet Tailscale)" + required: false ccv-iam-role: description: "AWS IAM role for CCV ECR authentication (devenv only)" required: false @@ -78,6 +96,23 @@ inputs: runs: using: composite steps: + - name: Setup CCIP devenv + if: inputs.ccip_env == 'devenv' + uses: ./.github/actions/setup-ccip-devenv + with: + canton-path: ${{ inputs.canton_path }} + ccv-iam-role: ${{ inputs.ccv-iam-role }} + jd-registry: ${{ inputs.jd-registry }} + jd-image: ${{ inputs.jd-image }} + + - name: Connect to Tailscale (prod-testnet) + if: inputs.ccip_env == 'prod-testnet' + uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4.1.1 + with: + oauth-client-id: ${{ inputs.TS_OAUTH_CLIENT_ID_CANTON }} + audience: ${{ inputs.TS_AUDIENCE_CANTON }} + tags: tag:bcy-validators-gha + - name: Install Go (prod-testnet) if: inputs.ccip_env == 'prod-testnet' uses: actions/setup-go@v6 @@ -99,6 +134,7 @@ runs: env: CANTON_LOAD_MESSAGE_RATE: ${{ inputs.message_rate }} CANTON_LOAD_DURATION: ${{ inputs.load_duration }} + CANTON_LOAD_SKIP_EXEC_CONFIRM: ${{ inputs.skip_exec_confirm }} run: | case "${{ inputs.direction }}" in canton2evm) TEST_RUN='^TestCanton2EVM_Load$' ;; @@ -121,12 +157,17 @@ runs: CANTON_CLIENT_ID: ${{ inputs.CANTON_OKTA_CLIENT_ID_TESTNET }} CANTON_CLIENT_SECRET: ${{ inputs.CANTON_OKTA_CLIENT_SECRET_TESTNET }} CANTON_PARTY_ID: ${{ inputs.party_id }} + CANTON_USER_ID: ${{ inputs.user_id }} CANTON_GRPC_URL: ${{ inputs.grpc_url }} + CANTON_ROUTER_INSTANCE_ID: ${{ inputs.router_instance_id }} + CANTON_SENDER_INSTANCE_ID: ${{ inputs.sender_instance_id }} + CANTON_RECEIVER_INSTANCE_ID: ${{ inputs.receiver_instance_id }} CANTON_LOAD_MESSAGE_RATE: ${{ inputs.message_rate }} CANTON_LOAD_DURATION: ${{ inputs.load_duration }} CANTON_CONFIRM_EXEC_TIMEOUT: ${{ inputs.confirm_exec_timeout }} CANTON_LOAD_SKIP_EXEC_CONFIRM: ${{ inputs.skip_exec_confirm }} PRIVATE_KEY: ${{ inputs.CCIP_PROD_TESTNET_PRIVATE_KEY }} + TEST_TIMEOUT: ${{ inputs.test_timeout }} run: | case "${{ inputs.direction }}" in canton2evm) TEST_RUN='^TestCanton2EVM_Load$' ;; @@ -135,4 +176,13 @@ runs: evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; esac - go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" + go test -tags=prodledger -timeout "$TEST_TIMEOUT" -v -count 1 -ccip-env=prod-testnet -run "$TEST_RUN" + + - name: Upload devenv logs + if: always() && inputs.ccip_env == 'devenv' + uses: ./.github/actions/upload-ccip-devenv-logs + with: + canton-path: ${{ inputs.canton_path }} + test-package-dir: load + log-dump-suffix: ccip-load-tests + artifact-name: container-logs-ccip-load-tests diff --git a/.github/actions/setup-ccip-devenv/action.yml b/.github/actions/setup-ccip-devenv/action.yml index 4ad9f2a15..4e4d9faec 100644 --- a/.github/actions/setup-ccip-devenv/action.yml +++ b/.github/actions/setup-ccip-devenv/action.yml @@ -12,11 +12,6 @@ inputs: # that generates their configs. A mismatch across config-format changes (e.g. #1193 # bootstrap monitoring.Config) makes the verifier container exit on startup. default: f20ea93af3005f17859ca1d7e6529e0121a0430c - canton-ref: - description: >- - chainlink-canton git ref. Leave empty to use the ref that triggered the workflow. - required: false - default: '' ccv-iam-role: description: AWS IAM role ARN for CCV ECR authentication required: true @@ -41,7 +36,7 @@ inputs: free-disk-space: description: Run the free-disk-space action before builds required: false - default: 'true' + default: "true" runs: using: composite @@ -58,7 +53,7 @@ runs: with: repository: smartcontractkit/chainlink-canton path: ${{ inputs.canton-path }} - ref: ${{ inputs.canton-ref != '' && inputs.canton-ref || github.sha }} + ref: ${{ github.sha }} - name: Check out chainlink-ccv uses: actions/checkout@v6 diff --git a/.github/workflows/ccip-load-command.yml b/.github/workflows/ccip-load-command.yml deleted file mode 100644 index cfcf4db12..000000000 --- a/.github/workflows/ccip-load-command.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: CCIP Load (ChatOps) -on: - repository_dispatch: - types: [ccip-load-command] - -concurrency: - group: ccip-load-pr-${{ github.event.client_payload.pull_request.number }} - cancel-in-progress: false - -permissions: {} - -jobs: - load: - name: CCIP load (ChatOps) - if: | - github.event.client_payload.slash_command.command == 'ccip-load' && - github.event.client_payload.pull_request.head.repo.full_name == github.repository - permissions: - contents: read - id-token: write - pull-requests: write - runs-on: ubuntu-latest-8cores-32GB - timeout-minutes: 120 - steps: - - name: Setup GitHub Token - id: setup-github-token - uses: smartcontractkit/.github/actions/setup-github-token@ef78fa97bf3c77de6563db1175422703e9e6674f # setup-github-token@0.2.1 - with: - aws-role-arn: ${{ secrets.GATI_AWS_ROLE_CANTON_CICD }} - aws-lambda-url: ${{ secrets.GATI_AWS_LABDA_URL_INTEGRATIONS }} - aws-region: ${{ secrets.GATI_AWS_REGION }} - - - name: Post comment - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - with: - repo-token: ${{ steps.setup-github-token.outputs.access-token }} - refresh-message-position: true - issue: ${{ github.event.client_payload.github.payload.issue.number }} - message: | - CCIP load test running... - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - name: Check out PR branch - uses: actions/checkout@v6 - with: - ref: ${{ github.event.client_payload.pull_request.head.sha }} - token: ${{ steps.setup-github-token.outputs.access-token }} - - - name: Resolve inputs - id: params - env: - NAMED_CCIP_ENV: ${{ github.event.client_payload.slash_command.args.named.ccip_env }} - NAMED_DIRECTION: ${{ github.event.client_payload.slash_command.args.named.direction }} - NAMED_MESSAGE_RATE: ${{ github.event.client_payload.slash_command.args.named.message_rate }} - NAMED_LOAD_DURATION: ${{ github.event.client_payload.slash_command.args.named.load_duration }} - NAMED_TEST_TIMEOUT: ${{ github.event.client_payload.slash_command.args.named.test_timeout }} - NAMED_CONFIG_FILE: ${{ github.event.client_payload.slash_command.args.named.config_file }} - NAMED_SKIP_EXEC_CONFIRM: ${{ github.event.client_payload.slash_command.args.named.skip_exec_confirm }} - NAMED_CONFIRM_EXEC_TIMEOUT: ${{ github.event.client_payload.slash_command.args.named.confirm_exec_timeout }} - NAMED_PARTY_ID: ${{ github.event.client_payload.slash_command.args.named.party_id }} - NAMED_GRPC_URL: ${{ github.event.client_payload.slash_command.args.named.grpc_url }} - run: | - echo "ccip_env=${NAMED_CCIP_ENV:-prod-testnet}" >> "$GITHUB_OUTPUT" - echo "direction=${NAMED_DIRECTION:-canton2evm}" >> "$GITHUB_OUTPUT" - 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_TEST_TIMEOUT}" ]; then - echo "test_timeout=${NAMED_TEST_TIMEOUT}" >> "$GITHUB_OUTPUT" - elif [ "$direction" = "evm2canton" ]; then - echo "test_timeout=45m" >> "$GITHUB_OUTPUT" - else - echo "test_timeout=30m" >> "$GITHUB_OUTPUT" - fi - - - name: Setup CCIP devenv - if: steps.params.outputs.ccip_env == 'devenv' - uses: ./.github/actions/setup-ccip-devenv - with: - canton-path: . - ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} - jd-registry: ${{ secrets.JD_REGISTRY }} - jd-image: ${{ secrets.JD_IMAGE }} - - - name: Run CCIP load test - uses: ./.github/actions/ccip-load-test - with: - ccip_env: ${{ steps.params.outputs.ccip_env }} - direction: ${{ steps.params.outputs.direction }} - message_rate: ${{ steps.params.outputs.message_rate }} - load_duration: ${{ steps.params.outputs.load_duration }} - test_timeout: ${{ steps.params.outputs.test_timeout }} - config_file: ${{ steps.params.outputs.config_file }} - canton_path: . - skip_exec_confirm: ${{ steps.params.outputs.skip_exec_confirm }} - confirm_exec_timeout: ${{ steps.params.outputs.confirm_exec_timeout }} - party_id: ${{ steps.params.outputs.party_id }} - grpc_url: ${{ steps.params.outputs.grpc_url }} - CANTON_OKTA_AUTHORIZER_TESTNET: ${{ secrets.CANTON_OKTA_AUTHORIZER_TESTNET }} - CANTON_OKTA_CLIENT_ID_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_ID_TESTNET }} - CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} - CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} - - - name: Upload devenv logs - if: always() && steps.params.outputs.ccip_env == 'devenv' - uses: ./.github/actions/upload-ccip-devenv-logs - with: - canton-path: . - test-package-dir: load - log-dump-suffix: ccip-load-tests - artifact-name: container-logs-ccip-load-tests - - - name: Update comment - if: always() - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - with: - repo-token: ${{ steps.setup-github-token.outputs.access-token }} - refresh-message-position: true - issue: ${{ github.event.client_payload.github.payload.issue.number }} - message-success: | - CCIP load test passed. - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - message-failure: | - CCIP load test failed. - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - message-cancelled: | - CCIP load test cancelled. - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/ccip-load-tests.yml b/.github/workflows/ccip-load-tests.yml index b60301990..8d8784750 100644 --- a/.github/workflows/ccip-load-tests.yml +++ b/.github/workflows/ccip-load-tests.yml @@ -22,12 +22,12 @@ on: - canton2evm-token - evm2canton-token message_rate: - description: "CANTON_LOAD_MESSAGE_RATE (e.g. 1/1s, 1/45s)" + description: "CANTON_LOAD_MESSAGE_RATE (e.g. 1/45s)" required: false - default: "1/1s" + default: "1/10s" type: string load_duration: - description: "CANTON_LOAD_DURATION (Go duration, e.g. 90s, 2m)" + description: "CANTON_LOAD_DURATION (e.g. 90s, 2m)" required: false default: "90s" type: string @@ -36,20 +36,15 @@ on: required: false default: "40m" type: string - canton_ref: - description: "chainlink-canton git ref (empty = workflow ref; devenv only)" - required: false - default: "" - type: string config_file: description: "CCIP_CONFIG_FILE basename under ccip/devenv (prod-testnet only)" required: false default: env-prod-testnet.ci.toml type: string skip_exec_confirm: - description: "CANTON_LOAD_SKIP_EXEC_CONFIRM (prod-testnet only)" + description: "CANTON_LOAD_SKIP_EXEC_CONFIRM" required: false - default: "true" + default: "false" type: string confirm_exec_timeout: description: "CANTON_CONFIRM_EXEC_TIMEOUT (prod-testnet only)" @@ -78,18 +73,14 @@ jobs: run: | if [ "${{ inputs.ccip_env }}" = "prod-testnet" ]; then echo "canton_path=." >> "$GITHUB_OUTPUT" - if [ "${{ inputs.message_rate }}" = "1/1s" ]; then - echo "message_rate=1/45s" >> "$GITHUB_OUTPUT" - else - echo "message_rate=${{ inputs.message_rate }}" >> "$GITHUB_OUTPUT" - fi + echo "message_rate=${{ inputs.message_rate }}" >> "$GITHUB_OUTPUT" if [ "${{ inputs.load_duration }}" = "90s" ]; then echo "load_duration=2m" >> "$GITHUB_OUTPUT" else 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 +88,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" @@ -108,8 +105,21 @@ jobs: if: inputs.ccip_env == 'devenv' uses: ./.github/actions/setup-ccip-devenv with: - canton-ref: ${{ inputs.canton_ref }} - canton-path: ${{ steps.prod_defaults.outputs.canton_path }} + ccip_env: ${{ inputs.ccip_env }} + direction: ${{ inputs.direction }} + message_rate: ${{ steps.prod_defaults.outputs.message_rate }} + load_duration: ${{ steps.prod_defaults.outputs.load_duration }} + test_timeout: ${{ steps.prod_defaults.outputs.test_timeout }} + config_file: ${{ inputs.config_file }} + canton_path: ${{ steps.prod_defaults.outputs.canton_path }} + 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 }} + CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} + CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} + TS_OAUTH_CLIENT_ID_CANTON: ${{ secrets.TS_OAUTH_CLIENT_ID_CANTON }} + TS_AUDIENCE_CANTON: ${{ secrets.TS_AUDIENCE_CANTON }} ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} jd-registry: ${{ secrets.JD_REGISTRY }} jd-image: ${{ secrets.JD_IMAGE }} diff --git a/.github/workflows/chatops.yml b/.github/workflows/chatops.yml index 6b0fae541..bad73e698 100644 --- a/.github/workflows/chatops.yml +++ b/.github/workflows/chatops.yml @@ -26,4 +26,3 @@ jobs: permission: write commands: | auto-fix - ccip-load diff --git a/.gitignore b/.gitignore index 161187529..4a9e611eb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,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 ce967f946..07b3e3cf7 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml go test -tags=prodledger -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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml CANTON_LOAD_SKIP_EXEC_CONFIRM=true go test -tags=prodledger -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$$' @@ -117,6 +126,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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml go test -tags=prodledger -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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml go test -tags=prodledger -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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml go test -tags=prodledger -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 && CCIP_CONFIG_FILE=env-prod-testnet.ci.toml go test -tags=prodledger -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..92a25692f 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -7,7 +7,7 @@ environment that includes Canton. Make sure to checkout [chainlink-ccv](https://github.com/smartcontractkit/chainlink-ccv/) in a parallel directory, i.e. so that chainlink-canton and chainlink-ccv have the same parent directory. This is needed so that we can build -needed docker images. Use ref **`695181f87033217a197386366e1ab563198f482e`** (matches `go.mod` and CI). +needed docker images. ## Spin up an environment @@ -30,19 +30,73 @@ 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. + +### Ledger bindings (`-tags=prodledger`) + +Devenv tests compile against `bindings/generated/latest` (current dev DAML module layout). Prod-testnet/mainnet contracts on ledger still use the older layout in `bindings/generated/v1_0_0`. + +Build prod-targeting tests with `-tags=prodledger` so devenv code resolves the correct template IDs (e.g. `CCIP.PerPartyRouter` vs `CCIP.RuntimeV1.PerPartyRouter`). Devenv runs omit the tag. + +| Target | Build tag | Bindings | +|---|---|---| +| devenv (default) | _(none)_ | `bindings/generated/latest` | +| prod-testnet / mainnet | `-tags=prodledger` | `bindings/generated/v1_0_0` | + +Implementation lives in [`ccip/devenv/ledgertarget/`](./ledgertarget/). + ## 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. -Schedule is configured via env vars (defaults are `1/1s` for 90s): +**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/10s` for 90s): | Env var | Form | Default | Meaning | |---|---|---|---| -| `CANTON_LOAD_MESSAGE_RATE` | `/` (e.g. `1/1s`, `1/20s`, `10/5m`) | `1/1s` | rate per rate-limit window | +| `CANTON_LOAD_MESSAGE_RATE` | `/` (e.g. `1/10s`, `1/20s`, `10/5m`) | `1/10s` | rate per rate-limit window | | `CANTON_LOAD_DURATION` | Go duration (e.g. `90s`, `10m`, `1h`) | `90s` | total runtime | Example — 1 message every 20 seconds for 10 minutes: @@ -64,9 +118,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. + +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: -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. +```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 +155,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`) -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. +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 +``` + +### 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 +202,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,61 +239,204 @@ 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 use the composite action (`.github/actions/ccip-load-test`) from **CCIP Canton Load Tests** (`ccip-load-tests.yml`) via manual `workflow_dispatch`. -| Input | Default | Maps to | -|---|---|---| -| `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 | +#### 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/10s` | `1/10s` | `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` | `false` | `true` for canton2evm only; `false` for token + evm2canton | `CANTON_LOAD_SKIP_EXEC_CONFIRM` | +| `confirm_exec_timeout` | — | `10m` | `CANTON_CONFIRM_EXEC_TIMEOUT` | + +**Devenv** spins up Docker via `setup-ccip-devenv` (same as CCIP E2E). **Prod-testnet** hits live Canton TestNet + Sepolia with no local devenv. + +#### 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` | -chainlink-ccv is pinned to **`695181f87033217a197386366e1ab563198f482e`** in `.github/actions/setup-ccip-devenv` and `go.mod` (same as CCIP E2E). +Devenv secrets unchanged: `CCV_IAM_ROLE`, `JD_REGISTRY`, `JD_IMAGE`. + +chainlink-ccv is pinned in `.github/actions/setup-ccip-devenv` (devenv only). ## Shortcut @@ -177,3 +446,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..c1bb3750a 100644 --- a/ccip/devenv/cldf.go +++ b/ccip/devenv/cldf.go @@ -4,14 +4,13 @@ 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" + 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-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 +23,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) + } + + 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) + } } - userResp, err := adminv2.NewUserManagementServiceClient(ledgerApiConn).GetUser(context.Background(), &adminv2.GetUserRequest{UserId: config.UserID}) + + 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, - }, - 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, + ValidatorAPIURL: resolveValidatorAPIURL(config.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 index c0234b457..a27016f47 100644 --- a/ccip/devenv/constants.go +++ b/ccip/devenv/constants.go @@ -1,6 +1,9 @@ package devenv -import "github.com/smartcontractkit/chainlink-ccv/protocol" +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-ccv/protocol" +) // Shared devenv test constants (message and token paths). @@ -10,12 +13,12 @@ const ( // 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 + CantonToEVMFeeAmount int64 = 5 // 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 + CantonToEVMTokenTransferFeeAmount int64 = 13 // Canton token amounts use 10-decimal fixed point (e.g. 1_000_000_000 = 0.1). CantonFixedPointScale int64 = 10_000_000_000 @@ -24,3 +27,9 @@ const ( // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. CantonToEVMTokenSequentialSends = 2 ) + +// EVMToCantonNoExecutionExecutor is Client.NO_EXECUTION_ADDRESS on EVM source chains. +// Canton dest execution is manual (ManuallyExecuteMessage); no EVM Executor supports Canton. +var EVMToCantonNoExecutionExecutor = protocol.UnknownAddress( + common.HexToAddress("0xEBa517d200000000000000000000000000000000").Bytes(), +) diff --git a/ccip/devenv/env-prod-testnet.ci.toml b/ccip/devenv/env-prod-testnet.ci.toml new file mode 100644 index 000000000..46d47c6f5 --- /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":["committeeverifier-tqkny@ccvOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"],"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://rpc-proxy-main-prod.tailec47a7.ts.net/gha-canton/16015286601757825753", internal_http_url = "https://rpc-proxy-main-prod.tailec47a7.ts.net/gha-canton/16015286601757825753", internal_ws_url = "wss://rpc-proxy-main-prod.tailec47a7.ts.net/gha-canton/16015286601757825753", ws_url = "wss://rpc-proxy-main-prod.tailec47a7.ts.net/gha-canton/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://testnet.cv1.bcy-v.metalhosts.com/api/json/" +validator_api_url = "https://testnet.cv1.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://testnet.cv1.bcy-v.metalhosts.com/api/json/" +validator_api_url = "https://testnet.cv1.bcy-v.metalhosts.com/api/validator/" +user_id = "" +jwt = "" diff --git a/ccip/devenv/fee_quoter_limits.go b/ccip/devenv/fee_quoter_limits.go index 4f96b1128..e65482286 100644 --- a/ccip/devenv/fee_quoter_limits.go +++ b/ccip/devenv/fee_quoter_limits.go @@ -11,7 +11,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/go-daml/pkg/service/ledger" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" + "github.com/smartcontractkit/chainlink-canton/ccip/devenv/ledgertarget" "github.com/smartcontractkit/chainlink-canton/contracts" feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter" "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" @@ -49,12 +49,12 @@ func (c *Chain) GetMaxPerMsgGasLimit(ctx context.Context, remoteChainSelector ui return uint32(cfg.MaxPerMsgGasLimit), nil } -func (c *Chain) feeQuoterDestConfig(ctx context.Context, remoteChainSelector uint64) (core.FeeQuoterDestChainConfig, error) { +func (c *Chain) feeQuoterDestConfig(ctx context.Context, remoteChainSelector uint64) (ledgertarget.FeeQuoterDestChainConfig, error) { if c.e == nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("canton chain environment is nil") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("canton chain environment is nil") } if len(c.chain.Participants) == 0 { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("no canton participants configured") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("no canton participants configured") } feeQuoterRef, err := c.e.DataStore.Addresses().Get(datastore.NewAddressRefKey( @@ -64,7 +64,7 @@ func (c *Chain) feeQuoterDestConfig(ctx context.Context, remoteChainSelector uin "", )) if err != nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("resolve FeeQuoter address: %w", err) + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("resolve FeeQuoter address: %w", err) } participant := c.chain.Participants[0] @@ -75,22 +75,22 @@ func (c *Chain) feeQuoterDestConfig(ctx context.Context, remoteChainSelector uin ctx, participant.LedgerServices.State, []string{party}, - core.FeeQuoter{}.GetTemplateID(), + ledgertarget.FeeQuoter{}.GetTemplateID(), feeQuoterAddress, ) if err != nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("find active FeeQuoter contract: %w", err) + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("find active FeeQuoter contract: %w", err) } createArgs := activeContract.GetCreatedEvent().GetCreateArguments() if createArgs == nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("FeeQuoter create arguments missing") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("FeeQuoter create arguments missing") } return destChainConfigFromFeeQuoterCreateArgs(createArgs, remoteChainSelector) } -func destChainConfigFromFeeQuoterCreateArgs(createArgs *apiv2.Record, remoteChainSelector uint64) (core.FeeQuoterDestChainConfig, error) { +func destChainConfigFromFeeQuoterCreateArgs(createArgs *apiv2.Record, remoteChainSelector uint64) (ledgertarget.FeeQuoterDestChainConfig, error) { selectorKey := strconv.FormatUint(remoteChainSelector, 10) for _, field := range createArgs.GetFields() { if field.GetLabel() != "destChainConfigs" { @@ -98,7 +98,7 @@ func destChainConfigFromFeeQuoterCreateArgs(createArgs *apiv2.Record, remoteChai } genMap := field.GetValue().GetGenMap() if genMap == nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs is not a GenMap") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs is not a GenMap") } for _, entry := range genMap.GetEntries() { key := strings.TrimSuffix(entry.GetKey().GetNumeric(), ".") @@ -107,18 +107,18 @@ func destChainConfigFromFeeQuoterCreateArgs(createArgs *apiv2.Record, remoteChai } record := entry.GetValue().GetRecord() if record == nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("dest chain config value is not a record") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("dest chain config value is not a record") } - var cfg core.FeeQuoterDestChainConfig + var cfg ledgertarget.FeeQuoterDestChainConfig if err := ledger.RecordToStruct(record, &cfg); err != nil { - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("parse dest chain config record: %w", err) + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("parse dest chain config record: %w", err) } return cfg, nil } - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("no FeeQuoter dest config for chain selector %d", remoteChainSelector) + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("no FeeQuoter dest config for chain selector %d", remoteChainSelector) } - return core.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs field not found on FeeQuoter") + return ledgertarget.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs field not found on FeeQuoter") } diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index c09ceaabb..23b517feb 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -23,7 +23,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-ccip/deployment/finality" "github.com/smartcontractkit/chainlink-ccip/deployment/lanes" @@ -44,21 +43,17 @@ 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/clientapi" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" - ccipevents "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/events" - 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" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_transfer_instruction_v1" + "github.com/smartcontractkit/chainlink-canton/ccip/devenv/ledgertarget" "github.com/smartcontractkit/chainlink-canton/contracts" cantonchangesets "github.com/smartcontractkit/chainlink-canton/deployment/changesets" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/committee_verifier" executor2 "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/executor" feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/global_config" - "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/token_admin_registry" dsutils "github.com/smartcontractkit/chainlink-canton/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" @@ -213,6 +208,7 @@ type Chain struct { // Send setup prerequisites routerAddress contracts.InstanceAddress senderAddress contracts.InstanceAddress + receiverAddress contracts.InstanceAddress registryAdmin string validatorAPIClients validatorAPIClients @@ -224,7 +220,7 @@ type Chain struct { sendsLeft uint64 // remaining sends 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 @@ -937,9 +933,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 @@ -1204,17 +1200,19 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint } } - maxDataBytes, err := c.GetMaxDataBytes(ctx, dest) - if err != nil { - return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( - "canton SendMessage: resolve max data bytes for destination %d: %w", dest, err, - ) - } - if len(fields.Data) > int(maxDataBytes) { - return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( - "canton SendMessage: payload exceeds destination maxDataBytes (%d > %d)", - len(fields.Data), maxDataBytes, - ) + if !c.isRemote() { + maxDataBytes, err := c.GetMaxDataBytes(ctx, dest) + if err != nil { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( + "canton SendMessage: resolve max data bytes for destination %d: %w", dest, err, + ) + } + if len(fields.Data) > int(maxDataBytes) { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( + "canton SendMessage: payload exceeds destination maxDataBytes (%d > %d)", + len(fields.Data), maxDataBytes, + ) + } } sendLog := c.logger.Info().Str("NextFeeCID", c.nextFeeCID) @@ -1271,34 +1269,34 @@ 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) } var disclosedContracts []*apiv2.DisclosedContract - sendArgs := ccipsender.Send{ + sendArgs := ledgertarget.Send{ RouterCid: types.CONTRACT_ID(routerCid), DestinationChainSelector: types.NUMERIC(strconv.FormatUint(dest, 10)), - Message: clientapi.Canton2AnyMessage{ + Message: ledgertarget.Canton2AnyMessage{ Receiver: types.TEXT(hex.EncodeToString(fields.Receiver)), Payload: types.TEXT(hex.EncodeToString(fields.Data)), - FeeToken: c.feeTokenInstrument, - ExtraArgs: clientapi.ExtraArgs{ - V3: &clientapi.GenericExtraArgsV3{ + FeeToken: ledgertarget.AdaptInstrumentId(c.feeTokenInstrument), + ExtraArgs: ledgertarget.ExtraArgs{ + V3: &ledgertarget.GenericExtraArgsV3{ GasLimit: types.INT64(opts.ExecutionGasLimit), - Executor: clientapi.ExecutorExtraArg{ - ExecutorUseDefault: &clientapi.ExecutorUseDefault{}, + Executor: ledgertarget.ExecutorExtraArg{ + ExecutorUseDefault: &ledgertarget.ExecutorUseDefault{}, }, }, }, }, - FeeTokenInput: ccipsender.FeeTokenInput{ + FeeTokenInput: ledgertarget.FeeTokenInput{ SenderInputCids: []types.CONTRACT_ID{types.CONTRACT_ID(c.nextFeeCID)}, FeeTokenTransferFactory: types.CONTRACT_ID(feeTransferFactorycid), - FeeTokenExtraArgs: splice_api_token_metadata_v1.ExtraArgs{ + FeeTokenExtraArgs: ledgertarget.AdaptExtraArgs(splice_api_token_metadata_v1.ExtraArgs{ Context: splice_api_token_metadata_v1.ChoiceContext{Values: testhelpers.ExtractChoiceContextValues(feeTransferFactoryChoiceContextValue)}, Meta: splice_api_token_metadata_v1.Metadata{Values: map[string]types.TEXT{}}, - }, + }), }, } @@ -1322,18 +1320,19 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("failed to get Token Pool Send disclosure for token pool at address %s: %w", tokenPoolAddress.String(), err) } - sendArgs.Message.TokenTransfer = &clientapi.TokenTransfer{ - Token: splice_api_token_holding_v1.InstrumentId{ + sendArgs.Message.TokenTransfer = &ledgertarget.TokenTransfer{ + Token: ledgertarget.AdaptInstrumentId(splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(outgoingMessage.TokenTransfer.Token.Admin), Id: types.TEXT(outgoingMessage.TokenTransfer.Token.Id), - }, + }), Amount: types.NUMERIC(outgoingMessage.TokenTransfer.Amount), } - sendArgs.TokenTransferInput = &ccipsender.TokenTransferInput{ - SenderInputCids: []types.CONTRACT_ID{types.CONTRACT_ID(c.nextTransferCID)}, - TokenPoolCid: types.CONTRACT_ID(tokenPoolSendDisclosure.ContractId), - Context: tokenPoolSendDisclosure.ChoiceContext, - } + tokenTransferInput := ledgertarget.NewTokenTransferInput( + []types.CONTRACT_ID{types.CONTRACT_ID(c.nextTransferCID)}, + types.CONTRACT_ID(tokenPoolSendDisclosure.ContractId), + tokenPoolSendDisclosure.ChoiceContext, + ) + sendArgs.TokenTransferInput = &tokenTransferInput tokenPoolRequiredCCVs = tokenPoolSendDisclosure.RequiredCCVs disclosedContracts = append(disclosedContracts, tokenPoolSendDisclosure.DisclosedContracts...) } @@ -1343,7 +1342,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("failed to get CCIP Send disclosure: %w", err) } - sendArgs.Context = ccipSendDisclosure.ChoiceContext + sendArgs.Context = ledgertarget.AdaptChoiceContext(ccipSendDisclosure.ChoiceContext) sendArgs.FeeTokenInput.FeeTokenConfigCid = types.CONTRACT_ID(ccipSendDisclosure.FeeTokenConfigCid) disclosedContracts = append(disclosedContracts, ccipSendDisclosure.DisclosedContracts...) @@ -1362,15 +1361,15 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("failed to get CCV Send disclosure for CCV %q: %w", v, err) } - sendArgs.CcvSendInputs = append(sendArgs.CcvSendInputs, ccipsender.CCVSendInput{ - CcvAddress: ccvSendDisclosure.Address.Binding(), - CcvCid: types.CONTRACT_ID(ccvSendDisclosure.ContractId), - Context: ccvSendDisclosure.ChoiceContext, - }) - sendArgs.Message.ExtraArgs.V3.Ccvs = append(sendArgs.Message.ExtraArgs.V3.Ccvs, clientapi.CCVExtraArg{ - CcvAddress: ccvSendDisclosure.Address.Binding(), - CcvArgs: "", - }) + sendArgs.CcvSendInputs = append(sendArgs.CcvSendInputs, ledgertarget.NewCCVSendInput( + ccvSendDisclosure.Address, + types.CONTRACT_ID(ccvSendDisclosure.ContractId), + ccvSendDisclosure.ChoiceContext, + )) + sendArgs.Message.ExtraArgs.V3.Ccvs = append(sendArgs.Message.ExtraArgs.V3.Ccvs, ledgertarget.NewCCVExtraArg( + ccvSendDisclosure.Address, + "", + )) allCCVs = append(allCCVs, ccvSendDisclosure.Address.InstanceAddress().Hex()) disclosedContracts = append(disclosedContracts, ccvSendDisclosure.DisclosedContracts...) } @@ -1388,10 +1387,11 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("failed to get Executor Send disclosure for Executor %q: %w", *ccipSendDisclosure.Executor, err) } - sendArgs.ExecutorInput = &ccipsender.ExecutorInput{ - ExecutorCid: types.CONTRACT_ID(executorSendDisclosure.ContractId), - Context: executorSendDisclosure.ChoiceContext, - } + executorInput := ledgertarget.NewExecutorInput( + types.CONTRACT_ID(executorSendDisclosure.ContractId), + executorSendDisclosure.ChoiceContext, + ) + sendArgs.ExecutorInput = &executorInput disclosedContracts = append(disclosedContracts, executorSendDisclosure.DisclosedContracts...) } @@ -1402,7 +1402,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint disclosedContracts = testhelpers.DeduplicateDisclosedContracts(disclosedContracts...) // Call CCIPSend - ccipSendReport, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Send, c.chain, contract.ChoiceInput[ccipsender.Send]{ + ccipSendReport, err := operations.ExecuteOperation(c.e.OperationsBundle, ledgertarget.SenderSendOperation, c.chain, contract.ChoiceInput[ledgertarget.Send]{ InstanceAddress: c.senderAddress, ParticipantIndex: clientIdx, Args: sendArgs, @@ -1424,7 +1424,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint { IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ TemplateFilter: &apiv2.TemplateFilter{ - TemplateId: contracts.TemplateIDFromBinding(ccipevents.CCIPMessageSent{}).ToLedgerIdentifier(), + TemplateId: contracts.TemplateIDFromBinding(ledgertarget.CCIPMessageSent{}).ToLedgerIdentifier(), IncludeCreatedEventBlob: true, }, }, @@ -1507,7 +1507,7 @@ type ccipMessageSentFromSendUpdate struct { // before this send; returned seqNo is either previousSeq+1 or the sequence from the encoded payload // when that path is present. func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSeq uint64) (ccipMessageSentFromSendUpdate, error) { - messageSentTemplateID := contracts.TemplateIDFromBinding(ccipevents.CCIPMessageSent{}) + messageSentTemplateID := contracts.TemplateIDFromBinding(ledgertarget.CCIPMessageSent{}) // Find CCIPMessageSent event in the events var created *apiv2.CreatedEvent @@ -1528,7 +1528,7 @@ func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSe return ccipMessageSentFromSendUpdate{}, fmt.Errorf("no CCIPMessageSent event found in sender transaction") } - parsed, err := bindings.UnmarshalCreatedEvent[ccipevents.CCIPMessageSent](created) + parsed, err := bindings.UnmarshalCreatedEvent[ledgertarget.CCIPMessageSent](created) if err != nil { return ccipMessageSentFromSendUpdate{}, fmt.Errorf("unmarshal CCIPMessageSent created event: %w", err) } diff --git a/ccip/devenv/ledgertarget/README.md b/ccip/devenv/ledgertarget/README.md new file mode 100644 index 000000000..e20fa6a25 --- /dev/null +++ b/ccip/devenv/ledgertarget/README.md @@ -0,0 +1,7 @@ +# ledgertarget + +Compile-time adapter for Canton ledger layout differences. + +Local devenv uses `bindings/generated/latest` (current DAML). Prod testnet/mainnet still runs `v1_0_0` contracts with different module paths and field names. + +Go build tags select the target: default for devenv, `-tags=prodledger` for prod. All tag-split bindings and operations live here so the rest of `devenv` stays tag-free. diff --git a/ccip/devenv/ledgertarget/context_adapt_devenv.go b/ccip/devenv/ledgertarget/context_adapt_devenv.go new file mode 100644 index 000000000..a4df7c085 --- /dev/null +++ b/ccip/devenv/ledgertarget/context_adapt_devenv.go @@ -0,0 +1,23 @@ +//go:build !prodledger + +package ledgertarget + +import ( + latestholding "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + latestmeta "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" +) + +// AdaptChoiceContext maps caller-side choice context into ledger Send bindings. +func AdaptChoiceContext(ctx latestmeta.ChoiceContext) latestmeta.ChoiceContext { + return ctx +} + +// AdaptExtraArgs maps caller-side token extra args into ledger Send bindings. +func AdaptExtraArgs(args latestmeta.ExtraArgs) latestmeta.ExtraArgs { + return args +} + +// AdaptInstrumentId maps caller-side instrument IDs into ledger Send bindings. +func AdaptInstrumentId(id latestholding.InstrumentId) latestholding.InstrumentId { + return id +} diff --git a/ccip/devenv/ledgertarget/context_adapt_prod.go b/ccip/devenv/ledgertarget/context_adapt_prod.go new file mode 100644 index 000000000..2632a5b5f --- /dev/null +++ b/ccip/devenv/ledgertarget/context_adapt_prod.go @@ -0,0 +1,50 @@ +//go:build prodledger + +package ledgertarget + +import ( + "encoding/json" + "fmt" + + latestholding "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + latestmeta "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" + prodholding "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/splice/splice_api_token_holding_v1" + prodmeta "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/splice/splice_api_token_metadata_v1" +) + +func adaptViaJSON[T any](in any) (T, error) { + var out T + data, err := json.Marshal(in) + if err != nil { + return out, fmt.Errorf("marshal for ledger binding adapt: %w", err) + } + if err := json.Unmarshal(data, &out); err != nil { + return out, fmt.Errorf("unmarshal for ledger binding adapt: %w", err) + } + + return out, nil +} + +func mustAdapt[T any](in any) T { + out, err := adaptViaJSON[T](in) + if err != nil { + panic(err) + } + + return out +} + +// AdaptChoiceContext maps caller-side choice context into v1_0_0 ledger Send bindings. +func AdaptChoiceContext(ctx latestmeta.ChoiceContext) prodmeta.ChoiceContext { + return mustAdapt[prodmeta.ChoiceContext](ctx) +} + +// AdaptExtraArgs maps caller-side token extra args into v1_0_0 ledger Send bindings. +func AdaptExtraArgs(args latestmeta.ExtraArgs) prodmeta.ExtraArgs { + return mustAdapt[prodmeta.ExtraArgs](args) +} + +// AdaptInstrumentId maps caller-side instrument IDs into v1_0_0 ledger Send bindings. +func AdaptInstrumentId(id latestholding.InstrumentId) prodholding.InstrumentId { + return mustAdapt[prodholding.InstrumentId](id) +} diff --git a/ccip/devenv/ledgertarget/doc.go b/ccip/devenv/ledgertarget/doc.go new file mode 100644 index 000000000..50449f968 --- /dev/null +++ b/ccip/devenv/ledgertarget/doc.go @@ -0,0 +1,7 @@ +// Package ledgertarget selects CCIP ledger bindings and operations at compile time. +// +// Devenv (default): bindings/generated/latest — current dev DAML layout. +// +// Prod / deployed ledger: go build -tags=prodledger +// Uses bindings/generated/v1_0_0 — template IDs matching Canton TestNet/mainnet today. +package ledgertarget diff --git a/ccip/devenv/ledgertarget/exports_devenv.go b/ccip/devenv/ledgertarget/exports_devenv.go new file mode 100644 index 000000000..f7b49cffd --- /dev/null +++ b/ccip/devenv/ledgertarget/exports_devenv.go @@ -0,0 +1,61 @@ +//go:build !prodledger + +package ledgertarget + +import ( + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipapi" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipcodec" + rt "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/clientapi" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/events" + ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" + chainlinkapi "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/chainlink/chainlinkapi" +) + +type ( + CreateRouter = rt.CreateRouter + PerPartyRouter = rt.PerPartyRouter + + RawInstanceAddress = chainlinkapi.RawInstanceAddress + + FinalityConfig = ccipcodec.FinalityConfig + + ExecutionStateChanged = events.ExecutionStateChanged + CCIPMessageSent = events.CCIPMessageSent + + Canton2AnyMessage = clientapi.Canton2AnyMessage + ExtraArgs = clientapi.ExtraArgs + GenericExtraArgsV3 = clientapi.GenericExtraArgsV3 + ExecutorExtraArg = clientapi.ExecutorExtraArg + ExecutorUseDefault = clientapi.ExecutorUseDefault + TokenTransfer = clientapi.TokenTransfer + CCVExtraArg = clientapi.CCVExtraArg + + ApplyPriceUpdatersUpdate = core.ApplyPriceUpdatersUpdate + UpdatePrices = core.UpdatePrices + PriceUpdates = core.PriceUpdates + TokenPriceUpdate = core.TokenPriceUpdate + FeeQuoter = core.FeeQuoter + FeeQuoterDestChainConfig = core.FeeQuoterDestChainConfig + + CCIPReceiver = ccipreceiver.CCIPReceiver + CCIPSender = ccipsender.CCIPSender + Send = ccipsender.Send + FeeTokenInput = ccipsender.FeeTokenInput + TokenTransferInput = ccipsender.TokenTransferInput + CCVSendInput = ccipsender.CCVSendInput + ExecutorInput = ccipsender.ExecutorInput + + ReceiverExecute = ccipreceiver.Execute + ReceiverCCVInput = ccipreceiver.CCVInput + ReceiverTokenTransferInput = ccipreceiver.TokenTransferInput +) + +const ( + MessageExecutionStateUNTOUCHED = ccipapi.MessageExecutionStateUNTOUCHED + MessageExecutionStateIN_PROGRESS = ccipapi.MessageExecutionStateIN_PROGRESS + MessageExecutionStateSUCCESS = ccipapi.MessageExecutionStateSUCCESS + MessageExecutionStateFAILURE = ccipapi.MessageExecutionStateFAILURE +) diff --git a/ccip/devenv/ledgertarget/exports_prod.go b/ccip/devenv/ledgertarget/exports_prod.go new file mode 100644 index 000000000..4e1c82559 --- /dev/null +++ b/ccip/devenv/ledgertarget/exports_prod.go @@ -0,0 +1,57 @@ +//go:build prodledger + +package ledgertarget + +import ( + rt "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/ccipruntime" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/core" + ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/sender" + chainlinkapi "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/chainlink/chainlinkapi" +) + +type ( + CreateRouter = rt.CreateRouter + PerPartyRouter = rt.PerPartyRouter + + RawInstanceAddress = chainlinkapi.RawInstanceAddress + + FinalityConfig = core.FinalityConfig + + ExecutionStateChanged = core.ExecutionStateChanged + CCIPMessageSent = core.CCIPMessageSent + + Canton2AnyMessage = core.Canton2AnyMessage + ExtraArgs = core.ExtraArgs + GenericExtraArgsV3 = core.GenericExtraArgsV3 + ExecutorExtraArg = core.ExecutorExtraArg + ExecutorUseDefault = core.ExecutorUseDefault + TokenTransfer = core.TokenTransfer + CCVExtraArg = core.CCVExtraArg + + ApplyPriceUpdatersUpdate = core.ApplyPriceUpdatersUpdate + UpdatePrices = core.UpdatePrices + PriceUpdates = core.PriceUpdates + TokenPriceUpdate = core.TokenPriceUpdate + FeeQuoter = core.FeeQuoter + FeeQuoterDestChainConfig = core.FeeQuoterDestChainConfig + + CCIPReceiver = ccipreceiver.CCIPReceiver + CCIPSender = ccipsender.CCIPSender + Send = ccipsender.Send + FeeTokenInput = ccipsender.FeeTokenInput + TokenTransferInput = ccipsender.TokenTransferInput + CCVSendInput = ccipsender.CCVSendInput + ExecutorInput = ccipsender.ExecutorInput + + ReceiverExecute = ccipreceiver.Execute + ReceiverCCVInput = ccipreceiver.CCVInput + ReceiverTokenTransferInput = ccipreceiver.TokenTransferInput +) + +const ( + MessageExecutionStateUNTOUCHED = core.MessageExecutionStateUNTOUCHED + MessageExecutionStateIN_PROGRESS = core.MessageExecutionStateIN_PROGRESS + MessageExecutionStateSUCCESS = core.MessageExecutionStateSUCCESS + MessageExecutionStateFAILURE = core.MessageExecutionStateFAILURE +) diff --git a/ccip/devenv/ledgertarget/inputs_devenv.go b/ccip/devenv/ledgertarget/inputs_devenv.go new file mode 100644 index 000000000..5912659fd --- /dev/null +++ b/ccip/devenv/ledgertarget/inputs_devenv.go @@ -0,0 +1,65 @@ +//go:build !prodledger + +package ledgertarget + +import ( + "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/chainlink/chainlinkapi" + latestmeta "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" + "github.com/smartcontractkit/chainlink-canton/contracts" +) + +func rawInstanceAddressBinding(addr contracts.RawInstanceAddress) chainlinkapi.RawInstanceAddress { + return chainlinkapi.RawInstanceAddress{Unpack: types.TEXT(addr)} +} + +func NewCCVSendInput(ccvAddress contracts.RawInstanceAddress, ccvCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) CCVSendInput { + return CCVSendInput{ + CcvAddress: rawInstanceAddressBinding(ccvAddress), + CcvCid: ccvCid, + Context: AdaptChoiceContext(ctx), + } +} + +func NewCCVExtraArg(ccvAddress contracts.RawInstanceAddress, ccvArgs string) CCVExtraArg { + return CCVExtraArg{ + CcvAddress: rawInstanceAddressBinding(ccvAddress), + CcvArgs: types.TEXT(ccvArgs), + } +} + +func NewExecutorInput(executorCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) ExecutorInput { + return ExecutorInput{ + ExecutorCid: executorCid, + Context: AdaptChoiceContext(ctx), + } +} + +func NewTokenTransferInput(senderInputCids []types.CONTRACT_ID, tokenPoolCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) TokenTransferInput { + return TokenTransferInput{ + SenderInputCids: senderInputCids, + TokenPoolCid: tokenPoolCid, + Context: AdaptChoiceContext(ctx), + } +} + +func NewReceiverCCVInput(ccvCid types.CONTRACT_ID, verifierResults types.TEXT, ctx latestmeta.ChoiceContext) ReceiverCCVInput { + return ReceiverCCVInput{ + CcvCid: ccvCid, + VerifierResults: verifierResults, + Context: AdaptChoiceContext(ctx), + } +} + +func NewReceiverTokenTransferInput( + tokenPoolCid types.CONTRACT_ID, + tokenReceiverParty types.PARTY, + ctx latestmeta.ChoiceContext, +) ReceiverTokenTransferInput { + return ReceiverTokenTransferInput{ + TokenPoolCid: tokenPoolCid, + TokenReceiverParty: tokenReceiverParty, + Context: AdaptChoiceContext(ctx), + } +} diff --git a/ccip/devenv/ledgertarget/inputs_prod.go b/ccip/devenv/ledgertarget/inputs_prod.go new file mode 100644 index 000000000..c645d77ff --- /dev/null +++ b/ccip/devenv/ledgertarget/inputs_prod.go @@ -0,0 +1,64 @@ +//go:build prodledger + +package ledgertarget + +import ( + "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/chainlink/chainlinkapi" + latestmeta "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" + "github.com/smartcontractkit/chainlink-canton/contracts" + "github.com/smartcontractkit/go-daml/pkg/types" +) + +func rawInstanceAddressBinding(addr contracts.RawInstanceAddress) chainlinkapi.RawInstanceAddress { + return chainlinkapi.RawInstanceAddress{Unpack: types.TEXT(addr)} +} + +func NewCCVSendInput(ccvAddress contracts.RawInstanceAddress, ccvCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) CCVSendInput { + return CCVSendInput{ + CcvAddress: rawInstanceAddressBinding(ccvAddress), + CcvCid: ccvCid, + CcvExtraContext: AdaptChoiceContext(ctx), + } +} + +func NewCCVExtraArg(ccvAddress contracts.RawInstanceAddress, ccvArgs string) CCVExtraArg { + return CCVExtraArg{ + CcvAddress: rawInstanceAddressBinding(ccvAddress), + CcvArgs: types.TEXT(ccvArgs), + } +} + +func NewExecutorInput(executorCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) ExecutorInput { + return ExecutorInput{ + ExecutorCid: executorCid, + ExecutorExtraContext: AdaptChoiceContext(ctx), + } +} + +func NewTokenTransferInput(senderInputCids []types.CONTRACT_ID, tokenPoolCid types.CONTRACT_ID, ctx latestmeta.ChoiceContext) TokenTransferInput { + return TokenTransferInput{ + SenderInputCids: senderInputCids, + TokenPoolCid: tokenPoolCid, + PoolExtraContext: AdaptChoiceContext(ctx), + } +} + +func NewReceiverCCVInput(ccvCid types.CONTRACT_ID, verifierResults types.TEXT, ctx latestmeta.ChoiceContext) ReceiverCCVInput { + return ReceiverCCVInput{ + CcvCid: ccvCid, + VerifierResults: verifierResults, + CcvExtraContext: AdaptChoiceContext(ctx), + } +} + +func NewReceiverTokenTransferInput( + tokenPoolCid types.CONTRACT_ID, + tokenReceiverParty types.PARTY, + ctx latestmeta.ChoiceContext, +) ReceiverTokenTransferInput { + return ReceiverTokenTransferInput{ + TokenPoolCid: tokenPoolCid, + TokenReceiverParty: tokenReceiverParty, + PoolExtraContext: AdaptChoiceContext(ctx), + } +} diff --git a/ccip/devenv/ledgertarget/ops_devenv.go b/ccip/devenv/ledgertarget/ops_devenv.go new file mode 100644 index 000000000..be76f3ea6 --- /dev/null +++ b/ccip/devenv/ledgertarget/ops_devenv.go @@ -0,0 +1,17 @@ +//go:build !prodledger + +package ledgertarget + +import ( + pprof "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" + receiverop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/receiver" + senderop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" +) + +var ( + CreateRouterOperation = pprof.CreateRouter + ReceiverDeployOperation = receiverop.Deploy + SenderDeployOperation = senderop.Deploy + ReceiverExecuteOperation = receiverop.Execute + SenderSendOperation = senderop.Send +) diff --git a/ccip/devenv/ledgertarget/ops_prod.go b/ccip/devenv/ledgertarget/ops_prod.go new file mode 100644 index 000000000..3ba81c363 --- /dev/null +++ b/ccip/devenv/ledgertarget/ops_prod.go @@ -0,0 +1,100 @@ +//go:build prodledger + +package ledgertarget + +import ( + "errors" + + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + rt "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/ccipruntime" + ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/v1_0_0/ccip/sender" + "github.com/smartcontractkit/chainlink-canton/contracts" + pprof "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" + receiverop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/receiver" + senderop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" + "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" +) + +var prodCreateRouterEncoder = rt.NewContract("", "CCIP.PerPartyRouter", "PerPartyRouterFactory").Encoder() + +var CreateRouterOperation = contract.NewExercise(contract.ExerciseParams[rt.CreateRouter]{ + Name: "canton/ccip/per_party_router_factory/create_router", + Version: pprof.Version, + Description: "Creates a new PerPartyRouter using the PerPartyRouterFactory", + ContractType: pprof.ContractType, + Validate: func(input rt.CreateRouter) error { + if input.InstanceId == "" { + return errors.New("instance ID cannot be empty") + } + if input.PartyOwner == "" { + return errors.New("router owner cannot be empty") + } + + return nil + }, + Template: rt.PerPartyRouterFactory{}, + Method: rt.PerPartyRouterFactory{}.CreateRouter, + EncodeMethod: prodCreateRouterEncoder.CreateRouter, +}) + +var ReceiverDeployOperation = contract.NewDeploy(contract.DeployParams[ccipreceiver.CCIPReceiver]{ + Name: "canton/ccip/receiver/deploy", + TypeAndVersion: deployment.NewTypeAndVersion(receiverop.ContractType, *receiverop.Version), + Description: "Deploys a CCIP Receiver contract on Canton", + Validate: func(template ccipreceiver.CCIPReceiver) error { + if template.Owner == "" { + return errors.New("owner cannot be empty") + } + + return nil + }, + PackageName: string(contracts.CCIPReceiver), + Prefix: "ccipreceiver", +}) + +var SenderDeployOperation = contract.NewDeploy(contract.DeployParams[ccipsender.CCIPSender]{ + Name: "canton/ccip/sender/deploy", + TypeAndVersion: deployment.NewTypeAndVersion(senderop.ContractType, *senderop.Version), + Description: "Deploys a CCIP Sender contract on Canton", + Validate: func(template ccipsender.CCIPSender) error { + if template.Owner == "" { + return errors.New("owner cannot be empty") + } + + return nil + }, + PackageName: string(contracts.CCIPSender), + Prefix: "ccipsender", +}) + +var prodReceiverEncoder = ccipreceiver.NewContract("", "CCIP.CCIPReceiver", "CCIPReceiver").Encoder() + +var ReceiverExecuteOperation = contract.NewExercise(contract.ExerciseParams[ccipreceiver.Execute]{ + Name: "canton/ccip/receiver/execute", + Version: receiverop.Version, + Description: "Calls the Execute choice on a CCIP Receiver contract", + ContractType: receiverop.ContractType, + Validate: func(input ccipreceiver.Execute) error { + return nil + }, + Template: ccipreceiver.CCIPReceiver{}, + Method: ccipreceiver.CCIPReceiver{}.Execute, + EncodeMethod: prodReceiverEncoder.Execute, +}) + +var prodSenderEncoder = ccipsender.NewContract("", "CCIP.CCIPSender", "CCIPSender").Encoder() + +var SenderSendOperation = contract.NewExercise(contract.ExerciseParams[ccipsender.Send]{ + Name: "canton/ccip/sender/send", + Version: senderop.Version, + Description: "Calls the Send choice on a CCIP Sender contract", + ContractType: senderop.ContractType, + Validate: func(input ccipsender.Send) error { + return nil + }, + Template: ccipsender.CCIPSender{}, + Method: ccipsender.CCIPSender{}.Send, + EncodeMethod: prodSenderEncoder.Send, +}) diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index df58d74d9..83ac88012 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -20,22 +20,12 @@ import ( "github.com/smartcontractkit/go-daml/pkg/types" "github.com/smartcontractkit/chainlink-canton/bindings" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipapi" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipcodec" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/events" - 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/ccip/devenv/ledgertarget" "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) @@ -44,67 +34,144 @@ func instanceIDFromEnv(key, defaultID string) contracts.InstanceID { return contracts.InstanceID(defaultID) } -// 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) +// 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, - per_party_router_factory.CreateRouter, + ledgertarget.CreateRouterOperation, c.chain, - contract.ChoiceInput[ccipruntime.CreateRouter]{ + contract.ChoiceInput[ledgertarget.CreateRouter]{ InstanceAddress: perPartyRouterFactoryDisclosure.Address.InstanceAddress(), ContractID: perPartyRouterFactoryDisclosure.ContractId, ParticipantIndex: c.clientParticipantIndex(), DisclosedContracts: contract.DisclosedContractsFromProto(perPartyRouterFactoryDisclosure.DisclosedContracts), - Args: ccipruntime.CreateRouter{ - PartyOwner: types.PARTY(partyOwner), + Args: ledgertarget.CreateRouter{ + PartyOwner: types.PARTY(partyId), InstanceId: types.TEXT(routerInstanceID.String()), }, }, - operations.WithForceExecute[contract.ChoiceInput[ccipruntime.CreateRouter], canton.Chain](), + operations.WithForceExecute[contract.ChoiceInput[ledgertarget.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) } - _, err = contract.FindActiveContractIDByInstanceAddress( - ctx, - clientParticipant.LedgerServices.State, - []string{partyOwner}, - ccipruntime.PerPartyRouter{}.GetTemplateID(), - routerAddress, - ) + 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(ledgertarget.PerPartyRouter{}).ToLedgerIdentifier() + activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf( - "per-party router not found at %s for party %s after CreateRouter: %w", - routerAddress, partyOwner, err, - ) + 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 + } + + 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 routerAddress, nil + return cid, nil } // DeployCCIPSender returns a sender-owned CCIPSender instance address, deploying only when missing. @@ -121,17 +188,17 @@ func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Partici ctx, participant.LedgerServices.State, contract.LedgerQueryParties(participant), - ccipsender.CCIPSender{}.GetTemplateID(), + ledgertarget.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]{ + _, err := operations.ExecuteOperation(c.e.OperationsBundle, ledgertarget.SenderDeployOperation, c.chain, contract.DeployInput[ledgertarget.CCIPSender]{ Qualifier: nil, ParticipantIndex: c.clientParticipantIndex(), - Template: ccipsender.CCIPSender{ + Template: ledgertarget.CCIPSender{ InstanceId: types.TEXT(instanceID), Owner: types.PARTY(partyId), }, @@ -142,7 +209,7 @@ func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Partici ctx, participant.LedgerServices.State, contract.LedgerQueryParties(participant), - ccipsender.CCIPSender{}.GetTemplateID(), + ledgertarget.CCIPSender{}.GetTemplateID(), senderAddress, ); findErr == nil { c.senderAddress = senderAddress @@ -157,18 +224,42 @@ func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Partici } 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), + ledgertarget.CCIPReceiver{}.GetTemplateID(), + receiverAddress, + ); err == nil { + c.receiverAddress = receiverAddress + return receiverAddress, nil + } + + requiredCCVs, err := receiverRequiredCCVsFromEnv() + if err != nil { + return contracts.InstanceAddress{}, err + } + 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, ledgertarget.ReceiverDeployOperation, c.chain, contract.DeployInput[ledgertarget.CCIPReceiver]{ Qualifier: nil, ParticipantIndex: c.clientParticipantIndex(), - Template: ccipreceiver.CCIPReceiver{ + Template: ledgertarget.CCIPReceiver{ + InstanceId: types.TEXT(instanceID), Owner: types.PARTY(partyId), - RequiredCCVs: nil, + RequiredCCVs: requiredCCVs, OptionalCCVs: nil, OptionalThreshold: 0, ReceiverFinalityConfig: finalityConfig, @@ -176,13 +267,40 @@ 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), + ledgertarget.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 } +func receiverRequiredCCVsFromEnv() ([]ledgertarget.RawInstanceAddress, error) { + raw := strings.TrimSpace(os.Getenv("CANTON_RECEIVER_REQUIRED_CCV")) + if raw == "" { + return nil, nil + } + + parsed, err := contracts.RawInstanceAddressFromString(raw) + if err != nil { + return nil, fmt.Errorf("parse CANTON_RECEIVER_REQUIRED_CCV: %w", err) + } + + return []ledgertarget.RawInstanceAddress{ + {Unpack: types.TEXT(parsed.String())}, + }, nil +} + // ManuallyExecuteMessage implements cciptestinterfaces.CCIP17. func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Message, gasLimit uint64, verifiers []protocol.UnknownAddress, verifierResults [][]byte) (cciptestinterfaces.ExecutionStateChangedEvent, error) { participant, clientIdx, err := c.ClientParticipant() @@ -220,12 +338,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") @@ -235,12 +350,12 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes if err != nil { return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to get CCIP execute disclosure: %w", err) } - executeArgs := ccipreceiver.Execute{ - Context: ccipExecuteDisclosure.ChoiceContext, + executeArgs := ledgertarget.ReceiverExecute{ + Context: ledgertarget.AdaptChoiceContext(ccipExecuteDisclosure.ChoiceContext), RouterCid: types.CONTRACT_ID(routerCid), EncodedMessage: types.TEXT(encodedMessageHex), TokenTransfer: nil, - CcvInputs: make([]ccipreceiver.CCVInput, len(verifiers)), + CcvInputs: make([]ledgertarget.ReceiverCCVInput, len(verifiers)), } disclosedContracts := ccipExecuteDisclosure.DisclosedContracts @@ -255,11 +370,11 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to get CCV execute disclosure for verifier %s: %w", verifier.String(), err) } - executeArgs.CcvInputs[i] = ccipreceiver.CCVInput{ - CcvCid: types.CONTRACT_ID(ccvExecuteDisclosure.ContractId), - VerifierResults: types.TEXT(hex.EncodeToString(vr)), - Context: ccvExecuteDisclosure.ChoiceContext, - } + executeArgs.CcvInputs[i] = ledgertarget.NewReceiverCCVInput( + types.CONTRACT_ID(ccvExecuteDisclosure.ContractId), + types.TEXT(hex.EncodeToString(vr)), + ccvExecuteDisclosure.ChoiceContext, + ) disclosedContracts = append(disclosedContracts, ccvExecuteDisclosure.DisclosedContracts...) } @@ -276,11 +391,12 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to get token pool execute disclosure: %w", err) } - executeArgs.TokenTransfer = &ccipreceiver.TokenTransferInput{ - TokenPoolCid: types.CONTRACT_ID(tokenPoolDisclosure.ContractId), - TokenReceiverParty: types.PARTY(executingParty), - Context: tokenPoolDisclosure.ChoiceContext, - } + tokenTransfer := ledgertarget.NewReceiverTokenTransferInput( + types.CONTRACT_ID(tokenPoolDisclosure.ContractId), + types.PARTY(executingParty), + tokenPoolDisclosure.ChoiceContext, + ) + executeArgs.TokenTransfer = &tokenTransfer disclosedContracts = append(disclosedContracts, tokenPoolDisclosure.DisclosedContracts...) } @@ -291,7 +407,7 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes Str("Receiver", hex.EncodeToString(message.Receiver)). Msg("Executing message...") - executeReport, err := operations.ExecuteOperation(c.e.OperationsBundle, receiver.Execute, c.chain, contract.ChoiceInput[ccipreceiver.Execute]{ + executeReport, err := operations.ExecuteOperation(c.e.OperationsBundle, ledgertarget.ReceiverExecuteOperation, c.chain, contract.ChoiceInput[ledgertarget.ReceiverExecute]{ InstanceAddress: receiverAddress, ParticipantIndex: clientIdx, Args: executeArgs, @@ -353,7 +469,7 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes } // Get ExecutionStateChangedEvent from events - expectedTemplateID := events.ExecutionStateChanged{}.GetTemplateID() + expectedTemplateID := ledgertarget.ExecutionStateChanged{}.GetTemplateID() for _, event := range update.GetTransaction().GetEvents() { //nolint:nestif // need to check if all of these are nil if createdEvent := event.GetCreated(); createdEvent != nil { @@ -379,7 +495,7 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes // parseExecutionStateChangedEvent parses a common.ExecutionStateChanged event from a Daml CreatedEvent and converts it to cciptestinterfaces.ExecutionStateChangedEvent. func parseExecutionStateChangedEvent(event *apiv2.CreatedEvent) (cciptestinterfaces.ExecutionStateChangedEvent, error) { - executionStateChanged, err := bindings.UnmarshalCreatedEvent[events.ExecutionStateChanged](event) + executionStateChanged, err := bindings.UnmarshalCreatedEvent[ledgertarget.ExecutionStateChanged](event) if err != nil { return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to unmarshal ExecutionStateChanged event: %w", err) } @@ -404,13 +520,13 @@ func parseExecutionStateChangedEvent(event *apiv2.CreatedEvent) (cciptestinterfa // Execution state var executionState cciptestinterfaces.MessageExecutionState switch executionStateChanged.Event.State { - case ccipapi.MessageExecutionStateUNTOUCHED: + case ledgertarget.MessageExecutionStateUNTOUCHED: executionState = cciptestinterfaces.ExecutionStateUntouched - case ccipapi.MessageExecutionStateIN_PROGRESS: + case ledgertarget.MessageExecutionStateIN_PROGRESS: executionState = cciptestinterfaces.ExecutionStateInProgress - case ccipapi.MessageExecutionStateSUCCESS: + case ledgertarget.MessageExecutionStateSUCCESS: executionState = cciptestinterfaces.ExecutionStateSuccess - case ccipapi.MessageExecutionStateFAILURE: + case ledgertarget.MessageExecutionStateFAILURE: executionState = cciptestinterfaces.ExecutionStateFailure default: return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("unknown execution state %q", executionStateChanged.Event.State) @@ -455,12 +571,12 @@ 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(events.ExecutionStateChanged{}).ToLedgerIdentifier() + templateID := contracts.TemplateIDFromBinding(ledgertarget.ExecutionStateChanged{}).ToLedgerIdentifier() activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) if err != nil { return cciptestinterfaces.ExecutionStateChangedEvent{}, false, fmt.Errorf("findExistingExecutionState: list active ExecutionStateChanged contracts: %w", err) @@ -502,8 +618,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") @@ -519,9 +635,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)) } @@ -539,32 +652,43 @@ func (c *Chain) fetchVerifierResult(ctx context.Context, messageID protocol.Byte }, nil } -func encodeReceiverFinalityConfig(finality int64) (ccipcodec.FinalityConfig, error) { +func encodeReceiverFinalityConfig(finality int64) (ledgertarget.FinalityConfig, error) { switch { case finality < 0: - return ccipcodec.FinalityConfig{}, fmt.Errorf("invalid finality %d: must be non-negative", finality) + return ledgertarget.FinalityConfig{}, fmt.Errorf("invalid finality %d: must be non-negative", finality) case finality == 0: - return ccipcodec.FinalityConfig{WaitForFinality: &types.UNIT{}}, nil + return ledgertarget.FinalityConfig{WaitForFinality: &types.UNIT{}}, nil case finality == 0x00010000: - return ccipcodec.FinalityConfig{WaitForSafe: &types.UNIT{}}, nil + return ledgertarget.FinalityConfig{WaitForSafe: &types.UNIT{}}, nil case finality > 0xFFFF: - return ccipcodec.FinalityConfig{}, fmt.Errorf("invalid finality %d: max supported block depth is 65535", finality) + return ledgertarget.FinalityConfig{}, fmt.Errorf("invalid finality %d: max supported block depth is 65535", finality) default: - return ccipcodec.FinalityConfig{BlockDepth: new(types.INT64(finality))}, nil + return ledgertarget.FinalityConfig{BlockDepth: new(types.INT64(finality))}, nil } } -// 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/participant.go b/ccip/devenv/participant.go index abea6ed7b..c180383b7 100644 --- a/ccip/devenv/participant.go +++ b/ccip/devenv/participant.go @@ -42,3 +42,10 @@ func (c *Chain) clientParticipantIndex() int { return OwnerParticipantIndex } + +// isRemote reports whether this chain targets live testnet infrastructure with +// only a client party. Remote callers lack FeeQuoter ACS visibility; limits +// must come from EDS disclosures or on-chain validation instead. +func (c *Chain) isRemote() bool { + return len(c.chain.Participants) <= 1 +} diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index da0a4a118..ee8da5ff4 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -4,20 +4,17 @@ import ( "math/big" "testing" - "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" "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "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" ) @@ -54,13 +51,6 @@ func TestEVM2Canton_Basic(t *testing.T) { common.DefaultCommitteeVerifierQualifier, "source committee verifier", ) - executorAddress := devenvtests.GetContractAddress( - t, ds, srcSelector, - datastore.ContractType(sequences.ExecutorProxyType), - proxy.Deploy.Version(), - common.DefaultExecutorQualifier, - "source executor", - ) t.Run("message transfer", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) @@ -70,7 +60,7 @@ func TestEVM2Canton_Basic(t *testing.T) { sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello message transfer from EVM!"), - }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddress, ccvAddr), 3) + }, devenvtests.EVMToCantonMessageOptions(200_000, cantondevenv.EVMToCantonFinalityConfig, ccvAddr), 3) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) @@ -119,18 +109,7 @@ func TestEVM2Canton_Basic(t *testing.T) { Amount: lane.TransferAmount, TokenAddress: srcToken, }, - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: lane.ExecutionGasLimit, - FinalityConfig: lane.FinalityConfig, - Executor: executorAddress, - CCVs: []protocol.CCV{ - { - CCVAddress: ccvAddr, - Args: []byte{}, - ArgsLen: 0, - }, - }, - }, 3) + }, devenvtests.EVMToCantonMessageOptions(lane.ExecutionGasLimit, lane.FinalityConfig, ccvAddr), 3) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) require.NotNil(t, sendMessageResult.Message.TokenTransfer) 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 index ed2a2b019..aff6c84f6 100644 --- a/ccip/devenv/tests/env.go +++ b/ccip/devenv/tests/env.go @@ -55,7 +55,7 @@ func (e CCIPEnv) ConfigPath() string { case EnvDevenv: return "env-canton-evm-out.toml" case EnvProdTestnet: - return "env-prod-testnet-out.toml" + return "env-prod-testnet.ci.toml" default: return "" } diff --git a/ccip/devenv/tests/env_test.go b/ccip/devenv/tests/env_test.go index 190a50ac3..1f72e840b 100644 --- a/ccip/devenv/tests/env_test.go +++ b/ccip/devenv/tests/env_test.go @@ -10,11 +10,11 @@ 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, "env-prod-testnet.local.toml") + require.Equal(t, "env-prod-testnet.local.toml", ResolveConfigPath(EnvProdTestnet)) + t.Setenv(envConfigFile, "ccip/devenv/custom.toml") require.Equal(t, "env-canton-evm-out.toml", ResolveConfigPath(EnvDevenv)) require.Equal(t, "custom.toml", ResolveConfigPath(EnvProdTestnet)) diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 257c6e10d..6fa15e406 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -11,18 +11,24 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" gethcrypto "github.com/ethereum/go-ethereum/crypto" chainsel "github.com/smartcontractkit/chain-selectors" + routeroperations "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + devenvcommon "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/tcapi" "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/erc20" "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/deployment/operations/ccip/committee_verifier" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) @@ -72,12 +78,17 @@ func ConfirmSendTimeout(t *testing.T, env CCIPEnv) time.Duration { return timeout } -// EVMToCantonMessageOptions returns standard message options for EVM→Canton sends with FTF. -func EVMToCantonMessageOptions(gasLimit uint32, executor, ccvAddr protocol.UnknownAddress) cciptestinterfaces.MessageOptions { +// EVMToCantonMessageOptions returns message options for EVM→Canton sends. +// Execution on Canton is manual; the EVM Executor is skipped via NO_EXECUTION_ADDRESS. +func EVMToCantonMessageOptions( + gasLimit uint32, + finality protocol.Finality, + ccvAddr protocol.UnknownAddress, +) cciptestinterfaces.MessageOptions { return cciptestinterfaces.MessageOptions{ ExecutionGasLimit: gasLimit, - FinalityConfig: cantondevenv.EVMToCantonFinalityConfig, - Executor: executor, + FinalityConfig: finality, + Executor: cantondevenv.EVMToCantonNoExecutionExecutor, CCVs: []protocol.CCV{ {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, }, @@ -199,10 +210,92 @@ func (b E2EBootstrap) SetupCantonTokenSend(t *testing.T, ctx context.Context, la } } -// SetupCantonReceive deploys the client party's PerPartyRouter before inbound messages -// are executed on Canton (e.g. EVM→Canton). +// SetupEVMTokenSend pre-approves the CCIP router to spend transfer tokens for N sends. +// The CCV EVM impl approves only the per-message amount on each send; without this +// upfront approval, load tests would pay one extra approve tx per message. +func (b E2EBootstrap) SetupEVMTokenSend(t *testing.T, ctx context.Context, lane TokenLane, sends int) { + t.Helper() + + require.Positive(t, sends) + require.NotNil(t, lane.TransferAmount) + require.False(t, lane.SrcToken.IsEmpty(), "SetupEVMTokenSend requires an EVM source token") + + requiredAllowance := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(sends))) + + opsEnv, err := b.Lib.CLDFEnvironment() + require.NoError(t, err) + + selector := b.EVM.ChainSelector() + chain, ok := opsEnv.BlockChains.EVMChains()[selector] + require.True(t, ok, "EVM chain %d not in CLDF environment", selector) + + ds, err := b.Lib.DataStore() + require.NoError(t, err) + + routerVersion := semver.MustParse(routeroperations.Deploy.Version()) + routerRefs := ds.Addresses().Filter( + datastore.AddressRefByChainSelector(selector), + datastore.AddressRefByType(datastore.ContractType(routeroperations.ContractType)), + datastore.AddressRefByVersion(routerVersion), + ) + require.Len(t, routerRefs, 1, "expected exactly one CCIP router for chain %d", selector) + + routerAddr := common.HexToAddress(routerRefs[0].Address) + tokenAddr := common.BytesToAddress(lane.SrcToken.Bytes()) + owner := chain.DeployerKey.From + + tkn, err := erc20.NewERC20(tokenAddr, chain.Client) + require.NoError(t, err) + + allowance, err := tkn.Allowance(&bind.CallOpts{Context: ctx}, owner, routerAddr) + require.NoError(t, err) + if allowance.Cmp(requiredAllowance) >= 0 { + t.Logf("EVM router allowance sufficient: have=%s need=%s (token=%s router=%s)", + allowance.String(), requiredAllowance.String(), tokenAddr.Hex(), routerAddr.Hex()) + return + } + + t.Logf("approving CCIP router %s for %s transfer tokens (%d sends × %s)", + routerAddr.Hex(), requiredAllowance.String(), sends, lane.TransferAmount.String()) + + auth := *chain.DeployerKey + auth.Context = ctx + auth.Value = nil // approve is non-payable; clear stale msg.Value from shared opts + + tx, err := tkn.Approve(&auth, routerAddr, requiredAllowance) + require.NoError(t, err) + + _, err = chain.Confirm(tx) + require.NoError(t, err) + + t.Logf("Approved router %s for transfer token %s amount=%s tx=%s", + routerAddr.Hex(), tokenAddr.Hex(), requiredAllowance.String(), tx.Hash().Hex()) +} + +// SetupCantonReceive sets CANTON_RECEIVER_REQUIRED_CCV when unset, then 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() + + if strings.TrimSpace(os.Getenv("CANTON_RECEIVER_REQUIRED_CCV")) == "" { + ds, err := b.Lib.DataStore() + require.NoError(t, err) + + cantonChain := GetChainFromMap(t, blockchain.TypeCanton, b.Cfg, b.ChainMap) + _, raw, err := testhelpers.ResolveAddressFromDatastore( + ds, + cantonChain.ChainSelector(), + committee_verifier.ContractType, + committee_verifier.Version, + devenvcommon.DefaultCommitteeVerifierQualifier, + ) + if err != nil && b.Env.IsRemote() { + require.FailNow(t, err.Error()+"; set CANTON_RECEIVER_REQUIRED_CCV to the Canton committee verifier raw address (instanceId@owner)") + } + require.NoError(t, err) + require.NoError(t, os.Setenv("CANTON_RECEIVER_REQUIRED_CCV", raw.String())) + } + require.NoError(t, b.Canton.SetupReceive(ctx)) } diff --git a/ccip/devenv/tests/load/gun_evm2canton_test.go b/ccip/devenv/tests/load/gun_evm2canton_test.go index 7d57f5d1f..8ba4c6496 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -32,13 +32,13 @@ func TestEVM2Canton_Load(t *testing.T) { t.Logf("EVM→Canton load: source=%d dest=%d receiver=%x", boot.EVM.ChainSelector(), cantonDest.Chain.ChainSelector(), cantonDest.Receiver) - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) + ccvAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( boot.EVM, []Destination{cantonDest}, ccvAddr, - executorAddr, + nil, LoadGunOptions{ ConfirmSend: EVMSourceConfirmSend(boot), ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 24f608228..4663768aa 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -17,8 +17,8 @@ import ( // // 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. +// Prod-testnet: requires PRIVATE_KEY wallet with TEST tokens and Sepolia ETH on Sepolia +// (one upfront router approve plus send fees), 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) { @@ -47,29 +47,30 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { cantonDest := cantonTokenLoadDestination(boot.Canton, receiver, lane) sched := loadSchedule(t) - estimatedMessages := estimateMessages(sched) + sendBudget := sequentialSendsForLoad(sched) 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) + requiredBalance := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(sendBudget))) + t.Logf("EVM sender token balance=%s requiredForRun=%s (sendBudget=%d; devenv pre-funds sender)", + senderBalance.String(), requiredBalance.String(), sendBudget) 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") } - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) + boot.SetupEVMTokenSend(t, ctx, lane, sendBudget) + + ccvAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( boot.EVM, []Destination{cantonDest}, ccvAddr, - executorAddr, + nil, LoadGunOptions{ ConfirmSend: EVMSourceConfirmSend(boot), ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index e404a3329..b60aaae90 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -9,8 +9,6 @@ import ( "time" chainsel "github.com/smartcontractkit/chain-selectors" - "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" @@ -23,6 +21,7 @@ import ( "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" @@ -33,7 +32,7 @@ const ( envLoadDuration = "CANTON_LOAD_DURATION" envLoadSkipExecConfirm = "CANTON_LOAD_SKIP_EXEC_CONFIRM" envLoadCallTimeout = "CANTON_LOAD_CALL_TIMEOUT" - defaultMessageRate = "1/1s" + defaultMessageRate = "1/10s" defaultLoadDuration = 90 * time.Second defaultLoadCallPadding = 2 * time.Minute defaultSendOnlyCallBudget = 5 * time.Minute @@ -384,11 +383,11 @@ func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.Un return Destination{ Chain: chain, Receiver: receiver, - buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, executorAddr protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { + buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, _ protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { return cciptestinterfaces.MessageFields{ Receiver: receiver, Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), - }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddr, ccvAddr), nil + }, devenvtests.EVMToCantonMessageOptions(200_000, cantondevenv.EVMToCantonFinalityConfig, ccvAddr), nil }, } } @@ -400,22 +399,15 @@ func cantonTokenLoadDestination(chain cciptestinterfaces.CCIP17, receiver protoc Chain: chain, Receiver: receiver, TokenLane: &laneCopy, - buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, executorAddr protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { + buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, _ protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { return cciptestinterfaces.MessageFields{ - Receiver: receiver, - Data: fmt.Appendf(nil, "evm2canton token load n=%d dest=%d", callNum, destSelector), - TokenAmount: cciptestinterfaces.TokenAmount{ - Amount: lane.TransferAmount, - TokenAddress: lane.SrcToken, - }, - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: lane.ExecutionGasLimit, - FinalityConfig: lane.FinalityConfig, - Executor: executorAddr, - CCVs: []protocol.CCV{ - {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, - }, - }, nil + Receiver: receiver, + Data: fmt.Appendf(nil, "evm2canton token load n=%d dest=%d", callNum, destSelector), + TokenAmount: cciptestinterfaces.TokenAmount{ + Amount: lane.TransferAmount, + TokenAddress: lane.SrcToken, + }, + }, devenvtests.EVMToCantonMessageOptions(lane.ExecutionGasLimit, lane.FinalityConfig, ccvAddr), nil }, } } @@ -444,26 +436,17 @@ func resolveCantonSourceAddrs(t *testing.T, lib ccv.Lib, cantonSelector uint64) return ccvAddr, executorAddr } -func resolveEVMSourceAddrs(t *testing.T, lib ccv.Lib, evmSelector uint64) (protocol.UnknownAddress, protocol.UnknownAddress) { +func resolveEVMSourceAddrs(t *testing.T, lib ccv.Lib, evmSelector uint64) protocol.UnknownAddress { t.Helper() ds, err := lib.DataStore() require.NoError(t, err) - ccvAddr := devenvtests.GetContractAddress( + return devenvtests.GetContractAddress( t, ds, evmSelector, datastore.ContractType(versioned_verifier_resolver.CommitteeVerifierResolverType), versioned_verifier_resolver.Version.String(), common.DefaultCommitteeVerifierQualifier, "source committee verifier", ) - executorAddr := devenvtests.GetContractAddress( - t, ds, evmSelector, - datastore.ContractType(sequences.ExecutorProxyType), - proxy.Deploy.Version(), - common.DefaultExecutorQualifier, - "source executor", - ) - - return ccvAddr, executorAddr } diff --git a/ccip/devenv/tests/token_lane.go b/ccip/devenv/tests/token_lane.go index 85a9f4e37..a6cce3d23 100644 --- a/ccip/devenv/tests/token_lane.go +++ b/ccip/devenv/tests/token_lane.go @@ -119,8 +119,12 @@ func ResolveTokenLane( cfg, matched := trySelectTokenConfig(cfgs, dir.PoolRef) if !matched { - t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", - srcSelector, poolRefString(dir.PoolRef), poolRefsString(cfgs)) + 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) } for _, sel := range destSelectors { @@ -252,6 +256,124 @@ func tokenDirectionsForEnv(cfg tokenConfigTOML, env CCIPEnv) (tokenDirectionsTOM } } +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 { 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/examples/cli/cmd/canton.go b/examples/cli/cmd/canton.go index d76d7cd8f..93c0dacc0 100644 --- a/examples/cli/cmd/canton.go +++ b/examples/cli/cmd/canton.go @@ -67,6 +67,7 @@ func NewCantonCmd(g *Globals) *cobra.Command { c.AddCommand(newCantonSendMessageCmd(g)) c.AddCommand(newCantonSendTokenCmd(g)) c.AddCommand(newCantonExecuteCmd(g)) + c.AddCommand(newCantonSyncReceiverCCVCmd(g)) c.AddCommand(newCantonListEventsCmd(g)) c.AddCommand(newCantonListHoldingsCmd(g)) c.AddCommand(newCantonCreateTransferCmd(g)) @@ -403,6 +404,41 @@ func newCantonExecuteCmd(g *Globals) *cobra.Command { return c } +func newCantonSyncReceiverCCVCmd(g *Globals) *cobra.Command { + var ( + requiredCCV string + finalityName string + ) + c := &cobra.Command{ + Use: "sync-receiver-ccv", + Short: "Deploy or update CCIPReceiver required CCVs (run once before inbound load/e2e on prod)", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + b, err := g.Resolve(ctx) + if err != nil { + return err + } + fin, err := finality.Parse(finalityName) + if err != nil { + return err + } + raw, err := contracts.RawInstanceAddressFromString(requiredCCV) + if err != nil { + return fmt.Errorf("parse --required-ccv: %w", err) + } + + _, err = cantonops.GetOrCreateReceiver(ctx, b.Participant, fin.Receiver, raw) + + return err + }, + } + c.Flags().StringVar(&requiredCCV, "required-ccv", "", "Canton committee verifier raw address (instanceId@owner)") + c.Flags().StringVar(&finalityName, "finality", "1", "receiver finality profile: finality|safe|1-65535") + _ = c.MarkFlagRequired("required-ccv") + + return c +} + func cantonExecute(ctx context.Context, b *clients.Bundle, vr protocol.VerifierResult, fin finality.Parsed) error { withToken := vr.Message.TokenTransfer != nil diff --git a/go.mod b/go.mod index 0b07625cb..80e7b27d9 100644 --- a/go.mod +++ b/go.mod @@ -437,7 +437,7 @@ require ( github.com/smartcontractkit/chainlink-common/keystore v1.0.2 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260427132147-1ef18876ae9b // indirect - github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd // indirect + github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect diff --git a/testhelpers/addresses.go b/testhelpers/addresses.go index 14ef0e431..0a4221a01 100644 --- a/testhelpers/addresses.go +++ b/testhelpers/addresses.go @@ -1,6 +1,8 @@ package testhelpers import ( + "fmt" + "github.com/Masterminds/semver/v3" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" @@ -24,7 +26,14 @@ func ResolveAddressFromDatastore( if err != nil { return datastore.AddressRef{}, "", err } - rawInstanceAddress, err := contracts.RawInstanceAddressFromString(addressRef.Labels.List()[0]) + labels := addressRef.Labels.List() + if len(labels) == 0 { + return datastore.AddressRef{}, "", fmt.Errorf( + "address ref %s/%s has no labels (raw instance address missing)", + contractType, qualifier, + ) + } + rawInstanceAddress, err := contracts.RawInstanceAddressFromString(labels[0]) if err != nil { return datastore.AddressRef{}, "", err }