diff --git a/.github/workflows/framework-golden-tests.yml b/.github/workflows/framework-golden-tests.yml index 753549330..52964ec48 100644 --- a/.github/workflows/framework-golden-tests.yml +++ b/.github/workflows/framework-golden-tests.yml @@ -35,6 +35,10 @@ jobs: config: smoke_aptos.toml count: 1 timeout: 10m + - name: TestCantonSmoke + config: smoke_canton.toml + count: 1 + timeout: 10m - name: TestTRONSmoke config: smoke_tron.toml count: 1 diff --git a/book/src/framework/components/blockchains/canton.md b/book/src/framework/components/blockchains/canton.md new file mode 100644 index 000000000..f1d576c04 --- /dev/null +++ b/book/src/framework/components/blockchains/canton.md @@ -0,0 +1,202 @@ +# Canton Blockchain Client + +This supports spinning up a Canton LocalNet instance. It is heavily based on +the [Splice LocalNet Docker Compose setup](https://github.com/hyperledger-labs/splice/blob/3aede18a641bb657e25eea240adfb869d5c12503/cluster/compose/localnet/compose.yaml). + +The LocalNet consists of one Super Validator and a variable number of additional participants/validators that can +be configured using the `number_of_canton_validators` parameter. + +## Configuration + +```toml +[blockchain_a] + type = "canton" + number_of_canton_validators = 5 # Controls the number of validators in the LocalNet + image = "0.5.3" # Optional, can be used to override the default Canton image tag + port = "8088" # Optional, defaults to 8080 +``` + +## Endpoints + +A reverse proxy is set up to route requests to the appropriate Canton node based on the URL path. + +| Endpoint Path | Description | Documentation | +|-----------------------------------------------------------|---------------------|--------------------------------------------------------------------------------| +| `http://scan.localhost:[PORT]/api/scan` | Scan API | https://docs.sync.global/app_dev/scan_api/index.html | +| `http://scan.localhost:[PORT]/registry` | Token Standard APIs | https://docs.sync.global/app_dev/token_standard/index.html#api-references | +| | | | +| `http://[PARTICIPANT].json-ledger-api.localhost:[PORT]` | JSON Ledger API | https://docs.digitalasset.com/build/3.3/reference/json-api/json-api.html | +| `grpc://[PARTICIPANT].grpc-ledger-api.localhost:[PORT]` | gRPC Ledger API | https://docs.digitalasset.com/build/3.3/reference/lapi-proto-docs.html | +| `grpc://[PARTICIPANT].admin-api.localhost:[PORT]` | gRPC Admin API | https://docs.digitalasset.com/operate/3.5/howtos/configure/apis/admin_api.html | +| `http://[PARTICIPANT].validator-api.localhost:[PORT]` | Validator API | https://docs.sync.global/app_dev/validator_api/index.html | +| `http://[PARTICIPANT].http-health-check.localhost:[PORT]` | HTTP Health Check | responds on GET /health | +| `grpc://[PARTICIPANT].grpc-health-check.localhost:[PORT]` | gRPC Health Check | https://grpc.io/docs/guides/health-checking/ | + +To access a participant's endpoint, replace `[PARTICIPANT]` with the participant's name (e.g., `sv`, `participant1`, +`participant2`, etc.). + +> [!NOTE] +> The maximum number of participants is 99. + +## Authentication + +The following endpoints require authentication: + +- JSON Ledger API +- gRPC Ledger API +- gRPC Admin API +- Validator API + +To authenticate, create a JWT bearer using the following claims: + +- `aud`: Set to the exported const `AuthProviderAudience` +- `sub`: Set to `user-[PARTICIPANT]` replacing `[PARTICIPANT]` with the participant's name like `sv`, `participant1`, + etc. + +Sign the JWT using the HMAC SHA256 algorithm with the secret of the exported const `AuthProviderSecret`. + +## Usage + +```golang +package examples + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/fullstorydev/grpcurl" + "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/jhump/protoreflect/grpcreflect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain/canton" +) + +type CfgCanton struct { + BlockchainA *blockchain.Input `toml:"blockchain_a" validate:"required"` +} + +func TestCantonSmoke(t *testing.T) { + in, err := framework.Load[CfgCanton](t) + require.NoError(t, err) + + bc, err := blockchain.NewBlockchainNetwork(in.BlockchainA) + require.NoError(t, err) + + t.Run("Test scan endpoint", func(t *testing.T) { + resp, err := resty.New().SetBaseURL(bc.NetworkSpecificData.CantonEndpoints.ScanAPIURL).R(). + Get("/v0/dso-party-id") + assert.NoError(t, err) + fmt.Println(resp) + }) + t.Run("Test registry endpoint", func(t *testing.T) { + resp, err := resty.New().SetBaseURL(bc.NetworkSpecificData.CantonEndpoints.RegistryAPIURL).R(). + Get("/metadata/v1/instruments") + assert.NoError(t, err) + fmt.Println(resp) + }) + + testParticipant := func(t *testing.T, name string, endpoints blockchain.CantonParticipantEndpoints) { + t.Run(fmt.Sprintf("Test %s endpoints", name), func(t *testing.T) { + j, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: "", + Subject: fmt.Sprintf("user-%s", name), + Audience: []string{canton.AuthProviderAudience}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), + NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: "", + }).SignedString([]byte(canton.AuthProviderSecret)) + + // JSON Ledger API + fmt.Println("Calling JSON Ledger API") + resp, err := resty.New().SetBaseURL(endpoints.JSONLedgerAPIURL).SetAuthToken(j).R(). + Get("/v2/packages") + assert.NoError(t, err) + fmt.Println(resp) + + // gRPC Ledger API - use reflection + fmt.Println("Calling gRPC Ledger API") + res, err := callGRPC(t.Context(), endpoints.GRPCLedgerAPIURL, "com.daml.ledger.api.v2.admin.PartyManagementService/GetParties", `{}`, []string{fmt.Sprintf("Authorization: Bearer %s", j)}) + assert.NoError(t, err) + fmt.Println(res) + + // gRPC Admin API - use reflection + fmt.Println("Calling gRPC Admin API") + res, err = callGRPC(t.Context(), endpoints.AdminAPIURL, "com.digitalasset.canton.admin.participant.v30.PackageService/ListDars", `{}`, []string{fmt.Sprintf("Authorization: Bearer %s", j)}) + assert.NoError(t, err) + fmt.Println(res) + + // Validator API + fmt.Println("Calling Validator API") + resp, err = resty.New().SetBaseURL(endpoints.ValidatorAPIURL).SetAuthToken(j).R(). + Get("/v0/admin/users") + assert.NoError(t, err) + fmt.Println(resp) + + // HTTP Health Check + fmt.Println("Calling HTTP Health Check") + resp, err = resty.New().SetBaseURL(endpoints.HTTPHealthCheckURL).R(). + Get("/health") + assert.NoError(t, err) + fmt.Println(resp) + + // gRPC Health Check + fmt.Println("Calling gRPC Health Check") + res, err = callGRPC(t.Context(), endpoints.GRPCHealthCheckURL, "grpc.health.v1.Health/Check", `{}`, nil) + assert.NoError(t, err) + fmt.Println(res) + }) + } + + // Call all participants, starting with the SV + testParticipant(t, "sv", bc.NetworkSpecificData.CantonEndpoints.SuperValidator) + for i := 1; i <= in.BlockchainA.NumberOfCantonValidators; i++ { + testParticipant(t, fmt.Sprintf("participant%d", i), bc.NetworkSpecificData.CantonEndpoints.Participants[i-1]) + } +} + +// callGRPC makes a gRPC call to the given URL and method with the provided JSON request and headers. +func callGRPC(ctx context.Context, url string, method string, jsonRequest string, headers []string) (string, error) { + conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return "", fmt.Errorf("failed to create grpc client: %w", err) + } + defer conn.Close() + + options := grpcurl.FormatOptions{EmitJSONDefaultFields: true} + jsonRequestReader := strings.NewReader(jsonRequest) + var output bytes.Buffer + + reflectClient := grpcreflect.NewClientAuto(ctx, conn) + defer reflectClient.Reset() + descriptorSource := grpcurl.DescriptorSourceFromServer(ctx, reflectClient) + + requestParser, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descriptorSource, jsonRequestReader, options) + if err != nil { + return "", fmt.Errorf("failed to create request parser and formatter: %w", err) + } + eventHandler := &grpcurl.DefaultEventHandler{ + Out: &output, + Formatter: formatter, + VerbosityLevel: 0, + } + + err = grpcurl.InvokeRPC(ctx, descriptorSource, conn, method, headers, eventHandler, requestParser.Next) + if err != nil { + return "", fmt.Errorf("rpc call failed: %w", err) + } + return output.String(), nil +} + +``` \ No newline at end of file diff --git a/framework/.changeset/v0.13.0.md b/framework/.changeset/v0.13.0.md new file mode 100644 index 000000000..70f78be19 --- /dev/null +++ b/framework/.changeset/v0.13.0.md @@ -0,0 +1 @@ +- Add support for Canton blockchain diff --git a/framework/components/blockchain/blockchain.go b/framework/components/blockchain/blockchain.go index 0a7d6bb86..a992a1a7f 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 + NumberOfCantonValidators int `toml:"number_of_canton_validators"` + // GAPv2 specific params HostNetworkMode bool `toml:"host_network_mode"` CertificatesPath string `toml:"certificates_path"` @@ -83,7 +88,8 @@ type Output struct { } type NetworkSpecificData struct { - SuiAccount *SuiWalletInfo + SuiAccount *SuiWalletInfo + CantonEndpoints *CantonEndpoints } // Node represents blockchain node output, URLs required for connection locally and inside docker network @@ -122,6 +128,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 +156,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..212283797 --- /dev/null +++ b/framework/components/blockchain/canton.go @@ -0,0 +1,137 @@ +package blockchain + +import ( + "context" + "fmt" + + "github.com/testcontainers/testcontainers-go" + + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain/canton" +) + +type CantonEndpoints struct { + ScanAPIURL string // https://docs.sync.global/app_dev/scan_api/index.html + RegistryAPIURL string // https://docs.sync.global/app_dev/token_standard/index.html#api-references + + // The endpoints for the super validator + SuperValidator CantonParticipantEndpoints + // The endpoints for the participants, in order from participant1 to participantN - depending on the number of validators requested + Participants []CantonParticipantEndpoints +} + +type CantonParticipantEndpoints struct { + JSONLedgerAPIURL string // https://docs.digitalasset.com/build/3.5/reference/json-api/json-api.html + GRPCLedgerAPIURL string // https://docs.digitalasset.com/build/3.5/reference/lapi-proto-docs.html + AdminAPIURL string // https://docs.digitalasset.com/operate/3.5/howtos/configure/apis/admin_api.html + ValidatorAPIURL string // https://docs.sync.global/app_dev/validator_api/index.html + + HTTPHealthCheckURL string // responds on GET /health + GRPCHealthCheckURL string // grpc.health.v1.Health/Check +} + +// newCanton sets up a Canton blockchain network with the specified number of validators. +// It creates a Docker network and starts the necessary containers for Postgres, Canton, Splice, and an Nginx reverse proxy. +// +// The reverse proxy is used to allow access to all validator participants through a single HTTP endpoint. +// The following routes are configured for each participant and the Super Validator (SV): +// - http://[PARTICIPANT].json-ledger-api.localhost:[PORT] -> JSON Ledger API => https://docs.digitalasset.com/build/3.3/reference/json-api/json-api.html +// - grpc://[PARTICIPANT].grpc-ledger-api.localhost:[PORT] -> gRPC Ledger API => https://docs.digitalasset.com/build/3.3/reference/lapi-proto-docs.html +// - grpc://[PARTICIPANT].admin-api.localhost:[PORT] -> gRPC Admin API => https://docs.digitalasset.com/operate/3.5/howtos/configure/apis/admin_api.html +// - http://[PARTICIPANT].validator-api.localhost:[PORT] -> Validator API => https://docs.sync.global/app_dev/validator_api/index.html +// - http://[PARTICIPANT].http-health-check.localhost:[PORT] -> HTTP Health Check => responds on GET /health +// - grpc://[PARTICIPANT].grpc-health-check.localhost:[PORT] -> gRPC Health Check => grpc.health.v1.Health/Check +// +// To access a participant's endpoints, replace [PARTICIPANT] with the participant's identifier, i.e. `sv`, `participant1`, `participant2`, ... +// +// Additionally, the global Scan service is accessible via: +// - http://scan.localhost:[PORT]/api/scan -> Scan API => https://docs.sync.global/app_dev/scan_api/index.html +// - http://scan.localhost:[PORT]/registry -> Token Standard API => https://docs.sync.global/app_dev/token_standard/index.html#api-references +// +// The PORT is the same for all routes and is specified in the input parameters, defaulting to 8080. +// +// Note: The maximum number of validators supported is 99, participants are numbered starting from `participant1` through `participant99`. +func newCanton(ctx context.Context, in *Input) (*Output, error) { + if in.NumberOfCantonValidators >= 100 { + return nil, fmt.Errorf("number of validators too high: %d, valid range is 0-99", in.NumberOfCantonValidators) + } + + // Set up Postgres container + postgresReq := canton.PostgresContainerRequest(in.NumberOfCantonValidators) + _, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: postgresReq, + Started: true, + }) + if err != nil { + return nil, err + } + + // Set up Canton container + cantonReq := canton.ContainerRequest(in.NumberOfCantonValidators, in.Image, postgresReq.Name) + _, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: cantonReq, + Started: true, + }) + if err != nil { + return nil, err + } + + // Set up Splice container + spliceReq := canton.SpliceContainerRequest(in.NumberOfCantonValidators, in.Image, postgresReq.Name, cantonReq.Name) + _, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: spliceReq, + Started: true, + }) + if err != nil { + return nil, err + } + + // Set up Nginx container + nginxReq := canton.NginxContainerRequest(in.NumberOfCantonValidators, in.Port, cantonReq.Name, spliceReq.Name) + nginxContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: nginxReq, + Started: true, + }) + if err != nil { + return nil, err + } + + host, err := nginxContainer.Host(ctx) + if err != nil { + return nil, err + } + + endpoints := &CantonEndpoints{ + ScanAPIURL: fmt.Sprintf("http://scan.%s:%s/api/scan", host, in.Port), + RegistryAPIURL: fmt.Sprintf("http://scan.%s:%s/registry", host, in.Port), + SuperValidator: CantonParticipantEndpoints{ + JSONLedgerAPIURL: fmt.Sprintf("http://sv.json-ledger-api.%s:%s", host, in.Port), + GRPCLedgerAPIURL: fmt.Sprintf("sv.grpc-ledger-api.%s:%s", host, in.Port), + AdminAPIURL: fmt.Sprintf("sv.admin-api.%s:%s", host, in.Port), + ValidatorAPIURL: fmt.Sprintf("http://sv.validator-api.%s:%s/api/validator", host, in.Port), + HTTPHealthCheckURL: fmt.Sprintf("http://sv.http-health-check.%s:%s", host, in.Port), + GRPCHealthCheckURL: fmt.Sprintf("sv.grpc-health-check.%s:%s", host, in.Port), + }, + Participants: nil, + } + for i := 1; i <= in.NumberOfCantonValidators; i++ { + participantEndpoints := CantonParticipantEndpoints{ + JSONLedgerAPIURL: fmt.Sprintf("http://participant%d.json-ledger-api.%s:%s", i, host, in.Port), + GRPCLedgerAPIURL: fmt.Sprintf("participant%d.grpc-ledger-api.%s:%s", i, host, in.Port), + AdminAPIURL: fmt.Sprintf("participant%d.admin-api.%s:%s", i, host, in.Port), + ValidatorAPIURL: fmt.Sprintf("http://participant%d.validator-api.%s:%s/api/validator", i, host, in.Port), + HTTPHealthCheckURL: fmt.Sprintf("http://participant%d.http-health-check.%s:%s", i, host, in.Port), + GRPCHealthCheckURL: fmt.Sprintf("participant%d.grpc-health-check.%s:%s", i, host, in.Port), + } + endpoints.Participants = append(endpoints.Participants, participantEndpoints) + } + + return &Output{ + UseCache: false, + Type: in.Type, + Family: FamilyCanton, + ContainerName: nginxReq.Name, + NetworkSpecificData: &NetworkSpecificData{ + CantonEndpoints: endpoints, + }, + }, nil +} diff --git a/framework/components/blockchain/canton/canton.go b/framework/components/blockchain/canton/canton.go new file mode 100644 index 000000000..9ca5dcf20 --- /dev/null +++ b/framework/components/blockchain/canton/canton.go @@ -0,0 +1,360 @@ +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" + Image = "ghcr.io/digital-asset/decentralized-canton-sync/docker/canton" +) + +// JWT Auth defaults +const ( + AuthProviderAudience = "https://chain.link" + AuthProviderSecret = "unsafe" +) + +// 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 := 1; i <= numberOfValidators; i++ { + 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 = ${API_SECRET} + }] + + user-management-service.additional-admin-user-id = "user-sv" + } +} + + ` + + // Add additional participants + for i := 1; i <= numberOfValidators; i++ { + 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 = ${API_SECRET} + }] + + user-management-service.additional-admin-user-id = "user-participant%[1]d" + } +} + + `, i) + } + + return config +} + +func ContainerRequest( + numberOfValidators int, + spliceVersion string, // optional, will default to SpliceVersion if empty + postgresContainerName string, +) testcontainers.ContainerRequest { + if spliceVersion == "" { + spliceVersion = SpliceVersion + } + cantonContainerName := framework.DefaultTCName("canton") + cantonReq := testcontainers.ContainerRequest{ + Image: fmt.Sprintf("%s:%s", Image, spliceVersion), + Name: cantonContainerName, + Networks: []string{framework.DefaultNetworkName}, + NetworkAliases: map[string][]string{ + framework.DefaultNetworkName: {cantonContainerName}, + }, + WaitingFor: wait.ForExec([]string{ + "/bin/bash", + "/app/health-check.sh", + }), + Env: map[string]string{ + "DB_SERVER": postgresContainerName, + "DB_USER": DefaultPostgresUser, + "DB_PASS": DefaultPostgresPass, + + "API_AUDIENCE": AuthProviderAudience, + "API_SECRET": AuthProviderSecret, + + "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..a1f299356 --- /dev/null +++ b/framework/components/blockchain/canton/nginx.go @@ -0,0 +1,231 @@ +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 ( + 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; + + server { + server_name localhost; + location /readyz { + add_header Content-Type text/plain; + return 200 'OK'; + } + } +} +` + +func getNginxTemplate(numberOfValidators int) string { + template := ` +# SV +server { + listen 8080; + server_name sv.json-ledger-api.localhost; + location / { + proxy_pass http://${CANTON_CONTAINER_NAME}:${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_CONTAINER_NAME}:${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}00; + } +} + +server { + listen 8080; + server_name sv.http-health-check.localhost; + location / { + proxy_pass http://${CANTON_CONTAINER_NAME}:${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}00; + } +} + +server { + listen 8080 http2; + server_name sv.grpc-health-check.localhost; + location / { + grpc_pass grpc://${CANTON_CONTAINER_NAME}:${CANTON_PARTICIPANT_GRPC_HEALTHCHECK_PORT_PREFIX}00; + } +} + +server { + listen 8080 http2; + server_name sv.admin-api.localhost; + location / { + grpc_pass grpc://${CANTON_CONTAINER_NAME}:${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}00; + } +} + +server { + listen 8080; + server_name sv.validator-api.localhost; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://${SPLICE_CONTAINER_NAME}:${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_CONTAINER_NAME}:5012/api/scan; + } + location /registry { + rewrite ^\/(.*) /$1 break; + proxy_pass http://${SPLICE_CONTAINER_NAME}:5012/registry; + } +} + ` + + // Add additional validators + for i := 1; i <= numberOfValidators; i++ { + template += fmt.Sprintf(` +# Participant %[1]d + server { + listen 8080; + server_name participant%[1]d.json-ledger-api.localhost; + location / { + proxy_pass http://${CANTON_CONTAINER_NAME}:${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_CONTAINER_NAME}:${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_CONTAINER_NAME}:${CANTON_PARTICIPANT_HTTP_HEALTHCHECK_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080 http2; + server_name participant%[1]d.grpc-health-check.localhost; + location / { + grpc_pass grpc://${CANTON_CONTAINER_NAME}:${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_CONTAINER_NAME}:${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}%02[1]d; + } + } + + server { + listen 8080; + server_name participant%[1]d.validator-api.localhost; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://${SPLICE_CONTAINER_NAME}:${SPLICE_VALIDATOR_ADMIN_API_PORT_PREFIX}%02[1]d/api/validator; + } + } + `, i) + } + + return template +} + +func NginxContainerRequest( + numberOfValidators int, + port string, + cantonContainerName string, + spliceContainerName string, +) testcontainers.ContainerRequest { + nginxContainerName := framework.DefaultTCName("nginx") + if port == "" { + port = "8080" + } + nginxReq := testcontainers.ContainerRequest{ + Image: DefaultNginxImage, + Name: nginxContainerName, + Networks: []string{framework.DefaultNetworkName}, + NetworkAliases: map[string][]string{ + framework.DefaultNetworkName: {nginxContainerName}, + }, + WaitingFor: wait.ForHTTP("/readyz").WithStartupTimeout(time.Second * 10), + 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, + + "CANTON_CONTAINER_NAME": cantonContainerName, + "SPLICE_CONTAINER_NAME": spliceContainerName, + }, + 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..3f8288c23 --- /dev/null +++ b/framework/components/blockchain/canton/postgres.go @@ -0,0 +1,93 @@ +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, +) 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{framework.DefaultNetworkName}, + NetworkAliases: map[string][]string{ + framework.DefaultNetworkName: {postgresContainerName}, + }, + 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..66a747661 --- /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 := 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" + `, 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_CONTAINER_NAME} + port = 5002 + } + ledger-api.client-config = { + address = ${CANTON_CONTAINER_NAME} + 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_CONTAINER_NAME} + port = ${CANTON_PARTICIPANT_ADMIN_API_PORT_PREFIX}00 + } + ledger-api { + client-config { + address = ${CANTON_CONTAINER_NAME} + port = ${CANTON_PARTICIPANT_LEDGER_API_PORT_PREFIX}00 + } + auth-config { + type = "self-signed" + user = "user-sv" + audience = ${API_AUDIENCE} + secret = ${API_SECRET} + } + } +} + +_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_CONTAINER_NAME} + port = 5009 + } + mediator-admin-client = { + address = ${CANTON_CONTAINER_NAME} + port = 5007 + } + sv-user = "user-sv" + 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_CONTAINER_NAME} + port = 5009 + } + internal-api { + address = ${CANTON_CONTAINER_NAME} + port = 5008 + } + external-public-api-url = "http://"${CANTON_CONTAINER_NAME}":5008" + } + mediator.admin-api { + address = ${CANTON_CONTAINER_NAME} + 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 = ${API_SECRET} + } + ledger-api-user = "user-sv" + validator-ledger-api-user = "user-sv" + + 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="user-sv" + 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 = ${API_SECRET} + } + ledger-api-user = "user-sv" + validator-wallet-users.0 = "sv" +} + +` + // Add additional participants + for i := 1; i <= numberOfValidators; i++ { + 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 = "user-participant%[1]d" + audience = ${API_AUDIENCE} + secret = ${API_SECRET} + } + } + } + auth = { + algorithm = "hs-256-unsafe" + audience = ${API_AUDIENCE} + secret = ${API_SECRET} + } + ledger-api-user = "user-participant%[1]d" + 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( + numberOfValidators int, + spliceVersion string, + postgresContainerName string, + cantonContainerName 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{framework.DefaultNetworkName}, + NetworkAliases: map[string][]string{ + framework.DefaultNetworkName: {spliceContainerName}, + }, + WaitingFor: wait.ForExec([]string{ + "/bin/bash", + "/app/health-check.sh", + }).WithStartupTimeout(time.Minute * 3), + Env: map[string]string{ + "DB_SERVER": postgresContainerName, + "DB_USER": DefaultPostgresUser, + "DB_PASS": DefaultPostgresPass, + + "API_AUDIENCE": AuthProviderAudience, + "API_SECRET": AuthProviderSecret, + "SPLICE_APP_VALIDATOR_LEDGER_API_AUTH_AUDIENCE": AuthProviderAudience, + + "CANTON_CONTAINER_NAME": cantonContainerName, + + "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 +} diff --git a/framework/examples/myproject/go.mod b/framework/examples/myproject/go.mod index 29aa30d66..ad2bdd251 100644 --- a/framework/examples/myproject/go.mod +++ b/framework/examples/myproject/go.mod @@ -12,8 +12,10 @@ require ( github.com/block-vision/sui-go-sdk v1.0.6 github.com/blocto/solana-go-sdk v1.30.0 github.com/ethereum/go-ethereum v1.15.0 + github.com/fullstorydev/grpcurl v1.9.3 github.com/go-resty/resty/v2 v2.16.5 - github.com/smartcontractkit/chainlink-testing-framework/framework v0.8.9 + github.com/jhump/protoreflect v1.17.0 + github.com/smartcontractkit/chainlink-testing-framework/framework v0.12.6 github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.0.0-20250707095700-c7855f06ddd1 github.com/smartcontractkit/chainlink-testing-framework/seth v1.50.10 github.com/smartcontractkit/chainlink-testing-framework/wasp v1.51.1 @@ -23,13 +25,18 @@ require ( ) require ( + github.com/bufbuild/protocompile v0.14.1 // indirect github.com/buger/goterm v1.0.4 // indirect + github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/danieljoos/wincred v1.2.1 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/minio/crc64nvme v1.0.0 // indirect @@ -37,11 +44,14 @@ require ( github.com/minio/minio-go/v7 v7.0.86 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect github.com/spf13/cobra v1.9.1 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/tjhop/slog-gokit v0.1.3 // indirect github.com/urfave/cli v1.22.16 // indirect + github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.31.0 // indirect @@ -137,7 +147,7 @@ require ( github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.3 // indirect github.com/gogo/status v1.1.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/btree v1.1.3 // indirect @@ -300,7 +310,7 @@ require ( google.golang.org/api v0.221.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.72.2 // indirect + google.golang.org/grpc v1.72.2 google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/guregu/null.v4 v4.0.0 // indirect diff --git a/framework/examples/myproject/go.sum b/framework/examples/myproject/go.sum index 678056ec2..6315f0ca2 100644 --- a/framework/examples/myproject/go.sum +++ b/framework/examples/myproject/go.sum @@ -149,6 +149,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/btcsuite/btcd v0.22.0-beta h1:LTDpDKUM5EeOFBPM8IXpinEcmZ6FWfNZbE3lfrfdnWo= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -309,7 +311,6 @@ github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -340,6 +341,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= @@ -372,6 +375,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg= github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -684,6 +689,8 @@ github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -1080,6 +1087,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/streamingfast/logging v0.0.0-20220405224725-2755dab2ce75 h1:ZqpS7rAhhKD7S7DnrpEdrnW1/gZcv82ytpMviovkli4= github.com/streamingfast/logging v0.0.0-20220405224725-2755dab2ce75/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -1168,6 +1177,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= diff --git a/framework/examples/myproject/smoke_canton.toml b/framework/examples/myproject/smoke_canton.toml new file mode 100644 index 000000000..84fc9edfb --- /dev/null +++ b/framework/examples/myproject/smoke_canton.toml @@ -0,0 +1,4 @@ +[blockchain_a] + type = "canton" + number_of_canton_validators = 5 + port = "8088" diff --git a/framework/examples/myproject/smoke_canton_test.go b/framework/examples/myproject/smoke_canton_test.go new file mode 100644 index 000000000..0593081c5 --- /dev/null +++ b/framework/examples/myproject/smoke_canton_test.go @@ -0,0 +1,140 @@ +package examples + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/fullstorydev/grpcurl" + "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/jhump/protoreflect/grpcreflect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain/canton" +) + +type CfgCanton struct { + BlockchainA *blockchain.Input `toml:"blockchain_a" validate:"required"` +} + +func TestCantonSmoke(t *testing.T) { + in, err := framework.Load[CfgCanton](t) + require.NoError(t, err) + + bc, err := blockchain.NewBlockchainNetwork(in.BlockchainA) + require.NoError(t, err) + + t.Run("Test scan endpoint", func(t *testing.T) { + resp, err := resty.New().SetBaseURL(bc.NetworkSpecificData.CantonEndpoints.ScanAPIURL).R(). + Get("/v0/dso-party-id") + assert.NoError(t, err) + fmt.Println(resp) + }) + t.Run("Test registry endpoint", func(t *testing.T) { + resp, err := resty.New().SetBaseURL(bc.NetworkSpecificData.CantonEndpoints.RegistryAPIURL).R(). + Get("/metadata/v1/instruments") + assert.NoError(t, err) + fmt.Println(resp) + }) + + testParticipant := func(t *testing.T, name string, endpoints blockchain.CantonParticipantEndpoints) { + t.Run(fmt.Sprintf("Test %s endpoints", name), func(t *testing.T) { + j, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: "", + Subject: fmt.Sprintf("user-%s", name), + Audience: []string{canton.AuthProviderAudience}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), + NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: "", + }).SignedString([]byte(canton.AuthProviderSecret)) + + // JSON Ledger API + fmt.Println("Calling JSON Ledger API") + resp, err := resty.New().SetBaseURL(endpoints.JSONLedgerAPIURL).SetAuthToken(j).R(). + Get("/v2/packages") + assert.NoError(t, err) + fmt.Println(resp) + + // gRPC Ledger API - use reflection + fmt.Println("Calling gRPC Ledger API") + res, err := callGRPC(t.Context(), endpoints.GRPCLedgerAPIURL, "com.daml.ledger.api.v2.admin.PartyManagementService/GetParties", `{}`, []string{fmt.Sprintf("Authorization: Bearer %s", j)}) + assert.NoError(t, err) + fmt.Println(res) + + // gRPC Admin API - use reflection + fmt.Println("Calling gRPC Admin API") + res, err = callGRPC(t.Context(), endpoints.AdminAPIURL, "com.digitalasset.canton.admin.participant.v30.PackageService/ListDars", `{}`, []string{fmt.Sprintf("Authorization: Bearer %s", j)}) + assert.NoError(t, err) + fmt.Println(res) + + // Validator API + fmt.Println("Calling Validator API") + resp, err = resty.New().SetBaseURL(endpoints.ValidatorAPIURL).SetAuthToken(j).R(). + Get("/v0/admin/users") + assert.NoError(t, err) + fmt.Println(resp) + + // HTTP Health Check + fmt.Println("Calling HTTP Health Check") + resp, err = resty.New().SetBaseURL(endpoints.HTTPHealthCheckURL).R(). + Get("/health") + assert.NoError(t, err) + fmt.Println(resp) + + // gRPC Health Check + fmt.Println("Calling gRPC Health Check") + res, err = callGRPC(t.Context(), endpoints.GRPCHealthCheckURL, "grpc.health.v1.Health/Check", `{}`, nil) + assert.NoError(t, err) + fmt.Println(res) + }) + } + + // Call all participants, starting with the SV + testParticipant(t, "sv", bc.NetworkSpecificData.CantonEndpoints.SuperValidator) + for i := 1; i <= in.BlockchainA.NumberOfCantonValidators; i++ { + testParticipant(t, fmt.Sprintf("participant%d", i), bc.NetworkSpecificData.CantonEndpoints.Participants[i-1]) + } +} + +// callGRPC makes a gRPC call to the given URL and method with the provided JSON request and headers. +func callGRPC(ctx context.Context, url string, method string, jsonRequest string, headers []string) (string, error) { + conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return "", fmt.Errorf("failed to create grpc client: %w", err) + } + defer conn.Close() + + options := grpcurl.FormatOptions{EmitJSONDefaultFields: true} + jsonRequestReader := strings.NewReader(jsonRequest) + var output bytes.Buffer + + reflectClient := grpcreflect.NewClientAuto(ctx, conn) + defer reflectClient.Reset() + descriptorSource := grpcurl.DescriptorSourceFromServer(ctx, reflectClient) + + requestParser, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descriptorSource, jsonRequestReader, options) + if err != nil { + return "", fmt.Errorf("failed to create request parser and formatter: %w", err) + } + eventHandler := &grpcurl.DefaultEventHandler{ + Out: &output, + Formatter: formatter, + VerbosityLevel: 0, + } + + err = grpcurl.InvokeRPC(ctx, descriptorSource, conn, method, headers, eventHandler, requestParser.Next) + if err != nil { + return "", fmt.Errorf("rpc call failed: %w", err) + } + return output.String(), nil +}