Skip to content
Merged
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,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/...
85 changes: 75 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 All @@ -73,6 +79,9 @@ func New(cfg *Config, l ledger.Ledger, i identity.Identity, opts ...Option) (*Cl
if l == nil {
return nil, fmt.Errorf("nil ledger client")
}
if i == nil {
return nil, fmt.Errorf("nil identity client")
}
s := applyOptions(opts)

return &Client{
Expand Down Expand Up @@ -122,15 +131,16 @@ 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.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,13 @@ 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
// (WithdrawalRequest, WithdrawalEvent). CorePackageID is validated as required at
// construction time, so this will never be empty in practice.
func (c *Client) corePackageID() string {
return c.cfg.CorePackageID
}

func isAlreadyExistsError(err error) bool {
if s, ok := status.FromError(err); ok {
return s.Code() == codes.AlreadyExists
Expand Down
6 changes: 6 additions & 0 deletions pkg/cantonsdk/bridge/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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.
CorePackageID string `yaml:"core_package_id" validate:"required"`
// Module is the DAML module name that contains WayfinderBridgeConfig template.
Module string `yaml:"module" validate:"required"`
}
Expand All @@ -36,5 +39,8 @@ func (c *Config) validate() error {
if c.Module == "" {
return fmt.Errorf("bridge module is required")
}
if c.CorePackageID == "" {
return fmt.Errorf("bridge core_package_id is required")
}
return nil
}
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
13 changes: 12 additions & 1 deletion pkg/cantonsdk/bridge/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@ 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))},
},
}
}

// encodeProcessWithdrawalArgs encodes the argument record for the
// Bridge.Contracts.WithdrawalRequest:ProcessWithdrawal choice.
// DAML signature: ProcessWithdrawal : Time -> ContractId WithdrawalEvent
// The choice takes a single `timestamp : Time` field.
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
19 changes: 19 additions & 0 deletions pkg/cantonsdk/values/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,22 @@ 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.
//
// The RecordField carries no Label: the Ledger API v2 command-preprocessing
// path matches newtype fields by position, not by name, so omitting the label
// is correct and intentional. NewtypeText decodes symmetrically via Fields[0].
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/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ func setDefaultConfigEnv(t *testing.T) {
t.Setenv("CANTON_CIP56_PACKAGE_ID", "cip56-package-id")
t.Setenv("CANTON_SPLICE_TRANSFER_PACKAGE_ID", "splice-transfer-package-id")
t.Setenv("CANTON_BRIDGE_PACKAGE_ID", "bridge-package-id")
t.Setenv("CANTON_BRIDGE_CORE_PACKAGE_ID", "bridge-core-package-id")
t.Setenv("ETHEREUM_RPC_URL", "https://eth.example")
t.Setenv("ETHEREUM_WS_URL", "wss://eth.example/ws")
t.Setenv("ETHEREUM_CHAIN_ID", "1")
Expand Down
1 change: 1 addition & 0 deletions pkg/config/defaults/config.api-server.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ canton:

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

token_provider:
Expand Down
1 change: 1 addition & 0 deletions pkg/config/defaults/config.api-server.local-devnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ canton:

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

token:
Expand Down
1 change: 1 addition & 0 deletions pkg/config/defaults/config.api-server.mainnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ canton:

bridge:
package_id: "${CANTON_BRIDGE_PACKAGE_ID}"
core_package_id: "${CANTON_BRIDGE_CORE_PACKAGE_ID}"
module: "Wayfinder.Bridge"

token:
Expand Down
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
1 change: 1 addition & 0 deletions pkg/config/defaults/config.relayer.local-devnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ canton:

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

bridge:
Expand Down
1 change: 1 addition & 0 deletions pkg/config/defaults/config.relayer.mainnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ canton:

bridge:
package_id: "${CANTON_BRIDGE_PACKAGE_ID}"
core_package_id: "${CANTON_BRIDGE_CORE_PACKAGE_ID}"
module: "Wayfinder.Bridge"

bridge:
Expand Down
Loading
Loading