Skip to content
Merged
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,5 @@ test-e2e-bridge:
go test -v -tags e2e -timeout 15m ./tests/e2e/tests/bridge/...

test-e2e-indexer:
@echo "not yet implemented"
CANTON_MASTER_KEY=$${CANTON_MASTER_KEY:-$$(openssl rand -base64 32)} \
go test -v -tags e2e -timeout 20m ./tests/e2e/tests/indexer/...
87 changes: 77 additions & 10 deletions pkg/cantonsdk/bridge/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const (
streamReconnectDelay = 5 * time.Second
streamMaxReconnectDelay = 60 * time.Second
withdrawalEventChannelCap = 10

bridgeContractsModule = "Bridge.Contracts"
)

// Bridge defines bridge operations.
Expand All @@ -47,6 +49,10 @@ type Bridge interface {
// InitiateWithdrawal creates a WithdrawalRequest for a user (choice on WayfinderBridgeConfig).
InitiateWithdrawal(ctx context.Context, req InitiateWithdrawalRequest) (string, error)

// ProcessWithdrawal exercises the ProcessWithdrawal choice on a WithdrawalRequest contract,
// burning tokens on Canton and creating a WithdrawalEvent. Returns the WithdrawalEvent CID.
ProcessWithdrawal(ctx context.Context, withdrawalRequestCID string) (string, error)

// CompleteWithdrawal marks a WithdrawalEvent as completed after the EVM release is finalized.
CompleteWithdrawal(ctx context.Context, req CompleteWithdrawalRequest) error

Expand Down Expand Up @@ -122,15 +128,19 @@ func (c *Client) IsDepositProcessed(ctx context.Context, evmTxHash string) (bool
return false, nil
}

// We enforce module/entity filtering via template id. This assumes deposits live in the same package/module.
// If your deposits are in a different package/module, adjust these template IDs accordingly.
// Common.FingerprintAuth templates live in the identity package, not the bridge package.
// Use the identity client's package ID so the ACS query targets the correct package.
identityPkgID := c.cfg.PackageID // fallback: same package (shouldn't happen in practice)
if c.identity != nil {
identityPkgID = c.identity.PackageID()
}
pendingTID := &lapiv2.Identifier{
PackageId: c.cfg.PackageID,
PackageId: identityPkgID,
ModuleName: "Common.FingerprintAuth",
EntityName: "PendingDeposit",
}
receiptTID := &lapiv2.Identifier{
PackageId: c.cfg.PackageID,
PackageId: identityPkgID,
ModuleName: "Common.FingerprintAuth",
EntityName: "DepositReceipt",
}
Expand Down Expand Up @@ -340,14 +350,62 @@ func (c *Client) InitiateWithdrawal(ctx context.Context, req InitiateWithdrawalR
if created == nil || created.TemplateId == nil {
continue
}
if created.TemplateId.ModuleName == "Bridge.Contracts" && created.TemplateId.EntityName == "WithdrawalRequest" {
if created.TemplateId.ModuleName == bridgeContractsModule && created.TemplateId.EntityName == "WithdrawalRequest" {
return created.ContractId, nil
}
}

return "", fmt.Errorf("WithdrawalRequest contract not found in response")
}

func (c *Client) ProcessWithdrawal(ctx context.Context, withdrawalRequestCID string) (string, error) {
cmd := &lapiv2.Command{
Command: &lapiv2.Command_Exercise{
Exercise: &lapiv2.ExerciseCommand{
TemplateId: &lapiv2.Identifier{
PackageId: c.corePackageID(),
ModuleName: bridgeContractsModule,
EntityName: "WithdrawalRequest",
},
ContractId: withdrawalRequestCID,
Choice: "ProcessWithdrawal",
ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeProcessWithdrawalArgs()}},
},
},
}

