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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions framework/.changeset/v0.13.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add support for Canton blockchain
11 changes: 10 additions & 1 deletion framework/components/blockchain/blockchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
TypeSui = "sui"
TypeTron = "tron"
TypeTon = "ton"
TypeCanton = "canton"
)

// Blockchain node family
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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'")
}
Expand All @@ -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)
}
Expand Down
101 changes: 101 additions & 0 deletions framework/components/blockchain/canton.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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"
)

// 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
// - grpc://[PARTICIPANT].grpc-ledger-api.localhost:[PORT] -> gRPC Ledger API
// - http://[PARTICIPANT].admin-api.localhost:[PORT] -> Admin API
// - http://[PARTICIPANT].wallet.localhost:[PORT] -> Wallet API
// - http://[PARTICIPANT].http-health-check.localhost:[PORT] -> HTTP Health Check
// - grpc://[PARTICIPANT].grpc-health-check.localhost:[PORT] -> gRPC Health Check
//
// To access a participant's endpoints, replace [PARTICIPANT] with the participant's identifier, i.e. `sv`, `participant01`, `participant02`, ...
//
// Additionally, the global Scan service is accessible via:
// - http://scan.localhost:[PORT]/api/scan -> Scan API
// - http://scan.localhost:[PORT]/registry -> Scan Registry
//
// The PORT is the same for all routes and is specified in the input parameters.
//
// Note: The maximum number of validators supported is 99, participants are numbered starting from `participant01` 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, max is 99", in.NumberOfCantonValidators)
}

// Create separate Docker network for Canton stack
dockerNetwork, err := network.New(ctx, network.WithAttachable())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason why these containers shouldn't be part of the default CTF network? or of both its own and the default network, if having a canton-specific Docker network is important for some reason?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about adding it to the default network as well, but I think that would require some more templating to have the Canton containers connect to each other. Right now they all have assigned simple network aliases like canton, splice, etc. which would lead to conflicts if two instances were to be spun up in parallel.

But I can of course add this, if you think it's worth it!

if err != nil {
return nil, err
}

// Set up Postgres container
postgresReq := canton.PostgresContainerRequest(in.NumberOfCantonValidators, dockerNetwork.Name)
_, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: postgresReq,
Started: true,
})
if err != nil {
return nil, err
}

// Set up Canton container
cantonReq := canton.ContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Image)
_, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: cantonReq,
Started: true,
})
if err != nil {
return nil, err
}

// Set up Splice container
spliceReq := canton.SpliceContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Image)
_, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: spliceReq,
Started: true,
})
if err != nil {
return nil, err
}

// Set up Nginx container
nginxReq := canton.NginxContainerRequest(dockerNetwork.Name, in.NumberOfCantonValidators, in.Port)
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
}

return &Output{
UseCache: false,
Type: in.Type,
Family: FamilyCanton,
ContainerName: nginxReq.Name,
Nodes: []*Node{
{
ExternalHTTPUrl: fmt.Sprintf("http://%s:%s", host, in.Port),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... shouldn't we return here all the URLs mentioned in the top-level comment?

Copy link
Member Author

@friedemannf friedemannf Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could potentially be tens, if not hundreds of different endpoints depending on the number of participants requested - not really sure how this fits the notion of Nodes, since all of them are technically being served by the same Canton node + there are these global endpoints that we'll need to interact with. Just returning them as a list of endpoints might be difficult to figure out what element is which endpoint.

The client/Chainlink node needs some elaborate config anyway, since ExternalHTTPUrl will return something like http://localhost:8080 but clients need http://participant01.json-ledger-api.localhost:8080 as well as grpc://participant01..., etc...

Maybe if we switched the routing up and changed it to json-ledger-api.participant01.localhost instead? This way we could at least return:

  • http://participant01.localhost
  • http://participant02.localhost
  • etc...

But what to do with the gRPC and global endpoints?

},
},
}, nil
}
Loading
Loading