From efe8d83358e9d854c5b5bd6e7e0c2770336e36d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedemann=20F=C3=BCrst?= <59653747+friedemannf@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:31:23 +0100 Subject: [PATCH 1/2] Add Canton Blockchain --- framework/components/blockchain/blockchain.go | 11 +- framework/components/blockchain/canton.go | 95 +++++ .../components/blockchain/canton/canton.go | 362 +++++++++++++++++ .../components/blockchain/canton/nginx.go | 214 ++++++++++ .../components/blockchain/canton/postgres.go | 94 +++++ .../components/blockchain/canton/splice.go | 370 ++++++++++++++++++ 6 files changed, 1145 insertions(+), 1 deletion(-) create mode 100644 framework/components/blockchain/canton.go create mode 100644 framework/components/blockchain/canton/canton.go create mode 100644 framework/components/blockchain/canton/nginx.go create mode 100644 framework/components/blockchain/canton/postgres.go create mode 100644 framework/components/blockchain/canton/splice.go diff --git a/framework/components/blockchain/blockchain.go b/framework/components/blockchain/blockchain.go index 0a7d6bb86..5c0df2be7 100644 --- a/framework/components/blockchain/blockchain.go +++ b/framework/components/blockchain/blockchain.go @@ -20,6 +20,7 @@ const ( TypeSui = "sui" TypeTron = "tron" TypeTon = "ton" + TypeCanton = "canton" ) // Blockchain node family @@ -30,12 +31,13 @@ const ( FamilySui = "sui" FamilyTron = "tron" FamilyTon = "ton" + FamilyCanton = "canton" ) // Input is a blockchain network configuration params type Input struct { // Common EVM fields - Type string `toml:"type" validate:"required,oneof=anvil geth besu solana aptos tron sui ton" envconfig:"net_type"` + Type string `toml:"type" validate:"required,oneof=anvil geth besu solana aptos tron sui ton canton" envconfig:"net_type"` Image string `toml:"image"` PullImage bool `toml:"pull_image"` Port string `toml:"port"` @@ -60,6 +62,9 @@ type Input struct { // Sui specific: faucet port for funding accounts FaucetPort string `toml:"faucet_port"` + // Canton specific + NumberOfValidators int `toml:"number_of_validators"` + // GAPv2 specific params HostNetworkMode bool `toml:"host_network_mode"` CertificatesPath string `toml:"certificates_path"` @@ -122,6 +127,8 @@ func NewWithContext(ctx context.Context, in *Input) (*Output, error) { out, err = newAnvilZksync(ctx, in) case TypeTon: out, err = newTon(ctx, in) + case TypeCanton: + out, err = newCanton(ctx, in) default: return nil, fmt.Errorf("blockchain type is not supported or empty, must be 'anvil' or 'geth'") } @@ -148,6 +155,8 @@ func TypeToFamily(t string) (ChainFamily, error) { return ChainFamily(FamilyTron), nil case TypeTon: return ChainFamily(FamilyTon), nil + case TypeCanton: + return ChainFamily(FamilyCanton), nil default: return "", fmt.Errorf("blockchain type is not supported or empty: %s", t) } diff --git a/framework/components/blockchain/canton.go b/framework/components/blockchain/canton.go new file mode 100644 index 000000000..b4847e935 --- /dev/null +++ b/framework/components/blockchain/canton.go @@ -0,0 +1,95 @@ +package blockchain + +import ( + "context" + "fmt" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/network" + + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain/canton" +) + +func newCanton(ctx context.Context, in *Input) (*Output, error) { + if in.NumberOfValidators >= 100 { + return nil, fmt.Errorf("number of validators too high: %d, max is 99", in.NumberOfValidators) + } + + // TODO - remove debug prints + fmt.Println("Starting Canton blockchain node...") + fmt.Println("Creating network...") + dockerNetwork, err := network.New(ctx, network.WithAttachable()) + if err != nil { + return nil, err + } + fmt.Println("Network created:", dockerNetwork.Name) + + // Set up Postgres container + postgresReq := canton.PostgresContainerRequest(in.NumberOfValidators, dockerNetwork.Name) + fmt.Printf("Starting postgres container %s...\n", postgresReq.Name) + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: postgresReq, + Started: true, + }) + if err != nil { + return nil, err + } + _ = c + fmt.Println("Postgres container started") + + // Set up Canton container + cantonReq := canton.CantonContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Image) + fmt.Printf("Starting canton container %s...\n", cantonReq.Name) + cantonContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: cantonReq, + Started: true, + }) + if err != nil { + return nil, err + } + _ = cantonContainer + fmt.Println("Canton container started") + + // Set up Splice container + spliceReq := canton.SpliceContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Image) + fmt.Printf("Starting splice container %s...\n", spliceReq.Name) + spliceContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: spliceReq, + Started: true, + }) + if err != nil { + return nil, err + } + _ = spliceContainer + fmt.Println("Splice container started") + + // Set up Nginx container + nginxReq := canton.NginxContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Port) + fmt.Printf("Starting nginx container %s...\n", nginxReq.Name) + nginxContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: nginxReq, + Started: true, + }) + if err != nil { + return nil, err + } + fmt.Println("Nginx container started") + + host, err := nginxContainer.Host(ctx) + if err != nil { + return nil, err + } + + return &Output{ + UseCache: false, + Type: in.Type, + Family: FamilyCanton, + ContainerName: nginxReq.Name, + Nodes: []*Node{ + { + ExternalHTTPUrl: fmt.Sprintf("http://%s:%s", host, in.Port), + InternalHTTPUrl: fmt.Sprintf("http://%s:%s", nginxReq.Name, in.Port), // TODO - should be docker-internal port instead? + }, + }, + }, nil +} diff --git a/framework/components/blockchain/canton/canton.go b/framework/components/blockchain/canton/canton.go new file mode 100644 index 000000000..cae25974f --- /dev/null +++ b/framework/components/blockchain/canton/canton.go @@ -0,0 +1,362 @@ +package canton + +import ( + "fmt" + "strings" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +// Canton Defaults +const ( + SpliceVersion = "0.5.3" + CantonImage = "ghcr.io/digital-asset/decentralized-canton-sync/docker/canton" +) + +// JWT Auth defaults +const ( + DefaultAuthProviderAudience = "https://chain.link" + DefaultUserName = "api-user" +) + +// Port prefixes for participants +const ( + DefaultParticipantJsonApiPortPrefix = "11" + DefaultParticipantAdminApiPortPrefix = "12" + DefaultLedgerApiPortPrefix = "13" + DefaultHTTPHealthcheckPortPrefix = "15" + DefaultGRPCHealthcheckPortPrefix = "16" + DefaultSpliceValidatorAdminApiPortPrefix = "22" +) + +func getCantonHealthCheckScript(numberOfValidators int) string { + script := ` +#!/bin/bash +# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -eou pipefail + +# SV +echo "Checking ${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}00" +grpcurl -plaintext "localhost:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}00" grpc.health.v1.Health/Check + + ` + // Add additional participants + for i := range numberOfValidators { + i += 1 // start from 1 since SV is 0 + script += fmt.Sprintf(` +# Participant %02[1]d +echo "Checking ${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}%02[1]d" +grpcurl -plaintext "localhost:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}%02[1]d" grpc.health.v1.Health/Check +`, i) + } + + return script +} + +func getCantonConfig(numberOfValidators int) string { + //language=hocon + config := ` +# re-used storage config block +_storage { + type = postgres + config { + dataSourceClass = "org.postgresql.ds.PGSimpleDataSource" + properties = { + serverName = ${?DB_SERVER} + portNumber = 5432 + databaseName = participant + currentSchema = participant + user = ${?DB_USER} + password = ${?DB_PASS} + tcpKeepAlive = true + } + } + parameters { + max-connections = 32 + migrate-and-start = true + } + } + +canton { + features { + enable-preview-commands = yes + enable-testing-commands = yes + } + parameters { + manual-start = no + non-standard-config = yes + # Bumping because our topology state can get very large due to + # a large number of participants. + timeouts.processing.verify-active = 40.seconds + timeouts.processing.slow-future-warn = 20.seconds + } + + # Bumping because our topology state can get very large due to + # a large number of participants. + monitoring.logging.delay-logging-threshold = 40.seconds +} + + +_participant { + init { + generate-topology-transactions-and-keys = false + identity.type = manual + } + + monitoring.grpc-health-server { + address = "0.0.0.0" + port = 5061 + } + storage = ${_storage} + + admin-api { + address = "0.0.0.0" + port = 5002 + } + + init.ledger-api.max-deduplication-duration = 30s + + ledger-api { + # TODO(DACH-NY/canton-network-internal#2347) Revisit this; we want to avoid users to have to set an exp field in their tokens + max-token-lifetime = Inf + # Required for pruning + admin-token-config.admin-claim=true + address = "0.0.0.0" + port = 5001 + + # We need to bump this because we run one stream per user + + # polling for domain connections which can add up quite a bit + # once you're around ~100 users. + rate-limit.max-api-services-queue-size = 80000 + interactive-submission-service { + enable-verbose-hashing = true + } + } + + http-ledger-api { + port = 7575 + address = 0.0.0.0 + path-prefix = ${?CANTON_PARTICIPANT_JSON_API_SERVER_PATH_PREFIX} + } + + parameters { + initial-protocol-version = 34 + # tune the synchronisation protocols contract store cache + caching { + contract-store { + maximum-size = 1000 # default 1e6 + expire-after-access = 120s # default 10 minutes + } + } + # Bump ACS pruning interval to make sure ACS snapshots are available for longer + journal-garbage-collection-delay = 24h + } + + # TODO(DACH-NY/canton-network-node#8331) Tune cache sizes + # from https://docs.daml.com/2.8.0/canton/usermanual/performance.html#configuration + # tune caching configs of the ledger api server + ledger-api { + index-service { + max-contract-state-cache-size = 1000 # default 1e4 + max-contract-key-state-cache-size = 1000 # default 1e4 + + # The in-memory fan-out will serve the transaction streams from memory as they are finalized, rather than + # using the database. Therefore, you should choose this buffer to be large enough such that the likeliness of + # applications having to stream transactions from the database is low. Generally, having a 10s buffer is + # sensible. Therefore, if you expect e.g. a throughput of 20 tx/s, then setting this number to 200 is sensible. + # The default setting assumes 100 tx/s. + max-transactions-in-memory-fan-out-buffer-size = 200 # default 1000 + } + # Restrict the command submission rate (mainly for SV participants, since they are granted unlimited traffic) + command-service.max-commands-in-flight = 30 # default = 256 + } + + monitoring.http-health-server { + address="0.0.0.0" + port=7000 + } + + topology.broadcast-batch-size = 1 +} + +# Sequencer +canton.sequencers.sequencer { + init { + generate-topology-transactions-and-keys = false + identity.type = manual + } + + storage = ${_storage} { + config.properties { + databaseName = "sequencer" + currentSchema = "sequencer" + } + } + + public-api { + address = "0.0.0.0" + port = 5008 + } + + admin-api { + address = "0.0.0.0" + port = 5009 + } + + monitoring.grpc-health-server { + address = "0.0.0.0" + port = 5062 + } + + sequencer { + config { + storage = ${_storage} { + config.properties { + databaseName = "sequencer" + currentSchema = "sequencer_driver" + } + } + } + type = reference + } +} + +# Mediator +canton.mediators.mediator { + init { + generate-topology-transactions-and-keys = false + identity.type = manual + } + + storage = ${_storage} { + config.properties { + databaseName = "mediator" + currentSchema = "mediator" + } + } + + admin-api { + address = "0.0.0.0" + port = 5007 + } + + monitoring.grpc-health-server { + address = "0.0.0.0" + port = 5061 + } +} + +################ +# Participants # +################ + +# SV +canton.participants.sv = ${_participant} { + storage.config.properties.databaseName = participant-sv + monitoring { + http-health-server.port = ${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}00 + grpc-health-server.port= ${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}00 + } + http-ledger-api.port = ${CANTON_PARTICIPANT_JSON_API_PORT_PREFIX}00 + admin-api.port = ${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}00 + + ledger-api{ + port = ${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}00 + auth-services = [{ + type = unsafe-jwt-hmac-256 + target-audience = ${API_AUDIENCE} + secret = "unsafe" + }] + + user-management-service.additional-admin-user-id = ${API_USER_NAME} + } +} + + ` + + // Add additional participants + for i := range numberOfValidators { + i += 1 // start from 1 since SV is 0 + config += fmt.Sprintf(` +# Participant %02[1]d +canton.participants.participant%[1]d = ${_participant} { + storage.config.properties.databaseName = participant-%[1]d + monitoring { + http-health-server.port = ${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}%02[1]d + grpc-health-server.port= ${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}%02[1]d + } + http-ledger-api.port = ${CANTON_PARTICIPANT_JSON_API_PORT_PREFIX}%02[1]d + admin-api.port = ${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}%02[1]d + + ledger-api{ + port = ${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}%02[1]d + auth-services = [{ + type = unsafe-jwt-hmac-256 + target-audience = ${API_AUDIENCE} + secret = "unsafe" + }] + + user-management-service.additional-admin-user-id = ${API_USER_NAME} + } +} + + `, i) + } + + return config +} + +func CantonContainerRequest( + networkName string, + numberOfValidators int, + spliceVersion string, // optional, will default to SpliceVersion if empty +) testcontainers.ContainerRequest { + if spliceVersion == "" { + spliceVersion = SpliceVersion + } + cantonContainerName := framework.DefaultTCName("canton") + cantonReq := testcontainers.ContainerRequest{ + Image: fmt.Sprintf("%s:%s", CantonImage, spliceVersion), + Name: cantonContainerName, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"canton"}, + }, + WaitingFor: wait.ForExec([]string{ + "/bin/bash", + "/app/health-check.sh", + }), + Env: map[string]string{ + "DB_SERVER": "postgres", + "DB_USER": DefaultPostgresUser, + "DB_PASS": DefaultPostgresPass, + + "API_AUDIENCE": DefaultAuthProviderAudience, + "API_USER_NAME": DefaultUserName, + + "CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX": DefaultHTTPHealthcheckPortPrefix, + "CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX": DefaultGRPCHealthcheckPortPrefix, + "CANTON_PARTICIPANT_JSON_API_PORT_PREFIX": DefaultParticipantJsonApiPortPrefix, + "CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX": DefaultParticipantAdminApiPortPrefix, + "CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX": DefaultLedgerApiPortPrefix, + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(getCantonHealthCheckScript(numberOfValidators)), + ContainerFilePath: "/app/health-check.sh", + FileMode: 0755, + }, { + Reader: strings.NewReader(getCantonConfig(numberOfValidators)), + ContainerFilePath: "/app/app.conf", + FileMode: 0755, + }, + }, + } + + return cantonReq +} diff --git a/framework/components/blockchain/canton/nginx.go b/framework/components/blockchain/canton/nginx.go new file mode 100644 index 000000000..9beb34f0c --- /dev/null +++ b/framework/components/blockchain/canton/nginx.go @@ -0,0 +1,214 @@ +package canton + +import ( + "fmt" + "strings" + + "github.com/testcontainers/testcontainers-go" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +const ( + DefaultNginxImage = "nginx:1.27.0" +) + +const nginxConfig = ` +events { + worker_connections 64; +} + +http { + include mime.types; + default_type application/octet-stream; + client_max_body_size 100M; + + # Logging + log_format json_combined escape=json + '{' + '"time_local":"$time_local",' + '"remote_addr":"$remote_addr",' + '"remote_user":"$remote_user",' + '"request":"$request",' + '"status": "$status",' + '"body_bytes_sent":"$body_bytes_sent",' + '"request_time":"$request_time",' + '"http_referrer":"$http_referer",' + '"http_user_agent":"$http_user_agent"' + '}'; + access_log /var/log/nginx/access.log json_combined; + error_log /var/log/nginx/error.log; + + include /etc/nginx/conf.d/participants.conf; +} +` + +func getNginxTemplate(numberOfValidators int) string { + template := ` +# SV +server { + listen 8080; + server_name sv.json-ledger-api.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_JSON_API_PORT_PREFIX}00; + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept'; + } +} + +server { + listen 8080 http2; + server_name sv.grpc-ledger-api.localhost; + location / { + grpc_pass grpc://canton:${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}00; + } +} + +server { + listen 8080; + server_name sv.http-health-check.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}00; + } +} + +server { + listen 8080; + server_name sv.grpc-health-check.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}00; + } +} + +server { + listen 8080 http2; + server_name sv.admin-api.localhost; + location / { + grpc_pass grpc://canton:${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}00; + } +} + +server { + listen 8080; + server_name sv.wallet.localhost; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}00/api/validator; + } +} + +server { + listen 8080; + server_name scan.localhost; + + location /api/scan { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:5012/api/scan; + } + location /registry { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:5012/registry; + } +} + ` + + // Add additional validators + for i := range numberOfValidators { + i += 1 // start from 1 since SV is 0 + template += fmt.Sprintf(` +# Participant %[1]d + server { + listen 8080; + server_name participant%[1]d.json-ledger-api.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_JSON_API_PORT_PREFIX}%02[1]d; + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept'; + } + } + + server { + listen 8080 http2; + server_name participant%[1]d.grpc-ledger-api.localhost; + location / { + grpc_pass grpc://canton:${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080; + server_name participant%[1]d.http-health-check.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080; + server_name participant%[1]d.grpc-health-check.localhost; + location / { + proxy_pass http://canton:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080 http2; + server_name participant%[1]d.admin-api.localhost; + location / { + grpc_pass grpc://canton:${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080; + server_name participant%[1]d.wallet.localhost; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}%02[1]d/api/validator; + } + } + `, i) + } + + return template +} + +func NginxContainerRequest( + networkName string, + numberOfValidators int, + port string, +) testcontainers.ContainerRequest { + nginxContainerName := framework.DefaultTCName("nginx") + nginxReq := testcontainers.ContainerRequest{ + Image: DefaultNginxImage, + Name: nginxContainerName, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"nginx"}, + }, + ExposedPorts: []string{fmt.Sprintf("%s:8080", port)}, + Env: map[string]string{ + "CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX": DefaultHTTPHealthcheckPortPrefix, + "CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX": DefaultGRPCHealthcheckPortPrefix, + "CANTON_PARTICIPANT_JSON_API_PORT_PREFIX": DefaultParticipantJsonApiPortPrefix, + "CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX": DefaultParticipantAdminApiPortPrefix, + "CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX": DefaultLedgerApiPortPrefix, + "SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX": DefaultSpliceValidatorAdminApiPortPrefix, + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(nginxConfig), + ContainerFilePath: "/etc/nginx/nginx.conf", + FileMode: 0755, + }, { + Reader: strings.NewReader(getNginxTemplate(numberOfValidators)), + ContainerFilePath: "/etc/nginx/templates/participants.conf.template", + FileMode: 0755, + }, + }, + } + + return nginxReq +} diff --git a/framework/components/blockchain/canton/postgres.go b/framework/components/blockchain/canton/postgres.go new file mode 100644 index 000000000..726ba545a --- /dev/null +++ b/framework/components/blockchain/canton/postgres.go @@ -0,0 +1,94 @@ +package canton + +import ( + "fmt" + "strings" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +const ( + DefaultPostgresImage = "postgres:14" + DefaultPostgresUser = "canton" + DefaultPostgresPass = "password" + DefaultPostgresDB = "canton" +) + +// language=bash +const initDbScript = ` +#!/usr/bin/env bash + +set -Eeo pipefail + +function create_database() { + local database=$1 + echo " Creating database: '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE DATABASE "$database"; + GRANT ALL PRIVILEGES ON DATABASE "$database" TO $POSTGRES_USER; +EOSQL +} + +if [ -n "$POSTGRES_INIT_DATABASES" ]; then + echo "Creating multiple databases: $POSTGRES_INIT_DATABASES" + for database in $(echo $POSTGRES_INIT_DATABASES | tr ',' ' '); do + create_database $database + done + echo "All databases created" +fi +` + +func PostgresContainerRequest( + numberOfValidators int, + networkName string, +) testcontainers.ContainerRequest { + postgresDatabases := []string{ + "sequencer", + "mediator", + "scan", + "sv", + "participant-sv", + "validator-sv", + } + for i := range numberOfValidators { + postgresDatabases = append(postgresDatabases, fmt.Sprintf("participant-%d", i+1)) + postgresDatabases = append(postgresDatabases, fmt.Sprintf("validator-%d", i+1)) + } + postgresContainerName := framework.DefaultTCName("postgres") + postgresReq := testcontainers.ContainerRequest{ + Image: DefaultPostgresImage, + Name: postgresContainerName, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"postgres"}, + }, + WaitingFor: wait.ForExec([]string{ + "pg_isready", + "-U", DefaultPostgresUser, + "-d", DefaultPostgresDB, + }), + Env: map[string]string{ + "POSTGRES_USER": DefaultPostgresUser, + "POSTGRES_PASSWORD": DefaultPostgresPass, + "POSTGRES_DB": DefaultPostgresDB, + "POSTGRES_INIT_DATABASES": strings.Join(postgresDatabases, ","), + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(initDbScript), + ContainerFilePath: "/docker-entrypoint-initdb.d/create-multiple-databases.sh", + FileMode: 0755, + }, + }, + Cmd: []string{ + "postgres", + "-c", "max_connections=2000", + "-c", "log_statement=all", + }, + } + + return postgresReq +} diff --git a/framework/components/blockchain/canton/splice.go b/framework/components/blockchain/canton/splice.go new file mode 100644 index 000000000..4da14184c --- /dev/null +++ b/framework/components/blockchain/canton/splice.go @@ -0,0 +1,370 @@ +package canton + +import ( + "fmt" + "strings" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +const ( + SpliceImage = "ghcr.io/digital-asset/decentralized-canton-sync/docker/splice-app" +) + +func getSpliceHealthCheckScript(numberOfValidators int) string { + script := ` +#!/bin/bash +# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -eou pipefail + +curl -f http://localhost:5012/api/scan/readyz +curl -f http://localhost:5014/api/sv/readyz + +# SV +curl -f "http://localhost:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}00/api/validator/readyz" +` + for i := range numberOfValidators { + i += 1 // start from 1 since SV is 0 + script += fmt.Sprintf(` +# Participant %02[1]d +curl -f "http://localhost:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}%02[1]d/api/validator/readyz" + `, i) + } + + return script +} + +func getSpliceConfig(numberOfValidators int) string { + //language=hocon + config := ` +_storage { + type = postgres + config { + dataSourceClass = "org.postgresql.ds.PGSimpleDataSource" + properties = { + serverName = ${DB_SERVER} + portNumber = 5432 + databaseName = validator + currentSchema = validator + user = ${DB_USER} + password = ${DB_PASS} + tcpKeepAlive = true + } + } + parameters { + max-connections = 32 + migrate-and-start = true + } + } + +_validator_backend { + latest-packages-only = true + domain-migration-id = 0 + storage = ${_storage} + admin-api = { + address = "0.0.0.0" + port = 5003 + } + participant-client = { + admin-api = { + address = canton + port = 5002 + } + ledger-api.client-config = { + address = canton + port = 5001 + } + } + scan-client { + type = "bft" + seed-urls = [] + seed-urls.0 = "http://localhost:5012" + } + + app-instances { + } + onboarding.sv-client.admin-api.url = "http://localhost:5014" + domains.global.alias = "global" + contact-point = "contact@local.host" + canton-identifier-config.participant = participant +} + +canton.features.enable-testing-commands = yes + +# SV +_sv_participant_client = { + admin-api { + address = canton + port = ${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}00 + } + ledger-api { + client-config { + address = canton + port = ${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}00 + } + auth-config { + type = "self-signed" + user = ${API_USER_NAME} + audience = ${API_AUDIENCE} + secret = "unsafe" + } + } +} + +_splice-instance-names { + network-name = "Splice" + network-favicon-url = "https://www.hyperledger.org/hubfs/hyperledgerfavicon.png" + amulet-name = "Amulet" + amulet-name-acronym = "AMT" + name-service-name = "Amulet Name Service" + name-service-name-acronym = "ANS" +} + +canton { + scan-apps.scan-app { + is-first-sv = true + domain-migration-id = 0 + storage = ${_storage} { + config.properties { + databaseName = scan + currentSchema = scan + } + } + + admin-api = { + address = "0.0.0.0" + port = 5012 + } + participant-client = ${_sv_participant_client} + sequencer-admin-client = { + address = canton + port = 5009 + } + mediator-admin-client = { + address = canton + port = 5007 + } + sv-user=${API_USER_NAME} + splice-instance-names = ${_splice-instance-names} + } + + sv-apps.sv { + latest-packages-only = true + domain-migration-id = 0 + expected-validator-onboardings = [ + ] + scan { + public-url="http://localhost:5012" + internal-url="http://localhost:5012" + } + local-synchronizer-node { + sequencer { + admin-api { + address = canton + port = 5009 + } + internal-api { + address = canton + port = 5008 + } + external-public-api-url = "http://canton:5008" + } + mediator.admin-api { + address = canton + port = 5007 + } + } + + storage = ${_storage} { + config.properties { + databaseName = sv + currentSchema = sv + } + } + + admin-api = { + address = "0.0.0.0" + port = 5014 + } + participant-client = ${_sv_participant_client} + + domains { + global { + alias = "global" + url = ${?SPLICE_APP_SV_GLOBAL_DOMAIN_URL} + } + } + + auth = { + algorithm = "hs-256-unsafe" + audience = ${API_AUDIENCE} + secret = "unsafe" + } + ledger-api-user = ${API_USER_NAME} + validator-ledger-api-user = ${API_USER_NAME} + + automation { + paused-triggers = [ + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredLockedAmuletTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAnsSubscriptionTrigger" + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAnsEntryTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpireTransferPreapprovalsTrigger", + ] + } + + onboarding = { + type = found-dso + name = sv + first-sv-reward-weight-bps = 10000 + round-zero-duration = ${?SPLICE_APP_SV_ROUND_ZERO_DURATION} + initial-tick-duration = ${?SPLICE_APP_SV_INITIAL_TICK_DURATION} + initial-holding-fee = ${?SPLICE_APP_SV_INITIAL_HOLDING_FEE} + initial-amulet-price = ${?SPLICE_APP_SV_INITIAL_AMULET_PRICE} + is-dev-net = true + public-key = ${?SPLICE_APP_SV_PUBLIC_KEY} + private-key = ${?SPLICE_APP_SV_PRIVATE_KEY} + initial-round = ${?SPLICE_APP_SV_INITIAL_ROUND} + } + initial-amulet-price-vote = ${?SPLICE_APP_SV_INITIAL_AMULET_PRICE_VOTE} + comet-bft-config = { + enabled = false + enabled = ${?SPLICE_APP_SV_COMETBFT_ENABLED} + connection-uri = "" + connection-uri = ${?SPLICE_APP_SV_COMETBFT_CONNECTION_URI} + } + contact-point = "contact@local.host" + canton-identifier-config = { + participant = sv + sequencer = sv + mediator = sv + } + + splice-instance-names = ${_splice-instance-names} + } +} + +# SV +canton.validator-apps.sv-validator_backend = ${_validator_backend} { + canton-identifier-config.participant = sv + onboarding = null + scan-client = null + scan-client = { + type = "trust-single" + url="http://localhost:5012" + } + sv-user=${API_USER_NAME} + sv-validator=true + storage.config.properties.databaseName = validator-sv + admin-api.port = ${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}00 + participant-client = ${_sv_participant_client} + auth = { + algorithm = "hs-256-unsafe" + audience = ${API_AUDIENCE} + secret = "unsafe" + } + ledger-api-user = ${API_USER_NAME} + validator-wallet-users.0 = "sv" +} + +` + // Add additional participants + for i := range numberOfValidators { + i += 1 // start from 1 since SV is 0 + config += fmt.Sprintf(` +# Participant %02[1]d +canton.validator-apps.participant%[1]d-validator_backend = ${_validator_backend} { + onboarding.secret = "participant%[1]d-validator-onboarding-secret" + validator-party-hint = "participant%[1]d-localparty-1" + domain-migration-dump-path = "/domain-upgrade-dump/domain_migration_dump-participant%[1]d.json" + storage.config.properties.databaseName = validator-%[1]d + admin-api.port = ${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}%02[1]d + participant-client { + admin-api.port = ${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}%02[1]d + + ledger-api = { + client-config.port = ${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}%02[1]d + auth-config = { + type = "self-signed" + user = ${API_USER_NAME} + audience = ${API_AUDIENCE} + secret = "unsafe" + } + } + } + auth = { + algorithm = "hs-256-unsafe" + audience = ${API_AUDIENCE} + secret = "unsafe" + } + ledger-api-user = ${API_USER_NAME} + validator-wallet-users.0="participant%[1]d" + + domains.global.buy-extra-traffic { + min-topup-interval = "1m" + target-throughput = "20000" + } +} + +canton.sv-apps.sv.expected-validator-onboardings += { secret = "participant%[1]d-validator-onboarding-secret" } + `, i) + } + + return config +} + +func SpliceContainerRequest( + networkName string, + numberOfValidators int, + spliceVersion string, +) testcontainers.ContainerRequest { + if spliceVersion == "" { + spliceVersion = SpliceVersion + } + spliceContainerName := framework.DefaultTCName("splice") + spliceReq := testcontainers.ContainerRequest{ + Image: fmt.Sprintf("%s:%s", SpliceImage, spliceVersion), + Name: spliceContainerName, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"splice"}, + }, + WaitingFor: wait.ForExec([]string{ + "/bin/bash", + "/app/health-check.sh", + }).WithStartupTimeout(time.Minute * 3), + Env: map[string]string{ + "DB_SERVER": "postgres", + "DB_USER": DefaultPostgresUser, + "DB_PASS": DefaultPostgresPass, + + "API_AUDIENCE": DefaultAuthProviderAudience, + "SPLICE_APP_VALIDATOR_LEDGER_API_AUTH_AUDIENCE": DefaultAuthProviderAudience, + "SPLICE_APP_VALIDATOR_AUTH_AUDIENCE": DefaultAuthProviderAudience, + "API_USER_NAME": DefaultUserName, + + "CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX": DefaultParticipantAdminApiPortPrefix, + "CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX": DefaultLedgerApiPortPrefix, + "SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX": DefaultSpliceValidatorAdminApiPortPrefix, + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(getSpliceHealthCheckScript(numberOfValidators)), + ContainerFilePath: "/app/health-check.sh", + FileMode: 0755, + }, { + Reader: strings.NewReader(getSpliceConfig(numberOfValidators)), + ContainerFilePath: "/app/app.conf", + FileMode: 0755, + }, + }, + } + + return spliceReq +} From a14bfbe3b6419b78f2b39cbddd017c46163b1632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedemann=20F=C3=BCrst?= <59653747+friedemannf@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:05:57 +0100 Subject: [PATCH 2/2] Update loops and config field --- framework/components/blockchain/blockchain.go | 2 +- framework/components/blockchain/canton.go | 12 ++++++------ framework/components/blockchain/canton/canton.go | 6 ++---- framework/components/blockchain/canton/nginx.go | 3 +-- framework/components/blockchain/canton/splice.go | 6 ++---- 5 files changed, 12 insertions(+), 17 deletions(-) diff --git a/framework/components/blockchain/blockchain.go b/framework/components/blockchain/blockchain.go index 5c0df2be7..2ecc9bb64 100644 --- a/framework/components/blockchain/blockchain.go +++ b/framework/components/blockchain/blockchain.go @@ -63,7 +63,7 @@ type Input struct { FaucetPort string `toml:"faucet_port"` // Canton specific - NumberOfValidators int `toml:"number_of_validators"` + NumberOfCantonValidators int `toml:"number_of_canton_validators"` // GAPv2 specific params HostNetworkMode bool `toml:"host_network_mode"` diff --git a/framework/components/blockchain/canton.go b/framework/components/blockchain/canton.go index b4847e935..973fc2088 100644 --- a/framework/components/blockchain/canton.go +++ b/framework/components/blockchain/canton.go @@ -11,8 +11,8 @@ import ( ) func newCanton(ctx context.Context, in *Input) (*Output, error) { - if in.NumberOfValidators >= 100 { - return nil, fmt.Errorf("number of validators too high: %d, max is 99", in.NumberOfValidators) + if in.NumberOfCantonValidators >= 100 { + return nil, fmt.Errorf("number of validators too high: %d, max is 99", in.NumberOfCantonValidators) } // TODO - remove debug prints @@ -25,7 +25,7 @@ func newCanton(ctx context.Context, in *Input) (*Output, error) { fmt.Println("Network created:", dockerNetwork.Name) // Set up Postgres container - postgresReq := canton.PostgresContainerRequest(in.NumberOfValidators, dockerNetwork.Name) + postgresReq := canton.PostgresContainerRequest(in.NumberOfCantonValidators, dockerNetwork.Name) fmt.Printf("Starting postgres container %s...\n", postgresReq.Name) c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: postgresReq, @@ -38,7 +38,7 @@ func newCanton(ctx context.Context, in *Input) (*Output, error) { fmt.Println("Postgres container started") // Set up Canton container - cantonReq := canton.CantonContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Image) + cantonReq := canton.CantonContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Image) fmt.Printf("Starting canton container %s...\n", cantonReq.Name) cantonContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: cantonReq, @@ -51,7 +51,7 @@ func newCanton(ctx context.Context, in *Input) (*Output, error) { fmt.Println("Canton container started") // Set up Splice container - spliceReq := canton.SpliceContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Image) + spliceReq := canton.SpliceContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Image) fmt.Printf("Starting splice container %s...\n", spliceReq.Name) spliceContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: spliceReq, @@ -64,7 +64,7 @@ func newCanton(ctx context.Context, in *Input) (*Output, error) { fmt.Println("Splice container started") // Set up Nginx container - nginxReq := canton.NginxContainerRequest(dockerNetwork.Name, in.NumberOfValidators, in.Port) + nginxReq := canton.NginxContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Port) fmt.Printf("Starting nginx container %s...\n", nginxReq.Name) nginxContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: nginxReq, diff --git a/framework/components/blockchain/canton/canton.go b/framework/components/blockchain/canton/canton.go index cae25974f..460d74566 100644 --- a/framework/components/blockchain/canton/canton.go +++ b/framework/components/blockchain/canton/canton.go @@ -46,8 +46,7 @@ grpcurl -plaintext "localhost:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX} ` // Add additional participants - for i := range numberOfValidators { - i += 1 // start from 1 since SV is 0 + for i := 1; i <= numberOfValidators; i++ { script += fmt.Sprintf(` # Participant %02[1]d echo "Checking ${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}%02[1]d" @@ -280,8 +279,7 @@ canton.participants.sv = ${_participant} { ` // Add additional participants - for i := range numberOfValidators { - i += 1 // start from 1 since SV is 0 + for i := 1; i <= numberOfValidators; i++ { config += fmt.Sprintf(` # Participant %02[1]d canton.participants.participant%[1]d = ${_participant} { diff --git a/framework/components/blockchain/canton/nginx.go b/framework/components/blockchain/canton/nginx.go index 9beb34f0c..c909cc92f 100644 --- a/framework/components/blockchain/canton/nginx.go +++ b/framework/components/blockchain/canton/nginx.go @@ -114,8 +114,7 @@ server { ` // Add additional validators - for i := range numberOfValidators { - i += 1 // start from 1 since SV is 0 + for i := 1; i <= numberOfValidators; i++ { template += fmt.Sprintf(` # Participant %[1]d server { diff --git a/framework/components/blockchain/canton/splice.go b/framework/components/blockchain/canton/splice.go index 4da14184c..f3461137d 100644 --- a/framework/components/blockchain/canton/splice.go +++ b/framework/components/blockchain/canton/splice.go @@ -29,8 +29,7 @@ curl -f http://localhost:5014/api/sv/readyz # SV curl -f "http://localhost:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}00/api/validator/readyz" ` - for i := range numberOfValidators { - i += 1 // start from 1 since SV is 0 + for i := 1; i <= numberOfValidators; i++ { script += fmt.Sprintf(` # Participant %02[1]d curl -f "http://localhost:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}%02[1]d/api/validator/readyz" @@ -275,8 +274,7 @@ canton.validator-apps.sv-validator_backend = ${_validator_backend} { ` // Add additional participants - for i := range numberOfValidators { - i += 1 // start from 1 since SV is 0 + for i := 1; i <= numberOfValidators; i++ { config += fmt.Sprintf(` # Participant %02[1]d canton.validator-apps.participant%[1]d-validator_backend = ${_validator_backend} {