resp, err := c.ledger.Command().SubmitAndWaitForTransaction(
c.ledger.AuthContext(ctx),
&lapiv2.SubmitAndWaitForTransactionRequest{
Commands: &lapiv2.Commands{
SynchronizerId: c.cfg.DomainID,
CommandId: uuid.NewString(),
UserId: c.cfg.UserID,
ActAs: []string{c.cfg.OperatorParty},
Commands: []*lapiv2.Command{cmd},
},
},
)
if err != nil {
return "", fmt.Errorf("process withdrawal: %w", err)
}
if resp.Transaction == nil {
return "", fmt.Errorf("process withdrawal: missing transaction in response")
}

for _, e := range resp.Transaction.Events {
created := e.GetCreated()
if created == nil || created.TemplateId == nil {
continue
}
if created.TemplateId.ModuleName == bridgeContractsModule && created.TemplateId.EntityName == "WithdrawalEvent" {
return created.ContractId, nil
}
}

return "", fmt.Errorf("WithdrawalEvent contract not found in response")
}

func (c *Client) CompleteWithdrawal(ctx context.Context, req CompleteWithdrawalRequest) error {
if err := req.validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
Expand All @@ -357,8 +415,8 @@ func (c *Client) CompleteWithdrawal(ctx context.Context, req CompleteWithdrawalR
Command: &lapiv2.Command_Exercise{
Exercise: &lapiv2.ExerciseCommand{
TemplateId: &lapiv2.Identifier{
PackageId: c.cfg.PackageID,
ModuleName: "Bridge.Contracts",
PackageId: c.corePackageID(),
ModuleName: bridgeContractsModule,
EntityName: "WithdrawalEvent",
},
ContractId: req.WithdrawalEventCID,
Expand Down Expand Up @@ -447,8 +505,8 @@ func (c *Client) streamWithdrawalEventsOnce(ctx context.Context, offset string,
IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{
TemplateFilter: &lapiv2.TemplateFilter{
TemplateId: &lapiv2.Identifier{
PackageId: c.cfg.PackageID,
ModuleName: "Bridge.Contracts",
PackageId: c.corePackageID(),
ModuleName: bridgeContractsModule,
EntityName: "WithdrawalEvent",
},
},
Expand Down Expand Up @@ -489,7 +547,7 @@ func (c *Client) streamWithdrawalEventsOnce(ctx context.Context, offset string,
}

tid := created.TemplateId
if tid.ModuleName != "Bridge.Contracts" || tid.EntityName != "WithdrawalEvent" {
if tid.ModuleName != bridgeContractsModule || tid.EntityName != "WithdrawalEvent" {
continue
}

Expand Down Expand Up @@ -536,6 +594,15 @@ func (c *Client) GetLatestLedgerOffset(ctx context.Context) (int64, error) {
return c.ledger.GetLedgerEnd(ctx)
}

// corePackageID returns the bridge-core package ID (for Bridge.Contracts templates).
// Falls back to PackageID when CorePackageID is not configured.
func (c *Client) corePackageID() string {
if c.cfg.CorePackageID != "" {
return c.cfg.CorePackageID
}
return c.cfg.PackageID
}

func isAlreadyExistsError(err error) bool {
if s, ok := status.FromError(err); ok {
return s.Code() == codes.AlreadyExists
Expand Down
4 changes: 4 additions & 0 deletions pkg/cantonsdk/bridge/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ type Config struct {
OperatorParty string `yaml:"operator_party"`
// PackageID is the package id that contains Wayfinder.Bridge templates/choices.
PackageID string `yaml:"package_id" validate:"required"`
// CorePackageID is the package id for Bridge.Contracts (bridge-core package).
// WithdrawalRequest and WithdrawalEvent live here, separate from PackageID.
// Falls back to PackageID if empty.
CorePackageID string `yaml:"core_package_id"`
// Module is the DAML module name that contains WayfinderBridgeConfig template.
Module string `yaml:"module" validate:"required"`
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cantonsdk/bridge/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func decodeWithdrawalEvent(ce *lapiv2.CreatedEvent, txID string) *WithdrawalEven
TransactionID: txID,
Issuer: values.Party(fields["issuer"]),
UserParty: values.Party(fields["userParty"]),
EvmDestination: values.Text(fields["evmDestination"]),
EvmDestination: values.NewtypeText(fields["evmDestination"]),
Amount: values.Numeric(fields["amount"]),
Fingerprint: values.Text(fields["fingerprint"]),
Status: decodeWithdrawalStatusV2(fields["status"]),
Expand Down
9 changes: 8 additions & 1 deletion pkg/cantonsdk/bridge/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ func encodeInitiateWithdrawalArgs(req InitiateWithdrawalRequest) *lapiv2.Record
{Label: "mappingCid", Value: values.ContractIDValue(req.MappingCID)},
{Label: "holdingCid", Value: values.ContractIDValue(req.HoldingCID)},
{Label: "amount", Value: values.NumericValue(req.Amount)},
{Label: "evmDestination", Value: values.TextValue(req.EvmDestination)},
{Label: "evmDestination", Value: values.NewtypeValue(values.TextValue(req.EvmDestination))},
},
}
}

func encodeProcessWithdrawalArgs() *lapiv2.Record {
return &lapiv2.Record{
Fields: []*lapiv2.RecordField{
{Label: "timestamp", Value: values.TimestampValue(time.Now())},
},
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/cantonsdk/identity/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ type Identity interface {
// AllocateExternalPartyWithSignature completes external party allocation using
// a client-provided DER signature of the topology multi-hash.
AllocateExternalPartyWithSignature(ctx context.Context, topology *ExternalPartyTopology, derSignature []byte) (*Party, error)

// PackageID returns the DAML package ID this client uses for identity templates
// (e.g. Common.FingerprintAuth). Callers that need to query identity templates
// using a ledger client directly (rather than through this client) can use this
// to construct the correct template identifier.
PackageID() string
}

// Client implements the Identity interface.
Expand Down Expand Up @@ -390,6 +396,11 @@ func isAlreadyExistsError(err error) bool {
return false
}

// PackageID returns the DAML package ID used by this client for identity templates.
func (c *Client) PackageID() string {
return c.cfg.PackageID
}

func normalizeFingerprint(fingerprint string) string {
if !strings.HasPrefix(fingerprint, "0x") {
fingerprint = "0x" + fingerprint
Expand Down
15 changes: 15 additions & 0 deletions pkg/cantonsdk/values/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ func TimestampOK(v *lapiv2.Value) (time.Time, bool) {
return time.UnixMicro(t.Timestamp), true
}

// NewtypeText extracts the inner Text from a DAML newtype encoded as a
// single-field Record (e.g. EvmAddress = EvmAddress { value : Text }).
// The DAML Ledger API v2 returns newtype values as Records in CreatedEvent
// payloads. Returns "" if v is nil, not a Record, or the first field is not Text.
func NewtypeText(v *lapiv2.Value) string {
if v == nil {
return ""
}
rec, ok := v.Sum.(*lapiv2.Value_Record)
if !ok || rec.Record == nil || len(rec.Record.Fields) == 0 {
return ""
}
return Text(rec.Record.Fields[0].Value)
}

// RecordField extracts a named field from a Record value, returning the sub-map.
// Returns nil when v is nil or not a Record.
func RecordField(v *lapiv2.Value) map[string]*lapiv2.Value {
Expand Down
15 changes: 15 additions & 0 deletions pkg/cantonsdk/values/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,18 @@ func Optional(v *lapiv2.Value) *lapiv2.Value {
},
}
}

// NewtypeValue encodes a DAML newtype as a single-field Record wrapping the
// inner value. Use this for types like Common.Types.EvmAddress which are
// defined as: newtype T = T with field : PrimType.
// The DAML Ledger API v2 represents newtypes as Records; passing the
// underlying primitive directly causes COMMAND_PREPROCESSING_FAILED.
func NewtypeValue(inner *lapiv2.Value) *lapiv2.Value {
return &lapiv2.Value{
Sum: &lapiv2.Value_Record{
Record: &lapiv2.Record{
Fields: []*lapiv2.RecordField{{Value: inner}},
},
},
}
}
1 change: 1 addition & 0 deletions pkg/config/defaults/config.relayer.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ canton:

bridge:
package_id: "6fac182df4943e7e2f70360b413b6e3ab10e65289ba0d971978b6d861a860d72"
core_package_id: "be290fc1304d9a221def6e04a291368600599c9265f58f942a2b80478c348fca"
module: "Wayfinder.Bridge"

bridge:
Expand Down
57 changes: 57 additions & 0 deletions pkg/relayer/engine/mocks/mock_canton_bridge.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 11 additions & 5 deletions tests/e2e/devstack/docker/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ const (
// deploy manifest to produce a fully populated stack.ServiceManifest.
type ServiceDiscovery struct {
projectName string
composeFile string
}

// NewServiceDiscovery returns a ServiceDiscovery scoped to the given Docker
// Compose project (e.g. "canton-e2e").
func NewServiceDiscovery(projectName string) *ServiceDiscovery {
return &ServiceDiscovery{projectName: projectName}
// Compose project (e.g. "canton-e2e") and compose file path. The compose file
// is passed explicitly to all docker compose subcommands so that Docker Compose
// v2 can resolve service definitions regardless of the current working directory.
func NewServiceDiscovery(projectName, composeFile string) *ServiceDiscovery {
return &ServiceDiscovery{projectName: projectName, composeFile: composeFile}
}

// deployManifest mirrors the JSON written by scripts/setup/docker-bootstrap.sh
Expand Down Expand Up @@ -191,6 +194,7 @@ func (d *ServiceDiscovery) serviceDSN(ctx context.Context, service, envVar, post
func (d *ServiceDiscovery) containerEnv(ctx context.Context, service, key string) (string, error) {
// Resolve container ID.
psCmd := dockerComposeCommand(ctx,
"-f", d.composeFile,
"-p", d.projectName,
"ps", "-q", service,
)
Expand Down Expand Up @@ -244,11 +248,12 @@ func (d *ServiceDiscovery) tcpEndpoint(ctx context.Context, service string, cont
return d.publishedPort(ctx, service, containerPort)
}

// publishedPort executes `docker compose -p <project> port <service> <port>`
// publishedPort executes `docker compose -f <file> -p <project> port <service> <port>`
// and returns the resolved "host:port" string (e.g. "0.0.0.0:54321" →
// "localhost:54321").
func (d *ServiceDiscovery) publishedPort(ctx context.Context, service string, containerPort int) (string, error) {
cmd := dockerComposeCommand(ctx,
"-f", d.composeFile,
"-p", d.projectName,
"port", service, fmt.Sprintf("%d", containerPort),
)
Expand All @@ -274,12 +279,13 @@ func (d *ServiceDiscovery) publishedPort(ctx context.Context, service string, co
// bootstrap container. The bootstrap service already has the e2e-deploy volume
// mounted at /tmp in the compose definition, so docker compose run inherits it:
//
// docker compose -p <project> run --rm bootstrap cat /tmp/e2e-deploy.json
// docker compose -f <file> -p <project> run --rm bootstrap cat /tmp/e2e-deploy.json
func (d *ServiceDiscovery) readDeployManifest(ctx context.Context) (*deployManifest, error) {
// Use --entrypoint cat to bypass the bootstrap container's own entrypoint
// (docker-bootstrap.sh), which writes status text to stdout and would
// corrupt the JSON before we can parse it.
cmd := dockerComposeCommand(ctx,
"-f", d.composeFile,
"-p", d.projectName,
"run", "--rm",
"--entrypoint", "cat",
Expand Down
Loading
Loading